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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user