From f4f7acee32bbaaa5738304481d3426089412b177 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 23:21:06 -0500 Subject: [PATCH 1/7] Preserve stopped dictation audio --- Sources/Dictation/CLAUDE.md | 9 + .../DictationStoppedAudioRecovery.swift | 157 ++++++++++++++++++ Sources/Speech/ParakeetEngine.swift | 25 +++ Sources/Speech/STTRouter.swift | 4 + Sources/TranscriptedApp.swift | 1 + .../Overlay/DictationSessionController.swift | 60 ++++++- .../DictationStoppedAudioRecoveryTests.swift | 124 ++++++++++++++ Tests/FastTests.manifest | 1 + scripts/entrypoints/run-tests.sh | 1 + 9 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 Sources/Dictation/DictationStoppedAudioRecovery.swift create mode 100644 Tests/DictationStoppedAudioRecoveryTests.swift diff --git a/Sources/Dictation/CLAUDE.md b/Sources/Dictation/CLAUDE.md index aca035292..3b8fc50c8 100644 --- a/Sources/Dictation/CLAUDE.md +++ b/Sources/Dictation/CLAUDE.md @@ -7,6 +7,7 @@ ## Files - `DictationSessionTimeout.swift` — uptime-based timeout helper so sleep does not consume a session's remaining record window +- `DictationStoppedAudioRecovery.swift` — writes a private recovery WAV plus restart-discovery metadata immediately after recording stops and retains both until transcript persistence succeeds or the user explicitly discards the session - `DictationStoragePaths.swift` — capture-library-backed storage root for dictation artifacts - `DictationTranscriptWriter.swift` — groups completed dictations into one markdown file per day; serializes day-file writes through `DictationTranscriptMutationLock` - `DictationTranscriptStore.swift` — shared seam for saving dictation markdown and reading the newest saved dictation back out @@ -27,6 +28,13 @@ - root: `/dictations/` - transcript folder: same as the dictation root - file shape: one `Dictations_YYYY-MM-DD.md` file per day, with multiple timestamped sections +- stopped-audio recovery: `~/Library/Application Support/Transcripted/state/dictation-audio-recovery/` + +Stopped-audio recovery is intentionally bounded and local. Launch scans at most +one pending metadata record for presentation, then `Show Audio` reveals the WAV +in Finder. The operational recovery path is Home -> Import Audio -> select that +WAV; this uses the normal local imported-audio transcription pipeline. Reveal or +restart never deletes the checkpoint. Each section captures: @@ -40,6 +48,7 @@ Each section captures: ## Test coverage - `Tests/DictationSessionTimeoutTests.swift` +- `Tests/DictationStoppedAudioRecoveryTests.swift` - `Tests/DictationTranscriptStoreTests.swift` - `Tests/DictationTranscriptWriterTests.swift` diff --git a/Sources/Dictation/DictationStoppedAudioRecovery.swift b/Sources/Dictation/DictationStoppedAudioRecovery.swift new file mode 100644 index 000000000..a20eeb02d --- /dev/null +++ b/Sources/Dictation/DictationStoppedAudioRecovery.swift @@ -0,0 +1,157 @@ +// DictationStoppedAudioRecovery.swift +// Durable audio checkpoint for a stopped dictation awaiting transcription. + +import Foundation + +struct DictationStoppedAudioRecovery: Equatable, Sendable { + let url: URL + let sessionID: UUID + let createdAt: Date +} + +enum DictationStoppedAudioRecoveryStore { + static let sampleRate: UInt32 = 16_000 + + private struct Metadata: Codable { + let version: Int + let sessionID: UUID + let createdAt: Date + let audioFilename: String + } + + static var defaultDirectory: URL { + FileManager.default.transcriptedStateDir + .appendingPathComponent("dictation-audio-recovery", isDirectory: true) + } + + static func persist( + samples16k: [Float], + sessionID: UUID, + createdAt: Date = Date(), + directory: URL? = nil, + fileManager: FileManager = .default + ) throws -> DictationStoppedAudioRecovery? { + guard !samples16k.isEmpty else { return nil } + + let folder = directory ?? defaultDirectory + try fileManager.createPrivateDirectory(at: folder) + let url = folder.appendingPathComponent("dictation_\(sessionID.uuidString.lowercased()).wav") + try wavData(samples16k: samples16k).write(to: url, options: .atomic) + fileManager.restrictFileToOwnerOnly(at: url) + let recovery = DictationStoppedAudioRecovery(url: url, sessionID: sessionID, createdAt: createdAt) + do { + try writeMetadata(for: recovery, fileManager: fileManager) + } catch { + try? fileManager.removeItem(at: url) + throw error + } + return recovery + } + + static func pendingRecoveries( + limit: Int = 10, + directory: URL? = nil, + fileManager: FileManager = .default + ) -> [DictationStoppedAudioRecovery] { + guard limit > 0 else { return [] } + let folder = directory ?? defaultDirectory + guard let enumerator = fileManager.enumerator( + at: folder, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants] + ) else { return [] } + + var recoveries: [DictationStoppedAudioRecovery] = [] + for case let metadataURL as URL in enumerator where metadataURL.pathExtension == "json" { + guard let metadata = try? JSONDecoder().decode(Metadata.self, from: Data(contentsOf: metadataURL)), + metadata.version == 1 else { continue } + let audioURL = folder.appendingPathComponent(metadata.audioFilename, isDirectory: false) + guard fileManager.fileExists(atPath: audioURL.path) else { continue } + recoveries.append(DictationStoppedAudioRecovery( + url: audioURL, + sessionID: metadata.sessionID, + createdAt: metadata.createdAt + )) + if recoveries.count >= limit { break } + } + return recoveries.sorted { $0.createdAt > $1.createdAt } + } + + @discardableResult + static func cleanup( + _ recovery: DictationStoppedAudioRecovery?, + transcriptPersisted: Bool = false, + explicitDiscard: Bool = false, + fileManager: FileManager = .default + ) -> Bool { + guard transcriptPersisted || explicitDiscard, + let recovery else { return false } + + do { + if fileManager.fileExists(atPath: recovery.url.path) { + try fileManager.removeItem(at: recovery.url) + } + let metadataURL = metadataURL(for: recovery.url) + if fileManager.fileExists(atPath: metadataURL.path) { + try fileManager.removeItem(at: metadataURL) + } + return true + } catch { + return false + } + } + + private static func writeMetadata( + for recovery: DictationStoppedAudioRecovery, + fileManager: FileManager + ) throws { + let metadata = Metadata( + version: 1, + sessionID: recovery.sessionID, + createdAt: recovery.createdAt, + audioFilename: recovery.url.lastPathComponent + ) + let url = metadataURL(for: recovery.url) + try JSONEncoder().encode(metadata).write(to: url, options: .atomic) + fileManager.restrictFileToOwnerOnly(at: url) + } + + private static func metadataURL(for audioURL: URL) -> URL { + audioURL.deletingPathExtension().appendingPathExtension("json") + } + + private static func wavData(samples16k: [Float]) -> Data { + let bytesPerSample: UInt16 = 2 + let channelCount: UInt16 = 1 + let dataByteCount = UInt32(samples16k.count) * UInt32(bytesPerSample) + var data = Data(capacity: 44 + Int(dataByteCount)) + + data.append(contentsOf: "RIFF".utf8) + append(UInt32(36) + dataByteCount, to: &data) + data.append(contentsOf: "WAVEfmt ".utf8) + append(UInt32(16), to: &data) + append(UInt16(1), to: &data) + append(channelCount, to: &data) + append(sampleRate, to: &data) + append(sampleRate * UInt32(channelCount) * UInt32(bytesPerSample), to: &data) + append(channelCount * bytesPerSample, to: &data) + append(UInt16(16), to: &data) + data.append(contentsOf: "data".utf8) + append(dataByteCount, to: &data) + + for sample in samples16k { + let finiteSample = sample.isFinite ? sample : 0 + let clamped = max(-1, min(1, finiteSample)) + let pcm = Int16((clamped * Float(Int16.max)).rounded()) + append(UInt16(bitPattern: pcm), to: &data) + } + return data + } + + private static func append(_ value: T, to data: inout Data) { + var littleEndian = value.littleEndian + withUnsafeBytes(of: &littleEndian) { bytes in + data.append(contentsOf: bytes) + } + } +} diff --git a/Sources/Speech/ParakeetEngine.swift b/Sources/Speech/ParakeetEngine.swift index 9679c9019..64bf11d8a 100644 --- a/Sources/Speech/ParakeetEngine.swift +++ b/Sources/Speech/ParakeetEngine.swift @@ -2324,6 +2324,31 @@ class ParakeetEngine: ObservableObject { return (nativeSampleCount, resampled) } + func snapshotRecordedSamplesForPersistence() async -> RecordedSpeechSamples? { + drainPendingSamplesIntoSampleBuffer() + + var segments = recoveredRecordingTimeline.segments + if !sampleBuffer.isEmpty { + segments.append(RecordedAudioSegment(sampleRate: safeNativeSampleRate(), samples: sampleBuffer)) + } + let nativeSampleCount = segments.reduce(0) { $0 + $1.samples.count } + guard nativeSampleCount > 0 else { return nil } + + let samples16k = await Task.detached(priority: .userInitiated) { + var combined: [Float] = [] + combined.reserveCapacity(nativeSampleCount) + for segment in segments { + combined.append(contentsOf: AudioResampler.resample( + segment.samples, + from: segment.sampleRate, + to: TranscriptedConstants.parakeetSampleRate + )) + } + return combined + }.value + return RecordedSpeechSamples(nativeSampleCount: nativeSampleCount, samples16k: samples16k) + } + /// Convert [Float] samples to AVAudioPCMBuffer for StreamingEouAsrManager. private func makePCMBuffer(from samples: [Float]) -> AVAudioPCMBuffer? { guard let format = eouPCMFormat, diff --git a/Sources/Speech/STTRouter.swift b/Sources/Speech/STTRouter.swift index 90c67fe12..f45a84aff 100644 --- a/Sources/Speech/STTRouter.swift +++ b/Sources/Speech/STTRouter.swift @@ -197,6 +197,10 @@ class STTRouter: ObservableObject { await parakeetEngine.stopRecording() } + func snapshotRecordedSamplesForPersistence() async -> RecordedSpeechSamples? { + await parakeetEngine.snapshotRecordedSamplesForPersistence() + } + func resetAfterFailedRecordingStart() async { activeRecordingModel = nil await parakeetEngine.resetAfterFailedRecordingStart() diff --git a/Sources/TranscriptedApp.swift b/Sources/TranscriptedApp.swift index 3f9344703..865906f0d 100644 --- a/Sources/TranscriptedApp.swift +++ b/Sources/TranscriptedApp.swift @@ -145,6 +145,7 @@ class TranscriptedAppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegat // Set up the floating overlay panel (pure AppKit — no NSHostingView) overlayController.setup(sttRouter: appState.sttRouter) + sessionController.presentPendingStoppedAudioRecoveryIfNeeded() // Meeting overlay + hotkey + speaker naming — Lane C wiring. if #available(macOS 14.0, *) { diff --git a/Sources/UI/Overlay/DictationSessionController.swift b/Sources/UI/Overlay/DictationSessionController.swift index 6678f15bb..5e85203fc 100644 --- a/Sources/UI/Overlay/DictationSessionController.swift +++ b/Sources/UI/Overlay/DictationSessionController.swift @@ -65,6 +65,7 @@ class DictationSessionController: ObservableObject { private var sessionStartTime: CFAbsoluteTime = 0 private var currentDictationTrigger: DictationTrigger = .unknown private var currentDictationSessionID = UUID() + private var stoppedAudioRecovery: DictationStoppedAudioRecovery? private var autoSendRequestDecision = DictationAutoSendRequestDecision.notEvaluated /// Max duration for a listening session before auto-cancel (5 minutes). @@ -97,6 +98,19 @@ class DictationSessionController: ObservableObject { } } + func presentPendingStoppedAudioRecoveryIfNeeded() { + guard !isDictating, + let overlayController, + let recovery = DictationStoppedAudioRecoveryStore.pendingRecoveries(limit: 1).first else { return } + overlayController.showError( + "A stopped dictation recording is available. Use Import Audio from Home to recover its transcript.", + actionTitle: "Show Audio", + action: { + NSWorkspace.shared.activateFileViewerSelecting([recovery.url]) + } + ) + } + // MARK: - Dictation Mode (Option+Space) /// Start dictation — show overlay and begin voice recording (no screenshot/vision) @@ -118,6 +132,7 @@ class DictationSessionController: ObservableObject { } isDictating = true currentDictationSessionID = UUID() + stoppedAudioRecovery = nil sessionSourceApp = sourceApp sessionPasteTarget = DictationPasteTarget.capture(sourceApp: sourceApp) sessionAnchorRect = anchorRect @@ -918,6 +933,27 @@ class DictationSessionController: ObservableObject { self.isDictating, self.currentDictationSessionID == taskSessionID else { return } + do { + if let recording = await appState.sttRouter.snapshotRecordedSamplesForPersistence() { + self.stoppedAudioRecovery = try DictationStoppedAudioRecoveryStore.persist( + samples16k: recording.samples16k, + sessionID: taskSessionID + ) + } + } catch { + appState.logger.log("DICTATION | failed to preserve stopped audio: \(error.localizedDescription)") + EventReporter.shared.capture( + level: .error, + engine: "dictation", + event: "dictation_stopped_audio_persistence_failed", + message: error.localizedDescription + ) + overlayController.showError("The recording stopped, but its audio couldn't be saved safely. Free some disk space and try again.") + self.isDictating = false + appState.runtimeDiagnostics.clearSession(kind: "dictation", outcome: "audio_persistence_failed") + return + } + // Surface model warmup honestly instead of calling it "Transcribing" // before the local dictation model is actually ready. if !appState.sttRouter.isModelLoaded { @@ -1035,6 +1071,9 @@ class DictationSessionController: ObservableObject { overlayController.showNoSpeechAndDismiss(trigger: currentDictationTrigger.rawValue, reason: emptyReason) isDictating = false appState.runtimeDiagnostics.clearSession(kind: "dictation", outcome: emptyReason.runtimeOutcome) + if emptyReason != .modelFailure { + self.discardStoppedAudioRecovery(explicitDiscard: true) + } return } @@ -1097,6 +1136,7 @@ class DictationSessionController: ObservableObject { ) let autoSendOutcome = finalization.autoEnterOutcome let saveResult = finalization.saveResult + self.discardStoppedAudioRecovery(transcriptPersisted: saveResult.saved != nil) let saveFailureMessage = saveResult.failureMessage let wordCount = text.split(whereSeparator: \.isWhitespace).count stopTiming.completedAt = CFAbsoluteTimeGetCurrent() @@ -1237,6 +1277,7 @@ class DictationSessionController: ObservableObject { ) { lastCompletedText = text let saveResult = persistDictationTranscript(text: text, delivery: .savedWithoutPaste) + discardStoppedAudioRecovery(transcriptPersisted: saveResult.saved != nil) let saveFailureMessage = saveResult.failureMessage let wordCount = text.split(whereSeparator: \.isWhitespace).count let durationSeconds = CFAbsoluteTimeGetCurrent() - sessionStartTime @@ -1314,9 +1355,12 @@ class DictationSessionController: ObservableObject { } /// Cancel dictation without pasting - func cancelDictation() { + func cancelDictation(preserveStoppedAudio: Bool = false) { guard let (appState, overlayController) = readyState() else { return } cancelActiveTasks(cancelRecording: true) + if !preserveStoppedAudio { + discardStoppedAudioRecovery(explicitDiscard: true) + } AppSoundPlayer.shared.play(.dictationCancelled) overlayController.hideWithCancelAnimation() isDictating = false @@ -1368,7 +1412,7 @@ class DictationSessionController: ObservableObject { } if isDictating { - cancelDictation() + cancelDictation(preserveStoppedAudio: true) } } @@ -1885,6 +1929,18 @@ class DictationSessionController: ObservableObject { } } + private func discardStoppedAudioRecovery( + transcriptPersisted: Bool = false, + explicitDiscard: Bool = false + ) { + guard DictationStoppedAudioRecoveryStore.cleanup( + stoppedAudioRecovery, + transcriptPersisted: transcriptPersisted, + explicitDiscard: explicitDiscard + ) else { return } + stoppedAudioRecovery = nil + } + private func startPersistingDictationTranscript( text: String, delivery: DictationDelivery diff --git a/Tests/DictationStoppedAudioRecoveryTests.swift b/Tests/DictationStoppedAudioRecoveryTests.swift new file mode 100644 index 000000000..8d2305854 --- /dev/null +++ b/Tests/DictationStoppedAudioRecoveryTests.swift @@ -0,0 +1,124 @@ +import Foundation + +func testDictationStoppedAudioRecovery() { + runSuite("Dictation stopped audio recovery writes a valid private WAV") { + let directory = makeRecoveryTestDirectory("wav") + defer { try? FileManager.default.removeItem(at: directory) } + let sessionID = UUID(uuidString: "00000000-0000-0000-0000-000000000123")! + + do { + let recovery = try DictationStoppedAudioRecoveryStore.persist( + samples16k: [-1, -0.5, 0, 0.5, 1], + sessionID: sessionID, + directory: directory + ) + assertNotNil(recovery, "non-empty stopped audio should be checkpointed") + guard let recovery else { return } + let data = try Data(contentsOf: recovery.url) + assertEqual(String(data: data[0..<4], encoding: .ascii), "RIFF", "recovery audio should use a WAV container") + assertEqual(String(data: data[8..<12], encoding: .ascii), "WAVE", "recovery audio should identify the WAV format") + assertEqual(readUInt32LE(data, offset: 24), 16_000, "recovery audio should be stored at the inference sample rate") + assertEqual(readUInt16LE(data, offset: 34), 16, "recovery audio should use 16-bit PCM") + assertEqual(readUInt32LE(data, offset: 40), 10, "WAV data length should match the sample count") + let attributes = try FileManager.default.attributesOfItem(atPath: recovery.url.path) + let permissions = (attributes[.posixPermissions] as? NSNumber)?.intValue + assertEqual(permissions, 0o600, "recovery audio should be owner-only") + let discovered = DictationStoppedAudioRecoveryStore.pendingRecoveries(limit: 1, directory: directory) + assertEqual(discovered, [recovery], "durable metadata should make recovery discoverable after store recreation") + } catch { + assertTrue(false, "recovery WAV should persist: \(error)") + } + } + + runSuite("Dictation stopped audio recovery survives failed transcript persistence") { + let directory = makeRecoveryTestDirectory("retain") + defer { try? FileManager.default.removeItem(at: directory) } + do { + let recovery = try DictationStoppedAudioRecoveryStore.persist( + samples16k: [0.25, -0.25], + sessionID: UUID(), + directory: directory + ) + let cleaned = DictationStoppedAudioRecoveryStore.cleanup( + recovery, + transcriptPersisted: false, + explicitDiscard: false + ) + assertFalse(cleaned, "failed transcript persistence must not clean recovery audio") + assertTrue(FileManager.default.fileExists(atPath: recovery!.url.path), "recovery audio must remain durable") + } catch { + assertTrue(false, "recovery audio should persist: \(error)") + } + } + + runSuite("Dictation stopped audio recovery cleans up only after success or explicit discard") { + for transcriptPersisted in [true, false] { + let directory = makeRecoveryTestDirectory(transcriptPersisted ? "saved" : "discarded") + defer { try? FileManager.default.removeItem(at: directory) } + do { + let recovery = try DictationStoppedAudioRecoveryStore.persist( + samples16k: [0.1], + sessionID: UUID(), + directory: directory + ) + let cleaned = DictationStoppedAudioRecoveryStore.cleanup( + recovery, + transcriptPersisted: transcriptPersisted, + explicitDiscard: !transcriptPersisted + ) + assertTrue(cleaned, "successful save or explicit discard should clean recovery audio") + assertFalse(FileManager.default.fileExists(atPath: recovery!.url.path), "cleaned recovery audio should be deleted") + assertTrue( + DictationStoppedAudioRecoveryStore.pendingRecoveries(directory: directory).isEmpty, + "cleanup should remove restart-discovery metadata" + ) + } catch { + assertTrue(false, "recovery cleanup should succeed: \(error)") + } + } + } + + runSuite("Dictation controller checkpoints audio before waiting for the model") { + do { + let source = try String( + contentsOf: repoFixtureURL("Sources/UI/Overlay/DictationSessionController.swift"), + encoding: .utf8 + ) + guard let persistRange = source.range(of: "DictationStoppedAudioRecoveryStore.persist("), + let modelWaitRange = source.range(of: "if !appState.sttRouter.isModelLoaded", range: persistRange.upperBound.. URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("DictationStoppedAudioRecoveryTests-\(suffix)-\(UUID().uuidString)", isDirectory: true) +} + +private func readUInt16LE(_ data: Data, offset: Int) -> UInt16 { + UInt16(data[offset]) | (UInt16(data[offset + 1]) << 8) +} + +private func readUInt32LE(_ data: Data, offset: Int) -> UInt32 { + UInt32(data[offset]) + | (UInt32(data[offset + 1]) << 8) + | (UInt32(data[offset + 2]) << 16) + | (UInt32(data[offset + 3]) << 24) +} diff --git a/Tests/FastTests.manifest b/Tests/FastTests.manifest index 352bf0fca..143be5317 100644 --- a/Tests/FastTests.manifest +++ b/Tests/FastTests.manifest @@ -94,6 +94,7 @@ DictationMicrophoneLoadingPresentationPolicyTests.swift:testDictationMicrophoneL DictationWarmupPresentationPolicyTests.swift:testDictationWarmupPresentationPolicy DictationRecordingStartOverlayPolicyTests.swift:testDictationRecordingStartOverlayPolicy DictationSessionTimeoutTests.swift:testDictationSessionTimeout +DictationStoppedAudioRecoveryTests.swift:testDictationStoppedAudioRecovery DictationInputDeviceSelectionPolicyTests.swift:testDictationInputDeviceSelectionPolicy DictationReadinessWaitPolicyTests.swift:testDictationReadinessWaitPolicy MicRecordingMergePlanTests.swift:testMicRecordingMergePlan diff --git a/scripts/entrypoints/run-tests.sh b/scripts/entrypoints/run-tests.sh index bf4038245..fb3a7069e 100755 --- a/scripts/entrypoints/run-tests.sh +++ b/scripts/entrypoints/run-tests.sh @@ -302,6 +302,7 @@ APP_SOURCES=( "Sources/Support/ClipboardRestoringTextPaster.swift" "Sources/Accessibility/AccessibilityBridge.swift" "Sources/Dictation/DictationSessionTimeout.swift" + "Sources/Dictation/DictationStoppedAudioRecovery.swift" "Sources/Dictation/DictationStoragePaths.swift" "Sources/Dictation/DictationStopFinalizationPolicy.swift" "Sources/Dictation/DictationTranscriptWriter.swift" From 3dfdf0bfbca1f24e9eb9ba0ea0dae06ab891da72 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Fri, 17 Jul 2026 06:23:16 -0500 Subject: [PATCH 2/7] Guard stopped audio recovery handoff --- .../DictationStoppedAudioRecovery.swift | 26 +++++++- .../Overlay/DictationSessionController.swift | 6 ++ .../DictationStoppedAudioRecoveryTests.swift | 62 +++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/Sources/Dictation/DictationStoppedAudioRecovery.swift b/Sources/Dictation/DictationStoppedAudioRecovery.swift index a20eeb02d..2b183cabd 100644 --- a/Sources/Dictation/DictationStoppedAudioRecovery.swift +++ b/Sources/Dictation/DictationStoppedAudioRecovery.swift @@ -9,6 +9,17 @@ struct DictationStoppedAudioRecovery: Equatable, Sendable { let createdAt: Date } +enum DictationStoppedAudioRecoveryCommitPolicy { + static func shouldPersist( + taskCancelled: Bool, + isDictating: Bool, + taskSessionID: UUID, + currentSessionID: UUID + ) -> Bool { + !taskCancelled && isDictating && taskSessionID == currentSessionID + } +} + enum DictationStoppedAudioRecoveryStore { static let sampleRate: UInt32 = 16_000 @@ -72,9 +83,20 @@ enum DictationStoppedAudioRecoveryStore { sessionID: metadata.sessionID, createdAt: metadata.createdAt )) - if recoveries.count >= limit { break } } - return recoveries.sorted { $0.createdAt > $1.createdAt } + return mostRecent(recoveries, limit: limit) + } + + static func mostRecent( + _ recoveries: [DictationStoppedAudioRecovery], + limit: Int + ) -> [DictationStoppedAudioRecovery] { + guard limit > 0 else { return [] } + return Array( + recoveries + .sorted { $0.createdAt > $1.createdAt } + .prefix(limit) + ) } @discardableResult diff --git a/Sources/UI/Overlay/DictationSessionController.swift b/Sources/UI/Overlay/DictationSessionController.swift index 5e85203fc..91032eb7d 100644 --- a/Sources/UI/Overlay/DictationSessionController.swift +++ b/Sources/UI/Overlay/DictationSessionController.swift @@ -935,6 +935,12 @@ class DictationSessionController: ObservableObject { do { if let recording = await appState.sttRouter.snapshotRecordedSamplesForPersistence() { + guard DictationStoppedAudioRecoveryCommitPolicy.shouldPersist( + taskCancelled: Task.isCancelled, + isDictating: self.isDictating, + taskSessionID: taskSessionID, + currentSessionID: self.currentDictationSessionID + ) else { return } self.stoppedAudioRecovery = try DictationStoppedAudioRecoveryStore.persist( samples16k: recording.samples16k, sessionID: taskSessionID diff --git a/Tests/DictationStoppedAudioRecoveryTests.swift b/Tests/DictationStoppedAudioRecoveryTests.swift index 8d2305854..6138c4a4a 100644 --- a/Tests/DictationStoppedAudioRecoveryTests.swift +++ b/Tests/DictationStoppedAudioRecoveryTests.swift @@ -78,6 +78,56 @@ func testDictationStoppedAudioRecovery() { } } + runSuite("Dictation stopped audio recovery limits after newest-first ordering") { + let oldSessionID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + let middleSessionID = UUID(uuidString: "00000000-0000-0000-0000-000000000002")! + let newSessionID = UUID(uuidString: "00000000-0000-0000-0000-000000000003")! + let recoveries = [ + DictationStoppedAudioRecovery(url: URL(fileURLWithPath: "/old.wav"), sessionID: oldSessionID, createdAt: Date(timeIntervalSince1970: 1)), + DictationStoppedAudioRecovery(url: URL(fileURLWithPath: "/new.wav"), sessionID: newSessionID, createdAt: Date(timeIntervalSince1970: 3)), + DictationStoppedAudioRecovery(url: URL(fileURLWithPath: "/middle.wav"), sessionID: middleSessionID, createdAt: Date(timeIntervalSince1970: 2)) + ] + + let limited = DictationStoppedAudioRecoveryStore.mostRecent(recoveries, limit: 2) + + assertEqual( + limited.map(\.sessionID), + [newSessionID, middleSessionID], + "the limit must select the newest recoveries regardless of enumeration order" + ) + } + + runSuite("Stopped audio persistence rejects cancelled and superseded sessions") { + let activeSessionID = UUID() + assertTrue( + DictationStoppedAudioRecoveryCommitPolicy.shouldPersist( + taskCancelled: false, + isDictating: true, + taskSessionID: activeSessionID, + currentSessionID: activeSessionID + ), + "the current live stop task should persist its recovery checkpoint" + ) + assertFalse( + DictationStoppedAudioRecoveryCommitPolicy.shouldPersist( + taskCancelled: true, + isDictating: true, + taskSessionID: activeSessionID, + currentSessionID: activeSessionID + ), + "a cancelled stop task must not persist after detached resampling returns" + ) + assertFalse( + DictationStoppedAudioRecoveryCommitPolicy.shouldPersist( + taskCancelled: false, + isDictating: true, + taskSessionID: activeSessionID, + currentSessionID: UUID() + ), + "an old stop task must not mutate a successor session" + ) + } + runSuite("Dictation controller checkpoints audio before waiting for the model") { do { let source = try String( @@ -90,6 +140,18 @@ func testDictationStoppedAudioRecovery() { return } assertTrue(persistRange.lowerBound < modelWaitRange.lowerBound, "durable checkpoint must precede the model failure boundary") + guard let snapshotRange = source.range(of: "snapshotRecordedSamplesForPersistence()"), + let commitGuardRange = source.range( + of: "DictationStoppedAudioRecoveryCommitPolicy.shouldPersist(", + range: snapshotRange.upperBound.. Date: Fri, 17 Jul 2026 06:31:20 -0500 Subject: [PATCH 3/7] Keep stopped audio work off main actor --- Sources/Speech/ParakeetEngine.swift | 33 ++++++++++++++--- Sources/Speech/STTRouter.swift | 18 ++++++--- .../Overlay/DictationSessionController.swift | 37 ++++++++++++++++--- .../DictationStoppedAudioRecoveryTests.swift | 16 ++++++++ 4 files changed, 88 insertions(+), 16 deletions(-) diff --git a/Sources/Speech/ParakeetEngine.swift b/Sources/Speech/ParakeetEngine.swift index 64bf11d8a..06b643d13 100644 --- a/Sources/Speech/ParakeetEngine.swift +++ b/Sources/Speech/ParakeetEngine.swift @@ -2324,6 +2324,24 @@ class ParakeetEngine: ObservableObject { return (nativeSampleCount, resampled) } + private func consumeRecordedSamples( + preparedRecording: RecordedSpeechSamples? + ) async -> (nativeSampleCount: Int, samples16k: [Float])? { + guard let preparedRecording else { + return await drainRecordedSamplesForInference() + } + + // The persistence snapshot already resampled this exact stopped + // recording. Consume the native buffers without repeating that work. + drainPendingSamplesIntoSampleBuffer() + sampleBuffer.removeAll(keepingCapacity: true) + clearRecoveredRecordingTimeline(keepingCapacity: true) + return ( + nativeSampleCount: preparedRecording.nativeSampleCount, + samples16k: preparedRecording.samples16k + ) + } + func snapshotRecordedSamplesForPersistence() async -> RecordedSpeechSamples? { drainPendingSamplesIntoSampleBuffer() @@ -2366,7 +2384,10 @@ class ParakeetEngine: ObservableObject { // MARK: - Transcription - func drainRecordedSamplesForExternalTranscription(engineName: String) async -> RecordedSpeechSamples? { + func drainRecordedSamplesForExternalTranscription( + engineName: String, + preparedRecording: RecordedSpeechSamples? = nil + ) async -> RecordedSpeechSamples? { lastEmptyTranscriptionReason = nil guard !isTranscribing else { EventReporter.shared.capture( @@ -2380,7 +2401,7 @@ class ParakeetEngine: ObservableObject { drainPendingSamplesIntoSampleBuffer() - guard !sampleBuffer.isEmpty || !recoveredRecordingTimeline.isEmpty else { + guard preparedRecording != nil || !sampleBuffer.isEmpty || !recoveredRecordingTimeline.isEmpty else { lastEmptyTranscriptionReason = .recordingTooShort EventReporter.shared.capture( level: .warning, @@ -2392,7 +2413,7 @@ class ParakeetEngine: ObservableObject { } isTranscribing = true - guard let recorded = await drainRecordedSamplesForInference() else { + guard let recorded = await consumeRecordedSamples(preparedRecording: preparedRecording) else { finishExternalTranscription() return nil } @@ -2506,7 +2527,7 @@ class ParakeetEngine: ObservableObject { } } - func transcribe() async -> String? { + func transcribe(preparedRecording: RecordedSpeechSamples? = nil) async -> String? { lastEmptyTranscriptionReason = nil guard !isTranscribing else { EventReporter.shared.capture(level: .warning, engine: "parakeet", event: "transcription_already_active", @@ -2514,7 +2535,7 @@ class ParakeetEngine: ObservableObject { return nil } drainPendingSamplesIntoSampleBuffer() - guard !sampleBuffer.isEmpty || !recoveredRecordingTimeline.isEmpty else { + guard preparedRecording != nil || !sampleBuffer.isEmpty || !recoveredRecordingTimeline.isEmpty else { lastEmptyTranscriptionReason = .recordingTooShort EventReporter.shared.capture(level: .warning, engine: "parakeet", event: "no_audio_samples", message: "No audio samples in buffer when transcribe() called") @@ -2530,7 +2551,7 @@ class ParakeetEngine: ObservableObject { isTranscribing = true let startTime = CFAbsoluteTimeGetCurrent() - guard let recorded = await drainRecordedSamplesForInference() else { + guard let recorded = await consumeRecordedSamples(preparedRecording: preparedRecording) else { finishTranscription() return nil } diff --git a/Sources/Speech/STTRouter.swift b/Sources/Speech/STTRouter.swift index f45a84aff..f80308f79 100644 --- a/Sources/Speech/STTRouter.swift +++ b/Sources/Speech/STTRouter.swift @@ -211,7 +211,7 @@ class STTRouter: ObservableObject { parakeetEngine.abandonBlockedRecordingStart(reason: reason) } - func transcribe() async -> String? { + func transcribe(preparedRecording: RecordedSpeechSamples? = nil) async -> String? { let model = activeRecordingModel ?? selectedModel lastEmptyTranscriptionReason = nil defer { @@ -220,11 +220,14 @@ class STTRouter: ObservableObject { switch model { case .parakeetTDTv3: - let text = await parakeetEngine.transcribe() + let text = await parakeetEngine.transcribe(preparedRecording: preparedRecording) lastEmptyTranscriptionReason = text == nil ? parakeetEngine.lastEmptyTranscriptionReason : nil return text case .whisperLargeV3Turbo, .whisperLargeV3: - return await transcribeUsingExternalEngine(model: model) { [self] recording in + return await transcribeUsingExternalEngine( + model: model, + preparedRecording: preparedRecording + ) { [self] recording in try await whisperEngine.transcribeSamples( recording.samples16k, source: .microphone, @@ -232,7 +235,10 @@ class STTRouter: ObservableObject { ) } case .nemotronStreaming: - return await transcribeUsingExternalEngine(model: model) { [self] recording in + return await transcribeUsingExternalEngine( + model: model, + preparedRecording: preparedRecording + ) { [self] recording in try await nemotronEngine.transcribeSamples( recording.samples16k, source: .microphone @@ -246,6 +252,7 @@ class STTRouter: ObservableObject { /// Parakeet samples rather than owning the audio graph themselves. private func transcribeUsingExternalEngine( model: TranscriptionModelChoice, + preparedRecording: RecordedSpeechSamples?, transcribe: (RecordedSpeechSamples) async throws -> String ) async -> String? { await initialize(model: model) @@ -261,7 +268,8 @@ class STTRouter: ObservableObject { } guard let recording = await parakeetEngine.drainRecordedSamplesForExternalTranscription( - engineName: model.engineName + engineName: model.engineName, + preparedRecording: preparedRecording ) else { lastEmptyTranscriptionReason = parakeetEngine.lastEmptyTranscriptionReason return nil diff --git a/Sources/UI/Overlay/DictationSessionController.swift b/Sources/UI/Overlay/DictationSessionController.swift index 91032eb7d..cfa39044e 100644 --- a/Sources/UI/Overlay/DictationSessionController.swift +++ b/Sources/UI/Overlay/DictationSessionController.swift @@ -924,6 +924,7 @@ class DictationSessionController: ObservableObject { let taskSessionID = currentDictationSessionID streamingTask = Task { var stopTiming = DictationStopTiming(requestedAt: stopRequestedAt) + var stoppedRecordingSnapshot: RecordedSpeechSamples? appState.runtimeDiagnostics.recordSession(kind: "dictation", stage: "stop_requested") if appState.sttRouter.isRecording || appState.sttRouter.hasRecoverableRecording { await appState.sttRouter.stopRecording() @@ -941,12 +942,36 @@ class DictationSessionController: ObservableObject { taskSessionID: taskSessionID, currentSessionID: self.currentDictationSessionID ) else { return } - self.stoppedAudioRecovery = try DictationStoppedAudioRecoveryStore.persist( - samples16k: recording.samples16k, - sessionID: taskSessionID - ) + let recovery = try await Task.detached(priority: .userInitiated) { + try DictationStoppedAudioRecoveryStore.persist( + samples16k: recording.samples16k, + sessionID: taskSessionID + ) + }.value + guard DictationStoppedAudioRecoveryCommitPolicy.shouldPersist( + taskCancelled: Task.isCancelled, + isDictating: self.isDictating, + taskSessionID: taskSessionID, + currentSessionID: self.currentDictationSessionID + ) else { + await Task.detached(priority: .utility) { + DictationStoppedAudioRecoveryStore.cleanup( + recovery, + explicitDiscard: true + ) + }.value + return + } + self.stoppedAudioRecovery = recovery + stoppedRecordingSnapshot = recording } } catch { + guard DictationStoppedAudioRecoveryCommitPolicy.shouldPersist( + taskCancelled: Task.isCancelled, + isDictating: self.isDictating, + taskSessionID: taskSessionID, + currentSessionID: self.currentDictationSessionID + ) else { return } appState.logger.log("DICTATION | failed to preserve stopped audio: \(error.localizedDescription)") EventReporter.shared.capture( level: .error, @@ -1021,7 +1046,9 @@ class DictationSessionController: ObservableObject { overlayController.resizePanelToCompact() appState.runtimeDiagnostics.recordSession(kind: "dictation", stage: "transcribing") stopTiming.transcriptionStartedAt = CFAbsoluteTimeGetCurrent() - let voiceText = await appState.sttRouter.transcribe() + let voiceText = await appState.sttRouter.transcribe( + preparedRecording: stoppedRecordingSnapshot + ) stopTiming.transcribedAt = CFAbsoluteTimeGetCurrent() guard !Task.isCancelled, self.isDictating, diff --git a/Tests/DictationStoppedAudioRecoveryTests.swift b/Tests/DictationStoppedAudioRecoveryTests.swift index 6138c4a4a..08203a209 100644 --- a/Tests/DictationStoppedAudioRecoveryTests.swift +++ b/Tests/DictationStoppedAudioRecoveryTests.swift @@ -152,6 +152,22 @@ func testDictationStoppedAudioRecovery() { snapshotRange.lowerBound < commitGuardRange.lowerBound && commitGuardRange.lowerBound < persistRange.lowerBound, "cancel/session guard must run after the detached snapshot and before recovery persistence" ) + assertTrue( + source.contains("let recovery = try await Task.detached(priority: .userInitiated)"), + "WAV encoding and disk writes must stay off the main actor" + ) + assertTrue( + source.contains("preparedRecording: stoppedRecordingSnapshot"), + "transcription should reuse the already-resampled stopped recording snapshot" + ) + let speechSource = try String( + contentsOf: repoFixtureURL("Sources/Speech/ParakeetEngine.swift"), + encoding: .utf8 + ) + assertTrue( + speechSource.contains("consumeRecordedSamples(preparedRecording: preparedRecording)"), + "speech inference should consume the prepared snapshot instead of resampling native buffers again" + ) assertTrue(source.contains("transcriptPersisted: saveResult.saved != nil"), "cleanup should be tied to successful transcript persistence") assertTrue(source.contains("if emptyReason != .modelFailure"), "model failures should retain recovery audio") assertTrue( From bb0010b134971bfced794f178e50a5888d1dc36a Mon Sep 17 00:00:00 2001 From: r3dbars Date: Fri, 17 Jul 2026 06:38:38 -0500 Subject: [PATCH 4/7] Retain recovery through imported audio failures --- .../Meeting/MeetingSessionController.swift | 21 +++++++++++++++++-- .../TranscriptionQueueCoordinator.swift | 7 ++++++- Sources/Speech/STTRouter.swift | 2 ++ .../DictationStoppedAudioRecoveryTests.swift | 16 ++++++++++++++ 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/Sources/Meeting/MeetingSessionController.swift b/Sources/Meeting/MeetingSessionController.swift index 342f5318d..dbda0bfa6 100644 --- a/Sources/Meeting/MeetingSessionController.swift +++ b/Sources/Meeting/MeetingSessionController.swift @@ -205,6 +205,7 @@ final class MeetingSessionController: ObservableObject { var liveCodexFinalTranscriptNeedsQueuedJobID = false var liveCodexAwaitedTranscriptionJobID: UUID? var activeQueuedTranscriptionJobID: UUID? + var activeStoppedAudioRecovery: DictationStoppedAudioRecovery? var shouldConfirmQuitForActiveCapture: Bool { isCaptureSessionActive || isFinishingRecording @@ -1458,11 +1459,15 @@ final class MeetingSessionController: ObservableObject { return false } + let stoppedAudioRecovery = DictationStoppedAudioRecoveryStore + .pendingRecoveries(limit: Int.max) + .first { $0.url.standardizedFileURL == sourceURL.standardizedFileURL } let outcome = transcriptionQueue.enqueueImportedAudioJob( audioURL: preparedAudio.copiedAudioURL, suggestedTitle: preparedAudio.suggestedTitle, recordingDate: preparedAudio.recordingDate, - startTrigger: .fileImport + startTrigger: .fileImport, + stoppedAudioRecovery: stoppedAudioRecovery ) DiagnosticsTrail.record( @@ -1546,7 +1551,8 @@ final class MeetingSessionController: ObservableObject { taskManager.cancelAll() if liveCodexSessionAwaitingFinalTranscript { finishLiveCodexSession(status: .failed, shouldAwaitFinalTranscript: false) - activeQueuedTranscriptionJobID = nil + activeQueuedTranscriptionJobID = nil + activeStoppedAudioRecovery = nil } state = .ready DiagnosticsTrail.record( @@ -2596,6 +2602,15 @@ final class MeetingSessionController: ObservableObject { switch status { case .transcriptSaved: lastTerminalTranscriptionOutcome = .transcriptSaved + if let stoppedAudioRecovery = activeStoppedAudioRecovery { + activeStoppedAudioRecovery = nil + Task.detached(priority: .utility) { + DictationStoppedAudioRecoveryStore.cleanup( + stoppedAudioRecovery, + transcriptPersisted: true + ) + } + } let transcriptionTrigger = activeTranscriptionTrigger let promptTelemetryProperties = activeDetectedPromptTranscriptionTelemetryProperties let promptRecordingStartedAt = activeDetectedPromptTranscriptionRecordingStartedAt @@ -2625,6 +2640,8 @@ final class MeetingSessionController: ObservableObject { activeTranscriptionCaptureDiagnostics = nil case .failed(let message): lastTerminalTranscriptionOutcome = .failed(message) + // A failed import must retain its original stopped-audio checkpoint. + activeStoppedAudioRecovery = nil let transcriptionTrigger = activeTranscriptionTrigger let diagnosticMessage = taskManager.lastFailureDiagnosticMessage ?? message let failureKind = MeetingFailureKind.classify(message: diagnosticMessage) diff --git a/Sources/Meeting/TranscriptionQueueCoordinator.swift b/Sources/Meeting/TranscriptionQueueCoordinator.swift index 0675c5e57..c7e8dd0c3 100644 --- a/Sources/Meeting/TranscriptionQueueCoordinator.swift +++ b/Sources/Meeting/TranscriptionQueueCoordinator.swift @@ -40,6 +40,7 @@ final class TranscriptionQueueCoordinator { let kind: Kind let startTrigger: MeetingSessionController.StartTrigger let sttModel: TranscriptionModelChoice + let stoppedAudioRecovery: DictationStoppedAudioRecovery? let promptTelemetryProperties: [String: String]? let promptRecordingStartedAt: Date? @@ -120,6 +121,7 @@ final class TranscriptionQueueCoordinator { ), startTrigger: startTrigger, sttModel: controller.sttRouter.selectedModel, + stoppedAudioRecovery: nil, promptTelemetryProperties: promptTelemetryProperties, promptRecordingStartedAt: promptRecordingStartedAt ) @@ -135,7 +137,8 @@ final class TranscriptionQueueCoordinator { audioURL: URL, suggestedTitle: String, recordingDate: Date, - startTrigger: MeetingSessionController.StartTrigger + startTrigger: MeetingSessionController.StartTrigger, + stoppedAudioRecovery: DictationStoppedAudioRecovery? = nil ) -> QueueInsertionOutcome { let job = QueuedTranscriptionJob( kind: .imported( @@ -145,6 +148,7 @@ final class TranscriptionQueueCoordinator { ), startTrigger: startTrigger, sttModel: controller.sttRouter.selectedModel, + stoppedAudioRecovery: stoppedAudioRecovery, promptTelemetryProperties: nil, promptRecordingStartedAt: nil ) @@ -298,6 +302,7 @@ final class TranscriptionQueueCoordinator { controller.sttAdapter.selectPreparedModel(job.sttModel) queuedRuntimeDiagnosticsJobIDs.remove(job.id) controller.activeQueuedTranscriptionJobID = job.id + controller.activeStoppedAudioRecovery = job.stoppedAudioRecovery switch job.kind { case .recorded(let micURL, let systemURL, let healthInfo, _, let meetingTitle, let recordingDate): diff --git a/Sources/Speech/STTRouter.swift b/Sources/Speech/STTRouter.swift index f80308f79..f892ead69 100644 --- a/Sources/Speech/STTRouter.swift +++ b/Sources/Speech/STTRouter.swift @@ -257,6 +257,7 @@ class STTRouter: ObservableObject { ) async -> String? { await initialize(model: model) guard isModelLoaded(for: model) else { + lastEmptyTranscriptionReason = .modelFailure EventReporter.shared.capture( level: .error, engine: model.engineName, @@ -287,6 +288,7 @@ class STTRouter: ObservableObject { } return text } catch { + lastEmptyTranscriptionReason = .modelFailure EventReporter.shared.capture( level: .error, engine: model.engineName, diff --git a/Tests/DictationStoppedAudioRecoveryTests.swift b/Tests/DictationStoppedAudioRecoveryTests.swift index 08203a209..c8d0c312a 100644 --- a/Tests/DictationStoppedAudioRecoveryTests.swift +++ b/Tests/DictationStoppedAudioRecoveryTests.swift @@ -168,6 +168,22 @@ func testDictationStoppedAudioRecovery() { speechSource.contains("consumeRecordedSamples(preparedRecording: preparedRecording)"), "speech inference should consume the prepared snapshot instead of resampling native buffers again" ) + let routerSource = try String( + contentsOf: repoFixtureURL("Sources/Speech/STTRouter.swift"), + encoding: .utf8 + ) + assertTrue( + routerSource.contains("lastEmptyTranscriptionReason = .modelFailure"), + "external model failures must retain stopped-audio recovery instead of looking like no speech" + ) + let meetingSource = try String( + contentsOf: repoFixtureURL("Sources/Meeting/MeetingSessionController.swift"), + encoding: .utf8 + ) + assertTrue( + meetingSource.contains("transcriptPersisted: true"), + "a successfully imported restart checkpoint should be retired after its transcript is saved" + ) assertTrue(source.contains("transcriptPersisted: saveResult.saved != nil"), "cleanup should be tied to successful transcript persistence") assertTrue(source.contains("if emptyReason != .modelFailure"), "model failures should retain recovery audio") assertTrue( From adc66dbd8f7ec857ae026041a2476fa2913be88f Mon Sep 17 00:00:00 2001 From: r3dbars Date: Fri, 17 Jul 2026 06:40:01 -0500 Subject: [PATCH 5/7] Clear recovery ownership on import cancel --- Sources/Meeting/MeetingSessionController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Meeting/MeetingSessionController.swift b/Sources/Meeting/MeetingSessionController.swift index dbda0bfa6..e0d66aae1 100644 --- a/Sources/Meeting/MeetingSessionController.swift +++ b/Sources/Meeting/MeetingSessionController.swift @@ -1551,9 +1551,9 @@ final class MeetingSessionController: ObservableObject { taskManager.cancelAll() if liveCodexSessionAwaitingFinalTranscript { finishLiveCodexSession(status: .failed, shouldAwaitFinalTranscript: false) + } activeQueuedTranscriptionJobID = nil activeStoppedAudioRecovery = nil - } state = .ready DiagnosticsTrail.record( level: .warning, From 1ddbb4df57052753cdb061a83200a5a032d1594e Mon Sep 17 00:00:00 2001 From: r3dbars Date: Fri, 17 Jul 2026 06:44:14 -0500 Subject: [PATCH 6/7] Retain quit-time audio checkpoints --- .../DictationStoppedAudioRecovery.swift | 7 ++++++ .../Overlay/DictationSessionController.swift | 22 ++++++++++++++----- .../DictationStoppedAudioRecoveryTests.swift | 19 ++++++++++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/Sources/Dictation/DictationStoppedAudioRecovery.swift b/Sources/Dictation/DictationStoppedAudioRecovery.swift index 2b183cabd..062fca195 100644 --- a/Sources/Dictation/DictationStoppedAudioRecovery.swift +++ b/Sources/Dictation/DictationStoppedAudioRecovery.swift @@ -18,6 +18,13 @@ enum DictationStoppedAudioRecoveryCommitPolicy { ) -> Bool { !taskCancelled && isDictating && taskSessionID == currentSessionID } + + static func shouldRetainPersistedRecovery( + taskSessionID: UUID, + preservationSessionID: UUID? + ) -> Bool { + taskSessionID == preservationSessionID + } } enum DictationStoppedAudioRecoveryStore { diff --git a/Sources/UI/Overlay/DictationSessionController.swift b/Sources/UI/Overlay/DictationSessionController.swift index cfa39044e..cd01f5c6e 100644 --- a/Sources/UI/Overlay/DictationSessionController.swift +++ b/Sources/UI/Overlay/DictationSessionController.swift @@ -66,6 +66,7 @@ class DictationSessionController: ObservableObject { private var currentDictationTrigger: DictationTrigger = .unknown private var currentDictationSessionID = UUID() private var stoppedAudioRecovery: DictationStoppedAudioRecovery? + private var stoppedAudioRecoveryPreservationSessionID: UUID? private var autoSendRequestDecision = DictationAutoSendRequestDecision.notEvaluated /// Max duration for a listening session before auto-cancel (5 minutes). @@ -133,6 +134,7 @@ class DictationSessionController: ObservableObject { isDictating = true currentDictationSessionID = UUID() stoppedAudioRecovery = nil + stoppedAudioRecoveryPreservationSessionID = nil sessionSourceApp = sourceApp sessionPasteTarget = DictationPasteTarget.capture(sourceApp: sourceApp) sessionAnchorRect = anchorRect @@ -954,12 +956,17 @@ class DictationSessionController: ObservableObject { taskSessionID: taskSessionID, currentSessionID: self.currentDictationSessionID ) else { - await Task.detached(priority: .utility) { - DictationStoppedAudioRecoveryStore.cleanup( - recovery, - explicitDiscard: true - ) - }.value + if !DictationStoppedAudioRecoveryCommitPolicy.shouldRetainPersistedRecovery( + taskSessionID: taskSessionID, + preservationSessionID: self.stoppedAudioRecoveryPreservationSessionID + ) { + await Task.detached(priority: .utility) { + DictationStoppedAudioRecoveryStore.cleanup( + recovery, + explicitDiscard: true + ) + }.value + } return } self.stoppedAudioRecovery = recovery @@ -1390,6 +1397,9 @@ class DictationSessionController: ObservableObject { /// Cancel dictation without pasting func cancelDictation(preserveStoppedAudio: Bool = false) { guard let (appState, overlayController) = readyState() else { return } + if preserveStoppedAudio { + stoppedAudioRecoveryPreservationSessionID = currentDictationSessionID + } cancelActiveTasks(cancelRecording: true) if !preserveStoppedAudio { discardStoppedAudioRecovery(explicitDiscard: true) diff --git a/Tests/DictationStoppedAudioRecoveryTests.swift b/Tests/DictationStoppedAudioRecoveryTests.swift index c8d0c312a..268f3b9c5 100644 --- a/Tests/DictationStoppedAudioRecoveryTests.swift +++ b/Tests/DictationStoppedAudioRecoveryTests.swift @@ -126,6 +126,21 @@ func testDictationStoppedAudioRecovery() { ), "an old stop task must not mutate a successor session" ) + + assertTrue( + DictationStoppedAudioRecoveryCommitPolicy.shouldRetainPersistedRecovery( + taskSessionID: activeSessionID, + preservationSessionID: activeSessionID + ), + "termination cancellation must retain a checkpoint already written for that session" + ) + assertFalse( + DictationStoppedAudioRecoveryCommitPolicy.shouldRetainPersistedRecovery( + taskSessionID: activeSessionID, + preservationSessionID: UUID() + ), + "a successor session must not retain an old task's checkpoint" + ) } runSuite("Dictation controller checkpoints audio before waiting for the model") { @@ -190,6 +205,10 @@ func testDictationStoppedAudioRecovery() { source.contains("cancelDictation(preserveStoppedAudio: true)"), "termination timeout must not convert a durable checkpoint into an implicit discard" ) + assertTrue( + source.contains("stoppedAudioRecoveryPreservationSessionID = currentDictationSessionID"), + "termination cancellation must mark the active session before cancelling its stop task" + ) let appSource = try String(contentsOf: repoFixtureURL("Sources/TranscriptedApp.swift"), encoding: .utf8) assertTrue( appSource.contains("sessionController.presentPendingStoppedAudioRecoveryIfNeeded()"), From 8949db3b36016d3797b01b9a5faebe16f644baf1 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Fri, 17 Jul 2026 06:52:10 -0500 Subject: [PATCH 7/7] Await quit-time audio checkpoint --- .../Overlay/DictationSessionController.swift | 33 +++++++++++++++++++ .../DictationStoppedAudioRecoveryTests.swift | 20 +++++++++++ 2 files changed, 53 insertions(+) diff --git a/Sources/UI/Overlay/DictationSessionController.swift b/Sources/UI/Overlay/DictationSessionController.swift index cd01f5c6e..aa136666b 100644 --- a/Sources/UI/Overlay/DictationSessionController.swift +++ b/Sources/UI/Overlay/DictationSessionController.swift @@ -5,6 +5,26 @@ import AppKit import AVFoundation import Combine +private actor DictationStoppedAudioCheckpointSignal { + private var isComplete = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + guard !isComplete else { return } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func complete() { + guard !isComplete else { return } + isComplete = true + let pendingWaiters = waiters + waiters.removeAll() + pendingWaiters.forEach { $0.resume() } + } +} + @MainActor class DictationSessionController: ObservableObject { enum DictationTrigger: String { @@ -67,6 +87,7 @@ class DictationSessionController: ObservableObject { private var currentDictationSessionID = UUID() private var stoppedAudioRecovery: DictationStoppedAudioRecovery? private var stoppedAudioRecoveryPreservationSessionID: UUID? + private var stoppedAudioCheckpointSignal: DictationStoppedAudioCheckpointSignal? private var autoSendRequestDecision = DictationAutoSendRequestDecision.notEvaluated /// Max duration for a listening session before auto-cancel (5 minutes). @@ -135,6 +156,7 @@ class DictationSessionController: ObservableObject { currentDictationSessionID = UUID() stoppedAudioRecovery = nil stoppedAudioRecoveryPreservationSessionID = nil + stoppedAudioCheckpointSignal = nil sessionSourceApp = sourceApp sessionPasteTarget = DictationPasteTarget.capture(sourceApp: sourceApp) sessionAnchorRect = anchorRect @@ -924,7 +946,12 @@ class DictationSessionController: ObservableObject { streamingTask?.cancel() let taskSessionID = currentDictationSessionID + let checkpointSignal = DictationStoppedAudioCheckpointSignal() + stoppedAudioCheckpointSignal = checkpointSignal streamingTask = Task { + defer { + Task { await checkpointSignal.complete() } + } var stopTiming = DictationStopTiming(requestedAt: stopRequestedAt) var stoppedRecordingSnapshot: RecordedSpeechSamples? appState.runtimeDiagnostics.recordSession(kind: "dictation", stage: "stop_requested") @@ -992,6 +1019,8 @@ class DictationSessionController: ObservableObject { return } + await checkpointSignal.complete() + // Surface model warmup honestly instead of calling it "Transcribing" // before the local dictation model is actually ready. if !appState.sttRouter.isModelLoaded { @@ -1455,6 +1484,10 @@ class DictationSessionController: ObservableObject { } if isDictating { + stoppedAudioRecoveryPreservationSessionID = currentDictationSessionID + if let stoppedAudioCheckpointSignal { + await stoppedAudioCheckpointSignal.wait() + } cancelDictation(preserveStoppedAudio: true) } } diff --git a/Tests/DictationStoppedAudioRecoveryTests.swift b/Tests/DictationStoppedAudioRecoveryTests.swift index 268f3b9c5..d9840df7a 100644 --- a/Tests/DictationStoppedAudioRecoveryTests.swift +++ b/Tests/DictationStoppedAudioRecoveryTests.swift @@ -209,6 +209,26 @@ func testDictationStoppedAudioRecovery() { source.contains("stoppedAudioRecoveryPreservationSessionID = currentDictationSessionID"), "termination cancellation must mark the active session before cancelling its stop task" ) + let terminationSource = source.components(separatedBy: "func finishDictationForTermination() async").last ?? "" + guard let preservationRange = terminationSource.range( + of: "stoppedAudioRecoveryPreservationSessionID = currentDictationSessionID" + ), + let checkpointWaitRange = terminationSource.range( + of: "await stoppedAudioCheckpointSignal.wait()", + range: preservationRange.upperBound..