perf(audio): pre-allocate capture scratch buffers to avoid realtime-thread Vec allocs (SDD-094)
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<f32>` 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<f32> 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<f32>` and `frame_scratch: Vec<f32>` 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.
This commit is contained in:
@@ -1154,6 +1154,19 @@ struct CaptureState {
|
|||||||
/// CaptureState never mutates this flag.
|
/// CaptureState never mutates this flag.
|
||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
frames_sent: Arc<AtomicU32>,
|
frames_sent: Arc<AtomicU32>,
|
||||||
|
/// 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<f32>,
|
||||||
|
/// 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<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "ios"))]
|
#[cfg(not(target_os = "ios"))]
|
||||||
@@ -1179,6 +1192,12 @@ impl CaptureState {
|
|||||||
voice_out_tx,
|
voice_out_tx,
|
||||||
transmit_active,
|
transmit_active,
|
||||||
frames_sent,
|
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.
|
// 1. Down-mix to mono + gain.
|
||||||
let mono: Vec<f32> = buf
|
// Reuse `self.mono_scratch` to avoid a per-callback Vec
|
||||||
.chunks(self.in_channels)
|
// allocation on the realtime audio thread; see struct
|
||||||
.map(|frame| {
|
// 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();
|
let sum: f32 = frame.iter().map(|s| s.to_f32_sample()).sum();
|
||||||
(sum / frame.len() as f32) * self.mic_gain
|
self.mono_scratch
|
||||||
})
|
.push((sum / frame.len() as f32) * mic_gain);
|
||||||
.collect();
|
}
|
||||||
|
|
||||||
// 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 {
|
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 {
|
} 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.resample_into_accum(&mono);
|
||||||
|
self.mono_scratch = mono;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Encode any complete frames. Clamp each sample to
|
// 3. Encode any complete frames. Clamp each sample to
|
||||||
@@ -1215,7 +1256,12 @@ impl CaptureState {
|
|||||||
// Soft-clamping at the engine boundary preserves headroom
|
// Soft-clamping at the engine boundary preserves headroom
|
||||||
// and matches what every other VoIP client does.
|
// and matches what every other VoIP client does.
|
||||||
while self.pcm_accum.len() >= FRAME_SAMPLES {
|
while self.pcm_accum.len() >= FRAME_SAMPLES {
|
||||||
let mut frame: Vec<f32> = 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() {
|
for s in frame.iter_mut() {
|
||||||
if *s > 1.0 {
|
if *s > 1.0 {
|
||||||
*s = 1.0;
|
*s = 1.0;
|
||||||
@@ -1223,7 +1269,7 @@ impl CaptureState {
|
|||||||
*s = -1.0;
|
*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) => {
|
Ok(len) => {
|
||||||
let packet = OutAudio::new(&AudioData::C2S {
|
let packet = OutAudio::new(&AudioData::C2S {
|
||||||
id: 0,
|
id: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user