fix(client/apple): a session that can't voice-process still sends its microphone
ci / bun-nix (pull_request) Successful in 25s
ci / docs-site (pull_request) Successful in 1m7s
ci / web (pull_request) Successful in 1m8s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / rust (pull_request) Successful in 5m44s
ci / bun-nix (pull_request) Successful in 25s
ci / docs-site (pull_request) Successful in 1m7s
ci / web (pull_request) Successful in 1m8s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / rust (pull_request) Successful in 5m44s
Field report: mic uplink dead on iOS, iPadOS and macOS alike, while Android on the same host works - so the host and the wire are fine. Two defects in the combined (voice-processing) engine, which became the default on all three Apple platforms a week ago and has never run on a device - CI only runs swift test on macOS, and the loopback test counts datagrams without decoding them. - The tap read the input format before the engine was prepared. Enabling voice processing swaps the engine's IO unit for the VPIO one and renegotiates its formats; until prepare() the input node can still report the pre-swap state, 0 Hz / 0 channels included, which installMicTap correctly refuses as 'no usable input device'. Both topologies now prepare first, so the chain is built against what the voice processor actually emits. - A mic chain that failed on the voice-processed engine took the whole uplink down for the session: that arm fell back to playback ONLY. The sibling failure a few lines above - the voice processor refusing to engage at all - already falls back to the split path, which is a working mic without echo cancellation. Both arms do that now. The mic outranks the AEC. Not reproduced locally (no Punktfunk entries in this Mac's log store, and collecting the device's log needs root), so this is a strong inference plus one proven logic defect rather than a confirmed fix. If it persists, Console filtered to subsystem io.unom.punktfunk / category audio names the stage: 'mic capture: N Hz' then 'audio engines joined' then, 10 s in, either 'mic uplink OK - peak ...' or the SILENCE warning. Follow-up worth doing separately: nothing reports whether the uplink actually opened, so the HUD offers a Mute Microphone button over a session sending nothing. Android gates that on a real micRunning signal.
This commit is contained in:
@@ -483,19 +483,33 @@ public final class SessionAudio {
|
||||
}
|
||||
engine.attach(source)
|
||||
engine.connect(source, to: engine.mainMixerNode, format: format)
|
||||
guard installMicTap(on: input, micUID: micUID, micChannel: micChannel) else {
|
||||
// Mic chain unavailable (logged) — keep the session audible on the plain playback
|
||||
// engine rather than playing through an idle voice processor.
|
||||
// BEFORE the tap reads a format. Enabling voice processing swaps the engine's IO unit
|
||||
// for the VPIO one and renegotiates its formats, and until the engine is prepared the
|
||||
// input node can still report the pre-swap state — 0 Hz / 0 channels included, which
|
||||
// `installMicTap` (correctly) refuses as "no usable input device". Preparing first means
|
||||
// the chain is built against what the voice processor will actually emit.
|
||||
engine.prepare()
|
||||
guard installMicTap(on: engine.inputNode, micUID: micUID, micChannel: micChannel) else {
|
||||
// Mic chain unavailable on the VOICE-PROCESSED engine (logged). The mic outranks the
|
||||
// echo cancellation, so fall back to the split path — its own engine, no voice
|
||||
// processor, the topology that shipped before AEC existed — rather than dropping the
|
||||
// uplink for the rest of the session. (The sibling failure above, where the voice
|
||||
// processor won't engage at all, already does exactly this; this arm used to give up
|
||||
// on the mic instead, which is how a whole session could go silent uplink-only.)
|
||||
engine.stop()
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
return
|
||||
}
|
||||
engine.prepare()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
log.error("combined engine failed to start: \(error.localizedDescription)")
|
||||
input.removeTap(onBus: 0)
|
||||
startPlayback(speakerUID: speakerUID) // no echo cancellation beats no audio
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
// Same rule: a working mic without echo cancellation beats no mic at all.
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
return
|
||||
}
|
||||
stateLock.lock()
|
||||
@@ -533,8 +547,16 @@ public final class SessionAudio {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
guard installMicTap(on: input, micUID: micUID, micChannel: micChannel) else { return }
|
||||
// Prepared before the tap reads a format, for the same reason the combined path does it:
|
||||
// a node that hasn't been through `prepare()` can still report the pre-negotiation
|
||||
// format (0 Hz / 0 channels on a device that is perfectly fine), which reads downstream
|
||||
// as "no microphone".
|
||||
engine.prepare()
|
||||
guard installMicTap(on: engine.inputNode, micUID: micUID, micChannel: micChannel) else {
|
||||
log.error("mic uplink unavailable — this session sends no microphone audio")
|
||||
engine.stop()
|
||||
return
|
||||
}
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user