fix(ios): VPIO silent playback + quiet speaker in stereo/studio presets
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled

Two on-device bugs in the native iOS Voice-Processing path (Swift-only;
no core/ABI change).

1. Voice Chat (VPIO) silent playback: doStartMicStream() called
   audioRestart() BEFORE startStream, so when the engine was already
   running (a remote stream had started it) it reopened with
   external_capture=false and opened a hardware miniaudio capture device.
   The announce-result restart then early-returns (engine already running)
   so that device was never dropped and fought the AVAudioEngine VPIO unit,
   silencing playback. Now: setExternalPlayback first, then startStream
   (stores external_feed synchronously), THEN audioRestart() — the core
   reopens in full external mode (no hardware devices). Added VPIO
   diagnostics: graph/route formats at start, ring written/read totals at
   teardown.

2. Stereo Mic / Studio quiet earpiece: the .builtInMicBtA2dp presets omit
   .defaultToSpeaker (it breaks A2DP) and skip forceSpeaker, so with no
   Bluetooth connected output pinned to the quiet receiver. New
   IOSAudioRouter.applyA2dpSpeakerFallback() overrides to the built-in
   speaker when no external (A2DP/wired/AirPlay) output is present and
   clears the override when one is — called after activation and on
   device-change route changes.
This commit is contained in:
2026-06-22 03:43:00 +02:00
parent 6c17881cc0
commit fb73b694d0
5 changed files with 91 additions and 19 deletions

View File

@@ -689,9 +689,35 @@ final class IOSAudioRouter: ObservableObject {
showsA2dpNoAecWarning = (bluetoothMode == .builtInMicBtA2dp)
}
/// Whether the current configuration wants Bluetooth A2DP output. Used after session
/// activation to clear any lingering speaker override that would pin output to the speaker.
var wantsA2dpOutput: Bool { bluetoothMode == .builtInMicBtA2dp }
/// Route fallback for the A2DP-output presets (Stereo Mic / Studio / BT Headphones + Mono
/// Mic, all `.builtInMicBtA2dp`). These presets deliberately omit `.defaultToSpeaker` (it
/// breaks A2DP routing) and skip the `forceSpeaker` override, so when NO external output
/// (Bluetooth A2DP / wired / AirPlay) is connected `.playAndRecord` pins output to the quiet
/// built-in receiver (earpiece). This routes to the loud built-in speaker instead via a
/// post-activation `overrideOutputAudioPort(.speaker)` the documented "A2DP if connected,
/// else speaker" behavior. When an external output IS present we clear the override so A2DP /
/// headphones / AirPlay are honored. No-op outside `.builtInMicBtA2dp` mode (other modes pick
/// their route via category options). Must be called AFTER the session is active.
func applyA2dpSpeakerFallback() {
guard bluetoothMode == .builtInMicBtA2dp else { return }
let session = AVAudioSession.sharedInstance()
// Treat the built-in receiver and speaker as "internal"; anything else (A2DP, headphones,
// USB, AirPlay) is an external output we should defer to.
let hasExternalOutput = session.currentRoute.outputs.contains {
$0.portType != .builtInReceiver && $0.portType != .builtInSpeaker
}
do {
if hasExternalOutput {
try session.overrideOutputAudioPort(.none)
logger.info("A2DP mode — external output present, clearing speaker override")
} else {
try session.overrideOutputAudioPort(.speaker)
logger.info("A2DP mode — no external output, routing to built-in speaker")
}
} catch {
logger.error("A2DP speaker fallback failed: \(error.localizedDescription)")
}
}
/// The selected input port object, if any.
var selectedPort: IOSAudioInputPort? {