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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 67 additions & 20 deletions OpenWhisp/Services/AppleSpeechEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
}
}

Expand Down
72 changes: 62 additions & 10 deletions OpenWhisp/Services/CaptureConfigChangePolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."
}
}
89 changes: 68 additions & 21 deletions OpenWhisp/Services/ParakeetStreamingEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down
Loading
Loading