feat(audio,ios): wire VPIO render callback to AudioHandler (commit 4/5, rc.8+64)
Replace the silence-emitting render callback from commit 1 with
real playback that drives AudioHandler::fill_buffer and downmixes
its 48 kHz stereo f32 output to the i16 mono buffer VPIO expects.
Pipeline per render callback (mirrors the cpal-output + sdl_output
contracts so the platform-neutral playback path is preserved):
1. Lock the shared Arc<Mutex<AudioHandler>>, ask fill_buffer to
populate a stereo-f32 scratch slice of length 2*num_frames.
AudioHandler runs Opus decode + per-client jitter buffer + mix
internally. Same primitive every other platform calls.
2. If output_muted is true, zero the i16 output buffer and return.
We still ran fill_buffer in step 1 so the jitter buffer drains
while muted — preventing unbounded growth — which matches the
cpal/SDL backend contract.
3. Downmix stereo -> mono with master gain:
mono_f32 = (l + r) * 0.5 * gain
i16_out = (mono_f32.clamp(-1.0, 1.0) * i16::MAX) as i16
The 0.5 average preserves total signal energy with 3 dB
headroom against sum-of-correlated-peaks clipping. Multiply by
gain after the downmix saves one mul per sample. Hard-clip on
the i16 cast is acceptable because the upstream stereo signal
is already in [-1.0, 1.0] from the f32 mix; only gain >1.0
creates clipping pressure and that path is identical to every
other backend's i16 conversion.
Closure ownership:
* scratch_stereo: Vec<f32> moved into the FnMut closure. First
callback grows it to 2*num_frames; subsequent callbacks reuse
the backing allocation. The audio thread never hits the
allocator on steady-state callbacks.
* handler_for_render / output_gain_for_render / output_muted_for_render
are Arc clones taken before the closure literal.
Public API change: AudioEngine -> IosVoiceUnit::start parameters
that were previously underscored (commit 1 placeholder) are now
all consumed by the wiring. Signature is unchanged, just the
binder names lose the leading underscore. engine.rs call-site
is unaffected.
Build verify on Mac (target aarch64-apple-ios): cargo check
clean in 0.33s, no errors, no warnings.
Build counter 63 -> 64 — About dialog shows v1.0.0-rc.8+64.
This commit is contained in:
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0-rc.8+63
|
||||
version: 1.0.0-rc.8+64
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.5
|
||||
|
||||
@@ -320,10 +320,11 @@ impl IosVoiceUnit {
|
||||
/// and SDL backends accept so engine.rs can swap backends with
|
||||
/// a `cfg`.
|
||||
///
|
||||
/// * `_handler` — shared AudioHandler the inbound forwarder
|
||||
/// * `handler` — shared AudioHandler the inbound forwarder
|
||||
/// feeds Opus packets into. The output render callback pulls
|
||||
/// decoded f32 frames from it.
|
||||
/// * `_output_gain` / `_output_muted` — same atomics the cpal
|
||||
/// decoded f32 frames from it (48 kHz stereo) and downmixes
|
||||
/// to the i16 mono buffer VPIO expects.
|
||||
/// * `output_gain` / `output_muted` — same atomics the cpal
|
||||
/// and SDL output paths read on every callback so the master
|
||||
/// volume + local-mute UI works identically across backends.
|
||||
/// * `voice_out_tx` — channel the capture pipeline sends
|
||||
@@ -333,14 +334,13 @@ impl IosVoiceUnit {
|
||||
/// * `frames_sent` — counter the bridge stats surface reads.
|
||||
/// * `mic_gain` — pre-encode amplitude scale.
|
||||
///
|
||||
/// Capture wiring landed in commit 3; playback wiring lands
|
||||
/// in commit 4 (the render callback still emits silence
|
||||
/// until then).
|
||||
/// Capture wiring landed in commit 3; playback wiring landed
|
||||
/// in commit 4. Route-change observation is commit 5.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn start(
|
||||
_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||
_output_gain: Arc<AtomicU32>,
|
||||
_output_muted: Arc<AtomicBool>,
|
||||
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||
output_gain: Arc<AtomicU32>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
@@ -445,14 +445,93 @@ impl IosVoiceUnit {
|
||||
})
|
||||
.map_err(|e| AudioError::Backend(format!("vpio set input callback: {e}")))?;
|
||||
|
||||
// Install the render callback that emits playback samples
|
||||
// to the hardware. The buffer iOS hands us is uninitialised
|
||||
// — we MUST fill it (writing silence if we have nothing,
|
||||
// never leaving stale frames). Commit 4 replaces the silence
|
||||
// loop with an AudioHandler::fill_buffer + downmix path.
|
||||
unit.set_render_callback(|args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
for s in args.data.buffer.iter_mut() {
|
||||
*s = 0;
|
||||
// Install the render callback that drives playback. The
|
||||
// buffer iOS hands us is uninitialised — we MUST fill it
|
||||
// (writing silence if we have nothing, never leaving stale
|
||||
// frames).
|
||||
//
|
||||
// Pipeline per callback:
|
||||
// 1. Lock the AudioHandler, ask it to fill a scratch
|
||||
// f32 stereo buffer (length = 2 * num_frames). The
|
||||
// handler runs Opus decode + per-client jitter
|
||||
// buffer + mix. Same primitive cpal + SDL output
|
||||
// paths use; this is the platform-neutral playback
|
||||
// contract from `tsclientlib::audio::AudioHandler`.
|
||||
// 2. Downmix to i16 mono with master gain. VPIO expects
|
||||
// mono int16 (the stream format we pinned above);
|
||||
// the handler produces stereo f32. We average L+R
|
||||
// to a single mono channel rather than dropping R —
|
||||
// the cpal-side mono-output path made the same
|
||||
// mistake briefly (commit 6a4dbad / fix) and lost
|
||||
// half the spatial mix.
|
||||
// 3. Local-mute zeroes the output but STILL drains
|
||||
// AudioHandler in step 1 so its jitter buffer
|
||||
// doesn't grow unbounded while muted. This is the
|
||||
// contract every other backend follows (matches
|
||||
// SdlOutput::callback and the cpal output stream).
|
||||
//
|
||||
// The closure owns the f32 scratch Vec so we re-use the
|
||||
// backing allocation across callbacks. The first few
|
||||
// callbacks may grow it; after that the audio thread
|
||||
// never touches the allocator.
|
||||
let mut scratch_stereo: Vec<f32> = Vec::with_capacity(FRAME_SAMPLES_MONO * 2);
|
||||
let handler_for_render = handler.clone();
|
||||
let output_gain_for_render = output_gain.clone();
|
||||
let output_muted_for_render = output_muted.clone();
|
||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
let out: &mut [i16] = args.data.buffer;
|
||||
// VPIO with our pinned stream format gives us a mono
|
||||
// buffer; `num_frames` == `out.len()`. Stereo f32
|
||||
// scratch is twice that.
|
||||
let num_frames = out.len();
|
||||
let needed = num_frames * 2;
|
||||
if scratch_stereo.len() < needed {
|
||||
scratch_stereo.resize(needed, 0.0);
|
||||
}
|
||||
// Zero only the live slice. AudioHandler::fill_buffer
|
||||
// writes silence into untouched samples but residual
|
||||
// values from earlier callbacks (when the buffer was
|
||||
// bigger) would leak through otherwise. `fill`
|
||||
// optimises to memset.
|
||||
scratch_stereo[..needed].fill(0.0);
|
||||
|
||||
{
|
||||
let mut h = handler_for_render.lock().unwrap();
|
||||
let _removed_ids = h.fill_buffer(&mut scratch_stereo[..needed]);
|
||||
// `_removed_ids` lists clients whose stream just
|
||||
// finished draining; the bridge layer surfaces
|
||||
// "stopped speaking" via BridgeEvent::VoiceState
|
||||
// already, so we ignore it here (matches SDL +
|
||||
// cpal output behaviour).
|
||||
}
|
||||
|
||||
if output_muted_for_render.load(Ordering::Relaxed) {
|
||||
for s in out.iter_mut() {
|
||||
*s = 0;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
||||
// Downmix stereo -> mono with gain. The `(l + r) * 0.5`
|
||||
// averaging preserves total signal energy with a 3 dB
|
||||
// headroom (sum-of-correlated peaks at full scale
|
||||
// would otherwise clip). Multiplying by gain after
|
||||
// the downmix saves one multiplication per sample.
|
||||
//
|
||||
// Cast to i16 with saturate-on-overflow. Hard-clip is
|
||||
// acceptable here because the upstream signal is
|
||||
// already in [-1.0, 1.0] from the f32 stereo mix;
|
||||
// gain values >1.0 are the only path to clipping and
|
||||
// hard-clip at the engine boundary is what every other
|
||||
// backend's i16 path does (see the cpal-side
|
||||
// FromF32 for i16 impl in engine.rs).
|
||||
for (i, dst) in out.iter_mut().enumerate() {
|
||||
let l = scratch_stereo[i * 2];
|
||||
let r = scratch_stereo[i * 2 + 1];
|
||||
let mono_f32 = (l + r) * 0.5 * gain;
|
||||
let clamped = mono_f32.clamp(-1.0, 1.0);
|
||||
*dst = (clamped * i16::MAX as f32) as i16;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user