Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ jobs:
with:
shared-key: "rust"
- run: cargo clippy --all-targets -- -D warnings
# Both cargo features on, so a warning that only appears in one feature
# configuration is caught here too.
- run: cargo clippy --all-targets --all-features -- -D warnings
- run: cargo fmt --all -- --check

test:
Expand All @@ -40,6 +43,10 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
shared-key: "rust"
# The full suite: `internal-tests` adds the crate-internal reference
# canceller and its golden-pair suite, which a default build leaves out.
- run: cargo test --all-targets --features internal-tests
# The default feature configuration, which is what a consumer compiles.
- run: cargo test --all-targets
# `cargo test --all-targets` does not run doctests, so the README usage
# example is exercised separately here.
Expand All @@ -58,7 +65,7 @@ jobs:
with:
shared-key: "rust"
- run: cargo build --all-targets
- run: cargo test --all-targets
- run: cargo test --all-targets --features internal-tests

audit:
name: Security Audit
Expand Down
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,45 @@
<!-- markdownlint-disable MD024 -->
# Decibri AEC Changelog

## [0.2.0] - 2026-07-30

Dependency and packaging hygiene, an allocation-free steady-state hot path, and
documentation corrections. Output samples are byte-identical to 0.1.0.

### Changed

- BREAKING: `tracing` is now an off-by-default cargo feature. A default build has
no dependency on `tracing` and the diagnostic emit sites compile to nothing.
Migration, for a consumer that wants the events back:
`decibri-aec = { version = "0.2", features = ["tracing"] }`.
- The real FFT owns its working buffer, allocated once at construction, so
neither transform direction allocates per call and `Aec::process` performs no
heap allocation in steady state. Output is byte-identical to 0.1.0.
- The crate-internal reference canceller and the golden-pair suite that validates
the pipeline against it are behind a new `internal-tests` cargo feature and
excluded from the published package. The feature is internal to development: it
adds no public API and there is nothing in it for a consumer to enable.

### Documentation

- `AecConfig::delay_hint_ms`: the offset a hint seeds is measured from the far-end
reference frontier as the caller's own feeding establishes it, not from an
absolute platform latency. The two directions of error are not equivalent: a
hint short of that offset is absorbed by the modelled tail, while a hint longer
than it cancels nothing and reports no error. The previous claim that a wrong
hint costs convergence time rather than correctness was wrong in the overshoot
direction and is removed, here and on `Aec::new`.
- `Aec::feed_reference`: a reference at a rate other than the configured one is
accepted silently, and the diagnostic is `AecMetrics::acquisition_parked`
climbing while `AecMetrics::delay_samples` stays `None`.
- `Aec::feed_reference`: automatic acquisition needs broadband far-end material.
Sustained periodic material can leave acquisition parked for a whole stream;
`AecConfig::delay_hint_ms` is the way around it, subject to that field's
frontier-relative caveat.
- SECURITY.md: the `tracing` dependency is opt-in.
- CONTRIBUTING.md: plain `cargo test` is not the full suite, and the command that
is.

## [0.1.0] - 2026-07-29

### Added
Expand Down
14 changes: 10 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,20 +51,26 @@ cargo build --release # optimised release build
### Testing

```
cargo test --all-targets # unit, integration, and example tests
cargo test --doc # the README usage example
cargo test --all-targets --features internal-tests # the full suite
cargo test --all-targets # the default feature set
cargo test --doc # the README usage example
```

Both must pass. `cargo test --all-targets` does not run doctests, so the second command is not optional.
All three must pass. `cargo test --all-targets` does not run doctests, so the last command is not optional.

Plain `cargo test` is not the full suite. The `internal-tests` feature adds the crate-internal reference canceller and the golden-pair suite that validates the pipeline against it, which together account for the difference between 111 and 136 library tests. Those two files are excluded from the published package, which is why they sit behind a feature rather than a plain `#[cfg(test)]`. Run the first command above when you want the full suite; CI runs both feature configurations.

The `internal-tests` feature is internal to development. It adds no public API and there is nothing in it for a consumer of the crate to enable.

### Linting

```
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --all-features -- -D warnings
```

Both must be clean. The CI pipeline runs exactly these commands.
All three must be clean. The CI pipeline runs exactly these commands. Clippy runs twice because a warning can appear in one feature configuration and not the other.

## What you should know before changing the engine

Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 20 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "decibri-aec"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
rust-version = "1.88"
license = "Apache-2.0"
Expand All @@ -11,6 +11,11 @@ keywords = ["audio", "echo-cancellation", "aec", "dsp", "adaptive-filter"]
categories = ["multimedia::audio"]
include = [
"/src/**",
# The crate-internal reference canceller and its golden-pair suite are
# `cfg(all(test, feature = "internal-tests"))` and never compile into a
# consumer build, so they are not shipped.
"!/src/golden.rs",
"!/src/rho.rs",
"/tests/**",
"/examples/cancel.rs",
"/examples/shared/**",
Expand All @@ -19,9 +24,22 @@ include = [
"/LICENSE",
]

[package.metadata.docs.rs]
all-features = true

[features]
# Forwards the diagnostic emit sites to `tracing`. Off by default: a default
# build has no dependency on `tracing` and the emit sites compile to nothing.
tracing = ["dep:tracing"]
# Compiles the crate-internal reference canceller and the golden-pair suite that
# validates the pipeline against it into test builds. INTERNAL: it adds no
# public API and nothing for a consumer to enable. The full test suite runs with
# it on.
internal-tests = []

[dependencies]
thiserror = "2"
tracing = { version = "0.1", default-features = false, features = ["std"] }
tracing = { version = "0.1", default-features = false, features = ["std"], optional = true }

[dev-dependencies]
# WAV read/write for the bench harness example only; never in the shipped
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,15 @@ On the recordings where that bound is meaningful, decibri captures around 80 per

```toml
[dependencies]
decibri-aec = "0.1"
decibri-aec = "0.2"
```

### Cargo features

- `tracing` (off by default). Forwards the engine's diagnostic events to [`tracing`](https://crates.io/crates/tracing). Without it the crate has no dependency on `tracing` and the emit sites compile to nothing. Enable it with `features = ["tracing"]`.

There is also an `internal-tests` feature, which is internal to this repository's own test suite. It adds no public API and there is nothing in it for a consumer to enable.

## Usage

```rust
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ For a library of this kind, the realistic issues are input handling rather than
- **No unsafe code.** The crate is built with `#![forbid(unsafe_code)]`, so the compiler rejects any `unsafe` block anywhere in the library.
- **No network access.** The library never opens a socket, resolves a name, or contacts any remote service, at any point.
- **No file access.** The library reads and writes no files. It takes sample slices and appends to a caller-owned buffer. The examples in this repository do read and write WAV files, but examples are not part of the published library.
- **No telemetry, analytics, or phone-home.** Nothing is collected and nothing is transmitted. The crate emits `tracing` events, which are inert unless the host application installs a subscriber, and which go wherever that host directs them.
- **No telemetry, analytics, or phone-home.** Nothing is collected and nothing is transmitted. The crate can emit `tracing` events, but only when the host opts in by enabling the off-by-default `tracing` cargo feature; a default build has no `tracing` dependency at all and the emit sites compile to nothing. Even with the feature on, the events are inert unless the host application installs a subscriber, and they go wherever that host directs them.
- **No elevated permissions and no hardware access.** The library does not open an audio device, request microphone permission, or need any privilege beyond running as the calling process.
- **A small dependency surface.** The runtime dependency tree is deliberately minimal, and the crate owns its own transform code rather than pulling in a third-party signal processing library.
- **Non-finite input is contained.** Input samples that are not finite are sanitised on entry rather than propagating through the filter state, and the adaptive filter carries a divergence guard.
Expand Down
44 changes: 40 additions & 4 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,46 @@ pub struct AecConfig {
pub max_search_delay_ms: u16,

/// Optional caller-supplied delay hint in milliseconds, used to seed the
/// delay search when an integrator has a measured platform latency
/// (Bluetooth especially). `None` (the default) runs the estimator with no
/// seed. A hint outside the search window is clamped with a warning, not
/// rejected: a wrong hint costs convergence time, not correctness.
/// delay search instead of estimating it. `None` (the default) runs the
/// estimator with no seed. A hint outside the search window is clamped, not
/// rejected.
///
/// # The hint is measured from the reference frontier, not from an absolute
/// timeline
///
/// The offset the hint seeds is measured from the far-end reference frontier
/// as the caller's own feeding establishes it: how far BACK from the newest
/// fed reference sample the echo of the block now being processed sits. It
/// is not an absolute platform figure. Two callers with the same physical
/// echo need different hints if they interleave
/// [`feed_reference`](crate::Aec::feed_reference) and
/// [`process`](crate::Aec::process) differently, because the frontier sits
/// somewhere different when the block is processed. A caller whose renderer
/// buffers ahead has a frontier that far ahead, and the hint has to include
/// that lead. Feeding the block's reference and then processing the block
/// keeps the lead at one block; keeping the reference N blocks ahead adds N
/// blocks to the offset.
///
/// So a measured platform latency is the right hint only for a caller who
/// feeds exactly in step with processing. A hint taken from platform
/// latency while the reference runs ahead is short by that lead.
///
/// # A hint that is too long is not recoverable
///
/// The two directions of error are not equivalent, and the difference
/// matters more than the size:
///
/// - SHORT of the frontier-relative offset: the remainder is inside the
/// modelled tail and adaptation absorbs it, at the cost of some of the
/// [`tail_ms`](AecConfig::tail_ms) budget and some convergence time.
/// Short by more than the tail cancels nothing.
/// - LONGER than the frontier-relative offset: the aligned reference is
/// older than the echo, which a causal filter cannot model, and NOTHING
/// is cancelled for as long as the hint stands. No error is returned and
/// the delay reads as locked.
///
/// A caller unsure of its own lead should therefore err short, or supply no
/// hint at all and let the estimator find the offset.
pub delay_hint_ms: Option<u16>,

/// Residual echo suppression setting. Default: [`Suppression::Conservative`].
Expand Down
61 changes: 57 additions & 4 deletions src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,22 @@
//! a canceller by [`AecConfig::model`] and drives it through the
//! [`EchoCanceller`] seam.

// Diagnostics shim: with the `tracing` feature enabled the emit sites below
// forward to `tracing`; without it they expand to nothing and the crate has no
// tracing dependency. The macro definitions keep the call sites identical in
// both configurations.
#[cfg(feature = "tracing")]
use tracing::{debug, warn};

#[cfg(not(feature = "tracing"))]
macro_rules! debug {
($($arg:tt)*) => {};
}
#[cfg(not(feature = "tracing"))]
macro_rules! warn {
($($arg:tt)*) => {};
}

use crate::acquire::{AcquireAction, DelayAcquirer};
use crate::canceller::{CancellerMetrics, EchoCanceller};
use crate::config::{AecConfig, AecModel, OutputTransitionPolicy};
Expand Down Expand Up @@ -347,6 +361,10 @@ enum OutputBlend {
/// more reason a host that CAN declare should: a declaration is applied on the
/// next `process` with no warm-up at all.
pub struct Aec {
/// The configuration the engine was constructed from. Read by the
/// diagnostic emit sites only, so a build without the `tracing` feature
/// never reads it.
#[cfg_attr(not(feature = "tracing"), allow(dead_code))]
config: AecConfig,
ring: ReferenceRing,
/// The canceller [`AecConfig::model`] selected, constructed with the
Expand Down Expand Up @@ -426,9 +444,12 @@ impl Aec {
/// Validates the configuration, sizes the reference ring, and seeds the
/// alignment offset from [`AecConfig::delay_hint_ms`]. This is the only
/// fallible operation: it returns [`AecError`] when a field is out of range.
/// A delay hint outside the search window is clamped with a warning rather
/// than rejected, because a wrong hint costs convergence time, not
/// correctness.
/// A delay hint outside the search window is clamped rather than rejected.
///
/// A hint is measured from the reference frontier the caller's own feeding
/// establishes, and a hint longer than that offset cancels nothing without
/// reporting an error. See [`AecConfig::delay_hint_ms`] before supplying
/// one.
pub fn new(config: AecConfig) -> Result<Aec, AecError> {
if !(8000..=48000).contains(&config.sample_rate) {
warn!(
Expand Down Expand Up @@ -577,7 +598,7 @@ impl Aec {
/// This is deliberately the only way a Rho instance ever reaches the
/// engine: it is `pub(crate)` and compiled only into test builds, so no
/// public string, selector, or constructor can produce it.
#[cfg(test)]
#[cfg(all(test, feature = "internal-tests"))]
pub(crate) fn with_internal_reference(config: AecConfig) -> Result<Aec, AecError> {
let mut aec = Aec::new(config)?;
aec.canceller = Box::new(crate::rho::RhoCanceller::new(
Expand Down Expand Up @@ -608,6 +629,37 @@ impl Aec {
/// the alignment. Whether the alignment still describes the stream is
/// decided in [`Aec::process`], against the near stream rather than the
/// ring (see [`AecMetrics::reference_reanchors`]).
///
/// # A reference at the wrong rate is accepted silently
///
/// Both streams are plain `&[f32]` with no rate attached, so a reference at
/// a rate other than [`AecConfig::sample_rate`] cannot be detected: it is
/// accepted with no error and no warning, and nothing is cancelled. This is
/// the likeliest integration mistake, because a host's playback and capture
/// devices commonly run at different rates.
///
/// The signature is [`AecMetrics::acquisition_parked`] climbing while
/// [`AecMetrics::delay_samples`] stays `None`, on a stream where audio is
/// definitely playing. That combination means the reference is not at the
/// configured rate, or is not the signal that produced the echo. Resample
/// the reference to the configured rate before feeding it.
///
/// # Automatic acquisition needs broadband far-end material
///
/// The delay search is correlation-based, so it needs a far end with an
/// unambiguous correlation peak. Sustained periodic material (a held tone, a
/// steady harmonic complex, and some music) has no such peak, because every
/// period is an equally good match. On material like that the acquisition
/// can stay parked for the whole stream: the reference flows, nothing is
/// cancelled, [`AecMetrics::delay_samples`] stays `None`, and no error is
/// returned. Speech and most broadband program material lock normally.
///
/// A caller that has to cancel against periodic far-end material can supply
/// [`AecConfig::delay_hint_ms`], which skips the search entirely and locks
/// on the supplied offset. Read that field's documentation before doing so:
/// the hint is measured from the reference frontier as the caller's own
/// feeding establishes it, not from an absolute platform latency, and a hint
/// longer than that offset cancels nothing.
pub fn feed_reference(&mut self, reference: &[f32]) {
sanitize_into(reference, &mut self.reference_scratch);
self.ring.push(&self.reference_scratch);
Expand Down Expand Up @@ -872,6 +924,7 @@ impl Aec {
self.declarations_without_decision = 0;
let unchanged = self.delay_known
&& self.delay_offset.abs_diff(delay as u64) <= self.relock_keep;
#[cfg(feature = "tracing")]
let previous = self.delay_offset;
// The offset is adopted either way. The acquisition has
// already taken `delay` as its own alignment, and every
Expand Down
Loading