diff --git a/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift b/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift index 4d50871..10fc50f 100644 --- a/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift +++ b/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift @@ -55,6 +55,10 @@ final class IOSAudioRouter: ObservableObject { @Published var inputPorts: [IOSAudioInputPort] = [] @Published var outputRoutes: [IOSAudioOutputRoute] = [] @Published var bluetoothMode: BluetoothMode = .btHfpVoice + /// User-requested speaker fallback: when on, route to the built-in speaker instead of the + /// earpiece (receiver) when no headphones/Bluetooth are connected. Orthogonal to the + /// bluetooth mode and presets. Default off — current behavior is unchanged for existing users. + @Published var forceSpeaker: Bool = false @Published var micMode: MicMode = .standard @Published var captureChannels: CaptureChannels = .mono @Published var selectedInputPortId: String? @@ -165,6 +169,7 @@ final class IOSAudioRouter: ObservableObject { private let kDataSourceId = "cat.voice.audio.dataSourceId" private let kPolarPattern = "cat.voice.audio.polarPattern" private let kPreset = "cat.voice.audio.preset" + private let kForceSpeaker = "cat.voice.audio.forceSpeaker" /// Re-entrancy guard: setCategory/setPreferredInput/etc. trigger route-change /// notifications synchronously on the same thread. Without this guard, @@ -322,9 +327,11 @@ final class IOSAudioRouter: ObservableObject { // .mixWithOthers is ALWAYS set — it keeps other audio (notably VoiceOver, which a // blind user needs to operate the phone) audible while our session is active. Never // drop it. - // .defaultToSpeaker is set ONLY for the speaker preset. It forces output to the - // built-in speaker instead of the receiver — but it also actively breaks A2DP - // routing in .playAndRecord, so it must NOT be set for the A2DP or HFP presets. + // .defaultToSpeaker is set for the speaker preset and, when the user enables the + // `forceSpeaker` toggle, for the HFP preset too — it forces output to the built-in + // speaker instead of the receiver while still yielding to connected BT/wired output. + // It also actively breaks A2DP routing in .playAndRecord, so it must NEVER be set for + // the A2DP preset (forceSpeaker is intentionally ignored there). // .allowAirPlay is added to the Bluetooth presets so AirPlay output also works. var options: AVAudioSession.CategoryOptions = [.mixWithOthers] switch bluetoothMode { @@ -347,6 +354,14 @@ final class IOSAudioRouter: ObservableObject { options.insert(.defaultToSpeaker) } + // User-requested speaker fallback: route to the built-in speaker instead of the + // receiver when no headphones/BT are connected. Skipped for the A2DP mode because + // .defaultToSpeaker breaks A2DP routing (see note above). Redundant for + // builtInMicSpeaker, which already sets it. + if forceSpeaker && bluetoothMode != .builtInMicBtA2dp { + options.insert(.defaultToSpeaker) + } + // 2. Set category + mode, chosen per scenario: // - Stereo capture: .default — .voiceChat (the AEC/VPIO path) forces MONO, so stereo // is only possible in a non-VPIO mode. .default supports multi-capsule stereo AND @@ -514,6 +529,7 @@ final class IOSAudioRouter: ObservableObject { selectedInputPortId = UserDefaults.standard.string(forKey: kInputPortId) selectedDataSourceId = UserDefaults.standard.string(forKey: kDataSourceId) selectedPolarPattern = UserDefaults.standard.string(forKey: kPolarPattern) + forceSpeaker = UserDefaults.standard.bool(forKey: kForceSpeaker) } /// Persist current selections to UserDefaults. func savePreferences() { @@ -523,6 +539,7 @@ final class IOSAudioRouter: ObservableObject { UserDefaults.standard.set(selectedInputPortId, forKey: kInputPortId) UserDefaults.standard.set(selectedDataSourceId, forKey: kDataSourceId) UserDefaults.standard.set(selectedPolarPattern, forKey: kPolarPattern) + UserDefaults.standard.set(forceSpeaker, forKey: kForceSpeaker) } // MARK: - Selection setters (called from SettingsView pickers) @@ -558,6 +575,13 @@ final class IOSAudioRouter: ObservableObject { refreshRoutes() } + func setForceSpeaker(_ on: Bool) { + forceSpeaker = on + savePreferences() + applyConfiguration() + refreshRoutes() + } + func selectMicMode(_ mode: MicMode) { micMode = mode savePreferences() diff --git a/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift b/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift index d40d656..ee1fd74 100644 --- a/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift +++ b/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift @@ -23,6 +23,13 @@ struct SettingsView: View { } .accessibilityLabel("Audio preset") + Toggle("Speaker output", isOn: Binding( + get: { router.forceSpeaker }, + set: { router.setForceSpeaker($0) } + )) + .accessibilityLabel("Speaker output") + .accessibilityHint("Routes audio to the speaker instead of the earpiece when no headphones are connected.") + if !router.hasBluetoothDevice && !router.hasWiredHeadset { Text("Connect Bluetooth headphones or a wired headset for more presets.") .font(.caption) diff --git a/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift b/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift index 9f5b765..2d9b295 100644 --- a/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift +++ b/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift @@ -4,6 +4,7 @@ import ReplayKit struct VoiceControlsView: View { @Bindable var session: SessionState + @StateObject private var router = IOSAudioRouter.shared var body: some View { HStack(spacing: 20) { @@ -37,6 +38,19 @@ struct VoiceControlsView: View { Spacer() + // Speaker output toggle — force the built-in speaker instead of the earpiece when + // no headphones/BT are connected. Mirrors the persisted Settings ▸ Audio toggle. + Button { + router.setForceSpeaker(!router.forceSpeaker) + } label: { + Image(systemName: router.forceSpeaker ? "speaker.wave.2.fill" : "speaker.fill") + .font(.title3) + .foregroundStyle(router.forceSpeaker ? Color.accentColor : .primary) + } + .accessibilityLabel(router.forceSpeaker + ? "Speaker on — turn off to use the earpiece" + : "Speaker off — turn on for speakerphone") + // Self mute (disabled when not in voice) Button { session.setMute(!session.voiceState.selfMuted, deafened: session.voiceState.selfDeafened)