From 16bedec9348e48c84e1d232a5bea4bd9a208765f Mon Sep 17 00:00:00 2001 From: Takumu Emura Date: Wed, 9 Sep 2026 12:11:58 +0900 Subject: [PATCH 1/3] feat(apple): make SpokenInstructionObserver's audio session management opt-out `SpokenInstructionObserver` currently always takes over the shared `AVAudioSession` around each spoken instruction. That is the right default for apps using the built-in `AVSpeechSynthesizer`, but it strands other apps' audio for apps that inject a custom `SpeechSynthesizer` playing through their own session. Why it strands: * `requestAudioFocus()` sets `.duckOthers` + `.interruptSpokenAudioAndMixWithOthers` and `mode = .voicePrompt`, then activates the session. * `releaseAudioFocus()` clears `hasAudioFocus` only *after* `setActive(false)` succeeds. * `setActive(false)` fails while the session still has active audio I/O. An app that keeps a microphone tap open (wake-word standby) or plays its own guidance audio therefore never releases focus: `hasAudioFocus` stays `true`, subsequent `requestAudioFocus()` calls early-return, and other apps stay ducked for the rest of the session. We measured this on-device: background music dropped from 0.80 to 0.30 system output volume ~1s after navigation started, and never recovered. Recovering from the host app is not a workable fix either. Re-applying `setCategory` on an already-active session interrupts other apps' playback, so "take the session back" trades a stuck duck for repeated audio dropouts. This adds `managesAudioSession` (default `true`, so existing behavior is unchanged) so that a host app which owns its audio session can opt out. --- .../Speech/SpokenInstructionObserver.swift | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/apple/Sources/FerrostarCore/Speech/SpokenInstructionObserver.swift b/apple/Sources/FerrostarCore/Speech/SpokenInstructionObserver.swift index b375493e0..44b5b5c2d 100644 --- a/apple/Sources/FerrostarCore/Speech/SpokenInstructionObserver.swift +++ b/apple/Sources/FerrostarCore/Speech/SpokenInstructionObserver.swift @@ -14,17 +14,43 @@ public class SpokenInstructionObserver { private let audioManager = AudioSessionManager() private var audioFocusReleaseTask: Task? + /// Whether this observer should take over the app's `AVAudioSession` while speaking. + /// + /// Defaults to `true`, which preserves the existing behavior: audio focus is requested + /// (ducking other apps) before speaking and released afterwards. + /// + /// Set this to `false` when the host app manages its own audio session. This matters for apps + /// that inject a custom ``SpeechSynthesizer`` which plays audio through the app's own session: + /// + /// * `requestAudioFocus()` sets `.duckOthers` and `.voicePrompt` on the shared session. + /// * `releaseAudioFocus()` only clears `hasAudioFocus` **after** `setActive(false)` succeeds, + /// and `setActive(false)` fails while the session still has active audio I/O. + /// + /// An app that keeps a microphone tap open (e.g. for wake-word standby) or plays its own + /// audio therefore never releases focus, so other apps stay ducked for the rest of the + /// session. Recovering by re-applying `setCategory` is not a workable fix either: changing + /// the category of an already-active session interrupts other apps' playback. + private let managesAudioSession: Bool + /// Creates a spoken instruction observer with any ``SpeechSynthesizer``. /// /// - Parameters: /// - synthesizer: The speech synthesizer. /// - isMuted: Whether the speech synthesizer is currently muted. Assume false if unknown. + /// - Parameters: + /// - synthesizer: The speech synthesizer. + /// - isMuted: Whether the speech synthesizer is currently muted. Assume false if unknown. + /// - managesAudioSession: Whether this observer may configure the shared `AVAudioSession` + /// while speaking. Defaults to `true` (existing behavior). Pass `false` if the host app + /// owns its audio session; see ``managesAudioSession``. public init( synthesizer: SpeechSynthesizer, - isMuted: Bool + isMuted: Bool, + managesAudioSession: Bool = true ) { self.synthesizer = synthesizer self.isMuted = isMuted + self.managesAudioSession = managesAudioSession } deinit { @@ -39,7 +65,9 @@ public class SpokenInstructionObserver { Task { cancelAudioFocusRelease() - await audioManager.requestAudioFocus() + if managesAudioSession { + await audioManager.requestAudioFocus() + } let utterance: AVSpeechUtterance = if #available(iOS 16.0, *), let ssml = instruction.ssml, @@ -51,7 +79,9 @@ public class SpokenInstructionObserver { } self.synthesizer.speak(utterance) - scheduleAudioFocusRelease() + if managesAudioSession { + scheduleAudioFocusRelease() + } } } @@ -69,7 +99,9 @@ public class SpokenInstructionObserver { public func stopAndClearQueue() { Task { synthesizer.stopSpeaking(at: .immediate) - await audioManager.releaseAudioFocus() + if managesAudioSession { + await audioManager.releaseAudioFocus() + } } } From a36214c2c1cb89ae65dd8afee3f74e43662ec57a Mon Sep 17 00:00:00 2001 From: Ian Wagner Date: Tue, 15 Sep 2026 12:00:59 +0900 Subject: [PATCH 2/3] Apply batched suggestions from code review Clean up comments. Co-authored-by: Ian Wagner --- .../Speech/SpokenInstructionObserver.swift | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/apple/Sources/FerrostarCore/Speech/SpokenInstructionObserver.swift b/apple/Sources/FerrostarCore/Speech/SpokenInstructionObserver.swift index 44b5b5c2d..683ba517f 100644 --- a/apple/Sources/FerrostarCore/Speech/SpokenInstructionObserver.swift +++ b/apple/Sources/FerrostarCore/Speech/SpokenInstructionObserver.swift @@ -29,7 +29,14 @@ public class SpokenInstructionObserver { /// An app that keeps a microphone tap open (e.g. for wake-word standby) or plays its own /// audio therefore never releases focus, so other apps stay ducked for the rest of the /// session. Recovering by re-applying `setCategory` is not a workable fix either: changing - /// the category of an already-active session interrupts other apps' playback. + /// Whether this observer should take over the app's `AVAudioSession` while speaking. + /// + /// When true, automatically manages audio focus (ducking other apps) + /// before speaking, and releases after each instruction. + /// + /// Setting it to `false` means the application will manage this itself. + /// This matters for some apps that inject a custom ``SpeechSynthesizer`` + /// which plays audio through the app's own session. private let managesAudioSession: Bool /// Creates a spoken instruction observer with any ``SpeechSynthesizer``. @@ -39,10 +46,11 @@ public class SpokenInstructionObserver { /// - isMuted: Whether the speech synthesizer is currently muted. Assume false if unknown. /// - Parameters: /// - synthesizer: The speech synthesizer. - /// - isMuted: Whether the speech synthesizer is currently muted. Assume false if unknown. - /// - managesAudioSession: Whether this observer may configure the shared `AVAudioSession` - /// while speaking. Defaults to `true` (existing behavior). Pass `false` if the host app - /// owns its audio session; see ``managesAudioSession``. + /// - isMuted: Whether the speech synthesizer is currently muted. (Normally this will be false, + /// unless you're providing your own "hot" synth.) + /// - managesAudioSession: Whether this observer should manage the shared `AVAudioSession` + /// while speaking. + /// Set to `false` if the host app will manage the audio session lifecycle and focus itself. public init( synthesizer: SpeechSynthesizer, isMuted: Bool, From 68a015d4be0c32a7bd8e4d31b52a1cc3c85ce8c8 Mon Sep 17 00:00:00 2001 From: Ian Wagner Date: Tue, 15 Sep 2026 12:35:06 +0900 Subject: [PATCH 3/3] More docs improvement --- .../Speech/SpokenInstructionObserver.swift | 30 +++++++------------ guide/src/ios-getting-started.md | 16 ++++++++++ 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/apple/Sources/FerrostarCore/Speech/SpokenInstructionObserver.swift b/apple/Sources/FerrostarCore/Speech/SpokenInstructionObserver.swift index 683ba517f..fac410b3b 100644 --- a/apple/Sources/FerrostarCore/Speech/SpokenInstructionObserver.swift +++ b/apple/Sources/FerrostarCore/Speech/SpokenInstructionObserver.swift @@ -4,9 +4,6 @@ import FerrostarCoreFFI import Foundation /// An Spoken instruction provider that triggers speech synthesis in response to navigation events. -/// -/// Automatically handles audio session management, -/// including ducking volume from other apps when appropriate. public class SpokenInstructionObserver { @Published public private(set) var isMuted: Bool @@ -14,7 +11,7 @@ public class SpokenInstructionObserver { private let audioManager = AudioSessionManager() private var audioFocusReleaseTask: Task? - /// Whether this observer should take over the app's `AVAudioSession` while speaking. + /// Whether this observer should manage the app's shared `AVAudioSession` while speaking. /// /// Defaults to `true`, which preserves the existing behavior: audio focus is requested /// (ducking other apps) before speaking and released afterwards. @@ -22,35 +19,30 @@ public class SpokenInstructionObserver { /// Set this to `false` when the host app manages its own audio session. This matters for apps /// that inject a custom ``SpeechSynthesizer`` which plays audio through the app's own session: /// - /// * `requestAudioFocus()` sets `.duckOthers` and `.voicePrompt` on the shared session. + /// * `requestAudioFocus()` sets `.duckOthers` and `.interruptSpokenAudioAndMixWithOthers` + /// on the shared session and uses the `.voicePrompt` mode. /// * `releaseAudioFocus()` only clears `hasAudioFocus` **after** `setActive(false)` succeeds, /// and `setActive(false)` fails while the session still has active audio I/O. /// /// An app that keeps a microphone tap open (e.g. for wake-word standby) or plays its own - /// audio therefore never releases focus, so other apps stay ducked for the rest of the - /// session. Recovering by re-applying `setCategory` is not a workable fix either: changing - /// Whether this observer should take over the app's `AVAudioSession` while speaking. + /// audio can therefore prevent focus from being released, so other apps stay ducked for the + /// rest of the session. Recovering by re-applying `setCategory` is not a workable fix either: + /// changing the category of an already-active session interrupts other apps' playback. /// - /// When true, automatically manages audio focus (ducking other apps) - /// before speaking, and releases after each instruction. - /// - /// Setting it to `false` means the application will manage this itself. - /// This matters for some apps that inject a custom ``SpeechSynthesizer`` - /// which plays audio through the app's own session. + /// When `true`, this observer automatically manages audio focus before speaking and releases + /// it after each instruction. Setting it to `false` means the application manages the audio + /// session lifecycle and focus itself. private let managesAudioSession: Bool /// Creates a spoken instruction observer with any ``SpeechSynthesizer``. /// /// - Parameters: /// - synthesizer: The speech synthesizer. - /// - isMuted: Whether the speech synthesizer is currently muted. Assume false if unknown. - /// - Parameters: - /// - synthesizer: The speech synthesizer. /// - isMuted: Whether the speech synthesizer is currently muted. (Normally this will be false, /// unless you're providing your own "hot" synth.) /// - managesAudioSession: Whether this observer should manage the shared `AVAudioSession` - /// while speaking. - /// Set to `false` if the host app will manage the audio session lifecycle and focus itself. + /// while speaking. Defaults to `true`. Set to `false` if the host app will manage the + /// audio session lifecycle and focus itself. public init( synthesizer: SpeechSynthesizer, isMuted: Bool, diff --git a/guide/src/ios-getting-started.md b/guide/src/ios-getting-started.md index 71e5c878b..f15dde362 100644 --- a/guide/src/ios-getting-started.md +++ b/guide/src/ios-getting-started.md @@ -143,6 +143,22 @@ Your navigation view can store the spoken instruction observer as an instance va @State private var spokenInstructionObserver = SpokenInstructionObserver.initAVSpeechSynthesizer() ``` +By default, `SpokenInstructionObserver` manages the shared `AVAudioSession` while speaking, +ducking audio from other apps and releasing audio focus afterwards. +If your app manages its own audio session, +you can opt out when creating the observer: + +```swift +@State private var spokenInstructionObserver = SpokenInstructionObserver( + synthesizer: AVSpeechSynthesizer(), + isMuted: false, + managesAudioSession: false +) +``` + +When audio session management is disabled, your app is responsible for managing the session +lifecycle and audio focus. + Then, you'll need to initialize `FerrostarCore` to reference it. As stated above, it has a default parameter to use `AVSpeechSynthesizer`. Finally, you can use this to drive state on navigation view.