Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .agent-review/visuals/issue-1681-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Issue 1681 review evidence

The existing Speakers review row now enables its play button when a matching,
bounded sample can be read from retained meeting audio. The layout is unchanged;
transcript rows remain static.

Native UI click/screenshot verification is incomplete. The no-prompt
`transcripted-qa permission-state --mode computer-use` probe reported that the
automation host lacks Accessibility/Event Posting permission. The owner's
existing app instance was left alone. No permission changes were requested.

Automated coverage uses synthetic fixtures only: scanner tests resolve raw and
styled speaker rows to the right retained channel, and muted AVPlayer tests
exercise actual sample playback, stop/replacement, and failure cleanup. These
checks do not prove the visible row behavior or reproduce the customer's audio.
6 changes: 3 additions & 3 deletions Sources/UI/Settings/HomeMeetingPreviewFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -601,9 +601,9 @@ struct HomeMeetingPreviewContent {
}

struct HomeMeetingTranscriptLine: Equatable {
/// Display-only clock string ("00:00"). It is not parsed into seconds:
/// nothing seeks or syncs to a line since playback highlighting was
/// removed, so the transcript only ever renders this as text.
/// The transcript renders this clock string as static text. Speaker review
/// can separately use it to bound a retained-audio sample; rows never
/// follow or highlight the playhead.
let time: String
let identity: HomeMeetingSpeakerIdentity
let text: String
Expand Down
16 changes: 12 additions & 4 deletions Sources/UI/Settings/SpeakerPeopleSettingsSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,11 @@ final class SpeakerPeopleSettingsViewModel: ObservableObject {
}

func playSample(for item: SpeakerPendingReviewItem) {
guard let url = item.clipURL else { return }
SpeakerClipPlayback.play(url)
if let url = item.clipURL {
SpeakerClipPlayback.play(url)
} else if let sample = item.retainedAudioSample {
SpeakerClipPlayback.shared.play(sample)
}
}

func openTranscript(for item: SpeakerPendingReviewItem) {
Expand Down Expand Up @@ -1094,11 +1097,14 @@ private struct SpeakerVoiceToNameRow: View {
@State private var clipDuration = SpeakerClipProgressBar.fallbackDuration

private var isPlaying: Bool {
group.representative.clipURL.map(playback.isPlaying) ?? false
if let clipURL = group.representative.clipURL {
return playback.isPlaying(clipURL)
}
return group.representative.retainedAudioSample.map(playback.isPlaying) ?? false
}

private var hasClip: Bool {
group.representative.clipURL != nil
group.representative.clipURL != nil || group.representative.retainedAudioSample != nil
}

var body: some View {
Expand Down Expand Up @@ -1179,6 +1185,8 @@ private struct SpeakerVoiceToNameRow: View {
}
if let clipURL = group.representative.clipURL {
clipDuration = probeClipDuration(clipURL)
} else if let sample = group.representative.retainedAudioSample {
clipDuration = sample.duration
}
}
.onChange(of: nameDraft) { _, _ in
Expand Down
76 changes: 73 additions & 3 deletions Sources/UI/Shared/SpeakerClipPlayback.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import AVFoundation
import Foundation

/// Plays one persisted speaker sample clip at a time.
Expand All @@ -22,6 +23,7 @@ final class SpeakerClipPlayback: ObservableObject {
/// transitions rather than consulting `NSSound.isPlaying` at read time,
/// so observers and the AX layer always agree with what was started.
@Published private(set) var activeURL: URL?
@Published private(set) var activeRetainedSample: SpeakerRetainedAudioSample?

private final class PlaybackDelegate: NSObject, NSSoundDelegate {
func sound(_ sound: NSSound, didFinishPlaying flag: Bool) {
Expand All @@ -33,8 +35,13 @@ final class SpeakerClipPlayback: ObservableObject {

private let playbackDelegate = PlaybackDelegate()
private var activeSound: NSSound?
private let retainedAudioPlayer: AVPlayer
private var retainedAudioObservers: [NSObjectProtocol] = []
private var retainedAudioStatusObserver: NSKeyValueObservation?

private init() {}
init(retainedAudioPlayer: AVPlayer = AVPlayer()) {
self.retainedAudioPlayer = retainedAudioPlayer
}

// MARK: - Static facade (AppKit consumers, existing call sites)

Expand All @@ -50,8 +57,7 @@ final class SpeakerClipPlayback: ObservableObject {
return
}

activeSound?.delegate = nil
activeSound?.stop()
stop()
activeURL = url
activeSound = NSSound(contentsOf: url, byReference: false)
activeSound?.delegate = playbackDelegate
Expand All @@ -66,11 +72,75 @@ final class SpeakerClipPlayback: ObservableObject {
activeURL == url
}

/// Stream a short range from retained audio instead of loading a long
/// meeting into NSSound or saving an unconfirmed global profile sample.
func play(_ sample: SpeakerRetainedAudioSample) {
if activeRetainedSample == sample {
stop()
return
}
stop()
guard sample.startTime.isFinite, sample.startTime >= 0,
sample.duration.isFinite, sample.duration > 0, sample.duration <= 8,
let url = OwnFileResolver.resolveExistingFile(candidateURLs: [sample.url]) else { return }

let item = AVPlayerItem(url: url)
item.forwardPlaybackEndTime = CMTime(seconds: sample.startTime + sample.duration, preferredTimescale: 600)
let player = retainedAudioPlayer
player.replaceCurrentItem(with: item)
activeRetainedSample = sample
retainedAudioStatusObserver = item.observe(\.status, options: [.initial, .new]) { [weak self, weak item] _, _ in
Task { @MainActor in
guard let self, let item, self.retainedAudioPlayer.currentItem === item,
item.status == .failed else { return }
self.stop()
}
}
for name in [Notification.Name.AVPlayerItemDidPlayToEndTime, .AVPlayerItemFailedToPlayToEndTime] {
retainedAudioObservers.append(NotificationCenter.default.addObserver(
forName: name,
object: item,
queue: .main
) { [weak self, weak item] _ in
Task { @MainActor in
guard let self, let item, self.retainedAudioPlayer.currentItem === item else { return }
self.stop()
}
})
}
player.seek(
to: CMTime(seconds: sample.startTime, preferredTimescale: 600),
toleranceBefore: .zero,
toleranceAfter: .zero
) { [weak self, weak item] finished in
Task { @MainActor in
guard let self, let item, self.retainedAudioPlayer.currentItem === item else { return }
guard finished else {
self.stop()
return
}
self.retainedAudioPlayer.play()
self.notifyStateDidChange()
}
}
}

func isPlaying(_ sample: SpeakerRetainedAudioSample) -> Bool {
activeRetainedSample == sample
}

func stop() {
activeSound?.delegate = nil
activeSound?.stop()
activeSound = nil
activeURL = nil
retainedAudioStatusObserver?.invalidate()
retainedAudioStatusObserver = nil
retainedAudioPlayer.pause()
retainedAudioPlayer.replaceCurrentItem(with: nil)
activeRetainedSample = nil
retainedAudioObservers.forEach(NotificationCenter.default.removeObserver)
retainedAudioObservers.removeAll()
notifyStateDidChange()
}

Expand Down
84 changes: 81 additions & 3 deletions Sources/UI/Shared/SpeakerReviewQueueScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import Foundation
import TranscriptedCore
#endif

/// A transcript-local preview. It never becomes the saved sample of an
/// unconfirmed global speaker profile.
struct SpeakerRetainedAudioSample: Equatable, Sendable {
let url: URL
let startTime: TimeInterval
let duration: TimeInterval
}

struct SpeakerPendingReviewItem: Identifiable, Sendable {
let speakerId: UUID
let diarizerSpeakerId: String
Expand All @@ -14,6 +22,7 @@ struct SpeakerPendingReviewItem: Identifiable, Sendable {
let fallbackDate: Date
let sampleText: String?
let clipURL: URL?
let retainedAudioSample: SpeakerRetainedAudioSample?
let callCount: Int
let profile: SpeakerProfile
let sourceName: String
Expand Down Expand Up @@ -113,15 +122,29 @@ enum SpeakerReviewQueueScanner {
)
let recordedAt = TranscriptFrontmatter.recordedAt(values: document.values)
let transcriptId = TranscriptFrontmatter.captureID(in: document.values)
let speakers = frontmatterSpeakers(from: document.lines)
let needsAudioFallback = speakers.contains {
$0.source == "db_pending" && $0.dbId.map { clipURLsByProfileID[$0] == nil } == true
}
let transcriptLines = needsAudioFallback ? HomeMeetingPreviewContent.make(from: markdown).transcriptLines : []
let audio = needsAudioFallback ? MeetingAudioArchiveResolver.attachment(forTranscript: transcriptURL) : nil
let meetingDuration = TranscriptFrontmatter.durationSeconds(from: document.values["duration"])

let items: [SpeakerPendingReviewItem] = frontmatterSpeakers(from: document.lines).compactMap { speaker in
let items: [SpeakerPendingReviewItem] = speakers.compactMap { speaker in
guard speaker.source == "db_pending",
let dbId = speaker.dbId,
let profile = profilesById[dbId],
profile.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false else {
return nil
}

let clipURL = clipURLsByProfileID[dbId]
let fallback = clipURL == nil ? retainedSample(
for: speaker,
lines: transcriptLines,
audio: audio,
meetingDuration: meetingDuration
) : nil
return SpeakerPendingReviewItem(
speakerId: dbId,
diarizerSpeakerId: speaker.id,
Expand All @@ -131,12 +154,13 @@ enum SpeakerReviewQueueScanner {
meetingTitle: meetingTitle,
recordedAt: recordedAt,
fallbackDate: fileDate,
sampleText: sampleText(
sampleText: fallback?.text ?? sampleText(
in: document.body,
speakerName: speaker.name,
channel: speaker.channel
),
clipURL: clipURLsByProfileID[dbId],
clipURL: clipURL,
retainedAudioSample: fallback?.sample,
callCount: profile.callCount,
profile: profile,
sourceName: speaker.name
Expand Down Expand Up @@ -191,6 +215,60 @@ enum SpeakerReviewQueueScanner {
let source: String
}

private static func retainedSample(
for speaker: FrontmatterSpeaker,
lines: [HomeMeetingTranscriptLine],
audio: MeetingAudioAttachment?,
meetingDuration: Int?
) -> (sample: SpeakerRetainedAudioSample, text: String?)? {
guard let audio else { return nil }
let urls = audio.retranscriptionURLs + audio.urls
let channelStem = speaker.channel == .mic
? MeetingAudioArchiveResolver.microphoneStem : MeetingAudioArchiveResolver.systemStem
let channelURL = urls.first { $0.deletingPathExtension().lastPathComponent == channelStem }
let importedURL = speaker.channel == .system
? urls.first { $0.deletingPathExtension().lastPathComponent == MeetingAudioArchiveResolver.importedStem }
: nil
let mixedURL = urls.first { $0.deletingPathExtension().lastPathComponent == MeetingAudioArchiveResolver.playbackStem }
guard let url = channelURL ?? importedURL ?? mixedURL else { return nil }

for (index, line) in lines.enumerated() {
// Styled transcripts omit the channel prefix. The existing parser
// resolves their identity only when the frontmatter match is unique.
guard line.identity.persistentSpeakerID == speaker.dbId,
line.identity.diarizerSpeakerID == speaker.id,
line.identity.channel == nil || line.identity.channel?.rawValue == speaker.channel.rawValue,
let start = timestampSeconds(line.time) else { continue }
var end = start + 8
if let next = lines.dropFirst(index + 1).first(where: {
url == mixedURL || $0.identity.channel == nil
|| $0.identity.channel?.rawValue == speaker.channel.rawValue
}) {
// Never preview across the next turn on this source. Equal or
// reversed timestamps are ambiguous, so try another utterance.
guard let nextStart = timestampSeconds(next.time), nextStart > start else { continue }
end = min(end, nextStart)
}
if let meetingDuration { end = min(end, TimeInterval(meetingDuration)) }
guard end > start else { continue }
return (
SpeakerRetainedAudioSample(url: url, startTime: start, duration: end - start),
cleanedSample(line.text)
)
}
return nil
}

private static func timestampSeconds(_ value: String) -> TimeInterval? {
let parts = value.split(separator: ":", omittingEmptySubsequences: false)
guard parts.count == 2 || parts.count == 3,
parts.allSatisfy({ !$0.isEmpty && $0.allSatisfy(\.isNumber) }),
let seconds = Int(parts[parts.count - 1]), seconds < 60,
parts.count != 3 || (Int(parts[1]).map { $0 < 60 } == true),
let total = TranscriptFrontmatter.durationSeconds(from: value) else { return nil }
return TimeInterval(total)
}

private static func frontmatterSpeakers(from lines: [String]) -> [FrontmatterSpeaker] {
var speakers: [FrontmatterSpeaker] = []
var inSpeakersBlock = false
Expand Down
Loading
Loading