Skip to content

fix: avoid heap allocations in the steady-state echo canceller path - #39

Open
pex wants to merge 1 commit into
dignifiedquire:mainfrom
pex:alloc-free
Open

pex wants to merge 1 commit into
dignifiedquire:mainfrom
pex:alloc-free

Conversation

@pex

@pex pex commented Sep 30, 2026

Copy link
Copy Markdown

Echo cancellation runs in real-time audio callbacks, where allocating memory can block. With AEC3 enabled, processing one 10 ms frame allocated about 40 times:

  • EchoRemover::process_capture created eleven per-channel working vectors per 4 ms block. They now live in a CaptureScratch struct that is allocated once and reset to the same zero/default state per block.
  • EchoCanceller3 built a Vec<Vec<&[f32]>> view per sub-frame for the frame blocker (capture and render). FrameBlocker::insert_sub_frame_and_extract_block now accepts owned buffers as well, so no view is built.
  • AudioBuffer::copy_to_buffer resampled into a temporary vector; it now resamples straight into the destination channel.

The output is bit-identical to 0.2.0 (same SHA-256 of 60 s of double-talk capture output). A new test, tests/no_allocation.rs, counts allocations in steady-state processing (40,500 before, 0 after).

Echo cancellation runs in real-time audio callbacks, where allocating memory
can block. With AEC3 enabled, processing one 10 ms frame allocated about 40
times:

- EchoRemover::process_capture created eleven per-channel working vectors
  per 4 ms block. They now live in a CaptureScratch struct that is allocated
  once and reset to the same zero/default state per block.
- EchoCanceller3 built a Vec<Vec<&[f32]>> view per sub-frame for the frame
  blocker (capture and render). FrameBlocker::insert_sub_frame_and_extract_block
  now accepts owned buffers as well, so no view is built.
- AudioBuffer::copy_to_buffer resampled into a temporary vector; it now
  resamples straight into the destination channel.

The output is bit-identical to 0.2.0 (same SHA-256 of 60 s of double-talk
capture output). A new test, tests/no_allocation.rs, counts allocations in
steady-state processing (40,500 before, 0 after).

Co-Authored-By: Claude <noreply@anthropic.com>
dignifiedquire added a commit that referenced this pull request Oct 1, 2026
This reverts the signature change of d417d08.
FrameBlocker::insert_sub_frame_and_extract_block is generic again over
Band: AsRef<[Channel]> and Channel: AsRef<[f32]>, as b10927e (#39) had
it. The call form of the published sonora-aec3 0.2.0, &[Vec<&[f32]>],
compiles again, and EchoCanceller3 still passes its owned
&[Vec<Vec<f32>>] buffers without building a view on each call.

d417d08's capacity fix in FrameBlocker::new and its test stay. The tests
now exercise both call forms: block_bitexactness passes borrowed views
built by make_sub_frame_view (restored), and blocker_and_framer passes
owned buffers. With d417d08's concrete signature, the view test does not
compile.

No API break remains, so the BREAKING CHANGE footer of d417d08 no longer
applies. Against the published 0.2.0 sources (a024d6e, the commit that
the 0.2.0 crates in the cargo cache were packaged from):
- A rustdoc JSON listing of the public API of all eight crates differs
  only in this method, which is now generic.
- cargo-semver-checks 0.46.0 (--baseline-rev a024d6e, offline) flags
  method_requires_different_generic_type_params here and nothing else.
  The Cargo SemVer guide classifies this change as minor ("generalizing
  a function to use generics (supporting original type)"): the 0.2.0
  type is the instantiation Band = Vec<&[f32]>, Channel = &[f32]. As the
  guide notes, a caller that relied on the parameter type to infer a
  type, for example the target of a collect() passed straight in, may
  need an annotation.

The output is unchanged: the same code runs for owned buffers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H29DamSLugosGXJSz1e5Yx
dignifiedquire added a commit that referenced this pull request Oct 1, 2026
b10927e added fmt to the existing ptr import as use std::{fmt, ptr};, the
only grouped std import in sonora-aec3. Every other file imports one std
path per line. (#39 review, F10.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H29DamSLugosGXJSz1e5Yx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant