diff --git a/Sources/Dictation/DictationTranscriptPersistence.swift b/Sources/Dictation/DictationTranscriptPersistence.swift new file mode 100644 index 000000000..9b85d627e --- /dev/null +++ b/Sources/Dictation/DictationTranscriptPersistence.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Measures the writer itself, independently of Auto Enter and MainActor publication. +struct DictationTranscriptPersistenceResult: Sendable { + let saved: SavedDictationTranscript? + let failureError: Error? + let startedAt: CFAbsoluteTime + let finishedAt: CFAbsoluteTime + + var failureMessage: String? { + guard failureError != nil else { return nil } + return "Transcripted couldn't save a local copy of this dictation. Check your save location and available disk space." + } + + static func measure( + now: () -> CFAbsoluteTime = CFAbsoluteTimeGetCurrent, + save: () throws -> SavedDictationTranscript + ) -> Self { + let startedAt = now() + do { + let saved = try save() + return Self(saved: saved, failureError: nil, startedAt: startedAt, finishedAt: now()) + } catch { + return Self(saved: nil, failureError: error, startedAt: startedAt, finishedAt: now()) + } + } +} + +enum DictationSessionCompletionPolicy { + static func canPublish(sessionID: UUID, currentSessionID: UUID, isDictating: Bool, cancelled: Bool) -> Bool { + sessionID == currentSessionID && isDictating && !cancelled + } +} diff --git a/Sources/Speech/ASRInferenceWaiterQueue.swift b/Sources/Speech/ASRInferenceWaiterQueue.swift new file mode 100644 index 000000000..8d1398fc4 --- /dev/null +++ b/Sources/Speech/ASRInferenceWaiterQueue.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Cancellation removes only a pending waiter. Once handed off, the caller owns +/// the reserved inference slot and must release it even if cancellation wins. +@MainActor +final class ASRInferenceWaiterQueue { + private struct Waiter { + let id: UUID + let continuation: CheckedContinuation + } + private var waiters: [Waiter] = [] + var count: Int { waiters.count } + var isEmpty: Bool { waiters.isEmpty } + + func wait() async throws { + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + waiters.append(Waiter(id: id, continuation: continuation)) + } + } onCancel: { + Task { @MainActor in self.cancel(id: id) } + } + } + + /// The caller reserves the handoff before calling this method. + func resumeFirst() { + guard !waiters.isEmpty else { return } + waiters.removeFirst().continuation.resume() + } + + private func cancel(id: UUID) { + guard let index = waiters.firstIndex(where: { $0.id == id }) else { return } + waiters.remove(at: index).continuation.resume(throwing: CancellationError()) + } +} diff --git a/Sources/Speech/DictationSession.swift b/Sources/Speech/DictationSession.swift index e68a6fb72..8dedbb646 100644 --- a/Sources/Speech/DictationSession.swift +++ b/Sources/Speech/DictationSession.swift @@ -131,14 +131,14 @@ extension DictationSession { isDictating: @escaping () -> Bool, onModelStateUpdate: @escaping (ParakeetModelState) -> Void ) async -> ModelWarmupOutcome { + let deadline = ProcessInfo.processInfo.systemUptime + + TranscriptedConstants.modelLoadWaitBudget if case .failed = appState.sttRouter.recordingModelDownloadState { - // A previous attempt failed; retry once before the wait loop - // treats .failed as terminal. - await appState.sttRouter.initializeRecordingModel() + // Retry once, observing the new attempt without awaiting native load. + appState.sttRouter.requestRecordingModelInitialization() + await appState.sttRouter.waitForRecordingModelLoadProgress(until: deadline) } - let deadline = ProcessInfo.processInfo.systemUptime - + TranscriptedConstants.modelLoadWaitBudget while ProcessInfo.processInfo.systemUptime < deadline { guard !Task.isCancelled, isDictating() else { return .aborted } @@ -151,18 +151,12 @@ extension DictationSession { case .failed(let message): return .failed(message) case .notLoaded, .cached: - let stateBefore = appState.sttRouter.recordingModelDownloadState.diagnosticName - await appState.sttRouter.initializeRecordingModel() - // If initialization bailed without progressing (e.g. - // mid-shutdown), sleep so this loop can't spin hot. - if appState.sttRouter.recordingModelDownloadState.diagnosticName == stateBefore { - try? await Task.sleep(nanoseconds: TranscriptedConstants.modelLoadPollInterval) - } + appState.sttRouter.requestRecordingModelInitialization() + await appState.sttRouter.waitForRecordingModelLoadProgress(until: deadline) case .downloading, .loading: - // Downloads publish progress the overlay refreshes on a - // short poll; an in-flight load is joined directly so - // recording starts the moment it settles. - await appState.sttRouter.waitForRecordingModelLoadProgress() + // Observe progress without letting a shared native load outlive + // this caller's deadline or cancellation. + await appState.sttRouter.waitForRecordingModelLoadProgress(until: deadline) } } diff --git a/Sources/Speech/ModelLoadProgressWaiter.swift b/Sources/Speech/ModelLoadProgressWaiter.swift new file mode 100644 index 000000000..9bda659ce --- /dev/null +++ b/Sources/Speech/ModelLoadProgressWaiter.swift @@ -0,0 +1,45 @@ +import Combine +import Foundation + +/// A caller-owned wait. Finishing releases its observation and timer, but never +/// cancels the shared model initialization that other sessions may still need. +@MainActor +final class ModelLoadProgressWaiter { + private var continuation: CheckedContinuation? + private var observation: AnyCancellable? + private var timer: Task? + + static func wait(for changes: AnyPublisher, until deadline: TimeInterval) async { + let waiter = ModelLoadProgressWaiter() + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + waiter.continuation = continuation + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard !Task.isCancelled, remaining > 0 else { + waiter.finish() + return + } + waiter.observation = changes.sink { [weak waiter] in + Task { @MainActor in waiter?.finish() } + } + waiter.timer = Task { @MainActor [weak waiter] in + do { try await Task.sleep(for: .seconds(remaining)) } + catch { return } + waiter?.finish() + } + } + } onCancel: { + Task { @MainActor in waiter.finish() } + } + } + + private func finish() { + guard let continuation else { return } + self.continuation = nil + observation?.cancel() + observation = nil + timer?.cancel() + timer = nil + continuation.resume() + } +} diff --git a/Sources/Speech/ParakeetAudioGraphOwnership.swift b/Sources/Speech/ParakeetAudioGraphOwnership.swift index bd995e7da..76a5cfe41 100644 --- a/Sources/Speech/ParakeetAudioGraphOwnership.swift +++ b/Sources/Speech/ParakeetAudioGraphOwnership.swift @@ -399,3 +399,14 @@ enum ParakeetZombieRecoveryOwnershipPolicy { && expectedOwner.matches(generation: currentGraphGeneration, engine: currentEngine) } } + +/// A stopped recording can change without replacing its native graph (for +/// example explicit discard). Both identities must survive async conversion. +struct ParakeetRecordedSamplesClaim: Equatable { + let graphOwner: ParakeetAudioGraphOwnerToken + let revision: UInt64 + + func isCurrent(owner: ParakeetAudioGraphOwnerToken, revision: UInt64, cancelled: Bool) -> Bool { + !cancelled && owner == graphOwner && revision == self.revision + } +} diff --git a/Sources/Speech/ParakeetEngine.swift b/Sources/Speech/ParakeetEngine.swift index 006b2a4a3..eef353c6a 100644 --- a/Sources/Speech/ParakeetEngine.swift +++ b/Sources/Speech/ParakeetEngine.swift @@ -51,7 +51,10 @@ class ParakeetEngine: ObservableObject { nonisolated let sharedMeetingMicRecorder = SharedMeetingMicRecorder() var sharedMeetingMicTransition = SharedMeetingMicTransitionState() // Completed tap batches and recovery segments share one rate-aware timeline. - var recoveredRecordingTimeline = RecordedAudioTimeline() + private var recordedSamplesRevision: UInt64 = 0 + var recoveredRecordingTimeline = RecordedAudioTimeline() { + didSet { recordedSamplesRevision &+= 1 } + } var preservingRecordingAcrossRecovery = false private nonisolated(unsafe) var nativeSampleRate: Double = 48000 private nonisolated(unsafe) var audioStartReferenceTime: CFAbsoluteTime? @@ -107,7 +110,7 @@ class ParakeetEngine: ObservableObject { private var zombieRecoveryRestartPending: Bool { zombieRecoveryState.isActive } private var asrInferenceActivity = ParakeetASRInferenceActivityState() private var asrInferenceHandoffCount = 0 - private var asrInferenceWaiters: [CheckedContinuation] = [] + private let asrInferenceWaiters = ASRInferenceWaiterQueue() private var pureSampleTranscriptionActivityCount = 0 var asrManagerReady = false nonisolated(unsafe) var didReceiveAudioSamples = false @@ -1976,40 +1979,7 @@ class ParakeetEngine: ObservableObject { } private func extractMonoSamples(from buffer: AVAudioPCMBuffer) -> [Float]? { - let frameCount = Int(buffer.frameLength) - let channelCount = Int(buffer.format.channelCount) - - guard frameCount > 0, channelCount > 0 else { return [] } - - if channelCount == 1 { - guard let channelData = buffer.floatChannelData?[0] else { return nil } - return Array(UnsafeBufferPointer(start: channelData, count: frameCount)) - } - - var monoSamples = Array(repeating: 0, count: frameCount) - - if buffer.format.isInterleaved { - guard let interleavedData = buffer.floatChannelData?[0] else { return nil } - for frame in 0.. ParakeetAudioGraphOwnerToken { @@ -2215,9 +2185,12 @@ class ParakeetEngine: ObservableObject { private func drainRecordedSamplesForInference() async -> (nativeSampleCount: Int, samples16k: [Float])? { drainPendingSamplesIntoTimeline() - let segments = recoveredRecordingTimeline.drain() - preservingRecordingAcrossRecovery = false - guard let recorded = await Self.resampleRecordedSegments(segments) else { return nil } + // Keep native audio until conversion succeeds. A converter failure is + // retryable and must not consume the only surviving recording. + let claim = ParakeetRecordedSamplesClaim(graphOwner: currentAudioGraphOwnerToken(), revision: recordedSamplesRevision) + guard let recorded = await resampleRecordedSegments(recoveredRecordingTimeline.segments), + claim.isCurrent(owner: currentAudioGraphOwnerToken(), revision: recordedSamplesRevision, cancelled: Task.isCancelled) else { return nil } + clearRecoveredRecordingTimeline(keepingCapacity: true) return (recorded.nativeSampleCount, recorded.samples16k) } @@ -2241,24 +2214,36 @@ class ParakeetEngine: ObservableObject { func snapshotRecordedSamplesForPersistence() async -> RecordedSpeechSamples? { drainPendingSamplesIntoTimeline() - return await Self.resampleRecordedSegments(recoveredRecordingTimeline.segments) + return await resampleRecordedSegments(recoveredRecordingTimeline.segments) } - private static func resampleRecordedSegments(_ segments: [RecordedAudioSegment]) async -> RecordedSpeechSamples? { + private func resampleRecordedSegments(_ segments: [RecordedAudioSegment]) async -> RecordedSpeechSamples? { 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] = [] - 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) + let claim = ParakeetRecordedSamplesClaim(graphOwner: currentAudioGraphOwnerToken(), revision: recordedSamplesRevision) + do { + let samples16k = try await Task.detached(priority: .userInitiated) { + var combined: [Float] = [] + for segment in segments { + combined.append(contentsOf: try AudioResampler.resampleForSpeech( + segment.samples, + from: segment.sampleRate, + to: TranscriptedConstants.parakeetSampleRate + )) + } + return combined + }.value + guard claim.isCurrent(owner: currentAudioGraphOwnerToken(), revision: recordedSamplesRevision, cancelled: Task.isCancelled) else { return nil } + return RecordedSpeechSamples(nativeSampleCount: nativeSampleCount, samples16k: samples16k) + } catch { + guard claim.isCurrent(owner: currentAudioGraphOwnerToken(), revision: recordedSamplesRevision, cancelled: Task.isCancelled) else { return nil } + lastEmptyTranscriptionReason = .modelFailure + EventReporter.shared.capture( + level: .error, engine: "parakeet", event: "audio_conversion_failed", + message: "Recorded audio conversion failed; native samples retained for retry" + ) + return nil + } } // MARK: - Transcription @@ -2292,8 +2277,19 @@ class ParakeetEngine: ObservableObject { } isTranscribing = true - guard let recorded = await consumeRecordedSamples(preparedRecording: preparedRecording) else { - finishExternalTranscription() + let conversionOwner = currentAudioGraphOwnerToken() + let conversionRevision = recordedSamplesRevision + let recorded = await consumeRecordedSamples(preparedRecording: preparedRecording) + guard ownsAudioGraph(conversionOwner) else { return nil } + if Task.isCancelled { + if recorded != nil || recordedSamplesRevision == conversionRevision { + finishTranscription() + } + return nil + } + guard let recorded else { + guard recordedSamplesRevision == conversionRevision else { return nil } + isTranscribing = false return nil } let nativeCount = recorded.nativeSampleCount @@ -2346,7 +2342,8 @@ class ParakeetEngine: ObservableObject { pureSampleTranscriptionActivityCount = max(0, pureSampleTranscriptionActivityCount - 1) } - private func beginASRInference() async { + private func beginASRInference() async throws { + try Task.checkCancellation() if asrInferenceActivity.canStartImmediately(reservedHandoffCount: asrInferenceHandoffCount) { asrInferenceActivity.begin() return @@ -2363,19 +2360,16 @@ class ParakeetEngine: ObservableObject { "waiter_count": "\(asrInferenceWaiters.count)" ] ) - await withCheckedContinuation { continuation in - asrInferenceWaiters.append(continuation) - } + try await asrInferenceWaiters.wait() asrInferenceHandoffCount = max(0, asrInferenceHandoffCount - 1) asrInferenceActivity.begin() } private func finishASRInference() { asrInferenceActivity.finish() - if let next = asrInferenceWaiters.first { - asrInferenceWaiters.removeFirst() + if !asrInferenceWaiters.isEmpty { asrInferenceHandoffCount += 1 - next.resume() + asrInferenceWaiters.resumeFirst() return } } @@ -2384,7 +2378,17 @@ class ParakeetEngine: ObservableObject { manager: AsrManager, samples: [Float] ) async throws -> String { - await beginASRInference() + let queueStartedAt = ProcessInfo.processInfo.systemUptime + try await beginASRInference() + let inferenceStartedAt = ProcessInfo.processInfo.systemUptime + defer { + let finishedAt = ProcessInfo.processInfo.systemUptime + let queueWaitMS = (inferenceStartedAt - queueStartedAt) * 1_000 + let inferenceMS = (finishedAt - inferenceStartedAt) * 1_000 + // Aggregate local diagnostics only; existing end-to-end timing keeps + // its semantics and neither samples nor transcript text are logged. + AppLogger.transcription.info("PARAKEET | ASR queue_wait_ms=\(String(format: "%.2f", queueWaitMS)) inference_ms=\(String(format: "%.2f", inferenceMS))") + } do { try Task.checkCancellation() // FluidAudio 0.15.x hands decoder-state ownership to the caller. Every batch @@ -2392,8 +2396,10 @@ class ParakeetEngine: ObservableObject { // contaminate each other's decoder context (0.7.9 kept per-source state // internally, keyed by the removed `source:` parameter). let decoderLayers = await manager.decoderLayerCount + try Task.checkCancellation() var decoderState = try TdtDecoderState(decoderLayers: decoderLayers) let result = try await manager.transcribe(samples, decoderState: &decoderState) + try Task.checkCancellation() let text = withExtendedLifetime(result) { String(result.text) } @@ -2406,6 +2412,7 @@ class ParakeetEngine: ObservableObject { } func transcribe(preparedRecording: RecordedSpeechSamples? = nil) async -> String? { + guard !Task.isCancelled else { return nil } lastEmptyTranscriptionReason = nil guard !isTranscribing else { EventReporter.shared.capture(level: .warning, engine: "parakeet", event: "transcription_already_active", @@ -2429,8 +2436,19 @@ class ParakeetEngine: ObservableObject { isTranscribing = true let startTime = CFAbsoluteTimeGetCurrent() - guard let recorded = await consumeRecordedSamples(preparedRecording: preparedRecording) else { - finishTranscription() + let conversionOwner = currentAudioGraphOwnerToken() + let conversionRevision = recordedSamplesRevision + let recorded = await consumeRecordedSamples(preparedRecording: preparedRecording) + guard ownsAudioGraph(conversionOwner) else { return nil } + if Task.isCancelled { + if recorded != nil || recordedSamplesRevision == conversionRevision { + finishTranscription() + } + return nil + } + guard let recorded else { + guard recordedSamplesRevision == conversionRevision else { return nil } + isTranscribing = false return nil } let nativeCount = recorded.nativeSampleCount @@ -2525,6 +2543,7 @@ class ParakeetEngine: ObservableObject { emptyContext["retry_elapsed_s"] = String(format: "%.3f", retryElapsed) emptyContext["retry_samples"] = "\(retrySamples.count)" } catch { + if Task.isCancelled || error is CancellationError { throw CancellationError() } emptyContext["retry_error"] = error.localizedDescription } } else if !analysis.hasUsableSpeechSignal { @@ -2558,6 +2577,10 @@ class ParakeetEngine: ObservableObject { ]) return corrected } catch { + if Task.isCancelled || error is CancellationError { + if ownsAudioGraph(conversionOwner) { finishTranscription() } + return nil + } let elapsed = CFAbsoluteTimeGetCurrent() - startTime if let fallbackDecision = ParakeetShortAudioGate.dictationFallback( nativeSampleCount: nativeCount, @@ -2605,6 +2628,7 @@ class ParakeetEngine: ObservableObject { /// - Returns: Transcribed text, trimmed. Empty string if Parakeet returned nothing. /// - Throws: Re-throws `AsrManager.transcribe` errors (including model-not-ready). func transcribeSamples(_ samples: [Float], source: AudioSource) async throws -> String { + try Task.checkCancellation() beginPureSampleTranscriptionActivity() defer { finishPureSampleTranscriptionActivity() } @@ -2640,6 +2664,7 @@ class ParakeetEngine: ObservableObject { samples: samples ) } catch { + if Task.isCancelled || error is CancellationError { throw CancellationError() } if let fallbackDecision = ParakeetShortAudioGate.meetingSegmentFallback( sampleCount: samples.count, sourceDescription: sourceDescription, diff --git a/Sources/Speech/ParakeetModelLifecycle.swift b/Sources/Speech/ParakeetModelLifecycle.swift index 0307ffcc5..9caaffeb6 100644 --- a/Sources/Speech/ParakeetModelLifecycle.swift +++ b/Sources/Speech/ParakeetModelLifecycle.swift @@ -173,15 +173,6 @@ extension ParakeetEngine { await task.value } - /// Await the in-flight model initialization task, if any. Returns true - /// when a task was joined. Unlike polling `modelDownloadState`, this - /// resumes the moment initialization settles (ready or failed). - func joinModelInitialization() async -> Bool { - guard let modelInitializationTask else { return false } - await modelInitializationTask.value - return true - } - private func performInitialize(generation: UInt64) async { defer { if generation == modelInitializationGeneration { diff --git a/Sources/Speech/STTRouter.swift b/Sources/Speech/STTRouter.swift index e4ee38606..0143678d5 100644 --- a/Sources/Speech/STTRouter.swift +++ b/Sources/Speech/STTRouter.swift @@ -269,23 +269,28 @@ class STTRouter: ObservableObject { refreshModelDownloadState() } - /// Wait for the next observable model-load transition. Joins the engine's - /// in-flight initialization when one exists — resuming the moment the load - /// settles instead of on a polling interval — and falls back to a short - /// poll sleep while a download is publishing progress or no - /// initialization handle exists (Whisper). Callers own the overall - /// timeout and must re-check `isModelLoaded` after each wait. - func waitForRecordingModelLoadProgress() async { + /// Starts the shared, deduplicated load without tying a UI waiter's deadline + /// or cancellation to the model's lifetime. + func requestRecordingModelInitialization() { let model = recordingModel - defer { refreshModelDownloadState() } - if model == .parakeetTDTv3 { - var isDownloading = false - if case .downloading = recordingModelDownloadState { isDownloading = true } - if !isDownloading, await parakeetEngine.joinModelInitialization() { - return - } + Task { @MainActor [weak self] in + await self?.initialize(model: model) } - try? await Task.sleep(nanoseconds: TranscriptedConstants.modelLoadPollInterval) + } + + /// Wait for a state transition, caller cancellation, or the caller's deadline. + /// Ready models return immediately; a stalled native load cannot strand the UI. + func waitForRecordingModelLoadProgress(until deadline: TimeInterval) async { + guard !isRecordingModelLoaded, !Task.isCancelled, + ProcessInfo.processInfo.systemUptime < deadline else { return } + let changes: AnyPublisher + if recordingModel == .parakeetTDTv3 { + changes = parakeetEngine.$modelDownloadState.dropFirst().map { _ in () }.eraseToAnyPublisher() + } else { + changes = whisperEngine.$modelDownloadState.dropFirst().map { _ in () }.eraseToAnyPublisher() + } + await ModelLoadProgressWaiter.wait(for: changes, until: deadline) + refreshModelDownloadState() } func startRecording() async -> Bool { @@ -350,7 +355,9 @@ class STTRouter: ObservableObject { switch model { case .parakeetTDTv3: let text = await parakeetEngine.transcribe(preparedRecording: preparedRecording) - lastEmptyTranscriptionReason = text == nil ? parakeetEngine.lastEmptyTranscriptionReason : nil + if !Task.isCancelled { + lastEmptyTranscriptionReason = text == nil ? parakeetEngine.lastEmptyTranscriptionReason : nil + } return text case .whisperLargeV3Turbo, .whisperLargeV3: return await transcribeUsingExternalEngine( @@ -375,6 +382,7 @@ class STTRouter: ObservableObject { transcribe: (RecordedSpeechSamples) async throws -> String ) async -> String? { await initialize(model: model) + guard !Task.isCancelled else { return nil } guard isModelLoaded(for: model) else { lastEmptyTranscriptionReason = .modelFailure EventReporter.shared.capture( @@ -391,15 +399,19 @@ class STTRouter: ObservableObject { engineName: model.engineName, preparedRecording: preparedRecording ) else { - lastEmptyTranscriptionReason = parakeetEngine.lastEmptyTranscriptionReason + if !Task.isCancelled { lastEmptyTranscriptionReason = parakeetEngine.lastEmptyTranscriptionReason } return nil } + let transcriptionOwner = parakeetEngine.currentAudioGraphOwnerToken() do { defer { - parakeetEngine.finishExternalTranscription() + if parakeetEngine.ownsAudioGraph(transcriptionOwner) { + parakeetEngine.finishExternalTranscription() + } } let text = try await transcribe(recording) + try Task.checkCancellation() let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { lastEmptyTranscriptionReason = .noSpeech @@ -407,6 +419,7 @@ class STTRouter: ObservableObject { } return text } catch { + if Task.isCancelled || error is CancellationError { return nil } lastEmptyTranscriptionReason = .modelFailure EventReporter.shared.capture( level: .error, diff --git a/Sources/Speech/WhisperEngine.swift b/Sources/Speech/WhisperEngine.swift index ab89ee905..8691becf1 100644 --- a/Sources/Speech/WhisperEngine.swift +++ b/Sources/Speech/WhisperEngine.swift @@ -55,6 +55,7 @@ final class WhisperEngine: ObservableObject { source: AudioSource, model: TranscriptionModelChoice ) async throws -> String { + try Task.checkCancellation() guard model.isWhisper else { throw NSError(domain: "WhisperEngine", code: 1, userInfo: [ NSLocalizedDescriptionKey: "\(model.title) is not a Whisper model." @@ -65,6 +66,7 @@ final class WhisperEngine: ObservableObject { await initialize(model: model) } + try Task.checkCancellation() guard let whisperKit, isModelLoaded(for: model) else { EventReporter.shared.capture( level: .error, @@ -113,6 +115,7 @@ final class WhisperEngine: ObservableObject { concurrentWorkerCount: 1 ) ) + try Task.checkCancellation() let elapsed = CFAbsoluteTimeGetCurrent() - startTime let trimmed = results .map(\.text) @@ -142,6 +145,7 @@ final class WhisperEngine: ObservableObject { // path. The processor is a no-op when the dictionary is empty. return CustomDictionaryTextProcessor.apply(to: trimmed) } catch { + if Task.isCancelled || error is CancellationError { throw CancellationError() } let elapsed = CFAbsoluteTimeGetCurrent() - startTime EventReporter.shared.capture( level: .error, diff --git a/Sources/Support/ClipboardRestoringTextPaster.swift b/Sources/Support/ClipboardRestoringTextPaster.swift index a6b4bedf0..235aadf37 100644 --- a/Sources/Support/ClipboardRestoringTextPaster.swift +++ b/Sources/Support/ClipboardRestoringTextPaster.swift @@ -14,6 +14,7 @@ private enum ClipboardPasteConfirmationWaitResult: Equatable { case confirmed case unconfirmed case focusChanged + case cancelled } enum TextPasteCopyReason: Equatable { @@ -25,6 +26,7 @@ enum TextPasteCopyReason: Equatable { } enum TextPasteFailureReason: String, Equatable { + case cancelled = "cancelled" case clipboardSnapshotIncomplete = "clipboard_snapshot_incomplete" case focusChangeClipboardWriteFailed = "focus_change_clipboard_write_failed" case accessibilityFallbackClipboardWriteFailed = "accessibility_fallback_clipboard_write_failed" @@ -90,9 +92,13 @@ struct ClipboardPasteTiming: Equatable { let clipboardReadAt: CFAbsoluteTime? let confirmationStartedAt: CFAbsoluteTime? let confirmationFinishedAt: CFAbsoluteTime? + var accessibilityCaptureMS: Int? = nil + var clipboardSnapshotMS: Int? = nil func measurements() -> [String: Int] { var values: [String: Int] = [:] + values["paste_ax_capture_ms"] = accessibilityCaptureMS + values["paste_clipboard_snapshot_ms"] = clipboardSnapshotMS values["paste_prepare_ms"] = milliseconds(from: startedAt, to: dispatchStartedAt) values["paste_dispatch_ms"] = milliseconds(from: dispatchStartedAt, to: dispatchFinishedAt) if let dispatchStartedAt, @@ -481,6 +487,7 @@ private struct FocusedTextPasteConfirmation { static func capture() -> FocusedTextPasteConfirmation? { let systemWideElement = AXUIElementCreateSystemWide() + AXUIElementSetMessagingTimeout(systemWideElement, messagingTimeout) var focusedElementValue: CFTypeRef? guard AXUIElementCopyAttributeValue( systemWideElement, @@ -656,6 +663,8 @@ final class ClipboardRestoringTextPaster { /// Epoch — begun per paste attempt, invalidated whenever the pending restore /// is cleared, superseded when a scheduled restore completes private var pasteEpoch = SupersessionEpoch() + private var operationEpoch = SupersessionEpoch() + private var latestStartedOperation: SupersessionEpoch.Token? private(set) var lastConfirmationDiagnostic: ClipboardPasteConfirmationDiagnostic? private(set) var lastPasteTiming: ClipboardPasteTiming? @@ -674,6 +683,11 @@ final class ClipboardRestoringTextPaster { } func restorePendingClipboardNow() { + operationEpoch.invalidate() + restorePendingClipboard() + } + + private func restorePendingClipboard() { guard let pending = clearPendingClipboardRestore() else { return } restorePasteboardItems( pending.savedItems, @@ -718,6 +732,10 @@ final class ClipboardRestoringTextPaster { fallbackRestoreDelay: UInt64 = TranscriptedConstants.clipboardRestoreFallbackDelay, pasteConfirmationWait: TimeInterval = TranscriptedConstants.clipboardPasteConfirmationWait ) -> TextPasteOutcome { + let operation = operationEpoch.begin() + latestStartedOperation = operation + let isCurrentOperation = { self.operationEpoch.isCurrent(operation) } + let cancelledOutcome = TextPasteOutcome.failed("Paste was cancelled.", reason: .cancelled) lastConfirmationDiagnostic = nil lastPasteTiming = nil let timingStartedAt = CFAbsoluteTimeGetCurrent() @@ -726,51 +744,75 @@ final class ClipboardRestoringTextPaster { var timingConfirmationStartedAt: CFAbsoluteTime? var timingConfirmationFinishedAt: CFAbsoluteTime? var timingProvider: TemporaryPasteboardStringProvider? + var accessibilityCaptureMS: Int? + var clipboardSnapshotMS: Int? defer { - lastPasteTiming = ClipboardPasteTiming( - startedAt: timingStartedAt, - dispatchStartedAt: timingDispatchStartedAt, - dispatchFinishedAt: timingDispatchFinishedAt, - clipboardReadAt: timingProvider?.firstReadAt, - confirmationStartedAt: timingConfirmationStartedAt, - confirmationFinishedAt: timingConfirmationFinishedAt - ) + if isCurrentOperation() { + lastPasteTiming = ClipboardPasteTiming( + startedAt: timingStartedAt, + dispatchStartedAt: timingDispatchStartedAt, + dispatchFinishedAt: timingDispatchFinishedAt, + clipboardReadAt: timingProvider?.firstReadAt, + confirmationStartedAt: timingConfirmationStartedAt, + confirmationFinishedAt: timingConfirmationFinishedAt, + accessibilityCaptureMS: accessibilityCaptureMS, + clipboardSnapshotMS: clipboardSnapshotMS + ) + } } discardPasteRetry() - restorePendingClipboardNow() + guard isCurrentOperation() else { return cancelledOutcome } + restorePendingClipboard() + guard isCurrentOperation() else { return cancelledOutcome } if let target, !target.matchesCurrentFrontmostApp(), - !waitForTargetActivation(target, timeout: activationWait) { + !waitForTargetActivation(target, timeout: activationWait, isCurrentOperation: isCurrentOperation) { + guard isCurrentOperation() else { return cancelledOutcome } guard copyTextToClipboard(text, to: pasteboard) else { return .failed( "Focus moved, and Transcripted couldn't put the text on your clipboard. It's still saved in your dictation history.", reason: .focusChangeClipboardWriteFailed ) } + guard isCurrentOperation() else { return cancelledOutcome } return .copied( "Focus moved before the text could paste. It's on your clipboard — press ⌘V to paste it.", reason: .focusChanged ) } - guard accessibilityTrusted() else { + guard isCurrentOperation() else { return cancelledOutcome } + let trusted = accessibilityTrusted() + guard isCurrentOperation() else { return cancelledOutcome } + guard trusted else { requestAccessibilityTrust() + guard isCurrentOperation() else { return cancelledOutcome } guard copyTextToClipboard(text, to: pasteboard) else { return .failed( "Accessibility is off, and Transcripted couldn't put the text on your clipboard. It's still saved in your dictation history.", reason: .accessibilityFallbackClipboardWriteFailed ) } + guard isCurrentOperation() else { return cancelledOutcome } return .copied( "Accessibility is off, so Transcripted can't paste for you. Your text is on the clipboard — press ⌘V.", reason: .accessibilityMissing ) } + let accessibilityStartedAt = CFAbsoluteTimeGetCurrent() let accessibilityConfirmation = confirmationSource?() ?? FocusedTextPasteConfirmation.capture() + accessibilityCaptureMS = max(0, Int(((CFAbsoluteTimeGetCurrent() - accessibilityStartedAt) * 1_000).rounded())) + guard isCurrentOperation() else { return cancelledOutcome } + let snapshotStartedAt = CFAbsoluteTimeGetCurrent() + let snapshotChangeCount = pasteboard.changeCount let savedItems = snapshotPasteboardItems(from: pasteboard) - guard savedItems.isComplete else { + clipboardSnapshotMS = max(0, Int(((CFAbsoluteTimeGetCurrent() - snapshotStartedAt) * 1_000).rounded())) + // Lazy clipboard providers can run while materializing a snapshot. Do + // not overwrite a newer clipboard with an older restore snapshot. + guard isCurrentOperation() else { return cancelledOutcome } + guard savedItems.isComplete, pasteboard.changeCount == snapshotChangeCount else { return .failed( "Couldn't paste automatically without risking your current clipboard. The dictation was saved, but paste-back did not run.", reason: .clipboardSnapshotIncomplete @@ -778,19 +820,42 @@ final class ClipboardRestoringTextPaster { } let pasteToken = pasteEpoch.begin() var temporaryChangeCount = 0 + var restoreInstalled = false + var clearedChangeCount: Int? + defer { + // Cancellation can arrive from a provider while the initial write + // is still in progress, before a pending restore can be installed. + // Roll back that borrowed clipboard, never a newer paste attempt. + if !restoreInstalled, latestStartedOperation == operation { + let observedCount = pasteboard.changeCount + let currentString = pasteboard.string(forType: .string) + if currentString == text || (currentString == nil && observedCount == clearedChangeCount) { + restoreClipboardSnapshot(savedItems, matching: currentString, + changeCount: observedCount, to: pasteboard) + } + if latestStartedOperation == operation { + temporaryPasteboardDataProvider = nil + } + } + } - pasteboard.clearContents() + clearedChangeCount = pasteboard.clearContents() + guard isCurrentOperation() else { return cancelledOutcome } let wroteTemporaryString = writeTemporaryString(text, to: pasteboard) + guard isCurrentOperation() else { return cancelledOutcome } if !wroteTemporaryString { - pasteboard.clearContents() - guard pasteboard.setString(text, forType: .string), - pasteboard.string(forType: .string) == text else { + clearedChangeCount = pasteboard.clearContents() + guard isCurrentOperation() else { return cancelledOutcome } + let wroteFallback = pasteboard.setString(text, forType: .string) + guard isCurrentOperation() else { return cancelledOutcome } + guard wroteFallback, pasteboard.string(forType: .string) == text else { return .failed( "Couldn't paste or copy the text automatically. It's still saved in your dictation history.", reason: .temporaryClipboardWriteFailed ) } } + guard isCurrentOperation() else { return cancelledOutcome } temporaryChangeCount = pasteboard.changeCount let temporaryProvider = temporaryPasteboardDataProvider timingProvider = temporaryProvider @@ -803,18 +868,23 @@ final class ClipboardRestoringTextPaster { token: pasteToken, delay: fallbackRestoreDelay ) + restoreInstalled = true let pasteDispatchedAt = CFAbsoluteTimeGetCurrent() timingDispatchStartedAt = pasteDispatchedAt - guard pasteDispatcher() else { + guard isCurrentOperation() else { return cancelledOutcome } + let dispatched = pasteDispatcher() + guard isCurrentOperation() else { return cancelledOutcome } + guard dispatched else { timingDispatchFinishedAt = CFAbsoluteTimeGetCurrent() - restorePendingClipboardNow() + restorePendingClipboard() guard copyTextToClipboard(text, to: pasteboard) else { return .failed( "Couldn't paste or copy the text automatically. It's still saved in your dictation history.", reason: .pasteDispatchClipboardRecoveryFailed ) } + guard isCurrentOperation() else { return cancelledOutcome } return .copied( "Couldn't paste automatically. Your text is on the clipboard — press ⌘V.", reason: .pasteEventCreationFailed @@ -856,9 +926,11 @@ final class ClipboardRestoringTextPaster { targetIsFrontmost: targetRemainsFrontmost, pasteConfirmed: confirmPasteReceived, stopWaitingUnconfirmed: stopWaitingAfterClipboardRead, + isCurrentOperation: isCurrentOperation, timeout: pasteConfirmationWait ) timingConfirmationFinishedAt = CFAbsoluteTimeGetCurrent() + guard isCurrentOperation(), pasteConfirmationResult != .cancelled else { return cancelledOutcome } guard pasteConfirmationResult == .confirmed else { var diagnostics = accessibilityConfirmation?.diagnosticsContext( clipboardReadAt: temporaryProvider?.firstReadAt, @@ -870,6 +942,7 @@ final class ClipboardRestoringTextPaster { "target_selection_observable": "false", "target_value_observable": "false", ] + guard isCurrentOperation() else { return cancelledOutcome } let targetStillFrontmost = pasteConfirmationResult == .unconfirmed diagnostics["target_still_frontmost"] = "\(targetStillFrontmost)" lastConfirmationDiagnostic = ClipboardPasteConfirmationDiagnostic( @@ -889,6 +962,7 @@ final class ClipboardRestoringTextPaster { reason: .fallbackClipboardRecoveryUnverified ) } + guard isCurrentOperation() else { return cancelledOutcome } return .copied( "Focus moved before Transcripted could confirm paste. The text is on your clipboard — press ⌘V.", reason: .focusChanged @@ -912,6 +986,7 @@ final class ClipboardRestoringTextPaster { // read inside this short wait. A miss therefore cannot prove paste failed. // Keep the text copied as recovery and report the dispatch neutrally; // concrete clipboard, event, and focus failures still return above. + guard isCurrentOperation() else { return cancelledOutcome } return .copied( "Transcripted sent paste, but this target did not expose paste confirmation. The text stays copied.", reason: .pasteConfirmationUnavailable @@ -929,6 +1004,7 @@ final class ClipboardRestoringTextPaster { pasteDispatchedAt: pasteDispatchedAt ) ?? "unknown" } + guard isCurrentOperation() else { return cancelledOutcome } lastConfirmationDiagnostic = ClipboardPasteConfirmationDiagnostic( event: "dictation_paste_confirmed", context: ["confirmation_mode": confirmationMode] @@ -1072,7 +1148,10 @@ final class ClipboardRestoringTextPaster { } } - private func waitForTargetActivation(_ target: DictationPasteTarget, timeout: TimeInterval) -> Bool { + private func waitForTargetActivation( + _ target: DictationPasteTarget, timeout: TimeInterval, + isCurrentOperation: () -> Bool + ) -> Bool { guard timeout > 0 else { return false } let start = Date() @@ -1082,6 +1161,7 @@ final class ClipboardRestoringTextPaster { timeout: timeout ) { _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.02)) + guard isCurrentOperation() else { return false } if target.matchesCurrentFrontmostApp() { return true } @@ -1125,36 +1205,38 @@ final class ClipboardRestoringTextPaster { targetIsFrontmost: @MainActor () -> Bool, pasteConfirmed: @MainActor () -> Bool, stopWaitingUnconfirmed: @MainActor () -> Bool, + isCurrentOperation: @MainActor () -> Bool, timeout: TimeInterval ) -> ClipboardPasteConfirmationWaitResult { - guard targetIsFrontmost() else { return .focusChanged } - if pasteConfirmed() { - return targetIsFrontmost() ? .confirmed : .focusChanged - } - guard targetIsFrontmost() else { return .focusChanged } - if stopWaitingUnconfirmed() { - return .unconfirmed + func check() -> ClipboardPasteConfirmationWaitResult? { + guard isCurrentOperation() else { return .cancelled } + let frontmost = targetIsFrontmost() + guard isCurrentOperation() else { return .cancelled } + guard frontmost else { return .focusChanged } + let confirmed = pasteConfirmed() + guard isCurrentOperation() else { return .cancelled } + let stillFrontmost = targetIsFrontmost() + guard isCurrentOperation() else { return .cancelled } + guard stillFrontmost else { return .focusChanged } + if confirmed { return .confirmed } + let stop = stopWaitingUnconfirmed() + guard isCurrentOperation() else { return .cancelled } + return stop ? .unconfirmed : nil } + if let result = check() { return result } guard timeout > 0 else { return .unconfirmed } - - let start = Date() - while Date().timeIntervalSince(start) < timeout { + let deadline = ProcessInfo.processInfo.systemUptime + timeout + while ProcessInfo.processInfo.systemUptime < deadline { _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.02)) - guard targetIsFrontmost() else { return .focusChanged } - if pasteConfirmed() { - return targetIsFrontmost() ? .confirmed : .focusChanged - } - if stopWaitingUnconfirmed() { - return .unconfirmed - } + if let result = check() { return result } } - guard targetIsFrontmost() else { return .focusChanged } - let confirmed = pasteConfirmed() - guard targetIsFrontmost() else { return .focusChanged } - return confirmed ? .confirmed : .unconfirmed + return check() ?? .unconfirmed } - func snapshotPasteboardItems(from pasteboard: any ClipboardPasteboard) -> PasteboardSnapshot { + func snapshotPasteboardItems( + from pasteboard: any ClipboardPasteboard, + readData: (NSPasteboardItem, NSPasteboard.PasteboardType) -> Data? = { $0.data(forType: $1) } + ) -> PasteboardSnapshot { var isComplete = true // This runs synchronously on the stop-to-paste path, so bound the whole // snapshot as well as each representation: one pathological clipboard @@ -1164,7 +1246,11 @@ final class ClipboardRestoringTextPaster { var typeData: [NSPasteboard.PasteboardType: Data] = [:] var skippedTypes = 0 for type in item.types { - guard let data = item.data(forType: type), + // Once full, avoid asking additional lazy providers to allocate + // data that cannot be retained. Individual provider fetches can + // still exceed the budget; NSPasteboard has no size preflight. + guard totalBytes < TranscriptedConstants.clipboardSnapshotMaxTotalBytes, + let data = readData(item, type), data.count <= TranscriptedConstants.clipboardSnapshotMaxTypeBytes, totalBytes + data.count <= TranscriptedConstants.clipboardSnapshotMaxTotalBytes else { skippedTypes += 1 @@ -1194,11 +1280,18 @@ final class ClipboardRestoringTextPaster { temporaryChangeCount: Int, to pasteboard: any ClipboardPasteboard ) { - guard savedItems.isComplete else { return } - guard pasteboard.changeCount == temporaryChangeCount, - pasteboard.string(forType: .string) == temporaryString else { - return - } + restoreClipboardSnapshot(savedItems, matching: temporaryString, + changeCount: temporaryChangeCount, to: pasteboard) + } + + private func restoreClipboardSnapshot( + _ savedItems: PasteboardSnapshot, matching expectedString: String?, + changeCount: Int, to pasteboard: any ClipboardPasteboard + ) { + guard savedItems.isComplete, + pasteboard.changeCount == changeCount, + pasteboard.string(forType: .string) == expectedString, + pasteboard.changeCount == changeCount else { return } pasteboard.clearContents() let items = savedItems.items.map { typeData -> NSPasteboardItem in diff --git a/Sources/Support/CustomDictionaryPreferences.swift b/Sources/Support/CustomDictionaryPreferences.swift index a22e9c556..b2f6c1257 100644 --- a/Sources/Support/CustomDictionaryPreferences.swift +++ b/Sources/Support/CustomDictionaryPreferences.swift @@ -19,8 +19,19 @@ enum CustomDictionaryPreferences { userDefaults.set(clampedRawText(rawText), forKey: rawTextKey) } + private static let parsedCacheLock = NSLock() + private static nonisolated(unsafe) var parsedCache: (raw: String, entries: [CustomDictionaryEntry])? + static func entries(userDefaults: UserDefaults = .standard) -> [CustomDictionaryEntry] { - entries(from: rawText(userDefaults: userDefaults)) + let raw = clampedRawText(rawText(userDefaults: userDefaults)) + parsedCacheLock.lock() + defer { parsedCacheLock.unlock() } + if let cached = parsedCache, cached.raw == raw { + return cached.entries + } + let parsed = entries(from: raw) + parsedCache = (raw, parsed) + return parsed } static func entries(from rawText: String) -> [CustomDictionaryEntry] { diff --git a/Sources/Support/DictationFillerCleanupPolicy.swift b/Sources/Support/DictationFillerCleanupPolicy.swift index 5780184fe..bdf80d41d 100644 --- a/Sources/Support/DictationFillerCleanupPolicy.swift +++ b/Sources/Support/DictationFillerCleanupPolicy.swift @@ -14,7 +14,7 @@ enum DictationFillerCleanupPolicy { pattern: #"(?i)^\s*(?:ok|okay|alright|all\s+right|so|well)[,.;:!?-]+\s*"# ) private static let duplicateWordRegex = try? NSRegularExpression( - pattern: #"(?i)(? String { - var current = text - while true { - var collapsed = false - let next = replacingMatches(regex: duplicateWordRegex, in: current, limit: 1) { match in - guard match.numberOfRanges >= 3, - let fullRange = Range(match.range(at: 0), in: current), - let wordRange = Range(match.range(at: 1), in: current), - let spacerRange = Range(match.range(at: 2), in: current) else { - return matchText(match, in: current) - } - - let fullText = String(current[fullRange]) - guard !fullText.contains(where: { ",.;:!?".contains($0) }) else { - return fullText - } - - collapsed = true - removedCount += 1 - return String(current[wordRange]) + String(current[spacerRange]) - } - - current = next - if !collapsed { - return current + replacingMatches(regex: duplicateWordRegex, in: text) { match in + guard let wordRange = Range(match.range(at: 1), in: text) else { + return matchText(match, in: text) } + let run = matchText(match, in: text) + removedCount += run.split(whereSeparator: \.isWhitespace).count - 1 + // Keep the first word's case. Spacing is normalized by the next pass. + return String(text[wordRange]) + " " } } @@ -131,8 +114,12 @@ enum DictationFillerCleanupPolicy { ) -> String { guard let regex else { return text } let fullRange = NSRange(text.startIndex.. AVAudioPCMBuffer? { - let frameCount = buffer.frameLength - let channelCount = Int(buffer.format.channelCount) - - guard channelCount > 0, frameCount > 0 else { return nil } - - guard let monoBuffer = AVAudioPCMBuffer(pcmFormat: monoFormat, frameCapacity: frameCount) else { - return nil - } - monoBuffer.frameLength = frameCount - - guard let monoData = monoBuffer.floatChannelData?[0] else { return nil } - - let dominantChannel = dominantChannelIndex(buffer: buffer, frameCount: Int(frameCount)) - - // Check if buffer is interleaved or non-interleaved - if buffer.format.isInterleaved { - // Interleaved: samples are [L0, R0, C0, S0, L1, R1, C1, S1, ...] - guard let interleavedData = buffer.floatChannelData?[0] else { return nil } - - for frame in 0.. 0, buffer.format.channelCount > 0, + buffer.floatChannelData != nil, + let monoBuffer = AVAudioPCMBuffer(pcmFormat: monoFormat, frameCapacity: buffer.frameLength), + let destination = monoBuffer.floatChannelData?[0] else { return nil } + monoBuffer.frameLength = buffer.frameLength + MicrophoneDownmix.copyMonoSamples(from: buffer, to: destination) return monoBuffer } - private func dominantChannelIndex(buffer: AVAudioPCMBuffer, frameCount: Int) -> Int { - let channelCount = Int(buffer.format.channelCount) - guard channelCount > 1, frameCount > 0 else { return 0 } - - var bestChannel = 0 - var bestEnergy: Float = -1 - - if buffer.format.isInterleaved { - guard let interleavedData = buffer.floatChannelData?[0] else { return 0 } - for channel in 0.. bestEnergy { - bestEnergy = energy - bestChannel = channel - } - } - } else { - guard let channelData = buffer.floatChannelData else { return 0 } - for channel in 0.. bestEnergy { - bestEnergy = energy - bestChannel = channel - } - } - } - - return bestChannel - } - /// Deep copy an AVAudioPCMBuffer to ensure data safety across async dispatch /// Required because system audio buffers use bufferListNoCopy and don't own their memory func deepCopyBuffer(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? { diff --git a/Sources/TranscriptedCore/Audio/AudioResampler.swift b/Sources/TranscriptedCore/Audio/AudioResampler.swift index b0215da6e..a1729d3b0 100644 --- a/Sources/TranscriptedCore/Audio/AudioResampler.swift +++ b/Sources/TranscriptedCore/Audio/AudioResampler.swift @@ -36,6 +36,60 @@ public enum AudioResampler { return output } + /// Antialiased conversion for stopped in-memory microphone recordings. + /// Feed bounded chunks and drain to end-of-stream so filter tails and short + /// recordings survive. Each caller owns a converter per native-rate segment. + public static func resampleForSpeech(_ samples: [Float], from inputRate: Double, to outputRate: Double = 16000) throws -> [Float] { + guard AudioRecordingFormatPolicy.isUsableSampleRate(inputRate), + AudioRecordingFormatPolicy.isUsableSampleRate(outputRate) else { + throw SpeechConversionError.invalidFormat + } + guard !samples.isEmpty, inputRate != outputRate else { return samples } + let expectedCount = Double(samples.count) * outputRate / inputRate + guard expectedCount.isFinite, expectedCount < Double(Int.max), + let sourceFormat = AVAudioFormat(standardFormatWithSampleRate: inputRate, channels: 1), + let targetFormat = AVAudioFormat(standardFormatWithSampleRate: outputRate, channels: 1), + let converter = AVAudioConverter(from: sourceFormat, to: targetFormat), + let source = AVAudioPCMBuffer(pcmFormat: sourceFormat, frameCapacity: 4096), + let destination = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: 4096) else { + throw SpeechConversionError.invalidFormat + } + converter.sampleRateConverterQuality = AVAudioQuality.max.rawValue + var offset = 0 + var output: [Float] = [] + output.reserveCapacity(Int(expectedCount)) + while true { + var conversionError: NSError? + let previousOffset = offset + let status = converter.convert(to: destination, error: &conversionError) { requestedFrames, inputStatus in + guard offset < samples.count else { + inputStatus.pointee = .endOfStream + return nil + } + let count = min(Int(requestedFrames), Int(source.frameCapacity), samples.count - offset) + source.frameLength = AVAudioFrameCount(count) + samples.withUnsafeBufferPointer { values in + source.floatChannelData![0].update(from: values.baseAddress! + offset, count: count) + } + offset += count + inputStatus.pointee = .haveData + return source + } + if let conversionError { throw conversionError } + guard status != .error else { throw SpeechConversionError.conversionFailed } + output.append(contentsOf: UnsafeBufferPointer(start: destination.floatChannelData![0], count: Int(destination.frameLength))) + if status == .endOfStream { break } + guard destination.frameLength > 0 || offset > previousOffset else { + throw SpeechConversionError.noProgress + } + } + return output + } + + private enum SpeechConversionError: Error { + case invalidFormat, conversionFailed, noProgress + } + /// Load a WAV file and return mono Float32 samples at the file's native sample rate. /// Converts stereo to mono by averaging channels. public static func loadWAV(url: URL) throws -> (samples: [Float], sampleRate: Double) { diff --git a/Sources/TranscriptedCore/Audio/MicrophoneDownmix.swift b/Sources/TranscriptedCore/Audio/MicrophoneDownmix.swift new file mode 100644 index 000000000..af4aa22bc --- /dev/null +++ b/Sources/TranscriptedCore/Audio/MicrophoneDownmix.swift @@ -0,0 +1,50 @@ +import AVFoundation + +/// Microphone channels can be silent duplicates or opposite-polarity pairs. +/// Keep the strongest channel instead of cancelling speech by averaging them. +public enum MicrophoneDownmix { + public static func monoSamples(from buffer: AVAudioPCMBuffer) -> [Float]? { + let frames = Int(buffer.frameLength) + let channels = Int(buffer.format.channelCount) + guard frames > 0, channels > 0 else { return [] } + guard let data = buffer.floatChannelData else { return nil } + if channels == 1 { + return Array(UnsafeBufferPointer(start: data[0], count: frames)) + } + var output = [Float](repeating: 0, count: frames) + output.withUnsafeMutableBufferPointer { destination in + copyMonoSamples(from: buffer, to: destination.baseAddress!) + } + return output + } + + /// Destination must have space for buffer.frameLength Float samples. + static func copyMonoSamples(from buffer: AVAudioPCMBuffer, to destination: UnsafeMutablePointer) { + let frames = Int(buffer.frameLength) + let channels = Int(buffer.format.channelCount) + guard frames > 0, channels > 0, let data = buffer.floatChannelData else { return } + let interleaved = buffer.format.isInterleaved + var strongest = 0 + var strongestEnergy: Double = -1 + if channels > 1 { + for channel in 0.. strongestEnergy { + strongestEnergy = energy + strongest = channel + } + } + } + if !interleaved || channels == 1 { + destination.update(from: data[strongest], count: frames) + } else { + for frame in 0.. DictationActiveTaskCancellationPlan { DictationActiveTaskCancellationPlan( - cancelStreamingTask: !sttIsTranscribing, + // Cancellation is cooperative: queued work can exit immediately, + // while native inference retains its busy state until it returns. + cancelStreamingTask: true, cancelSpeechEngine: cancelRecording && !sttIsTranscribing && (sttIsRecording || recordingStartWasInFlight) diff --git a/Sources/UI/Overlay/DictationSessionController.swift b/Sources/UI/Overlay/DictationSessionController.swift index 9b7552bcd..302c6027b 100644 --- a/Sources/UI/Overlay/DictationSessionController.swift +++ b/Sources/UI/Overlay/DictationSessionController.swift @@ -229,7 +229,8 @@ class DictationSessionController: ObservableObject { message: "Dictation started", context: dictationContext( extra: [ - "trigger": trigger.rawValue + "trigger": trigger.rawValue, + "request_to_recording_ms": "\(max(0, Int((CFAbsoluteTimeGetCurrent() - sessionStartTime) * 1_000)))" ] ) ) @@ -845,6 +846,8 @@ class DictationSessionController: ObservableObject { } await checkpointSignal.complete() + guard !Task.isCancelled, self.isDictating, + self.currentDictationSessionID == taskSessionID else { return } // Surface model warmup honestly instead of calling it "Transcribing" // before the local dictation model is actually ready. @@ -869,18 +872,13 @@ class DictationSessionController: ObservableObject { // Nothing is loading the model; kick (or join) the // deduped initialization instead of waiting for // another caller to do it. - let stateBefore = appState.sttRouter.recordingModelDownloadState.diagnosticName - await appState.sttRouter.initializeRecordingModel() - // If initialization bailed without progressing (e.g. - // mid-shutdown), sleep so this loop can't spin hot. - if appState.sttRouter.recordingModelDownloadState.diagnosticName == stateBefore { - try? await Task.sleep(nanoseconds: TranscriptedConstants.modelLoadPollInterval) - } + appState.sttRouter.requestRecordingModelInitialization() + await appState.sttRouter.waitForRecordingModelLoadProgress(until: modelWaitDeadline) case .downloading, .loading, .ready: - await appState.sttRouter.waitForRecordingModelLoadProgress() + await appState.sttRouter.waitForRecordingModelLoadProgress(until: modelWaitDeadline) } } - guard self.isDictating, + guard !Task.isCancelled, self.isDictating, self.currentDictationSessionID == taskSessionID else { return } guard appState.sttRouter.isRecordingModelLoaded else { appState.logger.log("DICTATION | voice model failed to load for transcription") @@ -982,7 +980,7 @@ class DictationSessionController: ObservableObject { // but do NOT paste it into whatever app now holds focus and do // NOT auto-send — the cap exists to rescue abandoned sessions, // not to inject text into an unattended app. - self.finalizeWithoutPaste( + await self.finalizeWithoutPaste( text: text, appState: appState, overlayController: overlayController, @@ -996,34 +994,40 @@ class DictationSessionController: ObservableObject { stopTiming.pasteStartedAt = CFAbsoluteTimeGetCurrent() let pasteOutcome = self.pasteWithClipboardRestore(text) stopTiming.pastedAt = CFAbsoluteTimeGetCurrent() + // Paste confirmation pumps the run loop, so cancellation/restart can occur here too. + guard DictationSessionCompletionPolicy.canPublish( + sessionID: taskSessionID, currentSessionID: self.currentDictationSessionID, + isDictating: self.isDictating, cancelled: Task.isCancelled + ) else { return } stopTiming.pasteBreakdown = self.textPaster.lastPasteTiming + // Capture ownership before suspending. The writer may outlive cancellation. + let recovery = self.stoppedAudioRecovery + let saveContext = self.dictationContext() + stopTiming.finalizationStartedAt = CFAbsoluteTimeGetCurrent() let finalization = await DictationStopFinalizer.finalize( order: DictationStopFinalizationPolicy.order, startSaving: { - stopTiming.saveStartedAt = CFAbsoluteTimeGetCurrent() return self.startPersistingDictationTranscript( text: text, - delivery: pasteOutcome.delivery + delivery: pasteOutcome.delivery, + recovery: recovery ) }, finishSaving: { saveTask in - let failure = await self.finishPersistingDictationTranscript( - saveTask, - delivery: pasteOutcome.delivery - ) - stopTiming.savedAt = CFAbsoluteTimeGetCurrent() - return failure + let result = await saveTask.value + self.publishDictationTranscriptPersistence(result, delivery: pasteOutcome.delivery, context: saveContext) + return result }, saveSynchronously: { - stopTiming.saveStartedAt = CFAbsoluteTimeGetCurrent() - let failure = self.persistDictationTranscript(text: text, delivery: pasteOutcome.delivery) - stopTiming.savedAt = CFAbsoluteTimeGetCurrent() - return failure + let result = self.persistDictationTranscript(text: text, delivery: pasteOutcome.delivery) + _ = DictationStoppedAudioRecoveryStore.cleanup(recovery, transcriptPersisted: result.saved != nil) + return result }, performAutoEnter: { stopTiming.autoEnterStartedAt = CFAbsoluteTimeGetCurrent() let outcome = await self.performAutoEnterIfNeeded( - pasteOutcome: pasteOutcome + pasteOutcome: pasteOutcome, + sessionID: taskSessionID ) stopTiming.autoEnterFinishedAt = CFAbsoluteTimeGetCurrent() return outcome @@ -1031,7 +1035,16 @@ class DictationSessionController: ObservableObject { ) let autoSendOutcome = finalization.autoEnterOutcome let saveResult = finalization.saveResult - self.discardStoppedAudioRecovery(transcriptPersisted: saveResult.saved != nil) + guard DictationSessionCompletionPolicy.canPublish( + sessionID: taskSessionID, currentSessionID: self.currentDictationSessionID, + isDictating: self.isDictating, cancelled: Task.isCancelled + ) else { return } + if saveResult.saved != nil, self.stoppedAudioRecovery == recovery { + self.stoppedAudioRecovery = nil + } + stopTiming.saveStartedAt = saveResult.startedAt + stopTiming.savedAt = saveResult.finishedAt + stopTiming.savePublishedAt = CFAbsoluteTimeGetCurrent() let saveFailureMessage = saveResult.failureMessage let wordCount = text.split(whereSeparator: \.isWhitespace).count stopTiming.completedAt = CFAbsoluteTimeGetCurrent() @@ -1172,10 +1185,20 @@ class DictationSessionController: ObservableObject { appState: TranscriptedAppState, overlayController: FloatingOverlayController, sessionID: UUID - ) { + ) async { lastCompletedText = text - let saveResult = persistDictationTranscript(text: text, delivery: .savedWithoutPaste) - discardStoppedAudioRecovery(transcriptPersisted: saveResult.saved != nil) + let recovery = stoppedAudioRecovery + let saveContext = dictationContext() + let saveTask = startPersistingDictationTranscript(text: text, delivery: .savedWithoutPaste, recovery: recovery) + let saveResult = await saveTask.value + publishDictationTranscriptPersistence(saveResult, delivery: .savedWithoutPaste, context: saveContext) + guard DictationSessionCompletionPolicy.canPublish( + sessionID: sessionID, currentSessionID: currentDictationSessionID, + isDictating: isDictating, cancelled: Task.isCancelled + ) else { return } + if saveResult.saved != nil, stoppedAudioRecovery == recovery { + stoppedAudioRecovery = nil + } let saveFailureMessage = saveResult.failureMessage let wordCount = text.split(whereSeparator: \.isWhitespace).count let durationSeconds = CFAbsoluteTimeGetCurrent() - sessionStartTime @@ -1763,7 +1786,8 @@ class DictationSessionController: ObservableObject { } private func performAutoEnterIfNeeded( - pasteOutcome: DictationPasteOutcome + pasteOutcome: DictationPasteOutcome, + sessionID: UUID ) async -> DictationAutoSendOutcome { guard autoSendRequestDecision.expected, pasteOutcome.allowsAutoSend else { @@ -1771,45 +1795,30 @@ class DictationSessionController: ObservableObject { } try? await Task.sleep(nanoseconds: TranscriptedConstants.dictationAutoEnterDelay) - guard !Task.isCancelled else { return .disabled } + guard DictationSessionCompletionPolicy.canPublish( + sessionID: sessionID, currentSessionID: currentDictationSessionID, + isDictating: isDictating, cancelled: Task.isCancelled + ) else { return .disabled } if pasteOutcome.requiresClipboardReadinessBeforeAutoSend { await textPaster.waitForClipboardReadyForAutoEnter() } - guard !Task.isCancelled else { return .disabled } + guard DictationSessionCompletionPolicy.canPublish( + sessionID: sessionID, currentSessionID: currentDictationSessionID, + isDictating: isDictating, cancelled: Task.isCancelled + ) else { return .disabled } return autoSender.send(autoSendRequestDecision.key, target: sessionPasteTarget) } - private struct DictationTranscriptPersistenceResult { - let saved: SavedDictationTranscript? - let failureMessage: String? - let failureError: Error? - - static func success(_ saved: SavedDictationTranscript) -> Self { - Self(saved: saved, failureMessage: nil, failureError: nil) - } - - static func failure(_ message: String) -> Self { - Self(saved: nil, failureMessage: message, failureError: nil) - } - - static func failure(_ error: Error) -> Self { - Self(saved: nil, failureMessage: nil, failureError: error) - } - } - @discardableResult private func persistDictationTranscript(text: String, delivery: DictationDelivery) -> DictationTranscriptPersistenceResult { - do { - let saved = try DictationTranscriptStore.save( - text: text, - sourceApp: sessionSourceApp, - delivery: delivery + let result = DictationTranscriptPersistenceResult.measure { + try DictationTranscriptWriter.save( + text: text, sourceAppName: sessionSourceApp?.localizedName ?? "Unknown", + sourceBundleID: sessionSourceApp?.bundleIdentifier, delivery: delivery ) - recordDictationTranscriptSaved(saved, delivery: delivery) - return .success(saved) - } catch { - return .failure(recordDictationTranscriptSaveFailed(error)) } + publishDictationTranscriptPersistence(result, delivery: delivery, context: dictationContext()) + return result } private func discardStoppedAudioRecovery( @@ -1826,46 +1835,45 @@ class DictationSessionController: ObservableObject { private func startPersistingDictationTranscript( text: String, - delivery: DictationDelivery + delivery: DictationDelivery, + recovery: DictationStoppedAudioRecovery? ) -> Task { let sourceAppName = sessionSourceApp?.localizedName ?? "Unknown" let sourceBundleID = sessionSourceApp?.bundleIdentifier return Task.detached(priority: .utility) { - do { - let saved = try DictationTranscriptWriter.save( + let result = DictationTranscriptPersistenceResult.measure { + try DictationTranscriptWriter.save( text: text, sourceAppName: sourceAppName, sourceBundleID: sourceBundleID, delivery: delivery ) - return .success(saved) - } catch { - return .failure(error) } + // Clean only this writer's checkpoint, even if a new session has started. + _ = DictationStoppedAudioRecoveryStore.cleanup(recovery, transcriptPersisted: result.saved != nil) + return result } } - private func finishPersistingDictationTranscript( - _ task: Task, - delivery: DictationDelivery - ) async -> DictationTranscriptPersistenceResult { - let result = await task.value + private func publishDictationTranscriptPersistence( + _ result: DictationTranscriptPersistenceResult, + delivery: DictationDelivery, + context: [String: String] + ) { + // Artifact notifications are global; diagnostics must retain the saving session's context. if let saved = result.saved { + recordDictationTranscriptSaved(saved, delivery: delivery, context: context) NotificationCenter.default.post(name: .dictationTranscriptDidSave, object: saved.url) - recordDictationTranscriptSaved(saved, delivery: delivery) - return result + } else if let error = result.failureError { + recordDictationTranscriptSaveFailed(error, context: context) } - - if let error = result.failureError { - return .failure(recordDictationTranscriptSaveFailed(error)) - } - return result } private func recordDictationTranscriptSaved( _ saved: SavedDictationTranscript, - delivery: DictationDelivery + delivery: DictationDelivery, + context: [String: String] ) { appState?.logger.log("DICTATION | saved markdown export at \(saved.url.lastPathComponent)") DiagnosticsTrail.record( @@ -1873,11 +1881,7 @@ class DictationSessionController: ObservableObject { engine: "dictation", event: "dictation_export_saved", message: "Saved dictation markdown export", - context: dictationContext( - extra: [ - "delivery": delivery.rawValue - ] - ) + context: context.merging(["delivery": delivery.rawValue]) { _, new in new } ) } @@ -1899,7 +1903,7 @@ class DictationSessionController: ObservableObject { ) } - private func recordDictationTranscriptSaveFailed(_ error: Error) -> String { + private func recordDictationTranscriptSaveFailed(_ error: Error, context: [String: String]) { appState?.logger.log("DICTATION | failed to save markdown export: \(error.localizedDescription)") DiagnosticsTrail.record( logger: appState?.logger, @@ -1907,9 +1911,8 @@ class DictationSessionController: ObservableObject { engine: "dictation", event: "dictation_export_failed", message: "Failed to save dictation markdown export", - context: dictationContext(extra: ["error": error.localizedDescription]) + context: context.merging(["error": error.localizedDescription]) { _, new in new } ) - return "Transcripted couldn't save a local copy of this dictation. Check your save location and available disk space." } private func trackDictationDeliveryFriction( @@ -2099,6 +2102,8 @@ private struct DictationStopTiming { var pasteBreakdown: ClipboardPasteTiming? var autoEnterStartedAt: CFAbsoluteTime? var autoEnterFinishedAt: CFAbsoluteTime? + var finalizationStartedAt: CFAbsoluteTime? + var savePublishedAt: CFAbsoluteTime? var saveStartedAt: CFAbsoluteTime? var savedAt: CFAbsoluteTime? var completedAt: CFAbsoluteTime? @@ -2125,6 +2130,8 @@ private struct DictationStopTiming { } values["auto_enter_ms"] = milliseconds(from: autoEnterStartedAt, to: autoEnterFinishedAt) values["save_ms"] = milliseconds(from: saveStartedAt, to: savedAt) + values["save_publication_wait_ms"] = milliseconds(from: savedAt, to: savePublishedAt) + values["finalization_ms"] = milliseconds(from: finalizationStartedAt, to: savePublishedAt) values["stop_to_paste_ms"] = milliseconds(from: requestedAt, to: pastedAt) values["stop_to_save_ms"] = milliseconds(from: requestedAt, to: savedAt) values["stop_to_done_ms"] = milliseconds(from: requestedAt, to: completedAt) diff --git a/Tests/ASRInferenceWaiterQueueTests.swift b/Tests/ASRInferenceWaiterQueueTests.swift new file mode 100644 index 000000000..4c725d1ea --- /dev/null +++ b/Tests/ASRInferenceWaiterQueueTests.swift @@ -0,0 +1,73 @@ +import Foundation + +@MainActor +func testASRInferenceWaiterQueue() async { + let queue = ASRInferenceWaiterQueue() + let first = Task { @MainActor () -> Bool in + do { try await queue.wait(); return true } catch { return false } + } + while queue.count < 1 { await Task.yield() } + let canceled = Task { @MainActor () -> Bool in + do { try await queue.wait(); return true } catch { return false } + } + while queue.count < 2 { await Task.yield() } + canceled.cancel() + let canceledWasGranted = await canceled.value + assertTrue(!canceledWasGranted, "queued cancellation finishes without waiting for active inference") + assertEqual(queue.count, 1, "cancellation removes only its own queued continuation") + queue.resumeFirst() + let firstWasGranted = await first.value + assertTrue(firstWasGranted, "remaining FIFO waiter still receives handoff") + assertTrue(queue.isEmpty, "handoff removes granted continuation") + + let handoff = Task { @MainActor () -> Bool in + do { try await queue.wait(); return true } catch { return false } + } + while queue.count < 1 { await Task.yield() } + queue.resumeFirst() + handoff.cancel() + let handoffWasGranted = await handoff.value + assertTrue(handoffWasGranted, "cancellation after handoff leaves slot release to the granted caller") + assertTrue(queue.isEmpty, "late cancellation cannot consume another waiter") + + let alreadyCanceled = Task { @MainActor () -> Bool in + do { try await queue.wait(); return true } catch { return false } + } + alreadyCanceled.cancel() + let alreadyCanceledWasGranted = await alreadyCanceled.value + assertTrue(!alreadyCanceledWasGranted, "pre-canceled requests never enter the queue") + assertTrue(queue.isEmpty, "pre-cancellation leaves no continuation behind") + + let plan = DictationActiveTaskCancellationPolicy.plan( + cancelRecording: true, recordingStartWasInFlight: false, + sttIsRecording: false, sttIsTranscribing: true + ) + var queuedBusy = true + let queuedSession = Task { @MainActor in + defer { queuedBusy = false } + do { try await queue.wait() } catch { } + } + while queue.isEmpty { await Task.yield() } + assertTrue(plan.cancelStreamingTask, "explicit session cancellation must reach the inference waiter") + if plan.cancelStreamingTask { queuedSession.cancel() } + else { queue.resumeFirst() } // Let a failed policy assertion finish without hanging the runner. + await queuedSession.value + assertTrue(!queuedBusy && queue.isEmpty, "real session cancellation plan releases a queued inference caller") + assertTrue(!plan.cancelSpeechEngine, "canceling a queued caller leaves shared native decoder work intact") + + var nativeCompletion: CheckedContinuation? + var nativeBusy = true + let activeSession = Task { @MainActor in + defer { nativeBusy = false } + // Stand in for native work that cannot stop until its callback returns. + await withCheckedContinuation { nativeCompletion = $0 } + } + while nativeCompletion == nil { await Task.yield() } + if plan.cancelStreamingTask { activeSession.cancel() } + await Task.yield() + assertTrue(nativeBusy, "cooperative session cancellation does not mark active native work idle") + nativeCompletion?.resume() + await activeSession.value + assertTrue(!nativeBusy, "active session becomes idle only after native completion") + +} diff --git a/Tests/AuditRegressionCoverageContractTests.swift b/Tests/AuditRegressionCoverageContractTests.swift index 69e751760..ded402573 100644 --- a/Tests/AuditRegressionCoverageContractTests.swift +++ b/Tests/AuditRegressionCoverageContractTests.swift @@ -36,7 +36,7 @@ func testAuditRegressionCoverageContract() { "focus drift should produce an honest copied result instead of a false pasted result" ) assertTrue( - source.contains("guard pasteDispatcher() else"), + source.contains("let dispatched = pasteDispatcher()") && source.contains("guard dispatched else"), "the paste dispatch result must remain an explicit fallback seam" ) } diff --git a/Tests/BluetoothRouteContractTests.swift b/Tests/BluetoothRouteContractTests.swift index 05e9aeb05..5308df5a1 100644 --- a/Tests/BluetoothRouteContractTests.swift +++ b/Tests/BluetoothRouteContractTests.swift @@ -407,7 +407,7 @@ func testBluetoothRouteContract() { let bufferFormat = tapBody.range(of: "Self.audioFormatSummary(buffer.format)"), let effectiveRate = tapBody.range(of: "ParakeetTapSampleRatePolicy.effectiveSampleRate"), let retainedRate = tapBody.range(of: "pendingSamples.append(monoSamples, sampleRate: effectiveSampleRate)"), - let segments = inferenceBody.range(of: "let segments = recoveredRecordingTimeline.drain()"), + let segments = inferenceBody.range(of: "resampleRecordedSegments(recoveredRecordingTimeline.segments)"), let resampleRate = inferenceBody.range(of: "from: segment.sampleRate") else { assertTrue(false, "dictation tap should use the delivered buffer format for sample-rate bookkeeping") return diff --git a/Tests/ClipboardRestoringTextPasterTests.swift b/Tests/ClipboardRestoringTextPasterTests.swift index 42c9107d6..bf61082c2 100644 --- a/Tests/ClipboardRestoringTextPasterTests.swift +++ b/Tests/ClipboardRestoringTextPasterTests.swift @@ -18,6 +18,117 @@ import Foundation func testClipboardRestoringTextPaster() async { await MainActor.run { + runSuite("ClipboardRestoringTextPaster cancellation while restoring the previous paste cannot restart") { + let board = FakeClipboardPasteboard(initialString: "original") + let paster = ClipboardRestoringTextPaster() + _ = paster.paste("previous", pasteboard: board, accessibilityTrusted: { true }, + requestAccessibilityTrust: {}, pasteDispatcher: { true }, pasteConfirmed: { true }) + board.onStringRead = { + board.onStringRead = nil + paster.restorePendingClipboardNow() + } + var dispatched = false + let result = paster.paste("cancelled next paste", pasteboard: board, + accessibilityTrusted: { true }, requestAccessibilityTrust: {}, + pasteDispatcher: { dispatched = true; return true }, pasteConfirmed: { true }) + assertEqual(result.failureReason, .cancelled, "restoring a prior clipboard cannot mint a new operation after cancellation") + assertFalse(dispatched, "cancelled operation must not dispatch") + assertEqual(board.string(forType: .string), "original", "prior clipboard should remain restored") + } + runSuite("ClipboardRestoringTextPaster cancellation inside initial write restores the original snapshot") { + let board = FakeClipboardPasteboard(initialString: "original") + let paster = ClipboardRestoringTextPaster() + board.onStringWritten = { + board.onStringWritten = nil + paster.restorePendingClipboardNow() + } + var dispatched = false + let result = paster.paste("cancelled", pasteboard: board, + accessibilityTrusted: { true }, requestAccessibilityTrust: {}, + pasteDispatcher: { dispatched = true; return true }, pasteConfirmed: { true }) + assertEqual(result.failureReason, .cancelled, "write callback cancellation must remain terminal") + assertFalse(dispatched, "write callback cancellation must not dispatch") + assertEqual(board.string(forType: .string), "original", "rollback must restore before the pending record exists") + } + + runSuite("ClipboardRestoringTextPaster cancellation during activation cannot copy or dispatch") { + let board = FakeClipboardPasteboard(initialString: "original") + let paster = ClipboardRestoringTextPaster() + var cancelled = false + var dispatched = false + CFRunLoopPerformBlock(CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) { + MainActor.assumeIsolated { + cancelled = true + paster.restorePendingClipboardNow() + } + } + let result = paster.paste("cancelled dictation", + target: DictationPasteTarget(processIdentifier: -1, bundleIdentifier: "invalid.test.target"), + activationWait: 0.2, pasteboard: board, + pasteDispatcher: { dispatched = true; return true }) + assertTrue(cancelled, "activation wait must run the queued cancellation") + assertEqual(result.failureReason, .cancelled, "cancelled attempt cannot report copied") + assertFalse(dispatched, "cancelled activation must never send Cmd+V") + assertEqual(board.string(forType: .string), "original", "cancelled activation must preserve clipboard") + } + runSuite("ClipboardRestoringTextPaster cancellation during confirmation restores without fallback copy") { + let board = FakeClipboardPasteboard(initialString: "original") + let paster = ClipboardRestoringTextPaster() + let result = paster.paste("cancelled dictation", pasteboard: board, + accessibilityTrusted: { true }, requestAccessibilityTrust: {}, + pasteDispatcher: { true }, pasteConfirmed: { + paster.restorePendingClipboardNow() + return true + }) + assertEqual(result.failureReason, .cancelled, "late confirmation must not resurrect a cancelled paste") + assertEqual(board.string(forType: .string), "original", "no recovery copy may overwrite restored clipboard") + } + + runSuite("ClipboardRestoringTextPaster stops fetching lazy data after snapshot budget is full") { + let board = FakeClipboardPasteboard(initialString: nil) + let item = NSPasteboardItem() + for type in [NSPasteboard.PasteboardType.string, .html, .rtf] { + assertTrue(item.setData(Data([1]), forType: type), "budget fixture must install each supported pasteboard representation") + } + assertEqual(item.types.count, 3, "budget fixture must expose three representations") + _ = board.writePasteboardItems([item]) + let data = Data(repeating: 1, count: TranscriptedConstants.clipboardSnapshotMaxTypeBytes) + var reads = 0 + let snapshot = ClipboardRestoringTextPaster().snapshotPasteboardItems(from: board) { _, _ in + reads += 1 + return data + } + assertEqual(reads, 2, "do not materialize a third representation after retaining the full budget") + assertTrue(snapshot.isComplete, "same item still has restorable representations") + let secondItem = NSPasteboardItem() + secondItem.setString("another item", forType: .string) + _ = board.writePasteboardItems([item, secondItem]) + reads = 0 + let incomplete = ClipboardRestoringTextPaster().snapshotPasteboardItems(from: board) { _, _ in + reads += 1 + return data + } + assertEqual(reads, 2, "later items must not trigger fetches after the total budget") + assertFalse(incomplete.isComplete, "an item with no saved representation must still block destructive paste") + } + + runSuite("ClipboardRestoringTextPaster preserves a clipboard changed during lazy snapshot") { + let board = FakeClipboardPasteboard(initialString: "old") + board.onPasteboardItemsRead = { _ = board.setString("new external copy", forType: .string) } + let paster = ClipboardRestoringTextPaster() + var dispatched = false + let outcome = paster.paste("dictation", pasteboard: board, + accessibilityTrusted: { true }, requestAccessibilityTrust: {}, + pasteDispatcher: { dispatched = true; return true }, + pasteConfirmed: { true }) + board.onPasteboardItemsRead = nil + assertFalse(dispatched, "do not send Cmd+V after losing clipboard snapshot ownership") + assertEqual(outcome.failureReason, .clipboardSnapshotIncomplete, "changed snapshot must fail safely") + assertEqual(board.string(forType: .string), "new external copy", "new clipboard must remain intact") + assertNotNil(paster.lastPasteTiming?.measurements()["paste_ax_capture_ms"], "AX capture has separate timing") + assertNotNil(paster.lastPasteTiming?.measurements()["paste_clipboard_snapshot_ms"], "snapshot has separate timing") + } + runSuite("DictationTargetConfirmationMode stays coarse and privacy-safe") { assertEqual( DictationTargetConfirmationMode.resolve( @@ -51,7 +162,7 @@ func testClipboardRestoringTextPaster() async { encoding: .utf8 ) assertTrue( - source.contains("AXUIElementSetMessagingTimeout(element, messagingTimeout)"), + source.contains("AXUIElementSetMessagingTimeout(element, messagingTimeout)") && source.contains("AXUIElementSetMessagingTimeout(systemWideElement, messagingTimeout)"), "paste confirmation must bound synchronous AX reads so busy editors cannot stall delivery" ) assertTrue( @@ -2220,6 +2331,7 @@ private final class FakeClipboardPasteboard: ClipboardPasteboard { private var storedString: String? private var storedItems: [NSPasteboardItem]? var onStringRead: (() -> Void)? + var onStringWritten: (() -> Void)? var onPasteboardItemsRead: (() -> Void)? init( @@ -2274,6 +2386,7 @@ private final class FakeClipboardPasteboard: ClipboardPasteboard { storedString = string storedItems = nil changeCount += 1 + onStringWritten?() return true } diff --git a/Tests/CustomDictionaryPreferencesTests.swift b/Tests/CustomDictionaryPreferencesTests.swift index ae55d0d41..429f4d6c2 100644 --- a/Tests/CustomDictionaryPreferencesTests.swift +++ b/Tests/CustomDictionaryPreferencesTests.swift @@ -1,6 +1,25 @@ import Foundation func testCustomDictionaryPreferences() { + runSuite("CustomDictionaryPreferences cached parsing follows raw values across suites and external edits") { + let (first, firstName) = makeCustomDictionaryDefaults() + let (second, secondName) = makeCustomDictionaryDefaults() + defer { + first.removePersistentDomain(forName: firstName) + second.removePersistentDomain(forName: secondName) + } + CustomDictionaryPreferences.setRawText("aye -> A", userDefaults: first) + CustomDictionaryPreferences.setRawText("bee -> B", userDefaults: second) + for _ in 0..<3 { + assertEqual(CustomDictionaryPreferences.entries(userDefaults: first), [CustomDictionaryEntry(spoken: "aye", replacement: "A")], "cache must follow the requested suite") + assertEqual(CustomDictionaryPreferences.entries(userDefaults: second), [CustomDictionaryEntry(spoken: "bee", replacement: "B")], "cache must not leak another suite") + } + first.set("see -> C", forKey: "customDictionaryRawText") + assertEqual(CustomDictionaryPreferences.entries(userDefaults: first), [CustomDictionaryEntry(spoken: "see", replacement: "C")], "direct preference edits must invalidate by value") + first.removeObject(forKey: "customDictionaryRawText") + assertEqual(CustomDictionaryPreferences.entries(userDefaults: first), [], "clearing preferences must clear cached content") + } + runSuite("CustomDictionaryPreferences defaults to an empty dictionary") { let (defaults, suiteName) = makeCustomDictionaryDefaults() defer { defaults.removePersistentDomain(forName: suiteName) } diff --git a/Tests/DictationFillerCleanupPolicyTests.swift b/Tests/DictationFillerCleanupPolicyTests.swift index d6d91eaf9..c3c343c30 100644 --- a/Tests/DictationFillerCleanupPolicyTests.swift +++ b/Tests/DictationFillerCleanupPolicyTests.swift @@ -1,6 +1,25 @@ import Foundation func testDictationFillerCleanupPolicy() { + runSuite("DictationFillerCleanupPolicy collapses whole runs with case and boundaries preserved") { + let cases: [(String, String, Int)] = [ + ("i I i I", "i", 3), + ("I\tI i feel ready", "I feel ready", 2), + ("I I, I I", "I, I", 2), + ("I I\nI I", "I \nI", 2), + ("I I_item I I9 I Î", "I I_item I I9 I Î.", 0), + ("👋 I I café", "👋 I café", 1) + ] + for (input, output, count) in cases { + let result = DictationFillerCleanupPolicy.clean(input) + assertEqual(result.text, output, "duplicate runs must preserve text boundaries") + assertEqual(result.removedCount, count, "count every removed word") + } + let longRun = DictationFillerCleanupPolicy.clean(Array(repeating: "I", count: 2_000).joined(separator: " ")) + assertEqual(longRun.text, "I", "long run must collapse completely") + assertEqual(longRun.removedCount, 1_999, "long run removal count must remain exact") + } + runSuite("DictationFillerCleanupPolicy removes clear spoken fillers") { let cleaned = DictationFillerCleanupPolicy.clean("Um, okay, I I think we should ship this uh today") diff --git a/Tests/DictationRecordingStartOverlayPolicyTests.swift b/Tests/DictationRecordingStartOverlayPolicyTests.swift index 060cb9a5f..153eb1990 100644 --- a/Tests/DictationRecordingStartOverlayPolicyTests.swift +++ b/Tests/DictationRecordingStartOverlayPolicyTests.swift @@ -222,7 +222,7 @@ func testDictationRecordingStartOverlayPolicy() { ) } - runSuite("DictationActiveTaskCancellationPolicy leaves active inference alone") { + runSuite("DictationActiveTaskCancellationPolicy cancels caller without tearing down inference") { let plan = DictationActiveTaskCancellationPolicy.plan( cancelRecording: true, recordingStartWasInFlight: false, @@ -230,7 +230,7 @@ func testDictationRecordingStartOverlayPolicy() { sttIsTranscribing: true ) - assertFalse(plan.cancelStreamingTask, "active CoreML transcription should be allowed to finish") + assertTrue(plan.cancelStreamingTask, "queued inference must receive caller cancellation") assertFalse(plan.cancelSpeechEngine, "active CoreML transcription should not race engine cleanup") } diff --git a/Tests/DictationSessionCapTests.swift b/Tests/DictationSessionCapTests.swift index 658e50e5d..d469496a2 100644 --- a/Tests/DictationSessionCapTests.swift +++ b/Tests/DictationSessionCapTests.swift @@ -98,7 +98,7 @@ func testDictationSessionCap() { to: "/// Cancel dictation without pasting" ) assertTrue( - finalizeBody.contains("persistDictationTranscript(text: text, delivery: .savedWithoutPaste)"), + finalizeBody.contains("startPersistingDictationTranscript(text: text, delivery: .savedWithoutPaste, recovery: recovery)"), "the cap finalize path should persist the transcript to the daily Markdown file" ) assertTrue( diff --git a/Tests/DictationStoppedAudioRecoveryTests.swift b/Tests/DictationStoppedAudioRecoveryTests.swift index 286c80350..23e795878 100644 --- a/Tests/DictationStoppedAudioRecoveryTests.swift +++ b/Tests/DictationStoppedAudioRecoveryTests.swift @@ -247,7 +247,7 @@ func testDictationStoppedAudioRecovery() { 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("DictationStoppedAudioRecoveryStore.cleanup(recovery, transcriptPersisted: result.saved != nil)"), "cleanup should be tied to successful transcript persistence") assertTrue(source.contains("if emptyReason != .modelFailure"), "model failures should retain recovery audio") assertTrue( source.contains("cancelDictation(preserveStoppedAudio: true)"), diff --git a/Tests/DictationTranscriptPersistenceTests.swift b/Tests/DictationTranscriptPersistenceTests.swift new file mode 100644 index 000000000..73ea36ffb --- /dev/null +++ b/Tests/DictationTranscriptPersistenceTests.swift @@ -0,0 +1,84 @@ +import Foundation + +@MainActor +private final class DictationCompletionTestState { + var sessionID = UUID() + var isDictating = true + var publications = 0 +} + +func testDictationTranscriptPersistence() async { + runSuite("Dictation save timing excludes delayed publication and includes failures") { + let saved = SavedDictationTranscript(url: URL(fileURLWithPath: "/tmp/synthetic-dictation.md"), title: "Synthetic") + var clock = [10.0, 10.003].makeIterator() + let result = DictationTranscriptPersistenceResult.measure(now: { clock.next()! }) { saved } + assertEqual(result.startedAt, 10.0, "writer start comes from the writer's clock") + assertEqual(result.finishedAt, 10.003, "writer finish is captured before publication") + assertNil(result.failureMessage, "successful save has no failure message") + enum SaveError: Error { case diskFull } + var errorClock = [20.0, 20.002].makeIterator() + let failure = DictationTranscriptPersistenceResult.measure(now: { errorClock.next()! }) { throw SaveError.diskFull } + assertEqual(failure.finishedAt, 20.002, "failed writes also have actual completion timing") + assertNil(failure.saved, "failed save must not claim an artifact") + assertTrue(failure.failureMessage != nil, "failed save retains actionable error copy") + } + + await runSuite("Delayed old save survives cancel and cannot publish over a new session") { @MainActor in + let state = DictationCompletionTestState() + let oldID = state.sessionID + let started = AsyncStream.makeStream() + let release = AsyncStream.makeStream() + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: folder) } + do { + let oldRecovery = try DictationStoppedAudioRecoveryStore.persist(samples16k: [0.1], sessionID: oldID, directory: folder) + let newID = UUID() + let newRecovery = try DictationStoppedAudioRecoveryStore.persist(samples16k: [0.2], sessionID: newID, directory: folder) + let output = folder.appendingPathComponent("synthetic.md") + let task = Task { @MainActor in + let result: DictationStopFinalizationResult = await DictationStopFinalizer.finalize( + order: .saveBeforeAutoEnter, + startSaving: { + Task.detached { + started.continuation.yield(()) + for await _ in release.stream { break } + let result = DictationTranscriptPersistenceResult.measure { + try "Synthetic retained text".write(to: output, atomically: true, encoding: .utf8) + return SavedDictationTranscript(url: output, title: "Synthetic") + } + _ = DictationStoppedAudioRecoveryStore.cleanup(oldRecovery, transcriptPersisted: result.saved != nil) + return result + } + }, + finishSaving: { await $0.value }, + saveSynchronously: { fatalError("Wrong finalization order") }, + performAutoEnter: { false } + ) + if DictationSessionCompletionPolicy.canPublish( + sessionID: oldID, currentSessionID: state.sessionID, + isDictating: state.isDictating, cancelled: Task.isCancelled + ) { + state.publications += 1 + state.isDictating = false + } + return result.saveResult + } + for await _ in started.stream { break } + task.cancel() + state.sessionID = newID + state.isDictating = true + release.continuation.yield(()) + let result = await task.value + assertTrue(result.saved != nil && FileManager.default.fileExists(atPath: output.path), "canceling UI cannot cancel a durable save already in progress") + assertEqual(state.publications, 0, "old completion must not publish over new session") + assertTrue(state.isDictating, "new session stays active") + assertEqual(state.sessionID, newID, "new session retains ownership") + assertFalse(FileManager.default.fileExists(atPath: oldRecovery!.url.path), "successful old save cleans its own checkpoint") + assertTrue(FileManager.default.fileExists(atPath: newRecovery!.url.path), "old save cannot delete new checkpoint") + assertFalse(DictationSessionCompletionPolicy.canPublish(sessionID: oldID, currentSessionID: newID, isDictating: true, cancelled: false), "identity alone fences uncanceled stale callbacks") + assertFalse(DictationSessionCompletionPolicy.canPublish(sessionID: newID, currentSessionID: newID, isDictating: true, cancelled: true), "cancellation fences current session callbacks") + } catch { + assertTrue(false, "synthetic persistence setup failed: \(error)") + } + } +} diff --git a/Tests/ModelLoadProgressWaiterTests.swift b/Tests/ModelLoadProgressWaiterTests.swift new file mode 100644 index 000000000..b6d124bf5 --- /dev/null +++ b/Tests/ModelLoadProgressWaiterTests.swift @@ -0,0 +1,77 @@ +import Combine +import Foundation + +@MainActor +func testModelLoadProgressWaiter() async { + let subject = PassthroughSubject() + var subscriptions = 0 + var cancellations = 0 + let changes = subject.handleEvents( + receiveSubscription: { _ in subscriptions += 1 }, + receiveCancel: { cancellations += 1 } + ).eraseToAnyPublisher() + let start = ProcessInfo.processInfo.systemUptime + await ModelLoadProgressWaiter.wait(for: changes, until: start + 0.02) + assertTrue(ProcessInfo.processInfo.systemUptime - start < 1, "a stalled load must release its caller at the deadline") + assertEqual(cancellations, 1, "timeout removes observation") + + let transition = Task { @MainActor in + await ModelLoadProgressWaiter.wait(for: changes, until: ProcessInfo.processInfo.systemUptime + 60) + } + while subscriptions < 2 { await Task.yield() } + subject.send(()) + await transition.value + assertEqual(cancellations, 2, "state transition removes observation and deadline timer") + + let canceled = Task { @MainActor in + await ModelLoadProgressWaiter.wait(for: changes, until: ProcessInfo.processInfo.systemUptime + 60) + } + while subscriptions < 3 { await Task.yield() } + canceled.cancel() + await canceled.value + assertEqual(cancellations, 3, "caller cancellation does not wait for native initialization") + + let alreadyCanceled = Task { @MainActor in + await ModelLoadProgressWaiter.wait(for: changes, until: ProcessInfo.processInfo.systemUptime + 60) + } + alreadyCanceled.cancel() + await alreadyCanceled.value + assertEqual(subscriptions, 3, "already canceled caller does not subscribe") + + await ModelLoadProgressWaiter.wait(for: changes, until: ProcessInfo.processInfo.systemUptime - 1) + assertEqual(subscriptions, 3, "expired deadline does not subscribe") + subject.send(()) + assertEqual(cancellations, 3, "late model progress cannot double-resume a finished waiter") + + // Each caller owns only its observation; timing out one must not finish or + // cancel another caller still waiting on the same shared initialization. + let shared = PassthroughSubject() + var sharedSubscriptions = 0 + var sharedCancellations = 0 + var survivorFinished = false + let sharedChanges = shared.handleEvents( + receiveSubscription: { _ in sharedSubscriptions += 1 }, + receiveCancel: { sharedCancellations += 1 } + ).eraseToAnyPublisher() + let survivor = Task { @MainActor in + await ModelLoadProgressWaiter.wait(for: sharedChanges, until: ProcessInfo.processInfo.systemUptime + 60) + survivorFinished = true + } + while sharedSubscriptions < 1 { await Task.yield() } + await ModelLoadProgressWaiter.wait(for: sharedChanges, until: ProcessInfo.processInfo.systemUptime + 0.02) + assertEqual(sharedCancellations, 1, "one caller timing out removes only its own subscription") + assertTrue(!survivorFinished, "another caller continues waiting after shared peer times out") + let canceledPeer = Task { @MainActor in + await ModelLoadProgressWaiter.wait(for: sharedChanges, until: ProcessInfo.processInfo.systemUptime + 60) + } + while sharedSubscriptions < 3 { await Task.yield() } + canceledPeer.cancel() + await canceledPeer.value + assertEqual(sharedCancellations, 2, "one caller canceling removes only its own subscription") + assertTrue(!survivorFinished, "another caller continues waiting after shared peer cancels") + shared.send(()) + await survivor.value + assertTrue(survivorFinished, "surviving caller receives the eventual shared model transition") + assertEqual(sharedCancellations, 3, "all completed callers release their observations") + +} diff --git a/Tests/ParakeetAudioGraphOwnershipTests.swift b/Tests/ParakeetAudioGraphOwnershipTests.swift index 56984daab..1acd4dfa4 100644 --- a/Tests/ParakeetAudioGraphOwnershipTests.swift +++ b/Tests/ParakeetAudioGraphOwnershipTests.swift @@ -4,6 +4,17 @@ import Foundation func testParakeetAudioGraphOwnership() async { + runSuite("Recorded conversion rejects cancelled, replaced, or discarded audio") { + let engine = NSObject() + let owner = ParakeetAudioGraphOwnerToken(generation: 3, engine: engine) + let claim = ParakeetRecordedSamplesClaim(graphOwner: owner, revision: 10) + assertTrue(claim.isCurrent(owner: owner, revision: 10, cancelled: false), "unchanged stopped audio can commit") + assertFalse(claim.isCurrent(owner: owner, revision: 10, cancelled: true), "cancelled conversion cannot publish errors or consume") + assertFalse(claim.isCurrent(owner: owner, revision: 11, cancelled: false), "same-graph discard/replacement invalidates conversion") + assertFalse(claim.isCurrent(owner: ParakeetAudioGraphOwnerToken(generation: 4, engine: engine), revision: 10, cancelled: false), "new recording generation owns its samples") + assertFalse(claim.isCurrent(owner: ParakeetAudioGraphOwnerToken(generation: 3, engine: NSObject()), revision: 10, cancelled: false), "replacement graph invalidates conversion") + } + runSuite("ParakeetZombieRecoveryOwnershipPolicy accepts only the exact active graph owner") { let engine = NSObject() let owner = ParakeetAudioGraphOwnerToken(generation: 7, engine: engine) @@ -706,8 +717,14 @@ func testParakeetAudioGraphOwnership() async { let entered = countLock.withLock { workersEntered } assertEqual(entered, 2, "the circuit must cap permanently blocked worker closures") - assertEqual(timeoutErrors, 2, "only the two admitted blocked workers should time out") - assertEqual(circuitOpenErrors, 10, "later attempts should fail immediately without new workers") + // The timeout starts when work is enqueued, not when its utility + // queue enters the closure. Under host load an attempt can expire + // before entry; that correctly consumes no blocked-worker capacity. + let queueExpiryErrors = timeoutErrors - entered + assertTrue(queueExpiryErrors >= 0, "every admitted blocked worker must time out") + assertEqual(timeoutErrors + circuitOpenErrors, 12, "every attempt must fail through the bounded coordinator") + assertTrue(circuitOpenErrors > 0, "after two workers block, later attempts must fail without entering work") + assertEqual(circuitOpenErrors, 10 - queueExpiryErrors, "only pre-entry queue expiries may replace circuit-open outcomes") for _ in 0.. [Float] { + (0.. Double { + let interior = samples.dropFirst(min(100, samples.count / 4)).dropLast(min(100, samples.count / 4)) + return sqrt(interior.reduce(0.0) { $0 + Double($1) * Double($1) } / Double(interior.count)) + } +} diff --git a/docs/dictation-hardening-2026-09-07.md b/docs/dictation-hardening-2026-09-07.md new file mode 100644 index 000000000..c226155bd --- /dev/null +++ b/docs/dictation-hardening-2026-09-07.md @@ -0,0 +1,35 @@ +# Dictation hardening — local trial + +This change follows the September 7 dictation audit. It preserves final-only dictation, per-app Auto Enter, recovery audio, daily Markdown storage, decoder serialization, and existing device-settling safeguards. + +## Workflow and ownership + +- Coordinator: session completion ownership, asynchronous session-cap persistence, save timing, combined build and trial artifact. +- Audio review: shared dominant-channel microphone conversion and antialiased stopped-audio resampling. +- Speech review: caller-bounded readiness waits and cancelable queued inference. +- Support review: paste attempt ownership, bounded AX/snapshot work, duplicate-word cleanup and dictionary cache. +- Adversarial review: speech reviewer checks audio and controller changes; support reviewer checks readiness and ASR handoff. Final integration belongs to the coordinator. Other model/hardware lanes were skipped because they add no distinct proof to these source-level checks. + +## Resulting behavior + +A canceled dictation's writer may finish saving its text, but can only clean its own recovery checkpoint and cannot reset a newer dictation's overlay or session. Saves triggered by the session cap also run off the main actor. Persistence diagnostics carry captured session context; writer timestamps exclude Auto Enter and delayed UI publication. + +Model readiness observes state changes with the existing timeout and caller cancellation. Waiting does not cancel shared model loading. Queued ASR requests can cancel before acquiring the serialized decoder; cancellation during handoff still releases its reservation correctly. + +Microphone downmix selects the strongest channel rather than averaging potentially silent/opposite-polarity channels. Stopped-audio conversion uses antialiasing, preserves segment sample rates, drains converter output, and retains native buffers if conversion fails. It is a quality safeguard with a small added conversion cost, not an inference speed optimization. + +Duplicate `I` runs are collapsed in one pass while preserving existing output and removal counts. Dictionary parsing is cached by the raw preference value as well as the existing compiled matcher cache. Pasteback keeps positive delivery checks and the existing Auto Enter/restoration delays; target lookup and clipboard snapshot timings are now separate. + +## Measurement semantics + +- `request_to_recording_ms` on `dictation_started` covers the controller request through successful start, across fast and waiting paths. Physical hotkey disambiguation before the controller is outside this span. The engine's existing `start_to_first_sample_ms` remains a separate audio-flow measurement. +- Local ASR diagnostics distinguish `queue_wait_ms` from `inference_ms`; existing end-to-end `decode_ms` remains unchanged. +- `save_ms` measures the actual writer call, including its storage lock wait, and `stop_to_save_ms` ends at writer completion. +- `save_publication_wait_ms` and `finalization_ms` explain delayed publication/Auto Enter separately. +- `paste_ax_capture_ms` and `paste_clipboard_snapshot_ms` split preparation work. No new raw text or audio is sent off-device. + +## Trial acceptance + +Use the exact local candidate for built-in mic, Bluetooth output with built-in input, and selected Bluetooth input. Try short and long dictations, route switching, cancel/restart, Auto Enter, an ordinary clipboard copy after dictation, and dictation while Zoom owns its mic. Check final inserted text and saved Markdown, not only a successful dispatch event. These physical-device checks remain a user trial; unit tests and synthetic benchmarks do not establish them. + +The task's local validation report records exact build identity, gate results, measurements, and remaining trial checks. This document does not assert release readiness or publish a new version. diff --git a/scripts/entrypoints/run-tests.sh b/scripts/entrypoints/run-tests.sh index 212073244..c3f183ed6 100755 --- a/scripts/entrypoints/run-tests.sh +++ b/scripts/entrypoints/run-tests.sh @@ -384,11 +384,14 @@ APP_SOURCES=( "Sources/Dictation/DictationSessionTimeout.swift" "Sources/Dictation/DictationStoppedAudioRecovery.swift" "Sources/Dictation/DictationStopFinalizationPolicy.swift" + "Sources/Dictation/DictationTranscriptPersistence.swift" "Sources/Speech/DictationInputDeviceSelectionPolicy.swift" "Sources/Speech/DictationReadinessWaitPolicy.swift" "Sources/Speech/DictationSessionTypes.swift" "Sources/Speech/ParakeetModelInitDiagnostics.swift" "Sources/Speech/ParakeetModelState.swift" + "Sources/Speech/ModelLoadProgressWaiter.swift" + "Sources/Speech/ASRInferenceWaiterQueue.swift" "Sources/Speech/ParakeetPrewarmPolicy.swift" "Sources/Speech/ParakeetAudioGraphOwnership.swift" "Sources/Speech/ParakeetTimedAudioEngineWorkLimiter.swift"