fix(ios audio): route earpiece reliably; stop fighting WebKit's audio session
Speaker worked but the earpiece was silent, and call audio flickered on speaker then dropped. Causes: (1) the category set .defaultToSpeaker, so overrideOutputAudioPort(.none) fell back to the loudspeaker instead of the receiver; (2) setSpeaker re-ran setCategory+setActive on every toggle mid-call, tearing down the audio unit WebKit's WebRTC engine was using and silencing the earpiece route. Fix: drop .defaultToSpeaker (drive the port explicitly), make setSpeaker flip ONLY overrideOutputAudioPort, and observe routeChangeNotification to re-assert the chosen route when WebKit reconfigures the session at call start / device change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,9 +2,19 @@ import Foundation
|
|||||||
import Capacitor
|
import Capacitor
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
||||||
// Earpiece <-> speaker toggle for calls. Registered by cap sync as a normal Capacitor plugin package, so it
|
// Earpiece <-> speaker toggle for calls. Registered by cap sync as a real Capacitor plugin package, so it
|
||||||
// appears as window.Capacitor.Plugins.AudioRoute (unlike an app-embedded class, which gets stripped/not
|
// shows up as window.Capacitor.Plugins.AudioRoute (an app-embedded class gets stripped in release builds).
|
||||||
// discovered in release builds). setSpeaker({on}) overrides the AVAudioSession output port.
|
//
|
||||||
|
// WHY THIS SHAPE (earlier version left the earpiece silent):
|
||||||
|
// * The category is configured WITHOUT .defaultToSpeaker, so overrideOutputAudioPort(.none) reliably means
|
||||||
|
// the earpiece/receiver. WITH .defaultToSpeaker, .none falls back to the loudspeaker and the earpiece
|
||||||
|
// never receives audio.
|
||||||
|
// * setSpeaker flips ONLY overrideOutputAudioPort. It must NOT re-setCategory/setActive mid-call: doing so
|
||||||
|
// tears down the audio unit WebKit's WebRTC engine is actively using and leaves the earpiece route silent
|
||||||
|
// (forcing .speaker is a strong enough override to survive it, which is why only speaker "worked").
|
||||||
|
// * WebKit reconfigures the shared AVAudioSession when call audio starts (and on headset plug/unplug), which
|
||||||
|
// would clobber our choice — so we observe routeChangeNotification and re-assert the desired port. That
|
||||||
|
// also removes the "call starts on speaker then drops to silence" flicker.
|
||||||
@objc(AudioRoutePlugin)
|
@objc(AudioRoutePlugin)
|
||||||
public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
public let identifier = "AudioRoutePlugin"
|
public let identifier = "AudioRoutePlugin"
|
||||||
@@ -13,21 +23,56 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
CAPPluginMethod(name: "setSpeaker", returnType: CAPPluginReturnPromise)
|
CAPPluginMethod(name: "setSpeaker", returnType: CAPPluginReturnPromise)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Desired output — the source of truth, re-applied on every route change. Defaults to earpiece; the JS
|
||||||
|
// side calls setSpeaker(true) at call start when it wants the loudspeaker (meetRoute defaults to 'speaker').
|
||||||
|
private var wantSpeaker = false
|
||||||
|
|
||||||
override public func load() {
|
override public func load() {
|
||||||
// Default calls to the loudspeaker when the plugin initialises (iOS uses the quiet earpiece otherwise).
|
|
||||||
let session = AVAudioSession.sharedInstance()
|
let session = AVAudioSession.sharedInstance()
|
||||||
try? session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP])
|
// NO .defaultToSpeaker — the output port is driven explicitly (see note above).
|
||||||
|
try? session.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
|
||||||
try? session.setActive(true)
|
try? session.setActive(true)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(routeChanged(_:)),
|
||||||
|
name: AVAudioSession.routeChangeNotification, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebKit/WebRTC reset the route (call audio just started, or a headset was (un)plugged). Re-assert ours,
|
||||||
|
// but ignore the change we caused ourselves (.override) so we don't fight in a feedback loop.
|
||||||
|
@objc private func routeChanged(_ note: Notification) {
|
||||||
|
if let info = note.userInfo,
|
||||||
|
let raw = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||||
|
let reason = AVAudioSession.RouteChangeReason(rawValue: raw),
|
||||||
|
reason == .override {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyRoute()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nudge the port toward the desired output, but only when it actually differs — this leaves an attached
|
||||||
|
// Bluetooth/wired headset alone in the earpiece case (its route is neither builtInSpeaker nor forced).
|
||||||
|
private func applyRoute() {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
let onSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
|
||||||
|
do {
|
||||||
|
if wantSpeaker && !onSpeaker {
|
||||||
|
try session.overrideOutputAudioPort(.speaker)
|
||||||
|
} else if !wantSpeaker && onSpeaker {
|
||||||
|
try session.overrideOutputAudioPort(.none)
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func setSpeaker(_ call: CAPPluginCall) {
|
@objc func setSpeaker(_ call: CAPPluginCall) {
|
||||||
let on = call.getBool("on") ?? true
|
wantSpeaker = call.getBool("on") ?? true
|
||||||
let session = AVAudioSession.sharedInstance()
|
let session = AVAudioSession.sharedInstance()
|
||||||
do {
|
do {
|
||||||
try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
|
// Flip ONLY the output port. No setCategory/setActive here — that would disrupt WebKit's audio.
|
||||||
try session.setActive(true)
|
try session.overrideOutputAudioPort(wantSpeaker ? .speaker : .none)
|
||||||
try session.overrideOutputAudioPort(on ? .speaker : .none)
|
call.resolve(["route": wantSpeaker ? "speaker" : "earpiece"])
|
||||||
call.resolve(["route": on ? "speaker" : "earpiece"])
|
|
||||||
} catch {
|
} catch {
|
||||||
call.reject(error.localizedDescription)
|
call.reject(error.localizedDescription)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audio-route",
|
"name": "audio-route",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"description": "iOS earpiece/speaker audio route toggle for Biz Connect",
|
"description": "iOS earpiece/speaker audio route toggle for Biz Connect",
|
||||||
"main": "dist/plugin.cjs.js",
|
"main": "dist/plugin.cjs.js",
|
||||||
"module": "dist/esm/index.js",
|
"module": "dist/esm/index.js",
|
||||||
|
|||||||
Reference in New Issue
Block a user