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

@@ -230,13 +230,16 @@ final class SessionState {
// runs in external mode (no hardware mic/playback). The mic stream is started with
// externalFeed so the core skips the hardware capture device; setExternalPlayback makes
// it skip the hardware playback device and deliver the mix to IOSVoiceProcessingEngine.
//
// ORDER MATTERS: set the external-playback flag now, but defer audioRestart() until
// AFTER startStream (below) so the MIC LocalStream which carries external_feed=true
// already exists when ensure_audio_running() derives external_capture. Restarting before
// the stream exists makes the core reopen a hardware capture device that is never dropped
// (the announce-result restart early-returns because the engine is already running); that
// lingering miniaudio capture unit then fights the AVAudioEngine VPIO unit on the same
// .voiceChat session and silences VPIO playback.
let useVPIO = IOSAudioRouter.shared.currentConfigUsesVoiceProcessing
if useVPIO {
client.setExternalPlayback(true)
client.audioRestart() // flip any already-running (pre-join) engine into external mode
} else {
client.setExternalPlayback(false)
}
client.setExternalPlayback(useVPIO)
let desc = StreamDescriptor(kind: .mic, deviceId: voiceState.currentDeviceId, label: "Mic",
externalFeed: useVPIO)
@@ -259,6 +262,10 @@ final class SessionState {
client.setCaptureChannels(streamId: streamId, channels: channels)
}
if useVPIO {
// The external-feed MIC stream now exists, so restart the core into full
// external mode (no hardware capture/playback, mixer-timer only) mic and
// speaker are owned entirely by the VPIO engine, which we start right after.
client.audioRestart()
IOSVoiceProcessingEngine.shared.start(
client: client, micStreamId: streamId, captureChannels: channels)
}