fix(ios audio): re-assert earpiece after WebKit re-forces speaker (debounced)
Telemetry proof: overrideOutputAudioPort(.none) routes to Receiver once, then every later toggle latches to built-in Speaker despite opts=36 (no .defaultToSpeaker) — WebKit's WebRTC engine re-forces the loudspeaker after our override. The prior build ignored .override-reason route changes and never corrected it. Now: react to ALL route changes, debounced 0.25s, and re-assert the chosen port only on a genuine mismatch (self-terminating, capped at 6/toggle to avoid thrash). setSpeaker returns a reason->port route-change trail so the log shows whether WebKit is one-shot or persistent. native marker 1.0.3-reassert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,17 +5,17 @@ import AVFoundation
|
|||||||
// Earpiece <-> speaker toggle for calls. Registered by cap sync as a real Capacitor plugin package, so it
|
// Earpiece <-> speaker toggle for calls. Registered by cap sync as a real Capacitor plugin package, so it
|
||||||
// shows up as window.Capacitor.Plugins.AudioRoute (an app-embedded class gets stripped in release builds).
|
// shows up as window.Capacitor.Plugins.AudioRoute (an app-embedded class gets stripped in release builds).
|
||||||
//
|
//
|
||||||
// WHY THIS SHAPE (earlier version left the earpiece silent):
|
// THE PROBLEM (from device telemetry): overrideOutputAudioPort(.none) routes to the receiver ONCE, then the
|
||||||
// * The category is configured WITHOUT .defaultToSpeaker, so overrideOutputAudioPort(.none) reliably means
|
// route latches to the built-in Speaker on every later toggle — even though the category has NO
|
||||||
// the earpiece/receiver. WITH .defaultToSpeaker, .none falls back to the loudspeaker.
|
// .defaultToSpeaker. That fingerprints WebKit's WebRTC engine re-forcing the loudspeaker AFTER our override.
|
||||||
// * setSpeaker flips ONLY overrideOutputAudioPort — no re-setCategory/setActive mid-call, which would tear
|
// The earlier build ignored .override-reason route changes (to avoid a feedback loop) and so never corrected
|
||||||
// down the audio unit WebKit's WebRTC engine is using and silence the earpiece.
|
// WebKit's re-force → stuck on speaker → earpiece silent.
|
||||||
// * WebKit reconfigures the shared AVAudioSession when call audio starts / on headset plug-unplug, which
|
|
||||||
// would clobber our choice — so we observe routeChangeNotification and re-assert the desired port.
|
|
||||||
//
|
//
|
||||||
// DIAGNOSTICS: setSpeaker resolves with the ACTUAL current output port + live category/mode/options and a
|
// THE FIX: re-assert the chosen port AFTER each route change settles (debounced 0.25s), reacting to ALL
|
||||||
// native-build marker, so the JS `route` telemetry shows exactly where iOS put the audio (the web __BUILD
|
// changes but acting ONLY on a real mismatch — so our own override (which also fires a change) finds the
|
||||||
// tag can't distinguish native binaries). This is what turns "override didn't throw" into "audio is on X".
|
// route already correct and does nothing, terminating the loop. Capped per toggle so a persistent adversary
|
||||||
|
// can't thrash the audio. A ring-buffer route log (reason -> port) rides back in setSpeaker's result so the
|
||||||
|
// telemetry shows the exact battle.
|
||||||
@objc(AudioRoutePlugin)
|
@objc(AudioRoutePlugin)
|
||||||
public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
public let identifier = "AudioRoutePlugin"
|
public let identifier = "AudioRoutePlugin"
|
||||||
@@ -24,73 +24,88 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
CAPPluginMethod(name: "setSpeaker", returnType: CAPPluginReturnPromise)
|
CAPPluginMethod(name: "setSpeaker", returnType: CAPPluginReturnPromise)
|
||||||
]
|
]
|
||||||
|
|
||||||
// Bump on every native change so the telemetry unambiguously identifies which binary is running.
|
// Bump on every native change so telemetry identifies the running binary unambiguously.
|
||||||
private static let nativeTag = "1.0.2-diag"
|
private static let nativeTag = "1.0.3-reassert"
|
||||||
|
|
||||||
// Desired output — the source of truth, re-applied on every route change. Defaults to earpiece; the JS
|
private var wantSpeaker = false // desired output; source of truth, re-applied on route changes
|
||||||
// side calls setSpeaker(true) at call start when it wants the loudspeaker (meetRoute defaults to 'speaker').
|
private var pending: DispatchWorkItem? // debounced re-assert
|
||||||
private var wantSpeaker = false
|
private var asserts = 0 // re-assert count this toggle (capped, anti-thrash)
|
||||||
|
private var routeLog: [String] = [] // reason->port trail, returned to JS for diagnosis
|
||||||
|
|
||||||
override public func load() {
|
override public func load() {
|
||||||
let session = AVAudioSession.sharedInstance()
|
let session = AVAudioSession.sharedInstance()
|
||||||
// NO .defaultToSpeaker — the output port is driven explicitly (see note above).
|
// NO .defaultToSpeaker — the port is driven explicitly so .none means the earpiece/receiver.
|
||||||
try? session.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
|
try? session.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
|
||||||
try? session.setActive(true)
|
try? session.setActive(true)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(routeChanged(_:)),
|
NotificationCenter.default.addObserver(self, selector: #selector(routeChanged(_:)),
|
||||||
name: AVAudioSession.routeChangeNotification, object: nil)
|
name: AVAudioSession.routeChangeNotification, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit { NotificationCenter.default.removeObserver(self) }
|
||||||
NotificationCenter.default.removeObserver(self)
|
|
||||||
|
private func reasonName(_ r: UInt) -> String {
|
||||||
|
switch r {
|
||||||
|
case 1: return "newDev"; case 2: return "oldDev"; case 3: return "catChg"
|
||||||
|
case 4: return "override"; case 6: return "wake"; case 7: return "noRoute"
|
||||||
|
case 8: return "cfgChg"; default: return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ports() -> String {
|
||||||
|
let outs = AVAudioSession.sharedInstance().currentRoute.outputs.map { $0.portType.rawValue }
|
||||||
|
return outs.isEmpty ? "(none)" : outs.joined(separator: "+")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func log(_ s: String) {
|
||||||
|
routeLog.append(s)
|
||||||
|
if routeLog.count > 16 { routeLog.removeFirst(routeLog.count - 16) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
@objc private func routeChanged(_ note: Notification) {
|
||||||
if let info = note.userInfo,
|
let raw = (note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt) ?? 0
|
||||||
let raw = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
log("\(reasonName(raw))>\(ports())")
|
||||||
let reason = AVAudioSession.RouteChangeReason(rawValue: raw),
|
// Re-assert AFTER WebKit's change settles; coalesce bursts into one late assert so we act last.
|
||||||
reason == .override {
|
pending?.cancel()
|
||||||
return
|
let work = DispatchWorkItem { [weak self] in self?.applyRoute() }
|
||||||
}
|
pending = work
|
||||||
applyRoute()
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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() {
|
private func applyRoute() {
|
||||||
|
guard asserts < 6 else { log("cap"); return } // anti-thrash: give up rather than ping-pong forever
|
||||||
let session = AVAudioSession.sharedInstance()
|
let session = AVAudioSession.sharedInstance()
|
||||||
let onSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
|
let onSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
|
||||||
do {
|
do {
|
||||||
if wantSpeaker && !onSpeaker {
|
if wantSpeaker && !onSpeaker {
|
||||||
try session.overrideOutputAudioPort(.speaker)
|
asserts += 1; try session.overrideOutputAudioPort(.speaker); log("assert>spk")
|
||||||
} else if !wantSpeaker && onSpeaker {
|
} else if !wantSpeaker && onSpeaker {
|
||||||
try session.overrideOutputAudioPort(.none)
|
asserts += 1; try session.overrideOutputAudioPort(.none); log("assert>none")
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch { log("assertErr") }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot of what iOS actually selected — this is the data that ends the guessing.
|
|
||||||
private func diag(_ applied: String) -> [String: Any] {
|
private func diag(_ applied: String) -> [String: Any] {
|
||||||
let s = AVAudioSession.sharedInstance()
|
let s = AVAudioSession.sharedInstance()
|
||||||
let outs = s.currentRoute.outputs.map { $0.portType.rawValue }.joined(separator: "+")
|
|
||||||
return [
|
return [
|
||||||
"route": applied,
|
"route": applied,
|
||||||
"native": AudioRoutePlugin.nativeTag,
|
"native": AudioRoutePlugin.nativeTag,
|
||||||
"outputs": outs.isEmpty ? "(none)" : outs, // "Receiver"=earpiece, "Speaker", "BluetoothA2DP"...
|
"outputs": ports(),
|
||||||
"category": s.category.rawValue,
|
"category": s.category.rawValue,
|
||||||
"mode": s.mode.rawValue,
|
"mode": s.mode.rawValue,
|
||||||
"opts": Int(s.categoryOptions.rawValue)
|
"opts": Int(s.categoryOptions.rawValue),
|
||||||
|
"log": routeLog.joined(separator: ",") // route-change trail since the previous toggle
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func setSpeaker(_ call: CAPPluginCall) {
|
@objc func setSpeaker(_ call: CAPPluginCall) {
|
||||||
wantSpeaker = call.getBool("on") ?? true
|
wantSpeaker = call.getBool("on") ?? true
|
||||||
|
asserts = 0
|
||||||
let session = AVAudioSession.sharedInstance()
|
let session = AVAudioSession.sharedInstance()
|
||||||
do {
|
do {
|
||||||
// Flip ONLY the output port. No setCategory/setActive here — that would disrupt WebKit's audio.
|
|
||||||
try session.overrideOutputAudioPort(wantSpeaker ? .speaker : .none)
|
try session.overrideOutputAudioPort(wantSpeaker ? .speaker : .none)
|
||||||
call.resolve(diag(wantSpeaker ? "speaker" : "earpiece"))
|
let out = diag(wantSpeaker ? "speaker" : "earpiece")
|
||||||
|
routeLog.removeAll() // reset the trail; next toggle reports what happened in between
|
||||||
|
call.resolve(out)
|
||||||
} catch {
|
} catch {
|
||||||
call.reject(error.localizedDescription)
|
call.reject(error.localizedDescription)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audio-route",
|
"name": "audio-route",
|
||||||
"version": "1.0.2",
|
"version": "1.0.3",
|
||||||
"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