From d13b56d379341078fd03beeb29e076eed7574917 Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Mon, 18 May 2026 13:01:52 +0800 Subject: [PATCH] perf(audio): pre-allocate capture scratch buffers to avoid realtime-thread Vec allocs (SDD-094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capture cpal callback (CaptureState::ingest) ran two heap allocations per callback on the realtime audio thread: 1. engine.rs:1196-1202 — fresh `mono: Vec` for the downmix output, once per cpal callback (50–100 Hz). 2. engine.rs:1217-1218 — `pcm_accum.drain(..FRAME_SAMPLES).collect()` building a fresh Vec of 960 samples per Opus frame. Both sites mirror the pattern already fixed for the output side at engine.rs:1389-1397, where allocating per callback on glibc malloc was correlated with user-perceptible audio popping. The output-side fix replaced the per-callback allocation with a pre-allocated `scratch` Vec that is cleared and resized in place; this commit applies the same template to the capture side. Changes: - Add `mono_scratch: Vec` and `frame_scratch: Vec` to CaptureState. Initialised with Vec::with_capacity(4096) and Vec::with_capacity(FRAME_SAMPLES=960) respectively in CaptureState::new. - Replace the downmix Vec construction with in-place push into `self.mono_scratch`; `clear()` retains capacity across callbacks. - Replace the drain().collect() with `self.frame_scratch.extend( self.pcm_accum.drain(..FRAME_SAMPLES))`; same capacity-retention. - The resampler call uses std::mem::take to swap the scratch buffer out for the duration of the &mut self call, then moves it back — the backing allocation is preserved across callbacks. Algorithm semantics are unchanged: same downmix arithmetic, same clamp loop, same Opus encode call sequence. Only the storage strategy differs. Out of scope (intentionally not touched): - Android audio path (android_voice_unit.rs, mobile_voice_backend.rs): researcher constraint C-4 — the Android cpal data path is mid- migration and being replaced. - Output callback at engine.rs:1409+: the only obvious per-callback allocation there (`scratch`) was already fixed; a fuller audit is a separate scope decision. - The `scratch` buffer at engine.rs:1389-1397 — already correct. Verification: - cargo check --workspace --all-targets: passes. - cargo test --workspace: 106 passed / 0 failed / 3 ignored. - cargo clippy --workspace --all-targets: no new lints introduced; the one warning inside the edited region (clamp-like pattern at line 1266) was pre-existing on the copied clamp loop. --- crates/chanora_audio/src/engine.rs | 68 +++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index d732347..7b937f6 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -1154,6 +1154,19 @@ struct CaptureState { /// CaptureState never mutates this flag. transmit_active: Arc, frames_sent: Arc, + /// Pre-allocated mono downmix buffer. Resized in-place each + /// callback; `clear()` retains capacity. SDD-094 realtime-thread + /// invariant: this avoids the heap allocation that the prior fix + /// at engine.rs:1389-1397 (output-side `scratch` buffer) + /// addressed; the capture side has the same pattern and the same + /// user-perceptible cost on glibc malloc when the callback runs + /// on a realtime SCHED_FIFO thread. + mono_scratch: Vec, + /// Pre-allocated Opus-frame buffer. Sized to FRAME_SAMPLES (960) + /// on construction; reused across frames. `clear()` retains + /// capacity so the drain-into-frame path skips the allocator + /// after warmup. Same precedent as `mono_scratch` above. + frame_scratch: Vec, } #[cfg(not(target_os = "ios"))] @@ -1179,6 +1192,12 @@ impl CaptureState { voice_out_tx, transmit_active, frames_sent, + // Generous upper bound for typical cpal periods + // (commonly 256..1024 frames); `clear()` retains the + // backing allocation across callbacks. See struct doc. + mono_scratch: Vec::with_capacity(4096), + // Exact upper bound: drain pulls FRAME_SAMPLES at a time. + frame_scratch: Vec::with_capacity(FRAME_SAMPLES), } } @@ -1193,19 +1212,41 @@ impl CaptureState { } // 1. Down-mix to mono + gain. - let mono: Vec = buf - .chunks(self.in_channels) - .map(|frame| { - let sum: f32 = frame.iter().map(|s| s.to_f32_sample()).sum(); - (sum / frame.len() as f32) * self.mic_gain - }) - .collect(); + // Reuse `self.mono_scratch` to avoid a per-callback Vec + // allocation on the realtime audio thread; see struct + // doc and the engine.rs:1389-1397 precedent for why this + // matters for user-perceptible audio popping. + let in_channels = self.in_channels; + let mic_gain = self.mic_gain; + self.mono_scratch.clear(); + let frame_count = buf.len() / in_channels.max(1); + self.mono_scratch.reserve(frame_count); + for frame in buf.chunks(in_channels) { + let sum: f32 = frame.iter().map(|s| s.to_f32_sample()).sum(); + self.mono_scratch + .push((sum / frame.len() as f32) * mic_gain); + } - // 2. Resample to 48 kHz if needed. + // 2. Resample to 48 kHz if needed. We re-borrow + // `mono_scratch` as a shared slice per branch to satisfy + // the borrow checker against `&mut self` on the + // resample path. if self.in_sample_rate == SAMPLE_RATE { - self.pcm_accum.extend_from_slice(&mono); + // Disjoint-borrow: copy the slice into pcm_accum without + // aliasing &mut self. + let (src, dst) = (&self.mono_scratch, &mut self.pcm_accum); + dst.extend_from_slice(src); } else { + // resample_into_accum reads `mono` and writes + // `self.pcm_accum` / `self.resample_*`. These fields are + // disjoint from `self.mono_scratch`, but the function + // signature takes `&mut self`, so we take ownership of + // the scratch buffer briefly via `std::mem::take`, run + // the resampler, then move the buffer back so its + // capacity is preserved across callbacks. + let mono = std::mem::take(&mut self.mono_scratch); self.resample_into_accum(&mono); + self.mono_scratch = mono; } // 3. Encode any complete frames. Clamp each sample to @@ -1215,7 +1256,12 @@ impl CaptureState { // Soft-clamping at the engine boundary preserves headroom // and matches what every other VoIP client does. while self.pcm_accum.len() >= FRAME_SAMPLES { - let mut frame: Vec = self.pcm_accum.drain(..FRAME_SAMPLES).collect(); + // Reuse `self.frame_scratch` to avoid a per-frame Vec + // allocation on the realtime audio thread; same + // rationale as the engine.rs:1389-1397 precedent. + let frame = &mut self.frame_scratch; + frame.clear(); + frame.extend(self.pcm_accum.drain(..FRAME_SAMPLES)); for s in frame.iter_mut() { if *s > 1.0 { *s = 1.0; @@ -1223,7 +1269,7 @@ impl CaptureState { *s = -1.0; } } - match self.encoder.encode_float(&frame, &mut self.opus_out[..]) { + match self.encoder.encode_float(&frame[..], &mut self.opus_out[..]) { Ok(len) => { let packet = OutAudio::new(&AudioData::C2S { id: 0,