fix(ios audio): force speaker over connected Bluetooth by dropping BT category options

overrideOutputAudioPort(.speaker) alone can't beat a connected BT headset (BT is higher
priority), so 'Speaker' snapped back to BT. Now setSpeaker(true) sets category options
[.defaultToSpeaker] (no allowBluetooth) so BT isn't an eligible output and the speaker
wins; setSpeaker(false) restores [.allowBluetooth,.allowBluetoothA2DP] and uses the
default port (routes to the headset). Observer re-holds speaker if a BT connect steals it.
Adds a TEMP web probe (nroute/sptap) to verify from telemetry. plugin v1.1.1, web batch149.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 11:01:06 +05:30
parent c3b0ef560d
commit cfbf950173
3 changed files with 35 additions and 21 deletions
@@ -6,13 +6,15 @@ import AVFoundation
// up as window.Capacitor.Plugins.AudioRoute (an app-embedded class gets stripped in release builds).
//
// The built-in EARPIECE is unreachable inside a WKWebView WebKit owns the WebRTC audio unit and forces the
// loudspeaker (proven on device: overrideOutputAudioPort(.none) is a no-op there). So this plugin exposes only
// what iOS actually allows for call audio:
// * setSpeaker(true) -> force the loudspeaker (overrideOutputAudioPort(.speaker))
// * setSpeaker(false) -> use the default output port: a connected Bluetooth/wired headset, else the default
// loudspeaker. So this plugin exposes only what iOS actually allows for call audio:
// * setSpeaker(true) -> force the loudspeaker. IMPORTANT: overrideOutputAudioPort(.speaker) ALONE cannot
// beat a connected Bluetooth headset (BT is higher priority), so we also DROP the
// Bluetooth options from the category (options [.defaultToSpeaker]) so BT isn't a
// candidate output that's what actually forces the speaker over BT.
// * setSpeaker(false) -> "Device": allow BT/wired again and use the default port, which routes to a connected
// headset (else the system default).
// It also REPORTS the active output to the web UI (getRoute() + a 'routeChange' event), because iOS hides audio
// outputs from JavaScript (enumerateDevices returns none), so the web layer can't otherwise show the correct
// speaker/bluetooth/headset icon.
// outputs from JavaScript, so the web layer can't otherwise show the correct speaker/bluetooth/headset icon.
@objc(AudioRoutePlugin)
public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "AudioRoutePlugin"
@@ -26,15 +28,30 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
private var pending: DispatchWorkItem?
override public func load() {
let s = AVAudioSession.sharedInstance()
try? s.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
try? s.setActive(true)
try? configureDevice() // start in "device" mode (BT/wired allowed); the web forces speaker per call
NotificationCenter.default.addObserver(self, selector: #selector(routeChanged),
name: AVAudioSession.routeChangeNotification, object: nil)
}
deinit { NotificationCenter.default.removeObserver(self) }
// Force the loudspeaker even over a connected BT headset: with no .allowBluetooth option, BT is not an
// eligible output, so the port override lands on the built-in speaker.
private func configureSpeaker() throws {
let s = AVAudioSession.sharedInstance()
try s.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker])
try s.setActive(true)
try s.overrideOutputAudioPort(.speaker)
}
// Allow BT/wired and use the default port (routes to a connected headset, else the system default).
private func configureDevice() throws {
let s = AVAudioSession.sharedInstance()
try s.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
try s.setActive(true)
try s.overrideOutputAudioPort(.none)
}
// The ACTIVE output, mapped to a simple label the web UI turns into an icon.
private func currentOutput() -> String {
for o in AVAudioSession.sharedInstance().currentRoute.outputs {
@@ -51,15 +68,13 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
}
@objc private func routeChanged() {
// Coalesce the burst iOS/WebKit fire on device (dis)connect, then re-hold the loudspeaker if the user
// chose it, and tell the web UI where the audio actually is so the icon stays correct.
// Coalesce the burst iOS fires on device (dis)connect, then re-hold the loudspeaker if the user chose
// it (a BT connect can steal the route), and tell the web UI where the audio actually is.
pending?.cancel()
let work = DispatchWorkItem { [weak self] in
guard let self = self else { return }
let s = AVAudioSession.sharedInstance()
if self.wantSpeaker && !s.currentRoute.outputs.contains(where: { $0.portType == .builtInSpeaker }) {
try? s.overrideOutputAudioPort(.speaker)
}
let onSpeaker = AVAudioSession.sharedInstance().currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
if self.wantSpeaker && !onSpeaker { try? self.configureSpeaker() }
self.notifyListeners("routeChange", data: ["output": self.currentOutput()])
}
pending = work
@@ -68,11 +83,8 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
@objc func setSpeaker(_ call: CAPPluginCall) {
wantSpeaker = call.getBool("on") ?? true
let s = AVAudioSession.sharedInstance()
do {
// "not speaker" means use the default port (headset if present); .voiceChat keeps AEC/AGC on.
if !wantSpeaker && s.category == .playAndRecord && s.mode != .voiceChat { try s.setMode(.voiceChat) }
try s.overrideOutputAudioPort(wantSpeaker ? .speaker : .none)
if wantSpeaker { try configureSpeaker() } else { try configureDevice() }
call.resolve(["output": currentOutput()]) // immediate (may be stale); the routeChange event corrects it
} catch {
call.reject(error.localizedDescription)