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

@@ -75,6 +75,14 @@ final class PCMRing {
/// Discard everything buffered call before (re)starting so stale pre-roll isn't played.
func reset() { OSMemoryBarrier(); readIdx = writeIdx }
/// Diagnostics: monotonic total samples written / read since the ring was created. The
/// indices are already cumulative, so these are free. Only read them when both threads are
/// quiesced (e.g. at teardown after the engine + mixer sink are stopped) they are not
/// synchronized for live cross-thread reads. Lets us tell "core never delivered PCM" (Bug 1
/// core path) apart from "PCM arrived but produced no sound" (AVAudioEngine output graph).
var debugTotalWritten: UInt64 { writeIdx }
var debugTotalRead: UInt64 { readIdx }
}
/// Native iOS voice-processing audio path (docs/voice.md §8 "iOS voice processing").
@@ -215,7 +223,17 @@ final class IOSVoiceProcessingEngine {
do {
try engine.start()
isRunning = true
logger.info("VPIO engine started — inFormat=\(inFormat), captureCh=\(targetCh)")
// Diagnostics: capture the negotiated graph formats and the live output route so a
// silent-playback report can be triaged (format/rate mismatch vs. routing vs. the
// core not delivering PCM see the ring stats logged in teardown()).
let outFmt = engine.outputNode.outputFormat(forBus: 0)
let mixFmt = engine.mainMixerNode.outputFormat(forBus: 0)
let route = AVAudioSession.sharedInstance().currentRoute.outputs
.map { "\($0.portName)[\($0.portType.rawValue)]" }.joined(separator: ", ")
logger.info("""
VPIO engine started — inFormat=\(inFormat), captureCh=\(targetCh), \
outputNode=\(outFmt), mainMixer=\(mixFmt), outputRoute=[\(route)]
""")
} catch {
logger.error("VPIO engine start failed: \(error.localizedDescription)")
teardown()
@@ -234,6 +252,10 @@ final class IOSVoiceProcessingEngine {
client?.setMixedOutputSink(nil, user: nil)
engine.inputNode.removeTap(onBus: 0)
if engine.isRunning { engine.stop() }
// Diagnostics (threads now quiesced): how much mixed PCM the core delivered into the ring
// vs. how much the render thread consumed. written==0 the core never delivered (Bug 1
// core/lifecycle path); written>0 with no audible output the AVAudioEngine output graph.
logger.info("VPIO ring stats — written=\(self.ring.debugTotalWritten) read=\(self.ring.debugTotalRead) samples")
try? engine.inputNode.setVoiceProcessingEnabled(false)
if let src = sourceNode {
engine.detach(src)