diff --git a/OpenWhisp/Services/AppleSpeechEngine.swift b/OpenWhisp/Services/AppleSpeechEngine.swift index eacd96e..467602c 100644 --- a/OpenWhisp/Services/AppleSpeechEngine.swift +++ b/OpenWhisp/Services/AppleSpeechEngine.swift @@ -32,6 +32,13 @@ final class AppleSpeechEngine: NSObject, StreamingTranscriptionEngine { // only value crossing threads is that immutable String. @MainActor private var lastPartial = "" @MainActor private var didStop = false + /// True while a settle-check for a configuration-change notification is + /// already scheduled — further notifications in that window coalesce into + /// the pending check instead of stacking checks (a storm posts several). + @MainActor private var configChangeCheckPending = false + /// Capture rebuilds performed for the CURRENT capture session; reset in + /// `runStart`. Compared against `CaptureConfigChangePolicy.maxRestartsPerSession`. + @MainActor private var configRestartsThisSession = 0 /// True once onFinal has fired for the current session (genuine or /// synthesized) — the stop() fallback checks it before synthesizing. @MainActor private var finalDelivered = false @@ -96,6 +103,7 @@ final class AppleSpeechEngine: NSObject, StreamingTranscriptionEngine { didStop = false finalDelivered = false lastPartial = "" + configRestartsThisSession = 0 let localeIdentifier = language == "auto" ? Locale.current.identifier : language let recognizer = SFSpeechRecognizer(locale: Locale(identifier: localeIdentifier)) @@ -280,32 +288,71 @@ final class AppleSpeechEngine: NSObject, StreamingTranscriptionEngine { } } + /// Do NOT restart here — schedule a settle-check instead. Restarting on + /// every notification was the v1.0.12 dead-mic regression (Parakeet, PR + /// #227): the notification also fires for changes our own teardown+rebuild + /// causes (on some systems capture start itself posts one), so a reflexive + /// handler loops teardown→rebuild→notification forever (~4/s) and the mic + /// never captures. The settle delay coalesces the storm and lets the io + /// unit finish renegotiating; the check then rebuilds only if the engine + /// actually STOPPED (the real device-loss case this observer exists for). @MainActor private func handleInputConfigurationChange() { + guard restartContext != nil, !configChangeCheckPending else { return } + configChangeCheckPending = true + Task { @MainActor in + try? await Task.sleep( + nanoseconds: UInt64(CaptureConfigChangePolicy.settleDelay * 1_000_000_000)) + self.configChangeCheckPending = false + self.settleCheckConfigurationChange() + } + } + + @MainActor + private func settleCheckConfigurationChange() { // Same session fence as the recognition callback: a stop or a newer // session owns the mic now — touch nothing. (Teardown also clears the // context.) - guard let context = restartContext, CaptureConfigChangePolicy.action( + guard let context = restartContext else { return } + switch CaptureConfigChangePolicy.action( generationMatches: generation == context.myGeneration, - didStop: didStop - ) == .restartCapture else { return } - NSLog("[AppleSpeechEngine] input device configuration changed — restarting capture") - removeConfigChangeObserver() - // The session fence held, so audioEngine is the engine the observer was - // armed for (re-arms replace the observer and the context together). - if let stale = audioEngine { - stale.inputNode.removeTap(onBus: 0) - stale.stop() - } - audioEngine = nil - do { - let (fresh, format) = try makeRoutedEngine() - try startCapture( - engine: fresh, format: format, - request: context.request, myGeneration: context.myGeneration) - } catch { - onError?(CaptureConfigChangePolicy.restartFailedMessage( - underlying: error.localizedDescription)) + didStop: didStop, + engineStillRunning: audioEngine?.isRunning ?? false, + restartsUsed: configRestartsThisSession + ) { + case .ignore: + return + case .restartCapture: + configRestartsThisSession += 1 + NSLog("[AppleSpeechEngine] capture stopped after an input configuration change — restarting (%d/%d)", + configRestartsThisSession, CaptureConfigChangePolicy.maxRestartsPerSession) + removeConfigChangeObserver() + // The session fence held, so audioEngine is the engine the observer + // was armed for (re-arms replace the observer and context together). + if let stale = audioEngine { + stale.inputNode.removeTap(onBus: 0) + stale.stop() + } + audioEngine = nil + do { + let (fresh, format) = try makeRoutedEngine() + try startCapture( + engine: fresh, format: format, + request: context.request, myGeneration: context.myGeneration) + } catch { + onError?(CaptureConfigChangePolicy.restartFailedMessage( + underlying: error.localizedDescription)) + } + case .giveUp: + NSLog("[AppleSpeechEngine] input device keeps stopping capture after %d rebuilds — giving up", + configRestartsThisSession) + removeConfigChangeObserver() + if let stale = audioEngine { + stale.inputNode.removeTap(onBus: 0) + stale.stop() + } + audioEngine = nil + onError?(CaptureConfigChangePolicy.gaveUpMessage) } } diff --git a/OpenWhisp/Services/CaptureConfigChangePolicy.swift b/OpenWhisp/Services/CaptureConfigChangePolicy.swift index 828a789..f163e56 100644 --- a/OpenWhisp/Services/CaptureConfigChangePolicy.swift +++ b/OpenWhisp/Services/CaptureConfigChangePolicy.swift @@ -12,27 +12,73 @@ import Foundation /// `AudioRecorder` has handled this since the AirPods-disconnect hang; the /// streaming engines never got the observer. /// +/// **The restart must not be reflexive** (the v1.0.12 dead-mic regression): +/// AVAudioEngine posts this notification for ANY io-unit configuration change of +/// the observed engine — including changes CAUSED by our own teardown+rebuild. +/// On systems where a capture start itself perturbs the device (observed: a +/// notification at every session start), a restart-on-every-notification handler +/// enters a self-sustaining loop (~4 restarts/second), and capture never lives +/// long enough to feed the decoder — the mic is dead for EVERY session. So the +/// handler must first let the transient settle (`settleDelay`, coalescing any +/// storm into one check), then consult the only signal that distinguishes a real +/// device loss from a spurious renegotiation: whether the engine is still +/// running. And as defense-in-depth against a genuinely flapping device, the +/// restarts are budgeted per capture session — past the budget the session fails +/// LOUDLY instead of looping silently. +/// /// Foundation-only (no AVFoundation) so it lives in OpenWhispCore and /// `swift test` pins the gates; the notification observer + tap rebuild live /// app-side in the engine. enum CaptureConfigChangePolicy { - /// What the engine should do when the notification fires. + /// How long after a configuration-change notification to wait before + /// deciding anything. Two jobs: coalesce a notification storm into a single + /// check, and give a transient renegotiation time to finish so the + /// `engineStillRunning` probe reads the settled state, not the mid-change + /// state. A real device loss keeps the engine stopped well past this. + static let settleDelay: TimeInterval = 0.3 + + /// Capture rebuilds allowed per capture session. A legitimate device change + /// needs exactly one; a couple more covers a messy unplug/replug. A device + /// that still stops capture after this many rebuilds will do so forever — + /// give up loudly instead of looping. + static let maxRestartsPerSession = 3 + + /// What the engine should do when the settle-check runs. enum Action: Equatable { - /// Stale notification — a stop or a newer session already superseded the - /// capture this observer was armed for. Touch nothing: the successor - /// session owns the mic now. + /// Stale notification (a stop or a newer session superseded the capture + /// this observer was armed for), or the engine is still running — the + /// change was a transient renegotiation, capture is alive, touch + /// nothing. Restarting a RUNNING engine is what looped v1.0.12. case ignore - /// The session is still live: rebuild the engine + tap and keep feeding - /// the SAME decode session. Audio captured before the change is already - /// in the feed stream, so the transcript loses only the glitch itself. + /// The engine genuinely stopped and the budget allows a rebuild: + /// rebuild the engine + tap and keep feeding the SAME decode session. + /// Audio captured before the change is already in the feed stream, so + /// the transcript loses only the glitch itself. case restartCapture + /// The engine stopped again after `maxRestartsPerSession` rebuilds — + /// the device is flapping. Tear capture down and fail the session + /// loudly (`gaveUpMessage`) instead of restarting forever. + case giveUp } /// `generationMatches` is the engine's session-generation fence (the same - /// gate the partial/EOU callbacks use); `didStop` is the engine's stop flag. - static func action(generationMatches: Bool, didStop: Bool) -> Action { - (generationMatches && !didStop) ? .restartCapture : .ignore + /// gate the partial/EOU callbacks use); `didStop` is the engine's stop flag; + /// `engineStillRunning` is the live `AVAudioEngine.isRunning` probe read at + /// settle time; `restartsUsed` counts rebuilds already performed for this + /// capture session. + static func action( + generationMatches: Bool, + didStop: Bool, + engineStillRunning: Bool, + restartsUsed: Int + ) -> Action { + guard generationMatches, !didStop else { return .ignore } + // Still rendering after the settle window: the change was cosmetic + // (renegotiation, another client touching the device). Capture is + // alive — a rebuild would only perturb the device again. + if engineStillRunning { return .ignore } + return restartsUsed < maxRestartsPerSession ? .restartCapture : .giveUp } /// User-facing error when the capture rebuild failed (no input device left @@ -42,4 +88,10 @@ enum CaptureConfigChangePolicy { static func restartFailedMessage(underlying: String) -> String { "Microphone changed mid-dictation and capture couldn't be restarted: \(underlying)" } + + /// User-facing error when the restart budget ran out — the input device + /// keeps stopping capture no matter how often it's rebuilt. + static var gaveUpMessage: String { + "The input device keeps interrupting capture — dictation stopped. Try a different microphone or start a new session." + } } diff --git a/OpenWhisp/Services/ParakeetStreamingEngine.swift b/OpenWhisp/Services/ParakeetStreamingEngine.swift index af15242..ba6f779 100644 --- a/OpenWhisp/Services/ParakeetStreamingEngine.swift +++ b/OpenWhisp/Services/ParakeetStreamingEngine.swift @@ -81,6 +81,13 @@ final class ParakeetStreamingEngine: NSObject, StreamingTranscriptionEngine { @MainActor private var lastPartial = "" @MainActor private var didStop = false + /// True while a settle-check for a configuration-change notification is + /// already scheduled — further notifications in that window coalesce into + /// the pending check instead of stacking checks (a storm posts several). + @MainActor private var configChangeCheckPending = false + /// Capture rebuilds performed for the CURRENT capture session; reset in + /// `runStart`. Compared against `CaptureConfigChangePolicy.maxRestartsPerSession`. + @MainActor private var configRestartsThisSession = 0 /// Session generation, bumped on every start and stop — late partial /// callbacks from a torn-down session fail the gate instead of leaking into /// the next one (same rationale as the other streaming engines). @@ -139,6 +146,7 @@ final class ParakeetStreamingEngine: NSObject, StreamingTranscriptionEngine { // would run first and then be clobbered by the queued runStop). lastPartial = "" didStop = false + configRestartsThisSession = 0 do { // Variant-aware language gate: English-only variants refuse a FIXED // non-English language up front (never silently mangle it); the @@ -377,32 +385,71 @@ final class ParakeetStreamingEngine: NSObject, StreamingTranscriptionEngine { } } + /// Do NOT restart here — schedule a settle-check instead. Restarting on + /// every notification was the v1.0.12 dead-mic regression: the notification + /// also fires for changes our own teardown+rebuild causes (on some systems + /// capture start itself posts one), so a reflexive handler loops + /// teardown→rebuild→notification forever (~4/s) and the mic never captures. + /// The settle delay coalesces the storm and lets the io unit finish + /// renegotiating; the check then rebuilds only if the engine actually + /// STOPPED (the real device-loss case this observer exists for). @MainActor private func handleInputConfigurationChange() { + guard restartContext != nil, !configChangeCheckPending else { return } + configChangeCheckPending = true + Task { @MainActor in + try? await Task.sleep( + nanoseconds: UInt64(CaptureConfigChangePolicy.settleDelay * 1_000_000_000)) + self.configChangeCheckPending = false + self.settleCheckConfigurationChange() + } + } + + @MainActor + private func settleCheckConfigurationChange() { // Same session fence as the partial/EOU hops: a stop or a newer session // owns the mic now — touch nothing. (Teardown also clears the context.) - guard let context = restartContext, CaptureConfigChangePolicy.action( + guard let context = restartContext else { return } + switch CaptureConfigChangePolicy.action( generationMatches: generation == context.myGeneration, - didStop: didStop - ) == .restartCapture else { return } - NSLog("[Parakeet] input device configuration changed — restarting capture") - removeConfigChangeObserver() - // The session fence held, so audioEngine is the engine the observer was - // armed for (re-arms replace the observer and the context together). - if let stale = audioEngine { - stale.inputNode.removeTap(onBus: 0) - stale.stop() - } - audioEngine = nil - do { - let (fresh, format) = try makeRoutedEngine() - try startCapture( - engine: fresh, format: format, - continuation: context.continuation, callbacks: context.callbacks, - myGeneration: context.myGeneration) - } catch { - context.callbacks.error?(CaptureConfigChangePolicy.restartFailedMessage( - underlying: error.localizedDescription)) + didStop: didStop, + engineStillRunning: audioEngine?.isRunning ?? false, + restartsUsed: configRestartsThisSession + ) { + case .ignore: + return + case .restartCapture: + configRestartsThisSession += 1 + NSLog("[Parakeet] capture stopped after an input configuration change — restarting (%d/%d)", + configRestartsThisSession, CaptureConfigChangePolicy.maxRestartsPerSession) + removeConfigChangeObserver() + // The session fence held, so audioEngine is the engine the observer + // was armed for (re-arms replace the observer and context together). + if let stale = audioEngine { + stale.inputNode.removeTap(onBus: 0) + stale.stop() + } + audioEngine = nil + do { + let (fresh, format) = try makeRoutedEngine() + try startCapture( + engine: fresh, format: format, + continuation: context.continuation, callbacks: context.callbacks, + myGeneration: context.myGeneration) + } catch { + context.callbacks.error?(CaptureConfigChangePolicy.restartFailedMessage( + underlying: error.localizedDescription)) + } + case .giveUp: + NSLog("[Parakeet] input device keeps stopping capture after %d rebuilds — giving up", + configRestartsThisSession) + removeConfigChangeObserver() + if let stale = audioEngine { + stale.inputNode.removeTap(onBus: 0) + stale.stop() + } + audioEngine = nil + context.callbacks.error?(CaptureConfigChangePolicy.gaveUpMessage) } } diff --git a/OpenWhisp/Services/SpeechAnalyzerStreamingEngine.swift b/OpenWhisp/Services/SpeechAnalyzerStreamingEngine.swift index 96bba4c..7bc1958 100644 --- a/OpenWhisp/Services/SpeechAnalyzerStreamingEngine.swift +++ b/OpenWhisp/Services/SpeechAnalyzerStreamingEngine.swift @@ -46,6 +46,13 @@ final class SpeechAnalyzerStreamingEngine: NSObject, StreamingTranscriptionEngin @MainActor private var lastVolatile = "" @MainActor private var finalDelivered = false @MainActor private var generation = 0 + /// True while a settle-check for a configuration-change notification is + /// already scheduled — further notifications in that window coalesce into + /// the pending check instead of stacking checks (a storm posts several). + @MainActor private var configChangeCheckPending = false + /// Capture rebuilds performed for the CURRENT capture session; reset in + /// `runStart`. Compared against `CaptureConfigChangePolicy.maxRestartsPerSession`. + @MainActor private var configRestartsThisSession = 0 /// What the mid-session configuration-change restart needs to rebuild capture /// into the live analyzer stream (see `armConfigChangeObserver`). Non-nil @@ -84,6 +91,7 @@ final class SpeechAnalyzerStreamingEngine: NSObject, StreamingTranscriptionEngin finalDelivered = false lastPartial = "" lastVolatile = "" + configRestartsThisSession = 0 let myGeneration = generation // Compile gate: the analyzer code below needs the macOS 26 SDK. On older @@ -321,33 +329,71 @@ final class SpeechAnalyzerStreamingEngine: NSObject, StreamingTranscriptionEngin } } + /// Do NOT restart here — schedule a settle-check instead. Restarting on + /// every notification was the v1.0.12 dead-mic regression (Parakeet, PR + /// #227): the notification also fires for changes our own teardown+rebuild + /// causes, so a reflexive handler loops teardown→rebuild→notification + /// forever and the mic never captures. The settle delay coalesces the storm + /// and lets the io unit finish renegotiating; the check then rebuilds only + /// if the engine actually STOPPED. @MainActor private func handleInputConfigurationChange() { + guard restartContext != nil, !configChangeCheckPending else { return } + configChangeCheckPending = true + Task { @MainActor in + try? await Task.sleep( + nanoseconds: UInt64(CaptureConfigChangePolicy.settleDelay * 1_000_000_000)) + self.configChangeCheckPending = false + self.settleCheckConfigurationChange() + } + } + + @MainActor + private func settleCheckConfigurationChange() { // Same session fence as the result hops: a stop or a newer session owns // the mic now — touch nothing. This engine has no didStop flag; // `finalDelivered` is its "session over" marker (a cancel bumps the // generation instead). Teardown also clears the context. - guard let context = restartContext, CaptureConfigChangePolicy.action( + guard let context = restartContext else { return } + switch CaptureConfigChangePolicy.action( generationMatches: generation == context.myGeneration, - didStop: finalDelivered - ) == .restartCapture else { return } - NSLog("[SpeechAnalyzerStreamingEngine] input device configuration changed — restarting capture") - removeConfigChangeObserver() - // The session fence held, so audioEngine is the engine the observer was - // armed for (re-arms replace the observer and the context together). - if let stale = audioEngine { - stale.inputNode.removeTap(onBus: 0) - stale.stop() - } - audioEngine = nil - do { - let (fresh, format) = try makeRoutedEngine() - try startCapture( - engine: fresh, format: format, - continuation: context.continuation, myGeneration: context.myGeneration) - } catch { - onError?(CaptureConfigChangePolicy.restartFailedMessage( - underlying: error.localizedDescription)) + didStop: finalDelivered, + engineStillRunning: audioEngine?.isRunning ?? false, + restartsUsed: configRestartsThisSession + ) { + case .ignore: + return + case .restartCapture: + configRestartsThisSession += 1 + NSLog("[SpeechAnalyzerStreamingEngine] capture stopped after an input configuration change — restarting (%d/%d)", + configRestartsThisSession, CaptureConfigChangePolicy.maxRestartsPerSession) + removeConfigChangeObserver() + // The session fence held, so audioEngine is the engine the observer + // was armed for (re-arms replace the observer and context together). + if let stale = audioEngine { + stale.inputNode.removeTap(onBus: 0) + stale.stop() + } + audioEngine = nil + do { + let (fresh, format) = try makeRoutedEngine() + try startCapture( + engine: fresh, format: format, + continuation: context.continuation, myGeneration: context.myGeneration) + } catch { + onError?(CaptureConfigChangePolicy.restartFailedMessage( + underlying: error.localizedDescription)) + } + case .giveUp: + NSLog("[SpeechAnalyzerStreamingEngine] input device keeps stopping capture after %d rebuilds — giving up", + configRestartsThisSession) + removeConfigChangeObserver() + if let stale = audioEngine { + stale.inputNode.removeTap(onBus: 0) + stale.stop() + } + audioEngine = nil + onError?(CaptureConfigChangePolicy.gaveUpMessage) } } diff --git a/OpenWhisp/Services/WhisperKitStreamingEngine.swift b/OpenWhisp/Services/WhisperKitStreamingEngine.swift index 42e25bf..e84b4b4 100644 --- a/OpenWhisp/Services/WhisperKitStreamingEngine.swift +++ b/OpenWhisp/Services/WhisperKitStreamingEngine.swift @@ -93,6 +93,14 @@ final class WhisperKitStreamingEngine: NSObject, StreamingTranscriptionEngine { let myGeneration: Int } + /// True while a settle-check for a configuration-change notification is + /// already scheduled — further notifications in that window coalesce into + /// the pending check instead of stacking checks (a storm posts several). + @MainActor private var configChangeCheckPending = false + /// Capture rebuilds performed for the CURRENT capture session; reset in + /// `runStart`. Compared against `CaptureConfigChangePolicy.maxRestartsPerSession`. + @MainActor private var configRestartsThisSession = 0 + func start(language: String, prompt: String) throws { let task = WhisperKitTaskMapper.map(languageSetting: language) // Enqueue SYNCHRONOUSLY on the main actor so call order == enqueue order @@ -153,6 +161,7 @@ final class WhisperKitStreamingEngine: NSObject, StreamingTranscriptionEngine { let kit = try await ensureLoaded() generation += 1 startedNotified = false + configRestartsThisSession = 0 let myGeneration = generation let handle = try WhisperKitBridge.makeStreamHandle( kit: kit, @@ -350,28 +359,64 @@ final class WhisperKitStreamingEngine: NSObject, StreamingTranscriptionEngine { } } + /// Do NOT restart here — schedule a settle-check instead. Restarting on + /// every notification was the v1.0.12 dead-mic regression (Parakeet, PR + /// #227): the notification also fires for changes our own teardown+rebuild + /// causes, so a reflexive handler loops teardown→rebuild→notification + /// forever and the mic never captures. The settle delay coalesces the storm + /// and lets the io unit finish renegotiating; the check then rebuilds only + /// if the capture engine actually STOPPED. @MainActor private func handleInputConfigurationChange() { + guard restartContext != nil, !configChangeCheckPending else { return } + configChangeCheckPending = true + Task { @MainActor in + try? await Task.sleep( + nanoseconds: UInt64(CaptureConfigChangePolicy.settleDelay * 1_000_000_000)) + self.configChangeCheckPending = false + self.settleCheckConfigurationChange() + } + } + + @MainActor + private func settleCheckConfigurationChange() { // Same session fence as the state callback: a stop or a newer session // owns the mic now — touch nothing. (Teardown also clears the context.) - guard let context = restartContext, CaptureConfigChangePolicy.action( + guard let context = restartContext, let handle = transcriber else { return } + switch CaptureConfigChangePolicy.action( generationMatches: generation == context.myGeneration, - didStop: didFinish - ) == .restartCapture, let handle = transcriber else { return } - NSLog("[WhisperKitStream] input device configuration changed — restarting capture") - removeConfigChangeObserver() - do { - let deviceID = try resolveInputDeviceID() - try handle.restartCapture(inputDeviceID: deviceID) - // Re-arm on the REPLACEMENT engine — the old observer died with the - // stale engine object. - if let engine = handle.captureAudioEngine { - armConfigChangeObserver( - for: engine, callbacks: context.callbacks, myGeneration: context.myGeneration) + didStop: didFinish, + engineStillRunning: handle.captureAudioEngine?.isRunning ?? false, + restartsUsed: configRestartsThisSession + ) { + case .ignore: + return + case .restartCapture: + configRestartsThisSession += 1 + NSLog("[WhisperKitStream] capture stopped after an input configuration change — restarting (%d/%d)", + configRestartsThisSession, CaptureConfigChangePolicy.maxRestartsPerSession) + removeConfigChangeObserver() + do { + let deviceID = try resolveInputDeviceID() + try handle.restartCapture(inputDeviceID: deviceID) + // Re-arm on the REPLACEMENT engine — the old observer died with + // the stale engine object. + if let engine = handle.captureAudioEngine { + armConfigChangeObserver( + for: engine, callbacks: context.callbacks, myGeneration: context.myGeneration) + } + } catch { + context.callbacks.error?(CaptureConfigChangePolicy.restartFailedMessage( + underlying: error.localizedDescription)) } - } catch { - context.callbacks.error?(CaptureConfigChangePolicy.restartFailedMessage( - underlying: error.localizedDescription)) + case .giveUp: + NSLog("[WhisperKitStream] input device keeps stopping capture after %d rebuilds — giving up", + configRestartsThisSession) + removeConfigChangeObserver() + // No engine teardown here: the capture engine is already stopped + // (that's what giveUp means) and the session stop that follows the + // error tears the stream down via the handle. + context.callbacks.error?(CaptureConfigChangePolicy.gaveUpMessage) } } diff --git a/Tests/OpenWhispCoreTests/CaptureConfigChangePolicyTests.swift b/Tests/OpenWhispCoreTests/CaptureConfigChangePolicyTests.swift index 0a77a00..f40ded0 100644 --- a/Tests/OpenWhispCoreTests/CaptureConfigChangePolicyTests.swift +++ b/Tests/OpenWhispCoreTests/CaptureConfigChangePolicyTests.swift @@ -3,16 +3,62 @@ import XCTest /// Pins the gates for the mid-session `AVAudioEngineConfigurationChange` /// recovery shared by ALL streaming capture engines (Parakeet, WhisperKit, -/// Apple Speech, SpeechAnalyzer): a live session restarts capture; a stale or -/// stopped session ignores the notification. Without the restart, a device -/// disconnect/format renegotiation leaves the session showing "Listening…" -/// while capturing NOTHING — words are silently lost for the session's -/// remainder (the bug this policy exists to close). +/// Apple Speech, SpeechAnalyzer). Two regressions meet here: +/// +/// - Without any restart, a device disconnect leaves the session showing +/// "Listening…" while capturing NOTHING (the pre-v1.0.12 word-loss bug). +/// - With a REFLEXIVE restart, the notification the rebuild itself posts loops +/// teardown→rebuild forever (~4/s) and the mic is dead for EVERY session +/// (the v1.0.12 regression). Hence the settle-check inputs: the restart is +/// allowed only when the engine genuinely STOPPED, within a per-session +/// budget that fails loudly when exhausted. final class CaptureConfigChangePolicyTests: XCTestCase { - func testLiveSessionRestartsCapture() { + func testStoppedEngineInLiveSessionRestartsCapture() { XCTAssertEqual( - CaptureConfigChangePolicy.action(generationMatches: true, didStop: false), + CaptureConfigChangePolicy.action( + generationMatches: true, didStop: false, + engineStillRunning: false, restartsUsed: 0), + .restartCapture + ) + } + + /// THE v1.0.12 dead-mic gate: a notification that leaves the engine running + /// (a transient renegotiation — including the one our own rebuild posts) + /// must NOT trigger a rebuild. Restarting a running engine re-perturbs the + /// device and self-sustains the loop. + func testRunningEngineIgnoresNotification() { + XCTAssertEqual( + CaptureConfigChangePolicy.action( + generationMatches: true, didStop: false, + engineStillRunning: true, restartsUsed: 0), + .ignore + ) + // Running trumps everything else — even with budget left. + XCTAssertEqual( + CaptureConfigChangePolicy.action( + generationMatches: true, didStop: false, + engineStillRunning: true, restartsUsed: 2), + .ignore + ) + } + + /// A device that keeps stopping capture exhausts the budget and fails + /// LOUDLY instead of looping silently. + func testBudgetExhaustedGivesUp() { + XCTAssertEqual( + CaptureConfigChangePolicy.action( + generationMatches: true, didStop: false, + engineStillRunning: false, + restartsUsed: CaptureConfigChangePolicy.maxRestartsPerSession), + .giveUp + ) + // The last budgeted restart is still allowed. + XCTAssertEqual( + CaptureConfigChangePolicy.action( + generationMatches: true, didStop: false, + engineStillRunning: false, + restartsUsed: CaptureConfigChangePolicy.maxRestartsPerSession - 1), .restartCapture ) } @@ -21,7 +67,9 @@ final class CaptureConfigChangePolicyTests: XCTestCase { // stop() ran but the generation hasn't rotated yet (runStop is queued // behind the chain): the mic is being torn down — don't rebuild it. XCTAssertEqual( - CaptureConfigChangePolicy.action(generationMatches: true, didStop: true), + CaptureConfigChangePolicy.action( + generationMatches: true, didStop: true, + engineStillRunning: false, restartsUsed: 0), .ignore ) } @@ -29,14 +77,22 @@ final class CaptureConfigChangePolicyTests: XCTestCase { func testSupersededSessionIgnores() { // A newer session bumped the generation: the notification belongs to a // torn-down capture and must not touch the successor's mic. - XCTAssertEqual( - CaptureConfigChangePolicy.action(generationMatches: false, didStop: false), - .ignore - ) - XCTAssertEqual( - CaptureConfigChangePolicy.action(generationMatches: false, didStop: true), - .ignore - ) + for didStop in [false, true] { + XCTAssertEqual( + CaptureConfigChangePolicy.action( + generationMatches: false, didStop: didStop, + engineStillRunning: false, restartsUsed: 0), + .ignore + ) + } + } + + /// The settle delay exists to coalesce the notification storm and let the + /// io unit finish renegotiating before `isRunning` is read; sub-100ms would + /// read mid-change state and re-enter the loop. + func testSettleDelayIsMeaningful() { + XCTAssertGreaterThanOrEqual(CaptureConfigChangePolicy.settleDelay, 0.1) + XCTAssertLessThanOrEqual(CaptureConfigChangePolicy.settleDelay, 1.0) } func testRestartFailedMessageCarriesUnderlyingReason() { @@ -45,4 +101,8 @@ final class CaptureConfigChangePolicyTests: XCTestCase { XCTAssertTrue(message.contains("Microphone changed mid-dictation")) XCTAssertTrue(message.contains("No audio input device available.")) } + + func testGaveUpMessageIsUserActionable() { + XCTAssertTrue(CaptureConfigChangePolicy.gaveUpMessage.contains("dictation stopped")) + } }