diag(ios audio): settled-route probe (+0.4s/+1.2s) + live mode in route log

1.0.3 telemetry showed mode already == .voiceChat when earpiece still landed on
Speaker, and setSpeaker read the route synchronously (stale) right after the override.
So the mode re-pin alone may be insufficient and the sync read is unreliable.

1.0.5: keeps the .voiceChat re-pin, but stamps live mode into every route-change log
line and re-reads the SETTLED port+mode at +0.4s and +1.2s after each toggle (reported
in the next toggle's trail). This definitively answers whether WebKit flips to .videoChat
and where the route truly settles. native marker 1.0.5-settle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 22:44:38 +05:30
parent b54ba10c69
commit 0282181ca6
2 changed files with 44 additions and 41 deletions
@@ -5,20 +5,18 @@ 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).
// //
// ROOT CAUSE (WebKit source: Source/WebCore/platform/audio/cocoa/MediaSessionManagerCocoa.mm, confirmed by // STATE OF THE INVESTIGATION (from device telemetry):
// device telemetry): in a WKWebView the app does NOT own the AVAudioSession WebKit's media process does. // * In a WKWebView the app does NOT own the AVAudioSession; WebKit's media process re-pins it while WebRTC
// While WebRTC capture is active it re-pins the session to .playAndRecord with mode .videoChat, and // capture is active. overrideOutputAudioPort is a *transient* override (Apple QA1754) that any route/category
// .videoChat auto-implies .defaultToSpeaker. overrideOutputAudioPort(.none) means "revert to the mode // change resets, so we must re-assert on AVAudioSession.routeChangeNotification (debounced, mismatch-only, capped).
// default", which under .videoChat is the LOUDSPEAKER so .none alone can never reach the earpiece once // * For earpiece, override(.none) reverts to the mode default the RECEIVER only if the mode is .voiceChat.
// WebKit has flipped the mode. Our first .none won only because our .voiceChat mode was still in effect // .videoChat implies .defaultToSpeaker => the default is the loudspeaker. So we pin .voiceChat before .none.
// (voiceChat's default route IS the receiver); after WebKit's reconfigure it latched to speaker. // * OPEN QUESTION this build answers: telemetry showed mode already == .voiceChat when earpiece still landed on
// // Speaker, AND setSpeaker read the route SYNCHRONOUSLY right after the override (which can be stale). So this
// THE FIX: for earpiece, set mode .voiceChat FIRST, then overrideOutputAudioPort(.none). overrideOutputAudioPort // build adds a reliable SETTLED probe: it re-reads the actual output port + mode at +0.4s and +1.2s after each
// is a *transient* override (Apple QA1754) that every route/category change resets, and WebKit reconfigures on // toggle, and stamps the live mode into every route-change log line. That tells us definitively whether WebKit
// capture start / setLocalDescription / ICE-connected / renegotiation so we also observe routeChangeNotification // flips to .videoChat and where the route truly settles i.e. whether the mode re-pin works or we've hit the
// and re-assert (debounced, mismatch-only, capped) after WebKit's change settles. This reactive re-assert is the // documented WKWebView ceiling (earpiece not reliably forceable on video calls).
// only lever a WKWebView host has (WebKit's internal RTCAudioSession / useManualAudio is unreachable; WebKit bug
// 167788 has left the app unable to disable WebKit's session management for 8+ years).
@objc(AudioRoutePlugin) @objc(AudioRoutePlugin)
public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "AudioRoutePlugin" public let identifier = "AudioRoutePlugin"
@@ -28,22 +26,19 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
] ]
// Bump on every native change so telemetry identifies the running binary unambiguously. // Bump on every native change so telemetry identifies the running binary unambiguously.
private static let nativeTag = "1.0.4-mode" private static let nativeTag = "1.0.5-settle"
private var wantSpeaker = false // desired output; source of truth, re-applied on route changes private var wantSpeaker = false // desired output; source of truth, re-applied on route changes
private var pending: DispatchWorkItem? // debounced re-assert private var pending: DispatchWorkItem? // debounced re-assert
private var asserts = 0 // re-assert count this toggle (capped, anti-thrash) private var asserts = 0 // re-assert count this toggle (capped, anti-thrash)
private var routeLog: [String] = [] // reason->port trail, returned to JS for diagnosis private var routeLog: [String] = [] // reason->port/mode trail, returned to JS for diagnosis
// Real external outputs when one of these is active and the user wants "earpiece", we must NOT force the
// built-in receiver (that would yank audio off the user's headset). Only correct the speaker<->receiver axis.
private let accessoryPorts: [AVAudioSession.Port] = [ private let accessoryPorts: [AVAudioSession.Port] = [
.bluetoothHFP, .bluetoothA2DP, .bluetoothLE, .headphones, .headsetMic, .usbAudio, .carAudio, .airPlay .bluetoothHFP, .bluetoothA2DP, .bluetoothLE, .headphones, .headsetMic, .usbAudio, .carAudio, .airPlay
] ]
override public func load() { override public func load() {
let session = AVAudioSession.sharedInstance() let session = AVAudioSession.sharedInstance()
// NO .defaultToSpeaker .voiceChat's default route is the receiver, so the port override decides output.
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(_:)),
@@ -60,65 +55,74 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
} }
} }
private func ports() -> String { private func modeShort(_ m: AVAudioSession.Mode) -> String {
let outs = AVAudioSession.sharedInstance().currentRoute.outputs.map { $0.portType.rawValue } if m == .voiceChat { return "vc" }
return outs.isEmpty ? "(none)" : outs.joined(separator: "+") if m == .videoChat { return "vid" }
if m == .default { return "def" }
return "oth"
}
// Actual output port(s) + live mode the ground-truth snapshot.
private func snap() -> String {
let s = AVAudioSession.sharedInstance()
let outs = s.currentRoute.outputs.map { $0.portType.rawValue }
return (outs.isEmpty ? "(none)" : outs.joined(separator: "+")) + "/" + modeShort(s.mode)
} }
private func log(_ s: String) { private func log(_ s: String) {
routeLog.append(s) routeLog.append(s)
if routeLog.count > 16 { routeLog.removeFirst(routeLog.count - 16) } if routeLog.count > 18 { routeLog.removeFirst(routeLog.count - 18) }
} }
@objc private func routeChanged(_ note: Notification) { @objc private func routeChanged(_ note: Notification) {
let raw = (note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt) ?? 0 let raw = (note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt) ?? 0
log("\(reasonName(raw))>\(ports())") log("\(reasonName(raw))>\(snap())")
// Re-assert AFTER WebKit's change settles; coalesce bursts into one late assert so we act last.
pending?.cancel() pending?.cancel()
let work = DispatchWorkItem { [weak self] in self?.applyRoute() } let work = DispatchWorkItem { [weak self] in self?.applyRoute() }
pending = work pending = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work) DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
} }
// Nudge the route toward the desired output, acting ONLY on a real built-in speaker<->receiver mismatch so
// our own override (which itself fires a route change) self-terminates instead of looping.
private func applyRoute() { private func applyRoute() {
guard asserts < 6 else { log("cap"); return } // anti-thrash: give up rather than ping-pong forever guard asserts < 6 else { log("cap"); return }
let s = AVAudioSession.sharedInstance() let s = AVAudioSession.sharedInstance()
let outs = s.currentRoute.outputs let outs = s.currentRoute.outputs
let onSpeaker = outs.contains { $0.portType == .builtInSpeaker } let onSpeaker = outs.contains { $0.portType == .builtInSpeaker }
let onAccessory = outs.contains { accessoryPorts.contains($0.portType) } let onAccessory = outs.contains { accessoryPorts.contains($0.portType) }
if onAccessory && !wantSpeaker { log("skipAcc"); return } // leave a real headset/BT alone if onAccessory && !wantSpeaker { log("skipAcc"); return }
do { do {
if wantSpeaker { if wantSpeaker {
if !onSpeaker { asserts += 1; try s.overrideOutputAudioPort(.speaker); log("assert>spk") } if !onSpeaker { asserts += 1; try s.overrideOutputAudioPort(.speaker); log("assert>spk") }
} else if onSpeaker { } else if onSpeaker {
asserts += 1 asserts += 1
// .none alone can't beat WebKit's .videoChat default (= speaker). Re-pin voiceChat first so the // .none reverts to the mode default; make that the RECEIVER by pinning .voiceChat first.
// "revert to default" the override does actually resolves to the RECEIVER.
if s.category != .playAndRecord { if s.category != .playAndRecord {
try s.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP]) try s.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
log("repinCat") log("repinCat")
} else if s.mode != .voiceChat { } else if s.mode != .voiceChat {
try s.setMode(.voiceChat) try s.setMode(.voiceChat); log("repinMode")
log("repinMode")
} }
try s.overrideOutputAudioPort(.none) try s.overrideOutputAudioPort(.none)
log("assert>rcv") log("assert>rcv:\(snap())")
} }
} catch { log("assertErr") } } catch { log("assertErr") }
} }
// Re-read the SETTLED route a beat after the override, since the immediate read can be stale.
private func scheduleSettleProbe() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in self?.log("s4>\(self?.snap() ?? "")") }
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in self?.log("s12>\(self?.snap() ?? "")") }
}
private func diag(_ applied: String) -> [String: Any] { private func diag(_ applied: String) -> [String: Any] {
let s = AVAudioSession.sharedInstance() let s = AVAudioSession.sharedInstance()
return [ return [
"route": applied, "route": applied,
"native": AudioRoutePlugin.nativeTag, "native": AudioRoutePlugin.nativeTag,
"outputs": ports(), "outputs": snap(),
"category": s.category.rawValue, "category": s.category.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 "log": routeLog.joined(separator: ",") // trail (incl. settled probes) since the previous toggle
] ]
} }
@@ -127,14 +131,13 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
asserts = 0 asserts = 0
let s = AVAudioSession.sharedInstance() let s = AVAudioSession.sharedInstance()
do { do {
// Earpiece: pin .voiceChat first so the very first press is correct even if WebKit already flipped
// the mode to .videoChat. Speaker: override(.speaker) works under any mode, no mode change needed.
if !wantSpeaker && s.category == .playAndRecord && s.mode != .voiceChat { if !wantSpeaker && s.category == .playAndRecord && s.mode != .voiceChat {
try s.setMode(.voiceChat) try s.setMode(.voiceChat)
} }
try s.overrideOutputAudioPort(wantSpeaker ? .speaker : .none) try s.overrideOutputAudioPort(wantSpeaker ? .speaker : .none)
scheduleSettleProbe() // settled reads land in the NEXT toggle's trail
let out = diag(wantSpeaker ? "speaker" : "earpiece") let out = diag(wantSpeaker ? "speaker" : "earpiece")
routeLog.removeAll() // reset the trail; next toggle reports what happened in between routeLog.removeAll()
call.resolve(out) call.resolve(out)
} catch { } catch {
call.reject(error.localizedDescription) call.reject(error.localizedDescription)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audio-route", "name": "audio-route",
"version": "1.0.4", "version": "1.0.5",
"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",