Skip to content

fix: allocation-free steady-state echo cancellation (builds on #39) - #40

Open
dignifiedquire wants to merge 15 commits into
mainfrom
alloc-free-followup
Open

dignifiedquire wants to merge 15 commits into
mainfrom
alloc-free-followup

Conversation

@dignifiedquire

@dignifiedquire dignifiedquire commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Summary

This PR builds on #39 by Roman Ernst and fixes every finding from its review. The first commit is the contributor's commit b10927e, unchanged and with its authorship. The rest of the PR adds six commits on top. It makes steady-state processing with echo cancellation allocation-free across the f32 and i16 APIs, every channel layout, and the 16, 32, and 48 kHz rates. Output is byte-identical to main.

Merge this instead of #39. It contains #39's commit as is, so #39 can be closed when this merges.

What #39 did

  • EchoRemover keeps its per-channel working buffers in a scratch struct instead of allocating 11 vectors per 4 ms block.
  • EchoCanceller3 no longer builds a view per sub-frame for the frame blocker.
  • AudioBuffer::copy_to_buffer resamples directly into the destination.
  • A new test, tests/no_allocation.rs, counts allocations on the current thread: 40,500 on main, 0 after the change.

What this PR adds

Commit Change Review finding
8fd36b6 Preallocates the i16 conversion and downmix buffers in AudioBuffer. A channel-select downmix now reads the channel directly, as upstream does. A debug_assert documents the direct-resample invariant. 1, 7
ba20000 Borrows the capture scratch in place instead of mem::take. Before, one panic left the C API instance broken for good, because the FFI layer catches panics and reuses the instance. A regression test covers this. Also drops the per-block reset(): every buffer is fully written before it is read, checked buffer by buffer in the commit message and empirically. Upstream doesn't zero them either. 2, 4
d417d08 FrameBlocker::insert_sub_frame_and_extract_block takes a concrete &[Vec<Vec<f32>>]. Each buffer gets its own capacity: vec![Vec::with_capacity(..); n] dropped the capacity on clone. The signature change is reverted below (800c96f, 99591ae); its BREAKING CHANGE footer no longer applies. 5, 6
5922530 Sums the multichannel render power without allocating. Predates #39; it cost 2,500 allocations per 10 s with stereo render. new
0cbfee6 Extends no_allocation to 12 scenarios (see below). 3
558b694 Borrows the capture buffer in place for the full-band copy, the same panic hazard one level up. There's no regression test, because a panic inside copy_to_buffer can't be triggered without injecting a fault. review follow-up
800c96f, 99591ae No API break. insert_sub_frame_and_extract_block keeps its exact 0.2.0 signature (&[Vec<&[f32]>]), and the new insert_owned_sub_frame_and_extract_block(&[Vec<Vec<f32>>]) takes owned buffers. Both share one private generic body, and EchoCanceller3 uses the owned method, so no views are built per call. A test pins the 0.2.0 signature. cargo-semver-checks (against the 0.2.0 tags) reports "no semver update required" for all 8 crates. maintainer decision
d10b2de Runs the tests of sonora, sonora-aec3, sonora-common-audio, sonora-fft and sonora-simd at opt-level = 3, through [profile.test.package.*]. Debug assertions and overflow checks stay on. cargo test --workspace (already built) drops from about 22 s to 2 s, and no_allocation from about 35 s CPU to 1.2 s. A clean build takes about 4 s longer. Results and coverage are unchanged. CI cost
74aabed, 102369d, 7cfea3f Test comments corrected (frees aren't counted; the no-AEC attenuation baseline is up to 6.4 dB). A 13th scenario runs noise suppression and checks that it ran: attenuation with it differs from attenuation without it. review nits
9e10a0e, dbcd920 The C API docs now say how a caller recovers after WapError::Internal. One std import per line in echo_remover.rs. review nits

Verification

The allocation test now covers 13 scenarios:

  • f32 mono at 16, 32, and 48 kHz, plus 48 kHz with 3-band processing;
  • i16 mono at 16 and 48 kHz;
  • stereo in with mono out, in f32 (48 kHz) and i16 (44.1 kHz);
  • stereo in and out, in f32 (48 kHz) and i16 (44.1 kHz);
  • capture output unused;
  • an echo delay change from 60 to 100 ms mid-run;
  • stereo with noise suppression, checking that noise suppression ran.

Each scenario asserts:

  • 0 allocations;
  • that AEC actually engaged: ERLE above 10 dB and output attenuation above 10 dB;
  • that the delay estimate is within 10 ms;
  • that the processing rate is the intended one.
Check Result
no_allocation on this branch 13/13 pass (debug and release; rustc 1.98 and 1.91.1)
The same test on #39's head b10927e 6 fail: i16 1,000–3,000, downmix 2,000, stereo 2,500–6,500 allocations per 10 s
The same test on main all fail (35,500–46,000)
f32 output vs main (8 scenarios: HPF+AEC3+NS+AGC2, 16/32/48 kHz, mono and stereo) byte-identical (release and debug)
i16 output vs main (new harness) byte-identical (release and debug)
cargo test --workspace 775 passed, 0 failed, 0 ignored (debug and release)
Public API vs 0.2.0 (cargo-semver-checks) no semver update required, all 8 crates
fmt, clippy -D warnings (both CI commands), MSRV 1.91.1, docs, fuzz check pass
Merge with #36, #37, #38 clean; merged with #38 (includes #36), the tests pass

When this branch is merged with #38's flush-to-zero guard, two stereo scenarios failed at first. The cause was the test signal, not the library: pauses of exact digital silence left only subnormal differences between the stereo channels, and flush-to-zero erased them. The far-end pauses now carry low-level noise, and the scenarios pass with and without #38.

Not in this PR

  • Reconfiguration: a stereo-detection change still allocates once (about 660 allocations at that frame) when AEC3 rebuilds its whole BlockProcessor. Upstream reallocates there too.
  • AGC2: with AGC2 enabled, steady-state processing still allocates (about 7,000–11,000 allocations per 10 s, in GainController2::process and the limiter). That predates this PR. Hence the title says echo cancellation.
  • C API float entry points: wap_process_stream_f32 and wap_process_reverse_stream_f32 (sonora-ffi/src/functions.rs:193, 224) build two small slice tables per call. Worth a follow-up.
  • CI time: handled by d10b2de. Before it, the extended test roughly doubled the Android jobs, which run under emulation (16–25 min, up from 8–10). Check the job times on this PR's latest CI run.

🤖 Generated with Claude Code

https://claude.ai/code/session_01H29DamSLugosGXJSz1e5Yx

pex and others added 7 commits September 30, 2026 20:22
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>
AudioBuffer still allocated a temporary vector per 10 ms frame when
downmixing float input (copy_from_float) and in the int16 conversions
(copy_from_interleaved_i16, copy_to_interleaved_i16). With echo
cancellation on, that is 1 allocation per frame for int16 at 16 kHz,
3 for int16 at 48 kHz and 2 for float stereo in, mono out. The C API's
wap_process_stream_i16 takes this path.

These temporaries now use one scratch buffer that AudioBuffer allocates
at construction, sized for the longer of the input and output frames.
The averaging downmix in copy_from_float writes into it; selecting one
channel reads that channel directly, as upstream does, instead of
copying it first.

copy_to_buffer resamples straight into the destination, which matches
the old copy only while the resampler writes exactly as many samples as
the destination holds. A debug assertion on resample's return value
now documents that.

The output is bit-identical: the capture and render output and the
statistics of 46 configurations (f32 and int16; 16, 32, 44.1 and 48 kHz;
rate conversion; mono, downmixed and stereo capture; both downmix
methods; 32 kHz maximum processing rate; unused capture output; echo
delay change) hash the same before and after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H29DamSLugosGXJSz1e5Yx
… reset

EchoRemover::process_capture moved its scratch buffers out of self with
mem::take and put them back only at the end of the call. sonora-ffi
catches panics and keeps using the instance, so a panic in between left
empty vectors behind, and every later call panicked on them. The buffers
are now borrowed in place by destructuring &mut self.scratch, which Rust
allows next to borrows of the other fields of self. The take, the
reassembly, the Default derive that only mem::take needed, and the
comment that described them are gone. A new test panics midway through
a call and checks that the next call works; it fails on the old code.

The scratch was also cleared at the start of every 4 ms block, about
5.7 KB per capture channel. On every path through process_capture, each
element is written before it is read:

- subtractor_output: Subtractor::process writes all fields for every
  channel (prediction_error, compute_metrics, zero_padded_fft, spectrum)
  before its own first read.
- e: form_linear_filter_output (signal_transition writes all 64).
- y_fft, e_fft: padded_fft, whose copy_from_packed_array writes every
  re and im bin.
- s2_linear: linear_echo_power; y2, e2: FftData::spectrum (all 65 bins).
- r2, r2_unbounded: read only when the capture output is used, and in
  that branch ResidualEchoEstimator::estimate first overwrites them on
  all four paths (copy of Y2, linear_estimate or non_linear_estimate).
- comfort_noise, high_band_comfort_noise: generate_comfort_noise writes
  all re bins and im[1..64]. Nothing writes im[0] and im[64], so they
  keep the zeros from construction with or without the reset.

Upstream does not clear these arrays either. The output is
bit-identical: 60 configurations, including saturated echo on the
linear and the non-linear residual echo paths and an unused or toggled
capture output, hash the same before and after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H29DamSLugosGXJSz1e5Yx
FrameBlocker::insert_sub_frame_and_extract_block became generic over two
AsRef parameters, mainly so that a test helper building borrowed views
kept compiling. Every production caller passes owned per-band,
per-channel buffers, and BlockFramer::insert_block_and_extract_sub_frame
takes those as a concrete &mut [Vec<Vec<f32>>]. The blocker now takes a
concrete &[Vec<Vec<f32>>], and the tests pass their buffers directly
instead of through make_sub_frame_view, which is gone.

FrameBlocker::new built its buffers with
vec![vec![Vec::with_capacity(BLOCK_SIZE); n]; m]. vec! clones its
element, and a cloned Vec does not keep the capacity, so all but one
buffer started empty and grew in the audio callback: 4 and then 2
allocations in the first two frames at 48 kHz mono, 8 and 4 at 48 kHz
stereo, and again whenever initialize() rebuilds the render blocker
after the stereo detection changes. Each buffer now gets its own
Vec::with_capacity. A new test checks the capacity and fails on the old
construction. No other vec![Vec::with_capacity(..); n] remains in
sonora-aec3 or sonora; BlockFramer fills its buffers with zeros, so
its clones keep a full block of capacity.

The output is bit-identical (the same 60 configurations as before).

BREAKING CHANGE: sonora-aec3's public
FrameBlocker::insert_sub_frame_and_extract_block now takes
sub_frame: &[Vec<Vec<f32>>]. In the published sonora-aec3 0.2.0 it took
&[Vec<&[f32]>], so callers that pass borrowed views no longer compile.
The next release must bump sonora-aec3's minor version (0.2 to 0.3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H29DamSLugosGXJSz1e5Yx
ResidualEchoEstimator::update_render_noise_power summed the render
channels into a stack array and then copied it into a Vec, once per
4 ms block whenever there is more than one render channel: 2.5
allocations per 10 ms frame, 2,500 in 10 s of 48 kHz stereo. Found
while extending tests/no_allocation.rs to stereo; it predates PR #39
and is in the published sonora-aec3 0.2.0.

It now uses the array directly, as update_reverb in the same file and
the upstream C++ do. The output is bit-identical (the same 60
configurations as before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H29DamSLugosGXJSz1e5Yx
…anges in no_allocation

tests/no_allocation.rs covered only 48 kHz mono float input with a
constant echo delay, and it never checked that the echo canceller did
anything. It now runs 12 scenarios, each in its own test:

- float at 16, 32 and 48 kHz, mono, at the default 32 kHz maximum
  processing rate (one and two bands);
- float at 48 kHz, mono, with a 48 kHz maximum processing rate (three
  bands);
- int16 at 16 and 48 kHz, mono (the C API's wap_process_stream_i16);
- float stereo in, mono out (downmix);
- float stereo in and out, with stereo render;
- int16 at 44.1 kHz, stereo in and mono out, and stereo in and out
  (downmix and multi-channel conversion with resampling both ways);
- set_capture_output_used(false);
- an echo delay change from 60 ms to 100 ms while counting.

Each run is 15 s of far end with pauses, a delayed echo, double talk and
noise. The far-end pauses keep a low noise floor rather than digital
silence. AEC3 treats render as stereo only after the channels have
differed for 2 s without a break; with exact zeros in the pauses the
stereo scenarios passed only because of subnormal differences between
the channels, and with the flush-to-zero of PR #38 they ran on mono
render and failed.

Allocations are counted after a 5 s warm-up, on the current thread
only, as before. Each test then checks that the stream ran at the
intended processing rate and that the echo canceller converged on the
real echo: ERLE above 10 dB, the echo on the output attenuated by more
than 10 dB (when the output is used) and the delay estimate within
10 ms of the true delay, which also shows that the delay change was
tracked. Measured values are above 16 dB.

On the PR #39 head, 6 of the 12 tests fail (per 10 s: 1000 allocations
for int16 16 kHz, 3000 for int16 48 kHz, 2000 for float stereo in mono
out, 2500 for float stereo, 3000 and 6500 for int16 44.1 kHz). Without
the multi-channel render power fix, the two stereo tests fail with 2500
allocations, also when merged with PR #38. The test takes about 4 s
wall time (about 32 s CPU time) in a debug build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H29DamSLugosGXJSz1e5Yx
The fullband copy in process_capture_stream_locked moved capture_audio
out of its Option with take(), called copy_to_buffer and put it back
afterwards. If copy_to_buffer panicked, for example at its
debug_assert_eq! on the resampled length or at an assert in
PushSincResampler, capture_audio stayed None. sonora-ffi catches panics
and keeps using the instance, so every later capture call would then
panic at capture_audio.as_mut().unwrap().

capture_audio and capture_fullband_audio are separate fields, so both
can be borrowed mutably at the same time and the take is not needed.
Nothing changes when no panic occurs.

The pattern predates PR #39; it is the one the echo remover's capture
scratch had.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H29DamSLugosGXJSz1e5Yx
@codecov-commenter

codecov-commenter commented Oct 1, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 96.17834% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.34%. Comparing base (413f64e) to head (7cfea3f).

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #40      +/-   ##
==========================================
+ Coverage   93.64%   94.34%   +0.69%     
==========================================
  Files         146      146              
  Lines       31130    31208      +78     
  Branches    31130    31208      +78     
==========================================
+ Hits        29153    29443     +290     
+ Misses       1753     1577     -176     
+ Partials      224      188      -36     
Flag Coverage Δ
rust 94.34% <96.17%> (+0.69%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

dignifiedquire and others added 8 commits October 1, 2026 13:27
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
The no_allocation tests run 12 scenarios of 15 s of audio each. In an
unoptimized build they take most of the test time; on the emulated
Android CI targets, the binary alone took 541 to 1,021 s. The sonora-aec3
unit tests are the other slow binary.

The test profile now builds sonora, sonora-aec3, sonora-common-audio,
sonora-fft and sonora-simd with opt-level 3, through
[profile.test.package.<name>] overrides. cargo test uses the test
profile, and so do cross test (the Android jobs), cargo nextest and
cargo llvm-cov nextest, which build through cargo test. dev builds
(cargo build, cargo run, the examples) stay unoptimized and debuggable;
[profile.dev.package.<name>] would have optimized those too. Debug
assertions and overflow checks stay on: the test profile inherits them
from dev, cargo passes -C debug-assertions=on with the new opt-level, and
rustc enables overflow checks with debug assertions. A probe test
confirmed that both still fire in these crates.

Measured on an M-series Mac (16 cores, other jobs running; two samples
each, without the kache wrapper, clean target directory):

                                     opt-level 0        opt-level 3
  cargo test --workspace --no-run    6.6-6.7 s wall     10.3-12.0 s wall
    (clean build)                    58 s CPU           91-120 s CPU
  cargo test --workspace (run)       21.9-22.0 s wall   1.9-2.0 s wall
                                     55-57 s CPU        4.2-4.5 s CPU
  no_allocation binary               4.1-5.7 s wall     0.1 s wall
                                     32-38 s CPU        1.1-1.2 s CPU
  cargo nextest run --workspace      19.4-21.9 s wall   1.0-1.2 s wall
  cargo llvm-cov nextest (clean)     28.6 s wall        13.6 s wall

The results do not change: 774 tests pass with cargo test (17 binaries)
and 768 with nextest, as before, and the coverage report is identical
for all 144 files. opt-level 1 builds no faster here and runs the tests
about three times slower than opt-level 3.

Trade-offs: a clean test build costs about 4-5 s more wall time. These
crates no longer share their compiled artifacts between cargo build and
cargo test. Tests in these crates run optimized code under a debugger;
cargo test --profile dev builds them without the override.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H29DamSLugosGXJSz1e5Yx
The header now says that the allocator counts alloc and realloc but not
frees. In the two scenarios with stereo render (f32_48k_stereo and
i16_44k_stereo), the echo canceller rebuilds its block processor
(echo_canceller3.rs, initialize) once it detects stereo render. 1000
blocks later, the new comfort noise generator drops its initial noise
estimate (comfort_noise_generator.rs:170-172), which frees one 520-byte
buffer (260 bytes per capture channel) inside the counted window. A
probe that also counted frees found exactly this one free in those two
scenarios and none in the others. Upstream frees the same buffer with
N2_initial_.reset() (comfort_noise_generator.cc:178-180).

The comment on the attenuation check said that without echo removal the
attenuation is about 0 dB. Measured with the echo canceller disabled and
the split-band high-pass filter that it enforces kept on (so that the
processing rate is unchanged), the attenuation reaches 6.39 dB in
f32_48k_stereo_in_mono_out: 3.5 dB because the downmix carries less echo
than the first microphone, which is the one measured, and 2.9 dB because
48 kHz audio is processed at 32 kHz. With the echo canceller, the lowest
value is 16.8 dB, so the 10 dB threshold still separates the two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H29DamSLugosGXJSz1e5Yx
Noise suppression runs in the same capture callback as the echo
canceller, but no scenario enabled it. f32_48k_stereo_noise_suppression
runs the f32_48k_stereo layout with noise suppression: two suppressors,
one per capture channel, each over two bands. It allocates nothing
today, and the test now asserts that. With an allocation added to
NoiseSuppressor::process, this scenario fails and the other 12 pass.

The attenuation check still needs the echo canceller here. A probe
measured 7.3 dB at most with noise suppression alone (echo canceller
disabled, its split-band high-pass filter kept), against 16.9 dB with
both in this scenario. The comment on the check now gives that figure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H29DamSLugosGXJSz1e5Yx
The C API catches panics and returns WapError::Internal, but the docs
did not say what a caller should do next. The call stops partway, so the
processing state may be inconsistent. The docs now say to call
wap_initialize(), and to destroy and recreate the instance if that also
fails.

What wap_initialize() resets, as read in the code: it sets all four
stream formats (functions.rs:144-163), then initialize_locked
(audio_processing_impl.rs:1052-1142) rebuilds the render, capture and
full-band capture audio buffers. It clears or reallocates the residual
echo detector's render queue and force-resets the high-pass filter. It
re-initializes the residual echo detector and rebuilds the echo
controller, AGC2, the noise suppressors and the capture levels adjuster
from the current configuration, which it keeps. Since ba20000, a panic
in the echo remover no longer leaves it unusable, and that commit's test
covers the case.

The header that build.rs generates with cbindgen carries the same text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H29DamSLugosGXJSz1e5Yx
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
…method

800c96f made FrameBlocker::insert_sub_frame_and_extract_block generic
over Band: AsRef<[Channel]> and Channel: AsRef<[f32]>, and its message
says "No API break remains". That is wrong. A generic method is not
source-compatible with every caller of the published 0.2.0 signature.
Of 10 call patterns that compile against the 0.2.0 sources (a024d6e),
6 fail against 800c96f's signature (checked at dbcd920, Rust 1.91.1):
- coercion to fn(&mut FrameBlocker, &[Vec<&[f32]>], &mut Block): E0308;
- passing the method where FnMut over those types is required:
  "implementation of FnMut is not general enough";
- arguments whose type only the parameter fixed:
  &[iter.map(|c| c.as_slice()).collect()], &[vec![a.as_ref(), ..]] and
  &[vec![a, b].into()] (E0283), and .map(AsRef::as_ref).collect::<Vec<_>>()
  (E0790).
cargo-semver-checks 0.46.0 rates that signature major
(method_requires_different_generic_type_params).

insert_sub_frame_and_extract_block now has the exact 0.2.0 signature,
sub_frame: &[Vec<&[f32]>], again. The new method
insert_owned_sub_frame_and_extract_block takes owned buffers,
sub_frame: &[Vec<Vec<f32>>], and EchoCanceller3 uses it for capture and
render, so it still builds no view per call. Both methods delegate to
one private generic helper, so the copy code exists once and is the
code that ran before. d417d08's capacity fix in FrameBlocker::new and
its test stay.

The change is additive. All 10 call patterns compile against this
commit. cargo-semver-checks 0.46.0 (--baseline-rev a024d6e, offline,
1.91.1 rustdoc) reports "no semver update required" for all eight
crates; the same run flags 800c96f's signature as major, so the check
detects the break. Neither d417d08's BREAKING CHANGE footer nor 800c96f's
generic signature applies to the next release.

A new test coerces the method to the 0.2.0 fn-pointer type, so making
it generic again breaks the test build (checked: E0308).
blocker_and_framer now calls the owned method; block_bitexactness
still passes borrowed views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H29DamSLugosGXJSz1e5Yx
…cenario

The header of no_allocation.rs says each test checks that a test cannot
pass because the processing it covers was skipped. The checks cover the
echo canceller only, so f32_48k_stereo_noise_suppression (102369d) still
passed when noise suppression did not run: with initialize_noise_suppressor
creating no suppressors, all 13 tests pass.

The scenario now also runs without noise suppression and requires the
echo attenuation of the two runs to differ by more than 1 dB. The input
is deterministic, so skipped noise suppression gives the same output and
the same attenuation. Measured on aarch64: 16.85 dB with noise
suppression, 18.97 dB without. The check does not assume a direction:
with the echo canceller in front, noise suppression raises the output
energy here. Under the mutation above the test now fails (18.97 dB both
ways). check() returns its Outcome so the test can reuse the first run.
The header now names both checks.

The extra run takes about 0.1 s with the optimized test profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H29DamSLugosGXJSz1e5Yx
@dignifiedquire dignifiedquire changed the title fix: allocation-free steady-state processing (builds on #39) fix: allocation-free steady-state echo cancellation (builds on #39) Oct 1, 2026
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.

3 participants