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
48 changes: 40 additions & 8 deletions apple/Sources/FerrostarCore/Speech/SpokenInstructionObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,53 @@ 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

let synthesizer: SpeechSynthesizer
private let audioManager = AudioSessionManager()
private var audioFocusReleaseTask: Task<Void, Never>?

/// 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.
///
/// 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 `.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 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`, 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.
/// - 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. 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
isMuted: Bool,
managesAudioSession: Bool = true
) {
self.synthesizer = synthesizer
self.isMuted = isMuted
self.managesAudioSession = managesAudioSession
}

deinit {
Expand All @@ -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,
Expand All @@ -51,7 +79,9 @@ public class SpokenInstructionObserver {
}

self.synthesizer.speak(utterance)
scheduleAudioFocusRelease()
if managesAudioSession {
scheduleAudioFocusRelease()
}
}
}

Expand All @@ -69,7 +99,9 @@ public class SpokenInstructionObserver {
public func stopAndClearQueue() {
Task {
synthesizer.stopSpeaking(at: .immediate)
await audioManager.releaseAudioFocus()
if managesAudioSession {
await audioManager.releaseAudioFocus()
}
}
}

Expand Down
16 changes: 16 additions & 0 deletions guide/src/ios-getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading