Compare commits

...
Author SHA1 Message Date
Aziz AlbaharandClaude Opus 4.8 5c82c9ac98 iOS: stop dictation teardown from interrupting background music
The composer hard-cancels dictation on every disappear, terminal switch,
focus loss, and send so the mic is never left hot. Almost all of those
fire when the user never dictated. `cancel()`/`stop()` route through
`stopEngineAndSession()`, which unconditionally did
`audioEngine.inputNode.removeTap(...)` and
`AVAudioSession.setActive(false, .notifyOthersOnDeactivation)`. Merely
accessing `AVAudioEngine.inputNode` instantiates the input audio unit and
forces the shared audio session active, and the `setActive(false)` perturbs
it again; either momentarily interrupts other apps' audio. That is the
1-2s external-music pause seen when swiping back from a workspace and when
sending a prompt from the composer (both go through this teardown).

Gate the engine/session teardown on real ownership: a new
`DictationAudioSessionOwnership` value, marked active only after
`setActive(true)` succeeds and claimed once per teardown. A teardown from
idle is now a true no-op (the call sites already assumed it was), so it
never touches the engine or the shared session. A real dictation session
still stops the engine and deactivates exactly once.

The ownership type is factored out of the iOS-only controller so its
lifecycle is host-testable; tests cover no-op-from-idle, run-once-after-
activation, and re-arm-on-restart.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-16 17:07:08 -07:00
3 changed files with 100 additions and 0 deletions
@@ -42,6 +42,14 @@ final class ComposerDictationController {
/// every teardown.
private let audioEngine = AVAudioEngine()
/// Tracks whether this controller owns an active shared audio session, so
/// teardown only touches the engine/session when dictation actually started.
/// A hard cancel from idle (composer disappear, terminal switch, focus loss,
/// send) must NOT touch `AVAudioEngine.inputNode` or call `setActive(false)`:
/// either one perturbs the shared audio session and briefly interrupts other
/// apps' audio even though nothing was recorded.
private var audioSession = DictationAudioSessionOwnership()
/// The in-flight recognition request, fed audio buffers from the engine tap.
private var request: SFSpeechAudioBufferRecognitionRequest?
@@ -271,6 +279,11 @@ final class ComposerDictationController {
// documented restrictions and would permanently disable the mic.
try session.setCategory(.record, mode: .measurement)
try session.setActive(true)
// From here we own an active session: a later teardown must stop the
// engine and deactivate. Recorded only after `setActive(true)` so a
// failure above (which never activated anything) tears down as a
// no-op and does not poke the shared session.
audioSession.markActivated()
} catch {
failStart()
return
@@ -364,6 +377,14 @@ final class ComposerDictationController {
/// session. Shared by the graceful stop (which keeps the recognition task and
/// callback alive) and the hard `teardown()`. Safe to call repeatedly.
private func stopEngineAndSession() {
// Only touch the engine/session when we actually activated one. A
// teardown from idle (composer disappear, terminal switch, focus loss, or
// send while dictation never ran) must be a true no-op: merely accessing
// `audioEngine.inputNode` instantiates the input audio unit and forces
// the shared `AVAudioSession` active, and `setActive(false, ...)` perturbs
// it again either momentarily interrupts other apps' music (~1-2s)
// despite nothing being recorded. The guard makes those calls harmless.
guard audioSession.takeForTeardown() else { return }
if audioEngine.isRunning {
audioEngine.stop()
}
@@ -99,3 +99,42 @@ struct ComposerDictationTextMerger {
return base + " " + trimmedTranscript
}
}
/// Tracks whether the dictation controller currently owns an active shared
/// `AVAudioSession`, gating teardown so it only touches the engine/session when
/// there is actually something to tear down. Factored out of the iOS-only
/// controller so the ownership lifecycle is host-testable without AVFoundation.
///
/// This exists because teardown is otherwise destructive even from idle. The
/// composer hard-cancels dictation on every disappear, terminal switch, focus
/// loss, and send (so the mic is never left hot), and most of those happen when
/// the user never dictated at all. Touching `AVAudioEngine.inputNode` (to remove
/// the tap) instantiates the input audio unit and forces the shared audio
/// session active, and `setActive(false, .notifyOthersOnDeactivation)` perturbs
/// it again; either momentarily interrupts other apps' audio (a 1-2s music
/// pause) even though nothing was ever recorded. Gating on real ownership makes
/// a teardown-from-idle a true no-op, which is what those call sites always
/// assumed it was.
struct DictationAudioSessionOwnership {
/// Whether the controller activated the shared audio session and has not yet
/// torn it down.
private(set) var isActive = false
init() {}
/// Record that the controller just activated the shared audio session
/// (`setActive(true)` succeeded). From here a teardown must run.
mutating func markActivated() {
isActive = true
}
/// Claim ownership for a single teardown: returns `true` exactly once after a
/// `markActivated()`, clearing the flag so repeated teardowns and any
/// teardown that never activated a session are no-ops. The caller only
/// touches `AVAudioEngine` / `AVAudioSession` when this returns `true`.
mutating func takeForTeardown() -> Bool {
guard isActive else { return false }
isActive = false
return true
}
}
@@ -194,6 +194,46 @@ import Testing
}
}
// MARK: - Audio session ownership (no music interruption from idle)
@Test func teardownFromIdleNeverTouchesAudioSession() {
// The regression: the composer hard-cancels dictation on every disappear,
// terminal switch, focus loss, and send, almost always while the user
// never dictated. If teardown ran the engine/session path then, it would
// poke the shared AVAudioSession and briefly pause other apps' music. With
// no prior activation, the teardown must claim nothing.
var ownership = DictationAudioSessionOwnership()
#expect(!ownership.isActive)
#expect(!ownership.takeForTeardown())
#expect(!ownership.takeForTeardown())
}
@Test func teardownAfterActivationRunsExactlyOnce() {
// A real dictation session activates, then the first teardown deactivates
// and clears ownership; any later teardown (a cancel racing a graceful
// stop, or a send right after) is a no-op so the session is never
// double-deactivated.
var ownership = DictationAudioSessionOwnership()
ownership.markActivated()
#expect(ownership.isActive)
#expect(ownership.takeForTeardown())
#expect(!ownership.isActive)
#expect(!ownership.takeForTeardown())
}
@Test func reactivationAfterTeardownIsOwnedAgain() {
// Starting dictation a second time (start stop start) re-arms
// ownership so the second session's teardown runs, while still leaving an
// idle controller's teardown a no-op.
var ownership = DictationAudioSessionOwnership()
ownership.markActivated()
#expect(ownership.takeForTeardown())
#expect(!ownership.takeForTeardown())
ownership.markActivated()
#expect(ownership.takeForTeardown())
#expect(!ownership.takeForTeardown())
}
@Test func gracefulStopFinalizesOnlyFromListening() {
// Mirrors the controller's `stop()` branch: a graceful stop finalizes from
// `.listening` and otherwise falls back to a hard cancel (which finalizes