feat(ios): user-toggleable speakerphone output

Add a "speaker output" override so users can route audio to the built-in
speaker instead of the earpiece when no headphones/Bluetooth are connected.
Previously the receiver was the only fallback on the default Voice Chat preset.

The toggle inserts .defaultToSpeaker into the AVAudioSession category options
(skipped for the A2DP mode, where it would break Bluetooth routing). It yields
to connected BT/wired output and is orthogonal to preset matching. Exposed both
as a call-bar button in VoiceControlsView and a persisted Settings toggle.
This commit is contained in:
2026-06-21 15:47:20 +02:00
parent 0c6b1a36cf
commit 5be6d8430d
3 changed files with 48 additions and 3 deletions

View File

@@ -55,6 +55,10 @@ final class IOSAudioRouter: ObservableObject {
@Published var inputPorts: [IOSAudioInputPort] = [] @Published var inputPorts: [IOSAudioInputPort] = []
@Published var outputRoutes: [IOSAudioOutputRoute] = [] @Published var outputRoutes: [IOSAudioOutputRoute] = []
@Published var bluetoothMode: BluetoothMode = .btHfpVoice @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 micMode: MicMode = .standard
@Published var captureChannels: CaptureChannels = .mono @Published var captureChannels: CaptureChannels = .mono
@Published var selectedInputPortId: String? @Published var selectedInputPortId: String?
@@ -165,6 +169,7 @@ final class IOSAudioRouter: ObservableObject {
private let kDataSourceId = "cat.voice.audio.dataSourceId" private let kDataSourceId = "cat.voice.audio.dataSourceId"
private let kPolarPattern = "cat.voice.audio.polarPattern" private let kPolarPattern = "cat.voice.audio.polarPattern"
private let kPreset = "cat.voice.audio.preset" private let kPreset = "cat.voice.audio.preset"
private let kForceSpeaker = "cat.voice.audio.forceSpeaker"
/// Re-entrancy guard: setCategory/setPreferredInput/etc. trigger route-change /// Re-entrancy guard: setCategory/setPreferredInput/etc. trigger route-change
/// notifications synchronously on the same thread. Without this guard, /// 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 // .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 // blind user needs to operate the phone) audible while our session is active. Never
// drop it. // drop it.
// .defaultToSpeaker is set ONLY for the speaker preset. It forces output to the // .defaultToSpeaker is set for the speaker preset and, when the user enables the
// built-in speaker instead of the receiver but it also actively breaks A2DP // `forceSpeaker` toggle, for the HFP preset too it forces output to the built-in
// routing in .playAndRecord, so it must NOT be set for the A2DP or HFP presets. // 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. // .allowAirPlay is added to the Bluetooth presets so AirPlay output also works.
var options: AVAudioSession.CategoryOptions = [.mixWithOthers] var options: AVAudioSession.CategoryOptions = [.mixWithOthers]
switch bluetoothMode { switch bluetoothMode {
@@ -347,6 +354,14 @@ final class IOSAudioRouter: ObservableObject {
options.insert(.defaultToSpeaker) 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: // 2. Set category + mode, chosen per scenario:
// - Stereo capture: .default .voiceChat (the AEC/VPIO path) forces MONO, so stereo // - 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 // 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) selectedInputPortId = UserDefaults.standard.string(forKey: kInputPortId)
selectedDataSourceId = UserDefaults.standard.string(forKey: kDataSourceId) selectedDataSourceId = UserDefaults.standard.string(forKey: kDataSourceId)
selectedPolarPattern = UserDefaults.standard.string(forKey: kPolarPattern) selectedPolarPattern = UserDefaults.standard.string(forKey: kPolarPattern)
forceSpeaker = UserDefaults.standard.bool(forKey: kForceSpeaker)
} }
/// Persist current selections to UserDefaults. /// Persist current selections to UserDefaults.
func savePreferences() { func savePreferences() {
@@ -523,6 +539,7 @@ final class IOSAudioRouter: ObservableObject {
UserDefaults.standard.set(selectedInputPortId, forKey: kInputPortId) UserDefaults.standard.set(selectedInputPortId, forKey: kInputPortId)
UserDefaults.standard.set(selectedDataSourceId, forKey: kDataSourceId) UserDefaults.standard.set(selectedDataSourceId, forKey: kDataSourceId)
UserDefaults.standard.set(selectedPolarPattern, forKey: kPolarPattern) UserDefaults.standard.set(selectedPolarPattern, forKey: kPolarPattern)
UserDefaults.standard.set(forceSpeaker, forKey: kForceSpeaker)
} }
// MARK: - Selection setters (called from SettingsView pickers) // MARK: - Selection setters (called from SettingsView pickers)
@@ -558,6 +575,13 @@ final class IOSAudioRouter: ObservableObject {
refreshRoutes() refreshRoutes()
} }
func setForceSpeaker(_ on: Bool) {
forceSpeaker = on
savePreferences()
applyConfiguration()
refreshRoutes()
}
func selectMicMode(_ mode: MicMode) { func selectMicMode(_ mode: MicMode) {
micMode = mode micMode = mode
savePreferences() savePreferences()

View File

@@ -23,6 +23,13 @@ struct SettingsView: View {
} }
.accessibilityLabel("Audio preset") .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 { if !router.hasBluetoothDevice && !router.hasWiredHeadset {
Text("Connect Bluetooth headphones or a wired headset for more presets.") Text("Connect Bluetooth headphones or a wired headset for more presets.")
.font(.caption) .font(.caption)

View File

@@ -4,6 +4,7 @@ import ReplayKit
struct VoiceControlsView: View { struct VoiceControlsView: View {
@Bindable var session: SessionState @Bindable var session: SessionState
@StateObject private var router = IOSAudioRouter.shared
var body: some View { var body: some View {
HStack(spacing: 20) { HStack(spacing: 20) {
@@ -37,6 +38,19 @@ struct VoiceControlsView: View {
Spacer() 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) // Self mute (disabled when not in voice)
Button { Button {
session.setMute(!session.voiceState.selfMuted, deafened: session.voiceState.selfDeafened) session.setMute(!session.voiceState.selfMuted, deafened: session.voiceState.selfDeafened)