From cbf97d42522dcbf4b282a25cceaadd9106db553f Mon Sep 17 00:00:00 2001 From: r3dbars Date: Thu, 10 Sep 2026 11:02:22 -0500 Subject: [PATCH] Restore speaker review samples from retained meeting audio --- .agent-review/visuals/issue-1681-review.md | 15 ++ .../HomeMeetingPreviewFormatter.swift | 6 +- .../SpeakerPeopleSettingsSection.swift | 16 +- Sources/UI/Shared/SpeakerClipPlayback.swift | 76 ++++++++- .../UI/Shared/SpeakerReviewQueueScanner.swift | 84 +++++++++- Tests/SpeakerClipPlaybackTests.swift | 106 ++++++++++++ Tests/SpeakerReviewQueueScannerTests.swift | 158 ++++++++++++++++++ .../SpeakerNamingCoordinatorTests.swift | 108 ++++++++++++ scripts/entrypoints/run-tests.sh | 1 + 9 files changed, 557 insertions(+), 13 deletions(-) create mode 100644 .agent-review/visuals/issue-1681-review.md create mode 100644 Tests/SpeakerClipPlaybackTests.swift diff --git a/.agent-review/visuals/issue-1681-review.md b/.agent-review/visuals/issue-1681-review.md new file mode 100644 index 000000000..33456e73e --- /dev/null +++ b/.agent-review/visuals/issue-1681-review.md @@ -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. diff --git a/Sources/UI/Settings/HomeMeetingPreviewFormatter.swift b/Sources/UI/Settings/HomeMeetingPreviewFormatter.swift index 1fe331b31..1ed8c6f17 100644 --- a/Sources/UI/Settings/HomeMeetingPreviewFormatter.swift +++ b/Sources/UI/Settings/HomeMeetingPreviewFormatter.swift @@ -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 diff --git a/Sources/UI/Settings/SpeakerPeopleSettingsSection.swift b/Sources/UI/Settings/SpeakerPeopleSettingsSection.swift index 2ae316bfd..c98daca74 100644 --- a/Sources/UI/Settings/SpeakerPeopleSettingsSection.swift +++ b/Sources/UI/Settings/SpeakerPeopleSettingsSection.swift @@ -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) { @@ -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 { @@ -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 diff --git a/Sources/UI/Shared/SpeakerClipPlayback.swift b/Sources/UI/Shared/SpeakerClipPlayback.swift index f8c04480a..a1690b912 100644 --- a/Sources/UI/Shared/SpeakerClipPlayback.swift +++ b/Sources/UI/Shared/SpeakerClipPlayback.swift @@ -1,4 +1,5 @@ import AppKit +import AVFoundation import Foundation /// Plays one persisted speaker sample clip at a time. @@ -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) { @@ -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) @@ -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 @@ -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() } diff --git a/Sources/UI/Shared/SpeakerReviewQueueScanner.swift b/Sources/UI/Shared/SpeakerReviewQueueScanner.swift index 3c4ce4afe..93372f1e3 100644 --- a/Sources/UI/Shared/SpeakerReviewQueueScanner.swift +++ b/Sources/UI/Shared/SpeakerReviewQueueScanner.swift @@ -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 @@ -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 @@ -113,8 +122,15 @@ 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], @@ -122,6 +138,13 @@ enum SpeakerReviewQueueScanner { 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, @@ -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 @@ -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 diff --git a/Tests/SpeakerClipPlaybackTests.swift b/Tests/SpeakerClipPlaybackTests.swift new file mode 100644 index 000000000..057e84c24 --- /dev/null +++ b/Tests/SpeakerClipPlaybackTests.swift @@ -0,0 +1,106 @@ +import AVFoundation +import Foundation + +@MainActor +func testSpeakerClipPlayback() async { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("SpeakerPlayback-\(UUID().uuidString).wav") + defer { try? FileManager.default.removeItem(at: url) } + do { + try writeSilentSpeakerPlaybackFixture(to: url) + } catch { + assertTrue(false, "could not create silent playback fixture: \(error)") + return + } + + await runSuite("SpeakerClipPlayback streams only the requested retained-audio range") { + let player = AVPlayer() + player.isMuted = true + let playback = SpeakerClipPlayback(retainedAudioPlayer: player) + defer { playback.stop() } + let sample = SpeakerRetainedAudioSample(url: url, startTime: 0.2, duration: 0.4) + playback.play(sample) + assertEqual(playback.activeURL, nil, "a retained range must not identify as a global profile clip") + assertTrue(abs((player.currentItem?.forwardPlaybackEndTime.seconds ?? 0) - 0.6) < 0.001) + + var observedProgress = false + var latestTime = 0.0 + let deadline = Date().addingTimeInterval(5) + while playback.isPlaying(sample), Date() < deadline { + let time = player.currentTime().seconds + if time.isFinite { + latestTime = max(latestTime, time) + observedProgress = observedProgress || (player.rate > 0 && time > 0.22) + } + try? await Task.sleep(nanoseconds: 10_000_000) + } + assertTrue(observedProgress, "the real muted player should advance within the selected range") + assertTrue(latestTime <= 0.62, "playback must not reach the following speaker's turn") + assertEqual(playback.activeRetainedSample, nil, "the configured range end should finish playback") + assertEqual(player.currentItem, nil, "range completion should release the long recording") + } + + await runSuite("SpeakerClipPlayback keeps same-file voices distinct and ignores stale seeks") { + let player = AVPlayer() + player.isMuted = true + let playback = SpeakerClipPlayback(retainedAudioPlayer: player) + defer { playback.stop() } + let first = SpeakerRetainedAudioSample(url: url, startTime: 0.2, duration: 0.4) + let second = SpeakerRetainedAudioSample(url: url, startTime: 0.8, duration: 0.4) + playback.play(first) + playback.play(second) + assertFalse(playback.isPlaying(first), "different voices in one file must not both highlight") + assertTrue(playback.isPlaying(second)) + let deadline = Date().addingTimeInterval(5) + while playback.isPlaying(second), Date() < deadline { + if player.currentTime().seconds >= 0.82 { break } + try? await Task.sleep(nanoseconds: 10_000_000) + } + assertTrue(player.currentTime().seconds >= 0.82, "a superseded seek must not stop the replacement sample") + playback.play(second) + assertEqual(playback.activeRetainedSample, nil, "clicking the playing sample should stop it") + assertEqual(player.currentItem, nil) + try? await Task.sleep(nanoseconds: 30_000_000) + assertEqual(playback.activeRetainedSample, nil, "late callbacks must not restart a stopped sample") + assertEqual(player.rate, 0) + } + + await runSuite("SpeakerClipPlayback clears a sample beyond the retained recording end") { + let player = AVPlayer() + player.isMuted = true + let playback = SpeakerClipPlayback(retainedAudioPlayer: player) + defer { playback.stop() } + playback.play(SpeakerRetainedAudioSample(url: url, startTime: 3, duration: 1)) + let deadline = Date().addingTimeInterval(5) + while playback.activeRetainedSample != nil, Date() < deadline { + try? await Task.sleep(nanoseconds: 10_000_000) + } + assertEqual(playback.activeRetainedSample, nil, "a truncated recording must not leave a sample stuck playing") + assertEqual(player.currentItem, nil) + } + + await runSuite("SpeakerClipPlayback clears failed retained-audio startup") { + let brokenURL = url.deletingPathExtension().appendingPathExtension("m4a") + try? Data([0, 1, 2]).write(to: brokenURL) + defer { try? FileManager.default.removeItem(at: brokenURL) } + let player = AVPlayer() + player.isMuted = true + let playback = SpeakerClipPlayback(retainedAudioPlayer: player) + defer { playback.stop() } + playback.play(SpeakerRetainedAudioSample(url: brokenURL, startTime: 0, duration: 1)) + let deadline = Date().addingTimeInterval(5) + while playback.activeRetainedSample != nil, Date() < deadline { + try? await Task.sleep(nanoseconds: 10_000_000) + } + assertEqual(playback.activeRetainedSample, nil, "a file that cannot load must not leave the row stuck playing") + assertEqual(player.currentItem, nil) + } +} + +private func writeSilentSpeakerPlaybackFixture(to url: URL) throws { + let format = AVAudioFormat(standardFormatWithSampleRate: 48_000, channels: 1)! + let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 96_000)! + buffer.frameLength = buffer.frameCapacity + buffer.floatChannelData![0].initialize(repeating: 0, count: Int(buffer.frameLength)) + let file = try AVAudioFile(forWriting: url, settings: format.settings) + try file.write(from: buffer) +} diff --git a/Tests/SpeakerReviewQueueScannerTests.swift b/Tests/SpeakerReviewQueueScannerTests.swift index d32a95280..223ba265a 100644 --- a/Tests/SpeakerReviewQueueScannerTests.swift +++ b/Tests/SpeakerReviewQueueScannerTests.swift @@ -1,6 +1,133 @@ import Foundation func testSpeakerReviewQueueScanner() { + runSuite("SpeakerReviewQueueScanner recovers system-only samples without changing profile clips") { + withRetainedReviewAudio(stems: ["recording.m4a"]) { transcriptURL, audioURLs in + let speakerId = UUID() + let markdown = deferredMarkdown( + speakerId: speakerId, + title: "Failed naming import", + speakerName: "Speaker 1", + sampleText: "A recoverable voice." + ) + "\n\n**00:05** [System/Someone else]\nThe next voice.\n" + try? markdown.write(to: transcriptURL, atomically: true, encoding: .utf8) + let profiles = [makeReviewQueueProfile(id: speakerId, name: nil)] + let items = SpeakerReviewQueueScanner.loadPendingItems( + transcriptsDirectory: transcriptURL.deletingLastPathComponent(), + profiles: profiles, + clipURLsByProfileID: [:] + ) + assertRetainedSample(items.first?.retainedAudioSample, SpeakerRetainedAudioSample( + url: audioURLs[0], startTime: 1, duration: 4 + ), "a failed system-only review should play its own turn from compressed retained audio") + assertEqual(items.first?.sampleText, "A recoverable voice.") + assertEqual(items.first?.clipURL, nil, "fallback must not masquerade as a confirmed profile clip") + assertEqual(try? String(contentsOf: transcriptURL, encoding: .utf8), markdown) + assertEqual(try? Data(contentsOf: audioURLs[0]), Data([1]), "scanning must leave retained audio unchanged") + + let existingClip = transcriptURL.deletingLastPathComponent().appendingPathComponent("\(speakerId.uuidString).wav") + try? Data([7, 8, 9]).write(to: existingClip) + let withClip = SpeakerReviewQueueScanner.pendingItems( + in: markdown, transcriptURL: transcriptURL, + profilesById: [speakerId: profiles[0]], clipURLsByProfileID: [speakerId: existingClip] + ) + assertEqual(withClip.first?.clipURL, existingClip) + assertEqual(withClip.first?.retainedAudioSample, nil, "a saved profile clip remains preferred") + assertEqual(try? Data(contentsOf: existingClip), Data([7, 8, 9]), "recovery must never overwrite another voice's clip") + } + } + + runSuite("SpeakerReviewQueueScanner resolves styled samples and clips the last turn to duration") { + withRetainedReviewAudio(stems: ["recording.wav"]) { transcriptURL, audioURLs in + let speakerId = UUID() + let markdown = deferredMarkdown( + speakerId: speakerId, title: "Styled import", speakerName: "Speaker 1", sampleText: "Late voice." + ) + .replacingOccurrences(of: "capture_type: meeting", with: "capture_type: meeting\nduration: \"01:34:00\"") + .replacingOccurrences(of: "**00:01** [System/Speaker 1]", with: "**01:33:58** [Speaker 1]") + let items = SpeakerReviewQueueScanner.pendingItems( + in: markdown, transcriptURL: transcriptURL, + profilesById: [speakerId: makeReviewQueueProfile(id: speakerId, name: nil)], clipURLsByProfileID: [:] + ) + assertRetainedSample(items.first?.retainedAudioSample, SpeakerRetainedAudioSample( + url: audioURLs[0], startTime: 5_638, duration: 2 + ), "styled 94-minute imports should resolve the exact voice and stop at the saved meeting end") + assertEqual(items.first?.sampleText, "Late voice.") + } + } + + runSuite("SpeakerReviewQueueScanner keeps mic and system voices on their own retained channels") { + withRetainedReviewAudio(stems: ["system_audio.wav", "microphone.m4a"]) { transcriptURL, audioURLs in + let systemId = UUID() + let micId = UUID() + let markdown = """ + --- + capture_type: meeting + duration: "00:20" + speakers: + - id: "1" + channel: system + db_id: "\(systemId.uuidString)" + name: "Speaker 1" + source: db_pending + - id: "1" + channel: mic + db_id: "\(micId.uuidString)" + name: "Speaker 1" + source: db_pending + --- + ## Full Transcript + [00:01] [System/Speaker 1] Remote voice. + [00:02] [Mic/Speaker 1] Local voice. + [00:06] [System/Someone else] Another remote voice. + """ + let items = SpeakerReviewQueueScanner.pendingItems( + in: markdown, transcriptURL: transcriptURL, + profilesById: [systemId: makeReviewQueueProfile(id: systemId, name: nil), micId: makeReviewQueueProfile(id: micId, name: nil)], + clipURLsByProfileID: [:] + ) + assertRetainedSample(items.first(where: { $0.channel == .system })?.retainedAudioSample, SpeakerRetainedAudioSample( + url: audioURLs[0], startTime: 1, duration: 5 + ), "system playback should stop before the next system turn, not an overlapping mic turn") + assertRetainedSample(items.first(where: { $0.channel == .mic })?.retainedAudioSample, SpeakerRetainedAudioSample( + url: audioURLs[1], startTime: 2, duration: 8 + ), "mic playback should use the mic file and stay capped at eight seconds") + try? FileManager.default.removeItem(at: audioURLs[1]) + let missingMic = SpeakerReviewQueueScanner.pendingItems( + in: markdown, transcriptURL: transcriptURL, + profilesById: [micId: makeReviewQueueProfile(id: micId, name: nil)], clipURLsByProfileID: [:] + ) + assertEqual(missingMic.first?.retainedAudioSample, nil, "missing mic audio must not play the remote track instead") + } + } + + runSuite("SpeakerReviewQueueScanner refuses missing audio and ambiguous timestamps") { + withRetainedReviewAudio(stems: ["recording.wav"]) { transcriptURL, audioURLs in + let speakerId = UUID() + let markdown = deferredMarkdown( + speakerId: speakerId, title: "Broken timing", speakerName: "Speaker 1", sampleText: "A voice." + ) + for marker in ["**00:99**", "**-1:01**", "**99999999999999999999:01**"] { + let items = SpeakerReviewQueueScanner.pendingItems( + in: markdown.replacingOccurrences(of: "**00:01**", with: marker), transcriptURL: transcriptURL, + profilesById: [speakerId: makeReviewQueueProfile(id: speakerId, name: nil)], clipURLsByProfileID: [:] + ) + assertEqual(items.first?.retainedAudioSample, nil, "malformed timing should stay unavailable") + } + let overlapping = SpeakerReviewQueueScanner.pendingItems( + in: markdown + "\n**00:01** [System/Other voice]\nOverlapping speaker.\n", transcriptURL: transcriptURL, + profilesById: [speakerId: makeReviewQueueProfile(id: speakerId, name: nil)], clipURLsByProfileID: [:] + ) + assertEqual(overlapping.first?.retainedAudioSample, nil, "same-time voices have no isolated sample range") + try? FileManager.default.removeItem(at: audioURLs[0]) + let missing = SpeakerReviewQueueScanner.pendingItems( + in: markdown, transcriptURL: transcriptURL, + profilesById: [speakerId: makeReviewQueueProfile(id: speakerId, name: nil)], clipURLsByProfileID: [:] + ) + assertEqual(missing.first?.retainedAudioSample, nil, "retention-pruned audio should remain unavailable") + } + } + runSuite("SpeakerReviewQueueScanner extracts deferred speakers with call context") { let speakerId = UUID() let transcriptId = UUID() @@ -360,6 +487,37 @@ func testSpeakerReviewQueueScanner() { } } +private func assertRetainedSample( + _ actual: SpeakerRetainedAudioSample?, + _ expected: SpeakerRetainedAudioSample, + _ message: String, + file: String = #file, + line: Int = #line +) { + // Directory enumeration and constructed URLs can spell /private/var + // differently on macOS. Compare both through the same normalization. + assertEqual(actual?.url.resolvingSymlinksInPath(), expected.url.resolvingSymlinksInPath(), message, file: file, line: line) + assertEqual(actual?.startTime, expected.startTime, message, file: file, line: line) + assertEqual(actual?.duration, expected.duration, message, file: file, line: line) +} + +private func withRetainedReviewAudio(stems: [String], _ body: (URL, [URL]) -> Void) { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("SpeakerReviewAudio-\(UUID().uuidString)") + let transcriptURL = directory.appendingPathComponent("Meeting.md") + let audioDirectory = MeetingAudioArchiveResolver.archiveDirectory(forTranscript: transcriptURL) + defer { try? FileManager.default.removeItem(at: directory) } + do { + try FileManager.default.createDirectory(at: audioDirectory, withIntermediateDirectories: true) + let urls = stems.map { audioDirectory.appendingPathComponent($0) } + // Scanner fixtures prove range/source resolution only. Playback tests + // use a real, silent WAV and exercise AVPlayer separately. + for url in urls { try Data([1]).write(to: url) } + body(transcriptURL, urls) + } catch { + assertTrue(false, "could not create retained-audio fixture: \(error)") + } +} + private func deferredMarkdown( speakerId: UUID, title: String, diff --git a/Tests/TranscriptedCoreTests/SpeakerTests/SpeakerNamingCoordinatorTests.swift b/Tests/TranscriptedCoreTests/SpeakerTests/SpeakerNamingCoordinatorTests.swift index 68436916d..1d98c9d6b 100644 --- a/Tests/TranscriptedCoreTests/SpeakerTests/SpeakerNamingCoordinatorTests.swift +++ b/Tests/TranscriptedCoreTests/SpeakerTests/SpeakerNamingCoordinatorTests.swift @@ -2583,6 +2583,114 @@ final class SpeakerNamingCoordinatorTests: XCTestCase { XCTAssertEqual(retry.recordingDate, TranscriptFrontmatter.recordedAt(in: originalTranscript)) } + @MainActor + func testSavedLongSystemOnlyMeetingFinalizesAfterUnicodeSummaryAndRename() async throws { + let harness = try makeHarness() + let transcriptId = UUID() + let speakerIds = (1...9).map { index in + harness.speakerDB.addOrUpdateSpeaker( + embedding: [Float](repeating: Float(index) / 10, count: 256), + existingId: nil + ).id + } + let utterances = (0..<441).map { index in + TranscriptionUtterance( + start: Double(index * 12), + end: Double(index * 12 + 5), + channel: 1, + speakerId: index % 9 + 1, + persistentSpeakerId: speakerIds[index % 9], + matchSimilarity: nil, + transcript: "Synthetic meeting sample \(index)." + ) + } + let result = TranscriptionResult( + micUtterances: [], systemUtterances: utterances, + duration: 5_620, processingTime: 598.1, + microphoneAudioOutcome: .notProvided + ) + let keys = (1...9).map { "system_\($0)" } + let originalURL = try XCTUnwrap(TranscriptSaver.saveTranscript( + result, + transcriptId: transcriptId, + speakerMappings: Dictionary(uniqueKeysWithValues: (1...9).map { + ("system_\($0)", SpeakerMapping(speakerId: String($0))) + }), + speakerSources: Dictionary(uniqueKeysWithValues: keys.map { ($0, "db_pending") }), + speakerDbIds: Dictionary(uniqueKeysWithValues: zip(keys, speakerIds)), + directory: harness.paths.transcripts, + statsStore: StatsDatabase(path: harness.paths.statsDB.path), + formatOptions: TranscriptFormatOptions(audioSources: [.systemAudio]) + )) + XCTAssertEqual(TranscriptSaver.transcriptIdentity(at: originalURL), transcriptId) + XCTAssertEqual(TranscriptSaver.resolveTranscriptURL(originalURL, transcriptId: transcriptId), originalURL) + + // Summary injection and title styling run after save. Keep both IDs intact, + // but make the old 2 KB UTF-8 probe end halfway through a character. + let saved = try String(contentsOf: originalURL, encoding: .utf8) + let identityLine = "transcript_id: \"\(transcriptId.uuidString)\"\n" + let identityRange = try XCTUnwrap(saved.range(of: identityLine)) + let prefix = String(saved[..