diff --git a/.github/scripts/ci_ffi.sh b/.github/scripts/ci_ffi.sh new file mode 100755 index 0000000..dd1cb6e --- /dev/null +++ b/.github/scripts/ci_ffi.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Build the C ABI and compile/run a C smoke test against the shipped header. +# +# `embedded-dsp-ffi` is a standalone workspace (see its Cargo.toml), so it is +# addressed by manifest path rather than as a root-workspace member, and its +# artifacts are redirected under the main `target/` directory. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" + +manifest="crates/embedded-dsp-ffi/Cargo.toml" +export CARGO_TARGET_DIR="${EDS_FFI_TARGET_DIR:-$repo_root/target/ffi}" +out_dir="$CARGO_TARGET_DIR/debug" + +cargo clippy --manifest-path "$manifest" --all-targets -- -D warnings +cargo build --manifest-path "$manifest" + +# The C compiler is the check: the test uses every exported symbol through the +# public header, so a Rust/header signature drift fails to compile or link. +cc -std=c11 -Wall -Wextra -Werror -pedantic \ + crates/embedded-dsp-ffi/tests/c_smoke.c \ + -I crates/embedded-dsp-ffi/include \ + -L "$out_dir" -lembedded_dsp_ffi \ + -Wl,-rpath,"$out_dir" \ + -lm -o "$out_dir/eds_c_smoke" + +"$out_dir/eds_c_smoke" diff --git a/.github/scripts/ci_fuzz.sh b/.github/scripts/ci_fuzz.sh new file mode 100755 index 0000000..62b52e5 --- /dev/null +++ b/.github/scripts/ci_fuzz.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Bounded differential fuzzing of the FIR and biquad kernels. +# +# The targets compare the `f32` kernels against in-process `f64` references, so a +# found input is a real numerical divergence, not just a crash. Bounded so the +# job stays well under a minute; run longer locally with FUZZ_RUNS. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root/fuzz" + +cargo fuzz build + +for target in fir_differential biquad_differential; do + echo "fuzzing: $target" + cargo fuzz run "$target" -- -runs="${FUZZ_RUNS:-20000}" -max_len=4096 +done diff --git a/.github/scripts/ci_miri.sh b/.github/scripts/ci_miri.sh new file mode 100755 index 0000000..d47a588 --- /dev/null +++ b/.github/scripts/ci_miri.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Miri undefined-behaviour check. +# +# Two unit tests are skipped, for reasons unrelated to this crate: +# * `nalgebra_interop` reaches libm's x86 `sqrtss` inline assembly, which Miri +# cannot execute ("inline assembly is not supported"). +# * `compile_time_atan_helpers_and_private_divi` asserts last-bit `atan` +# accuracy against the native libm; Miri's float shims are not bit-identical. +# Everything else (the 67 remaining unit tests) plus the fixed-point and +# elementwise integration suites run under Miri. +# +# Set MIRI_CARGO to a toolchain-qualified cargo (e.g. "cargo +nightly-2026-07-25") +# to run against a specific nightly locally. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" + +cargo_bin="${MIRI_CARGO:-cargo}" +export MIRIFLAGS="${MIRIFLAGS:--Zmiri-disable-isolation}" + +$cargo_bin miri test -p embedded-dsp --all-features --lib -- \ + --skip nalgebra_interop \ + --skip compile_time_atan_helpers_and_private_divi +$cargo_bin miri test -p embedded-dsp --all-features --test fixed_basic_types +$cargo_bin miri test -p embedded-dsp --all-features --test basic_math diff --git a/.github/scripts/ci_python.sh b/.github/scripts/ci_python.sh new file mode 100755 index 0000000..f150b08 --- /dev/null +++ b/.github/scripts/ci_python.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Build the PyO3 extension and run the Python smoke test against it. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" + +manifest="crates/embedded-dsp-py/Cargo.toml" +export CARGO_TARGET_DIR="${EDS_PY_TARGET_DIR:-$repo_root/target/py}" + +cargo build --manifest-path "$manifest" + +# Import the cdylib under the name the module's init symbol expects. +stage="$(mktemp -d)" +cp "$CARGO_TARGET_DIR/debug/libembedded_dsp.so" "$stage/embedded_dsp.so" + +PYTHONPATH="$stage" python3 crates/embedded-dsp-py/tests/smoke.py + +rm -rf "$stage" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60b1776..f48a4de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,6 +140,28 @@ jobs: echo "::endgroup::" done + hack: + name: Feature powerset (cargo-hack) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: taiki-e/install-action@cargo-hack + - uses: Swatinem/rust-cache@v2 + # `--features libm` keeps the always-on base (the crate requires `std` or + # `libm`), then every feature is checked individually on top of it. + - run: cargo hack check -p embedded-dsp --each-feature --no-dev-deps --features libm + + machete: + name: Unused dependencies (cargo-machete) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo install cargo-machete --locked + - run: cargo machete + test: name: Test runs-on: ubuntu-latest @@ -174,10 +196,51 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: bash .github/scripts/ci_docs.sh + ffi: + name: C ABI + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: bash .github/scripts/ci_ffi.sh + + python: + name: Python bindings + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - uses: Swatinem/rust-cache@v2 + - run: bash .github/scripts/ci_python.sh + + miri: + name: Miri (UB) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@nightly + with: + components: miri, rust-src + - uses: Swatinem/rust-cache@v2 + - run: bash .github/scripts/ci_miri.sh + + fuzz: + name: Fuzz (bounded) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@nightly + - uses: Swatinem/rust-cache@v2 + - run: cargo install cargo-fuzz --locked + - run: bash .github/scripts/ci_fuzz.sh + publish-dry-run: name: Publish dry-run runs-on: ubuntu-latest - continue-on-error: true steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml new file mode 100644 index 0000000..bb92b9a --- /dev/null +++ b/.github/workflows/mutants.yml @@ -0,0 +1,40 @@ +name: Mutants + +# Weekly mutation testing over the always-compiled core. Informational: it runs on +# a schedule, never on pull requests. +# +# `cargo-mutants` discovers modules from the `mod` declarations it can see. This +# crate declares its feature modules through the `gated_mod!` macro, which it +# cannot follow, so only `math`, `types`, and `intrinsics` are mutable today; +# widen `--file` once discovery covers the feature modules. + +on: + schedule: + - cron: "17 5 * * 1" # Mondays, 05:17 UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + mutants: + name: Mutation testing (shard ${{ matrix.shard }}) + runs-on: ubuntu-latest + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + shard: ["1/8", "2/8", "3/8", "4/8", "5/8", "6/8", "7/8", "8/8"] + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo install cargo-mutants --locked + - name: Mutate + run: | + cargo mutants -p embedded-dsp \ + --file crates/embedded-dsp/src/math.rs \ + --file crates/embedded-dsp/src/types.rs \ + --file crates/embedded-dsp/src/intrinsics.rs \ + --shard "${{ matrix.shard }}" \ + --timeout 120 diff --git a/CHANGELOG.md b/CHANGELOG.md index 873050d..d6cf296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,15 @@ All notable changes to this project are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [Unreleased] +## [0.6.0] - 2026-09-13 ### Added +- **C ABI** (`embedded-dsp-ffi`): a standalone `staticlib`/`cdylib` exposing FIR, Direct Form I biquad cascades, the complex FFT, and the audio-EQ designer to C and C++, with a hand-maintained header and a C smoke test that compiles, links, and runs against it in CI. +- **Python bindings** (`embedded-dsp-py`): a PyO3 stable-ABI (`abi3-py39`) extension module (`embedded_dsp`) exposing the audio-EQ designer, FIR, and biquad cascades, with maturin wheel metadata. +- **Verification CI**: a Miri undefined-behaviour job, bounded `cargo-fuzz` differential targets (`fuzz/`, `f32` against in-process `f64`) backed by an always-on randomized differential test, a scheduled sharded `cargo-mutants` run over the always-compiled core, `cargo-hack` per-feature builds, and `cargo-machete`. +- **Unified audio-EQ builder, `IHo`, and WebAudio export** (`filter_design`): `EqFilter` + `EqShape` + `BiquadType` design every RBJ Audio EQ Cookbook response from one fluent, validating builder — including the `IHo` (integrator-over-harmonic-oscillator) section `idsp` had and this crate lacked — and `WebAudioFilter` mirrors a `BiquadFilterNode`'s `type`/`frequency`/`detune`/`Q`/`gain`. Coefficients are bit-checked against `idsp`'s `iir::coefficients` across all nine response types. + - **RBJ EQ parameter conversions**: `biquad_q_from_bw` and `biquad_q_from_shelf_slope` turn an octave bandwidth or a shelf slope into the `q` the biquad designers take. The bandwidth relation is bilinear-transform corrected, so a band keeps its octave width as the centre approaches Nyquist. Also corrected the `biquad_bandpass_coeffs` doc, which named the wrong variant. - **Swept-sine inverse filter**: `Sweep::inverse_filter` deconvolves an exponential sweep in one complex multiply per bin, recovering an impulse response at `t = 0`. Matches `idsp` 0.22.1. - **Hyperbolic CORDIC**: `cordic_sqrt_atanh2_q31` and `cordic_atanh_q31` add the hyperbolic vectoring mode, within ~`6e-9` of `f64` across the representable domain. The `cosh`/`sinh` rotation and the linear modes are deliberately not ported; see the README note. @@ -39,6 +44,25 @@ All notable changes to this project are documented in this file. The format foll - **LMS/NLMS and the recursive moving average are generic over `DspSample`**: `LmsInstance`/`NlmsInstance` (built on `AdaptiveSample`) and `RecursiveMovingAverage` replace four width twins, again keeping the old names as aliases/wrappers. The q15 paths are bit-exact with the kernels they replace; `RecursiveMovingAverage::` becomes `RecursiveMovingAverage::`. - **One composition vocabulary (`pipeline`)**: `Process`/`Inplace` are now blanket-derived from `SplitProcess`/`SplitInplace` — a stateless stage implements `SplitProcess` once and inherits `Process` (and, through the second blanket, `DspNode`) — so `Split`, `Chain`, `Gain`, `Limiter`, `Offset`, `Identity`, `Buffer`, `SinglePoleFilter`, `PidInstanceF32/Q15`, and `DcBlockerQ15` lost their hand-written bridges. The split vocabulary's receivers became `&mut self` and its methods were renamed to keep method resolution unambiguous: `SplitProcess::process`/`block` → `process_with_state`/`block_with_state`, `SplitInplace::inplace` → `inplace_with_state`, `SplitViewProcess::process_view` → `process_view_with_state`, `SplitViewInplace::inplace_view` → `inplace_view_with_state`. `DspNode::process_block`'s shorter-buffer clamp and the specialised `Buffer`/`ChunkInOut` block/in-place paths are preserved. +### Removed + +- **The duplicate `filtering::Dsm` and `filtering::XorShift32` are gone.** They shadowed the dedicated `dsm::Dsm` / `dither::XorShift32` modules, so only one of each could be re-exported at the crate root. The dedicated modules are now the single implementation, re-exported at the crate root as before, and `filtering` re-exports neither name. `dsm::Dsm` is `Default` + `process()` + `reset()` — the historical `new()`/`process_sample()` names are deliberately **not** carried over. `dither::XorShift32` gains the `next_f32()` / `tpdf_dither_f32()` helpers that only the removed duplicate had. + +- **The sample-genericization compatibility layer is removed.** The width-suffixed instance aliases (`FirInstanceF32/Q31/Q15`, `BiquadCascadeInstanceF32/Q15/Q31`, `BiquadCascadeDf2tInstanceF32/Q15/Q31`, `LmsInstanceF32/Q15`, `NlmsInstanceF32/Q15`, `PidInstanceF32/Q31/Q15`, `HilbertTransformF32/Q15`, `RecursiveMovingAverageQ15`) and their thin width wrappers (`fir_f32/q31/q15`, `biquad_cascade_df1_*`, `biquad_cascade_df2t_*`, `lms_*`, `lms_leaky_*`, `nlms_*`, `pid_f32/q31/q15`) are gone. Call the generic API directly instead: `FirInstance`, `BiquadCascadeInstance`, `LmsInstance`, `HilbertTransform<'_, f32>`, `RecursiveMovingAverage`, and the free functions `fir` / `biquad_cascade_df1` / `biquad_cascade_df2t` / `lms` / `lms_leaky` / `nlms`; PID updates are `PidInstance::::process`. + +### Changed + +- **`filtering` is split into family submodules** (`fir`, `biquad`, `convolution`, `adaptive`, `recursive`, `lockin`, `int_filters`, `normal_form`, `wdf`) behind a re-exporting facade; `embedded_dsp::filtering::*` and the crate-root glob are unchanged. The integration-test suite is likewise consolidated from 46 files into 36 domain-named files, with every test and its `required-features` preserved. +- **docs.rs shows feature-gate badges** for every module (`#[cfg_attr(docsrs, doc(cfg(...)))]`). + +## [0.5.1] - 2026-09-06 + +### Changed + +- **The `fixed` dependency is now optional**, pulled in only by the fixed-point features; default builds no longer compile it. +- **Dependency bumps**: `defmt` 0.3 → 1.1, plus Dependabot action updates. +- **CI**: Codecov coverage, a `cargo-deny` audit, Dependabot, and a release workflow. + ## [0.5.0] - 2026-09-01 ### Added diff --git a/COOKBOOK.md b/COOKBOOK.md index 58f066a..17268c5 100644 --- a/COOKBOOK.md +++ b/COOKBOOK.md @@ -22,8 +22,8 @@ Execute high-frequency (20–50 kHz) current loop control in pure Q15 or floatin use embedded_dsp::*; // Setup controller states -let mut id_pid = PidInstanceQ15::new(8000, 2000, 0); // D-axis flux PID -let mut iq_pid = PidInstanceQ15::new(12000, 3000, 0); // Q-axis torque PID +let mut id_pid = PidInstance::::new(8000, 2000, 0); // D-axis flux PID +let mut iq_pid = PidInstance::::new(12000, 3000, 0); // Q-axis torque PID // ADC current measurements (Phase A, B, C) in Q15 format let i_a: q15 = 12000; @@ -46,8 +46,8 @@ park_q15(i_alpha, i_beta, sin_theta, cos_theta, &mut i_d, &mut i_q); // 4. Current Loop PID Regulators let target_i_d: q15 = 0; // Zero d-axis current (maximum torque per ampere) let target_i_q: q15 = 15000; // Commanded torque -let v_d = pid_q15(&mut id_pid, target_i_d.saturating_sub(i_d)); -let v_q = pid_q15(&mut iq_pid, target_i_q.saturating_sub(i_q)); +let v_d = id_pid.process(target_i_d.saturating_sub(i_d)); +let v_q = iq_pid.process(target_i_q.saturating_sub(i_q)); // 5. Inverse Park Transform (rotating dq -> stationary αβ voltage commands) let mut v_alpha: q15 = 0; @@ -77,7 +77,7 @@ let post_shift = biquad_quantize_and_scale_q15( // 3. Initialise DMA block processor let mut biquad_state = [0i16; 4]; -let mut biquad = BiquadCascadeInstanceQ15::init(1, &q15_coeffs, &mut biquad_state, post_shift); +let mut biquad = BiquadCascadeInstance::::with_post_shift(1, &q15_coeffs, &mut biquad_state, post_shift); let mut dc_blocker = DcBlockerQ15::new(32112); // R ≈ 0.98 // In DMA callback / audio loop @@ -85,7 +85,7 @@ fn process_dma_buffer( dma_rx: &[q15], dma_tx: &mut [q15], dc_blocker: &mut DcBlockerQ15, - biquad: &mut BiquadCascadeInstanceQ15, + biquad: &mut BiquadCascadeInstance<'_, q15>, ) { let mut temp = [0i16; 64]; let len = dma_rx.len().min(temp.len()); @@ -96,7 +96,7 @@ fn process_dma_buffer( } // Step B: Biquad Filtering - biquad_cascade_df1_q15(biquad, &temp[..len], &mut dma_tx[..len]); + biquad_cascade_df1(biquad, &temp[..len], &mut dma_tx[..len]); } ``` diff --git a/Cargo.lock b/Cargo.lock index 7f2a309..7ccf321 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1025,7 +1025,7 @@ dependencies = [ [[package]] name = "embedded-dsp" -version = "0.5.1" +version = "0.6.0" dependencies = [ "bytemuck", "defmt", @@ -1041,7 +1041,7 @@ dependencies = [ [[package]] name = "embedded-dsp-studio" -version = "0.5.1" +version = "0.6.0" dependencies = [ "eframe", "egui_extras", diff --git a/Cargo.toml b/Cargo.toml index d436ec6..0dd24d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,9 +4,13 @@ members = [ "crates/embedded-dsp", "crates/embedded-dsp-studio", ] +# Standalone link targets, built by their own CI jobs and kept out of +# `--workspace` builds: `fuzz` needs a nightly toolchain, and the C ABI must not +# inherit `embedded-dsp`'s `defmt` feature into its cdylib link. +exclude = ["fuzz", "crates/embedded-dsp-ffi"] [workspace.package] -version = "0.5.1" +version = "0.6.0" edition = "2024" rust-version = "1.93" authors = ["Gerzain Mata "] @@ -16,6 +20,11 @@ readme = "README.md" [workspace.lints.rust] unsafe_code = "deny" +# `docsrs` is set by `[package.metadata.docs.rs].rustdoc-args` (see the crate +# manifest) so `#[cfg_attr(docsrs, doc(cfg(...)))]` adds feature-gate badges on +# docs.rs only. Declaring the custom cfg keeps `unexpected_cfgs` quiet under +# `-D warnings` on stable, where the cfg is never set. +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(docsrs)"] } [workspace.lints.clippy] # The DSP kernels are written as index loops over parallel arrays; many index @@ -25,7 +34,7 @@ unsafe_code = "deny" needless_range_loop = "allow" [workspace.dependencies] -embedded-dsp = { path = "crates/embedded-dsp", version = "0.5.1", default-features = false } +embedded-dsp = { path = "crates/embedded-dsp", version = "0.6.0", default-features = false } fixed = { version = "1.31.0", default-features = false } libm = { version = "0.2.11" } defmt = { version = "1.1", default-features = false } diff --git a/MIGRATING.md b/MIGRATING.md new file mode 100644 index 0000000..ab0bd19 --- /dev/null +++ b/MIGRATING.md @@ -0,0 +1,139 @@ +# Migrating to 0.6 + +0.6 is a breaking minor release. It removes two layers of backwards-compatibility +shims that existed only to keep pre-0.6 call sites compiling, so that the public +API tells the truth about what this crate actually supports. There is one +additive change you may want to adopt too. + +If you are on 0.5.x, the compiler will point at every call site that needs an +edit; the tables below give the mechanical replacements. + +--- + +## 1. The width-suffixed aliases and wrappers are gone + +Since the sample-genericization work, every filter could be written once over +`DspSample`, but the old per-width names were kept as type aliases and thin +wrappers. They are removed; call the generic API directly. + +### Type aliases + +| Removed | Replacement | +| :-- | :-- | +| `FirInstanceF32<'a>` / `FirInstanceQ31<'a>` / `FirInstanceQ15<'a>` | `FirInstance<'a, f32>` / `FirInstance<'a, q31>` / `FirInstance<'a, q15>` | +| `BiquadCascadeInstanceF32<'a>` / `…Q15` / `…Q31` | `BiquadCascadeInstance<'a, f32>` / `…, q15` / `…, q31` | +| `BiquadCascadeDf2tInstanceF32<'a>` / `…Q15` / `…Q31` | `BiquadCascadeDf2tInstance<'a, f32>` / … | +| `LmsInstanceF32<'a>` / `LmsInstanceQ15<'a>` | `LmsInstance<'a, f32>` / `LmsInstance<'a, q15>` | +| `NlmsInstanceF32<'a>` / `NlmsInstanceQ15<'a>` | `NlmsInstance<'a, f32>` / `NlmsInstance<'a, q15>` | +| `PidInstanceF32` / `PidInstanceQ31` / `PidInstanceQ15` | `PidInstance` / `PidInstance` / `PidInstance` | +| `HilbertTransformF32<'a>` / `HilbertTransformQ15<'a>` | `HilbertTransform<'a, f32>` / `HilbertTransform<'a, q15>` | +| `RecursiveMovingAverageQ15` | `RecursiveMovingAverage` | + +### Wrapper functions + +| Removed | Replacement | +| :-- | :-- | +| `fir_f32` / `fir_q31` / `fir_q15` | `fir` | +| `biquad_cascade_df1_f32` / `…_q15` / `…_q31` | `biquad_cascade_df1` | +| `biquad_cascade_df2t_f32` / `…_q15` / `…_q31` | `biquad_cascade_df2t` | +| `lms_f32` / `lms_q15` | `lms` | +| `lms_leaky_f32` / `lms_leaky_q15` | `lms_leaky` | +| `nlms_f32` / `nlms_q15` | `nlms` | +| `pid_f32` / `pid_q31` / `pid_q15` | `PidInstance::::process` (or `instance.process(x)`) | + +The per-type `*_f32` / `*_q15` / `*_q31` **functions** in `distance`, +`transform`, `interpolation`, and `audio` are *not* affected — those are the +primary API, not shims. + +### Before / after + +```rust +// 0.5 +use embedded_dsp::filtering::{ + fir_q15, FirInstanceQ15, biquad_cascade_df1_f32, BiquadCascadeInstanceF32, +}; +let mut fir = FirInstanceQ15::init(32, &coeffs, &mut state); +fir_q15(&mut fir, &input, &mut output); + +// 0.6 +use embedded_dsp::filtering::{fir, FirInstance}; +let mut fir = FirInstance::::init(32, &coeffs, &mut state); +fir(&mut fir, &input, &mut output); +``` + +> Watch for locals that shadow a generic function name. A local named `fir`, +> `lms`, `nlms`, or `biquad_cascade_df1` will hide the free function once the +> suffixed wrapper is gone; rename the local (`fir_inst`, …) or call through the +> fully-qualified path. + +### Features that changed meaning + +`filtering` no longer re-exports `Dsm` or `XorShift32`. Enable the `dsm` / +`dither` features (both are in `full`) and use those modules. + +--- + +## 2. `filtering::Dsm` and `filtering::XorShift32` are gone + +Two public types were duplicated: `filtering::Dsm` / `filtering::XorShift32` +shadowed the dedicated `dsm` / `dither` modules, which could not be re-exported +at the crate root while the duplicates existed. The dedicated modules are now the +single implementation. + +| Removed | Replacement | +| :-- | :-- | +| `filtering::Dsm` | `dsm::Dsm` (also `embedded_dsp::Dsm`) | +| `filtering::XorShift32` | `dither::XorShift32` (also `embedded_dsp::XorShift32`) | + +The surviving `dsm::Dsm` is the `idsp`-verified carry-chained MASH implementation, +not the old `filtering::Dsm` (whose accumulator chain differed). Its API is +`Default` + `process(x) -> i8` + `reset()`. The historical `new()` and +`process_sample()` convenience names are deliberately **not** carried over; +`XorShift32` keeps `next_u32` and gains the `next_f32` / `tpdf_dither_f32` +helpers that only the removed duplicate had. + +```rust +// 0.5 +use embedded_dsp::filtering::Dsm; +let mut dsm = Dsm::<3>::new(); +let out = dsm.process_sample(x); + +// 0.6 +use embedded_dsp::dsm::Dsm; +let mut dsm = Dsm::<3>::default(); +let out = dsm.process(x); +``` + +--- + +## 3. Additive: unified audio-EQ builder + +Not a migration, but the replacement for hand-rolling RBJ coefficient calls. +`filter_design` now has one validating builder for every Audio EQ Cookbook +response — low/high/band-pass, all-pass, notch, peaking, both shelves, and the +`IHo` integrator-over-harmonic-oscillator section — plus a WebAudio export. + +```rust +use embedded_dsp::filter_design::{BiquadType, EqFilter, WebAudioFilter}; + +// Fluent, validating, generic over every response type. +let peaking = EqFilter::new(1_000.0, 48_000.0).q(0.707).gain_db(6.0).peaking(); +let band = EqFilter::new(1_000.0, 48_000.0).bandwidth_octaves(1.0).bandpass(); + +// `try_build` reports out-of-range parameters instead of emitting a bad biquad. +let iho = EqFilter::new(2_000.0, 48_000.0) + .q(0.707) + .gain_db(-6.0) + .try_build(BiquadType::Iho)?; + +// WebAudio `BiquadFilterNode` parameters, detune included. +let wa = WebAudioFilter { + frequency_hz: 1_000.0, + detune_cents: 1_200.0, // +1 octave + ..Default::default() +}; +let coeffs = wa.build(); +``` + +Output is `[b0, b1, b2, a1, a2]` in this crate's Direct Form I convention, ready +for `BiquadCascadeInstance` or `Biquad`. diff --git a/README.md b/README.md index 4aa42fc..78ba5b6 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ A high-performance **`#![no_std]` Rust Digital Signal Processing library** designed for microcontrollers (Cortex-M, RISC-V, AVR, Xtensa), bare-metal DSP, and real-time audio/sensor pipelines. +> Upgrading from 0.5? See **[MIGRATING.md](MIGRATING.md)** for the 0.6 breaking changes (removed width-suffixed aliases/wrappers and the duplicate `filtering::Dsm`/`XorShift32`). + --- ## Highlights @@ -69,7 +71,7 @@ gaps are marked and explained after the table. | Biquad generic integer `i8`/`i16`/`i32`/`i64` | ✅ `BiquadInt` | ✅ | | Biquad DF1 wide (`Q32.32`) / dither actions | ✅ | ✅ | | Control-plane settings via `miniconf` | ✅ `config::BiquadSettings` | ✅ | -| Audio EQ builder / WebAudio export | ➖ individual RBJ `biquad_*_coeffs` functions | ✅ `iir::coefficients::{Filter, Shape, Type, WebAudio}` | +| Audio EQ builder / WebAudio export | ✅ `EqFilter`/`EqShape`/`BiquadType` validating builder, every RBJ type incl. `IHo`, plus `WebAudioFilter` | ✅ `iir::coefficients::{Filter, Shape, Type, WebAudio}` | | Normal-form IIR | ✅ arbitrary numerator | ⚠️ forced `p.im·z⁻¹` factor | | Wave digital allpass filters | ✅ | ✅ | | PI²D² controller builder (per-action limits) | ✅ `PidBuilder` | ✅ | @@ -89,14 +91,14 @@ gaps are marked and explained after the table. | Block/lane block processing | ✅ `DspNode`, `Split`/`SplitProcess`, `Lanes`, `Pair`, `Parallel`, `ByLane`, typed `View`/`ViewMut` (`FrameMajor`/`LaneMajor`, `as_layout`), chunk bridges (`ChunkInOut`, `PerFrame`, `FnSplitProcess`), gated `Buffer` | ✅ same ideas in `dsp-process`; the scratch-buffer `Major` is deliberately not mirrored (see note) | | Companding (G.711 µ/A-law) | ✅ | ❌ | | In-repo micro-benchmarks | ✅ | ✅ (`tests/embedded`) | -| Python bindings | ❌ | ✅ (`py` / `numpy`) | +| Python bindings | ✅ `embedded-dsp-py` (PyO3, abi3) | ✅ (`py` / `numpy`) | | Interactive WebAssembly studio | ✅ | ❌ | Legend: ✅ full support · ➖ partial/alternative coverage · ⚠️ quirk · ❌ not provided. -**Where `idsp` still leads:** Python bindings for offline analysis and filter design. Everything else -is at parity or an `embedded-dsp` advantage — including the typed view framework, chunk bridges, and -gated `Buffer`, all carried natively rather than depending on `dsp-process` — except `Major`, below. +**Where `idsp` still leads:** nowhere in API surface. The one deliberate omission is `Major` (block/lane +scratch-buffer traversal), argued below; the Python lead is closed by `embedded-dsp-py`, a PyO3 stable-ABI +module exposing the audio-EQ designer, FIR, and biquad cascades. **CORDIC modes.** `idsp`'s linear `mul` and hyperbolic `cosh_sinh` are correct only inside a `±0.5`/ `±0.3` band (an upstream sign bug); `div` and the circular/hyperbolic modes this crate ports are fine @@ -139,13 +141,13 @@ Add to your `Cargo.toml`: ```toml [dependencies] # Standard std environment (all modules enabled) -embedded-dsp = "0.5.1" +embedded-dsp = "0.6.0" # Bare-metal #![no_std] with libm -embedded-dsp = { version = "0.5.1", default-features = false, features = ["libm", "full"] } +embedded-dsp = { version = "0.6.0", default-features = false, features = ["libm", "full"] } # Minimal firmware footprint (only FIR/Biquad filtering + basic math) -embedded-dsp = { version = "0.5.1", default-features = false, features = ["libm", "filtering", "basic-math"] } +embedded-dsp = { version = "0.6.0", default-features = false, features = ["libm", "filtering", "basic-math"] } ``` ### Basic Example diff --git a/crates/embedded-dsp-ffi/.gitignore b/crates/embedded-dsp-ffi/.gitignore new file mode 100644 index 0000000..2c96eb1 --- /dev/null +++ b/crates/embedded-dsp-ffi/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock diff --git a/crates/embedded-dsp-ffi/Cargo.toml b/crates/embedded-dsp-ffi/Cargo.toml new file mode 100644 index 0000000..dabb6b0 --- /dev/null +++ b/crates/embedded-dsp-ffi/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "embedded-dsp-ffi" +version = "0.6.0" +edition = "2024" +rust-version = "1.93" +authors = ["Gerzain Mata "] +description = "C ABI (staticlib + cdylib) for embedded-dsp: FIR, biquad cascades, complex FFT, and the audio-EQ designer." +license = "MIT OR Apache-2.0" +repository = "https://github.com/leftger/embedded-dsp" +# Not published to crates.io: it is a C link target, not a Rust dependency. +publish = false + +# Standalone workspace: the C ABI is built by its own CI job, so it is never +# swept into a `--workspace --all-features` build (which would unify +# embedded-dsp's `defmt` feature into the cdylib link and fail). +[workspace] + +[lib] +crate-type = ["staticlib", "cdylib", "rlib"] + +[lints.rust] +# Raw-pointer dereferencing is the whole point of an FFI layer. +unsafe_code = "allow" + +[lints.clippy] +needless_range_loop = "allow" + +[dependencies] +embedded-dsp = { path = "../embedded-dsp", features = ["std", "full", "fixed"] } diff --git a/crates/embedded-dsp-ffi/include/embedded_dsp.h b/crates/embedded-dsp-ffi/include/embedded_dsp.h new file mode 100644 index 0000000..a9737ed --- /dev/null +++ b/crates/embedded-dsp-ffi/include/embedded_dsp.h @@ -0,0 +1,92 @@ +/* + * embedded-dsp C ABI. + * + * Hand-maintained header for the `embedded-dsp-ffi` crate. The C smoke test in + * `crates/embedded-dsp-ffi/tests/c_smoke.c` (run by `.github/scripts/ci_ffi.sh`) + * compiles against this header and links the produced library, so the two cannot + * drift without CI failing. + * + * Every function returns 0 (`EDS_OK`) on success and a negative `EDS_ERROR_*` + * code on failure. The processing kernels are streaming: `state` is read and + * written in place and must be zeroed by the caller once, before the first call. + * They never reset the state themselves. + */ + +#ifndef EMBEDDED_DSP_H +#define EMBEDDED_DSP_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Return codes. */ +#define EDS_OK 0 +#define EDS_ERROR_NULL (-1) /* a required pointer was null */ +#define EDS_ERROR_LENGTH (-2) /* a length or index was out of range */ +#define EDS_ERROR_TYPE (-3) /* an enum discriminant was not recognised */ +#define EDS_ERROR_PANIC (-4) /* the call panicked; caught at the FFI boundary */ +#define EDS_ERROR_ARG (-5) /* a parameter failed validation */ + +/* Biquad response types accepted by `eds_eq_coeffs`. */ +#define EDS_BIQUAD_LOWPASS 0 +#define EDS_BIQUAD_HIGHPASS 1 +#define EDS_BIQUAD_BANDPASS 2 +#define EDS_BIQUAD_ALLPASS 3 +#define EDS_BIQUAD_NOTCH 4 +#define EDS_BIQUAD_PEAKING 5 +#define EDS_BIQUAD_LOWSHELF 6 +#define EDS_BIQUAD_HIGHSHELF 7 +#define EDS_BIQUAD_IHO 8 + +/* Version string (e.g. "0.6.0"). Static storage; do not free. */ +const char *eds_version(void); + +/* + * FIR filter, `f32`, over a block. + * + * coeffs : num_taps coefficients + * state : num_taps values, zeroed by the caller before the first call + * src : len input samples + * dst : len output samples + */ +int32_t eds_fir_f32(uint32_t num_taps, const float *coeffs, float *state, + const float *src, float *dst, uint32_t len); + +/* + * Direct Form I biquad cascade, `f32`, over a block. + * + * coeffs : 5 * num_stages values, [b0, b1, b2, a1, a2] per stage + * state : 4 * num_stages values, zeroed before the first call + * post_shift : extra fixed-point headroom; pass 0 for `f32` + * src, dst : len samples each + */ +int32_t eds_biquad_cascade_df1_f32(uint32_t num_stages, uint32_t post_shift, + const float *coeffs, float *state, + const float *src, float *dst, uint32_t len); + +/* + * In-place complex FFT of `n_complex` interleaved (re, im) pairs. + * + * data : 2 * n_complex floats + * ifft_flag : non-zero selects the inverse transform (unnormalised) + */ +int32_t eds_cfft_f32(float *data, uint32_t n_complex, uint32_t ifft_flag); + +/* + * Designs audio-EQ biquad coefficients [b0, b1, b2, a1, a2] (Direct Form I). + * + * typ : an EDS_BIQUAD_* constant + * q : quality factor + * gain_db: used by peaking, the shelves, and IHo + * out_coeffs: 5 floats; written as the passthrough biquad on EDS_ERROR_ARG + */ +int32_t eds_eq_coeffs(uint32_t typ, float frequency_hz, float sample_rate_hz, + float q, float gain_db, float *out_coeffs); + +#ifdef __cplusplus +} +#endif + +#endif /* EMBEDDED_DSP_H */ diff --git a/crates/embedded-dsp-ffi/src/lib.rs b/crates/embedded-dsp-ffi/src/lib.rs new file mode 100644 index 0000000..5b60c29 --- /dev/null +++ b/crates/embedded-dsp-ffi/src/lib.rs @@ -0,0 +1,234 @@ +//! C ABI for `embedded-dsp`. +//! +//! Exposes the hot kernels — FIR, Direct Form I biquad cascades, and the complex +//! FFT — plus the audio-EQ designer as a plain C API, so C and C++ firmware can +//! link the same verified Rust implementations the Rust API uses. +//! +//! Built as both a static library (`libembedded_dsp_ffi.a`) and a shared library +//! (`libembedded_dsp_ffi.so`/`.dylib`/`.dll`). The matching header is +//! [`include/embedded_dsp.h`](../../../include/embedded_dsp.h). +//! +//! Every fallible entry point returns [`EDS_OK`] (`0`) on success and a negative +//! error code on bad input. A panic is caught at the boundary and reported as +//! [`EDS_ERROR_PANIC`] rather than unwinding into C. +//! +//! The kernels are *streaming*: `state` is read and updated in place and must be +//! zeroed by the caller exactly once, before the first call. The functions rebuild +//! a zero-cost instance around the caller's buffers each call; they never reset +//! the state themselves. + +#![deny(missing_docs)] + +use core::ffi::c_char; +use core::panic::AssertUnwindSafe; + +use embedded_dsp::filter_design::{BiquadType, EqFilter}; +use embedded_dsp::filtering::{BiquadCascadeInstance, FirInstance, biquad_cascade_df1, fir}; +use embedded_dsp::transform::cfft_f32; + +/// Success. +pub const EDS_OK: i32 = 0; +/// A required pointer was null. +pub const EDS_ERROR_NULL: i32 = -1; +/// A length or index was out of range. +pub const EDS_ERROR_LENGTH: i32 = -2; +/// An enum discriminant was not recognised. +pub const EDS_ERROR_TYPE: i32 = -3; +/// The call panicked; it was caught at the FFI boundary. +pub const EDS_ERROR_PANIC: i32 = -4; +/// A parameter failed validation (e.g. frequency outside `(0, fs/2)`). +pub const EDS_ERROR_ARG: i32 = -5; + +const VERSION_C: &str = concat!(env!("CARGO_PKG_VERSION"), "\0"); + +/// Returns the library version as a NUL-terminated string (e.g. `"0.6.0"`). +/// +/// The pointer is to a `'static` string owned by the library and must not be freed. +#[unsafe(no_mangle)] +pub extern "C" fn eds_version() -> *const c_char { + VERSION_C.as_ptr().cast() +} + +/// Runs `f`, converting a panic into [`EDS_ERROR_PANIC`]. +fn catch(f: impl FnOnce() -> i32) -> i32 { + std::panic::catch_unwind(AssertUnwindSafe(f)).unwrap_or(EDS_ERROR_PANIC) +} + +/// # Safety +/// `ptr` must be valid for `len` consecutive `f32` reads. +unsafe fn slice_in<'a>(ptr: *const f32, len: usize) -> Option<&'a [f32]> { + if ptr.is_null() { + None + } else { + Some(unsafe { core::slice::from_raw_parts(ptr, len) }) + } +} + +/// # Safety +/// `ptr` must be valid for `len` consecutive `f32` writes. +unsafe fn slice_out<'a>(ptr: *mut f32, len: usize) -> Option<&'a mut [f32]> { + if ptr.is_null() { + None + } else { + Some(unsafe { core::slice::from_raw_parts_mut(ptr, len) }) + } +} + +/// Maps the C discriminant to a [`BiquadType`]. +fn eq_type(disc: u32) -> Option { + Some(match disc { + 0 => BiquadType::Lowpass, + 1 => BiquadType::Highpass, + 2 => BiquadType::Bandpass, + 3 => BiquadType::Allpass, + 4 => BiquadType::Notch, + 5 => BiquadType::Peaking, + 6 => BiquadType::Lowshelf, + 7 => BiquadType::Highshelf, + 8 => BiquadType::Iho, + _ => return None, + }) +} + +/// FIR filter, `f32`, in place over a block. +/// +/// `coeffs` holds `num_taps` coefficients, `state` holds `num_taps` values (zeroed +/// by the caller before the first call), and `src`/`dst` are `len` samples each. +/// +/// # Safety +/// All pointers must be non-null and valid for the lengths above; `coeffs`/`src` +/// are read, `state`/`dst` are written. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn eds_fir_f32( + num_taps: u32, + coeffs: *const f32, + state: *mut f32, + src: *const f32, + dst: *mut f32, + len: u32, +) -> i32 { + catch(|| { + let taps = num_taps as usize; + if taps == 0 || taps > u16::MAX as usize { + return EDS_ERROR_LENGTH; + } + let (Some(coeffs), Some(state), Some(src), Some(dst)) = ( + unsafe { slice_in(coeffs, taps) }, + unsafe { slice_out(state, taps) }, + unsafe { slice_in(src, len as usize) }, + unsafe { slice_out(dst, len as usize) }, + ) else { + return EDS_ERROR_NULL; + }; + let mut instance = FirInstance:: { + num_taps: taps as u16, + coeffs, + state, + }; + fir(&mut instance, src, dst); + EDS_OK + }) +} + +/// Direct Form I biquad cascade, `f32`, in place over a block. +/// +/// `coeffs` holds `5 * num_stages` values `[b0, b1, b2, a1, a2]` per stage, +/// `state` holds `4 * num_stages` values, and `src`/`dst` are `len` samples each. +/// `post_shift` provides extra fixed-point headroom; `f32` ignores it (pass `0`). +/// +/// # Safety +/// All pointers must be non-null and valid for the lengths above. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn eds_biquad_cascade_df1_f32( + num_stages: u32, + post_shift: u32, + coeffs: *const f32, + state: *mut f32, + src: *const f32, + dst: *mut f32, + len: u32, +) -> i32 { + catch(|| { + let stages = num_stages as usize; + if stages == 0 || stages > u8::MAX as usize || post_shift > u8::MAX as u32 { + return EDS_ERROR_LENGTH; + } + let (Some(coeffs), Some(state), Some(src), Some(dst)) = ( + unsafe { slice_in(coeffs, stages * 5) }, + unsafe { slice_out(state, stages * 4) }, + unsafe { slice_in(src, len as usize) }, + unsafe { slice_out(dst, len as usize) }, + ) else { + return EDS_ERROR_NULL; + }; + let mut instance = BiquadCascadeInstance:: { + num_stages: stages as u8, + post_shift: post_shift as u8, + coeffs, + state, + }; + biquad_cascade_df1(&mut instance, src, dst); + EDS_OK + }) +} + +/// In-place complex FFT of `n_complex` interleaved `(re, im)` pairs. +/// +/// `data` holds `2 * n_complex` floats; `ifft_flag` selects the inverse transform. +/// Scaling matches [`cfft_f32`]: the forward transform is unnormalised. +/// +/// # Safety +/// `data` must be non-null and valid for `2 * n_complex` floats. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn eds_cfft_f32(data: *mut f32, n_complex: u32, ifft_flag: u32) -> i32 { + catch(|| { + let n = n_complex as usize; + let Some(data) = (unsafe { slice_out(data, 2 * n) }) else { + return EDS_ERROR_NULL; + }; + cfft_f32(data, n, ifft_flag as u8, 1); + EDS_OK + }) +} + +/// Designs audio-EQ biquad coefficients `[b0, b1, b2, a1, a2]` (Direct Form I). +/// +/// `typ` is the response: `0` low-pass, `1` high-pass, `2` band-pass, `3` all-pass, +/// `4` notch, `5` peaking, `6` low shelf, `7` high shelf, `8` `IHo`. `gain_db` +/// applies to peaking, the shelves, and `IHo`. Writes the passthrough biquad +/// `[1, 0, 0, 0, 0]` on [`EDS_ERROR_ARG`]. +/// +/// # Safety +/// `out_coeffs` must be non-null and valid for 5 floats. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn eds_eq_coeffs( + typ: u32, + frequency_hz: f32, + sample_rate_hz: f32, + q: f32, + gain_db: f32, + out_coeffs: *mut f32, +) -> i32 { + catch(|| { + let Some(kind) = eq_type(typ) else { + return EDS_ERROR_TYPE; + }; + let Some(out) = (unsafe { slice_out(out_coeffs, 5) }) else { + return EDS_ERROR_NULL; + }; + match EqFilter::new(frequency_hz, sample_rate_hz) + .q(q) + .gain_db(gain_db) + .try_build(kind) + { + Ok(coeffs) => { + out.copy_from_slice(&coeffs); + EDS_OK + } + Err(_) => { + out.copy_from_slice(&[1.0, 0.0, 0.0, 0.0, 0.0]); + EDS_ERROR_ARG + } + } + }) +} diff --git a/crates/embedded-dsp-ffi/tests/c_smoke.c b/crates/embedded-dsp-ffi/tests/c_smoke.c new file mode 100644 index 0000000..9a41bc4 --- /dev/null +++ b/crates/embedded-dsp-ffi/tests/c_smoke.c @@ -0,0 +1,69 @@ +/* + * C smoke test for the embedded-dsp C ABI. + * + * Compiled against include/embedded_dsp.h and linked against the cdylib by + * .github/scripts/ci_ffi.sh, so a signature drift between the Rust exports and + * the header fails CI. Kept dependency-free beyond libc/libm. + */ + +#include "embedded_dsp.h" + +#include +#include +#include + +int main(void) { + printf("embedded-dsp C ABI %s\n", eds_version()); + + /* EQ designer: a +6 dB peaking filter must be finite. */ + float coeffs[5] = {0}; + assert(eds_eq_coeffs(EDS_BIQUAD_PEAKING, 1000.0f, 48000.0f, 0.707f, 6.0f, + coeffs) == EDS_OK); + for (int i = 0; i < 5; ++i) { + assert(isfinite(coeffs[i])); + } + + /* IHo is a first-class type through the C API too. */ + assert(eds_eq_coeffs(EDS_BIQUAD_IHO, 2000.0f, 48000.0f, 0.707f, -6.0f, + coeffs) == EDS_OK); + + /* Out-of-range parameters are reported, not silently accepted. */ + assert(eds_eq_coeffs(EDS_BIQUAD_LOWPASS, 0.0f, 48000.0f, 0.707f, 0.0f, + coeffs) == EDS_ERROR_ARG); + assert(eds_eq_coeffs(99, 1000.0f, 48000.0f, 0.707f, 0.0f, coeffs) == + EDS_ERROR_TYPE); + + /* FIR: a 3-tap moving average over an impulse reproduces the taps. */ + float fir_coeffs[3] = {0.25f, 0.5f, 0.25f}; + float state[3] = {0}; + float src[4] = {1.0f, 0.0f, 0.0f, 0.0f}; + float dst[4] = {0}; + assert(eds_fir_f32(3, fir_coeffs, state, src, dst, 4) == EDS_OK); + assert(fabsf(dst[0] - 0.25f) < 1e-6f); + assert(fabsf(dst[1] - 0.5f) < 1e-6f); + assert(fabsf(dst[2] - 0.25f) < 1e-6f); + assert(fabsf(dst[3]) < 1e-6f); + + /* Biquad cascade: a unity passthrough stage is transparent. */ + float bq_coeffs[5] = {1.0f, 0.0f, 0.0f, 0.0f, 0.0f}; + float bq_state[4] = {0}; + float bq_dst[4] = {0}; + assert(eds_biquad_cascade_df1_f32(1, 0, bq_coeffs, bq_state, src, bq_dst, 4) == + EDS_OK); + for (int i = 0; i < 4; ++i) { + assert(fabsf(bq_dst[i] - src[i]) < 1e-6f); + } + + /* CFFT: four DC samples put all energy in bin 0. */ + float fft[8] = {1, 0, 1, 0, 1, 0, 1, 0}; + assert(eds_cfft_f32(fft, 4, 0) == EDS_OK); + assert(fabsf(fft[0] - 4.0f) < 1e-4f); + assert(fabsf(fft[1]) < 1e-4f); + + /* Null pointers are rejected without crashing. */ + assert(eds_fir_f32(3, NULL, state, src, dst, 4) == EDS_ERROR_NULL); + assert(eds_cfft_f32(NULL, 4, 0) == EDS_ERROR_NULL); + + puts("c_smoke: ok"); + return 0; +} diff --git a/crates/embedded-dsp-py/.gitignore b/crates/embedded-dsp-py/.gitignore new file mode 100644 index 0000000..2c96eb1 --- /dev/null +++ b/crates/embedded-dsp-py/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock diff --git a/crates/embedded-dsp-py/Cargo.toml b/crates/embedded-dsp-py/Cargo.toml new file mode 100644 index 0000000..158dcaa --- /dev/null +++ b/crates/embedded-dsp-py/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "embedded-dsp-py" +version = "0.6.0" +edition = "2024" +rust-version = "1.93" +authors = ["Gerzain Mata "] +description = "Python bindings for embedded-dsp (PyO3, abi3): FIR, biquad cascades, and the audio-EQ designer." +license = "MIT OR Apache-2.0" +repository = "https://github.com/leftger/embedded-dsp" +# Built into wheels by maturin; not a crates.io dependency. +publish = false + +# Standalone workspace, built by its own CI job and never swept into +# `--workspace --all-features` (which would unify `defmt` into the cdylib link). +[workspace] + +[lib] +name = "embedded_dsp" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = { version = "0.29", features = ["extension-module", "abi3-py39"] } +# Renamed so the dependency does not collide with the `embedded_dsp` module name. +embedded_dsp_core = { package = "embedded-dsp", path = "../embedded-dsp", features = ["std", "full", "fixed"] } diff --git a/crates/embedded-dsp-py/README.md b/crates/embedded-dsp-py/README.md new file mode 100644 index 0000000..bf8eff5 --- /dev/null +++ b/crates/embedded-dsp-py/README.md @@ -0,0 +1,30 @@ +# embedded-dsp (Python) + +Python bindings for [`embedded-dsp`](https://github.com/leftger/embedded-dsp), +built with PyO3's stable ABI (CPython 3.9+). They expose the same verified Rust +kernels as the Rust and C APIs: the audio-EQ designer, FIR, and Direct Form I +biquad cascades. + +```python +import embedded_dsp + +# Audio-EQ biquad coefficients [b0, b1, b2, a1, a2] +peaking = embedded_dsp.eq_coeffs("peaking", 1_000.0, 48_000.0, 0.707, gain_db=6.0) +iho = embedded_dsp.eq_coeffs("iho", 2_000.0, 48_000.0, 0.707, gain_db=-6.0) + +# Streaming kernels over a block +out = embedded_dsp.fir_f32([0.25, 0.5, 0.25], [1.0, 0.0, 0.0, 0.0]) +filtered = embedded_dsp.biquad_cascade_f32(peaking, [1.0, 2.0, 3.0, 4.0]) +``` + +## Building + +Wheels are built with [maturin](https://github.com/PyO3/maturin): + +``` +maturin build --release # from crates/embedded-dsp-py +``` + +`eq_coeffs` accepts `lowpass`, `highpass`, `bandpass`, `allpass`, `notch`, +`peaking`, `lowshelf`, `highshelf`, and `iho`; `gain_db` applies to `peaking`, +the shelves, and `iho`. diff --git a/crates/embedded-dsp-py/pyproject.toml b/crates/embedded-dsp-py/pyproject.toml new file mode 100644 index 0000000..db60a41 --- /dev/null +++ b/crates/embedded-dsp-py/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["maturin>=1.7,<2"] +build-backend = "maturin" + +[project] +name = "embedded-dsp" +description = "Python bindings for embedded-dsp: FIR, biquad cascades, and the audio-EQ designer." +requires-python = ">=3.9" +license = "MIT OR Apache-2.0" +readme = "README.md" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Multimedia :: Sound/Audio :: Analysis", +] + +[tool.maturin] +module-name = "embedded_dsp" +features = ["pyo3/extension-module"] diff --git a/crates/embedded-dsp-py/src/lib.rs b/crates/embedded-dsp-py/src/lib.rs new file mode 100644 index 0000000..6c4aee6 --- /dev/null +++ b/crates/embedded-dsp-py/src/lib.rs @@ -0,0 +1,124 @@ +//! Python bindings for `embedded-dsp`, built with PyO3's stable ABI (`abi3-py39`). +//! +//! A thin surface over the same verified Rust kernels the C ABI and the Rust API +//! expose: the audio-EQ designer, FIR, and biquad cascades. Wheels are built by +//! maturin from this crate (`module-name = "embedded_dsp"`). + +use embedded_dsp_core::filter_design::{BiquadType, EqFilter}; +use embedded_dsp_core::filtering::{BiquadCascadeInstance, FirInstance, biquad_cascade_df1, fir}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// Crate version string. +#[pyfunction] +fn version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +/// Maps a response name to a [`BiquadType`]. +fn biquad_type(name: &str) -> PyResult { + Ok(match name.to_ascii_lowercase().as_str() { + "lowpass" => BiquadType::Lowpass, + "highpass" => BiquadType::Highpass, + "bandpass" => BiquadType::Bandpass, + "allpass" => BiquadType::Allpass, + "notch" => BiquadType::Notch, + "peaking" => BiquadType::Peaking, + "lowshelf" => BiquadType::Lowshelf, + "highshelf" => BiquadType::Highshelf, + "iho" => BiquadType::Iho, + other => { + return Err(PyValueError::new_err(format!( + "unknown biquad type {other:?} (expected lowpass, highpass, bandpass, \ + allpass, notch, peaking, lowshelf, highshelf, or iho)" + ))); + } + }) +} + +/// Designs `[b0, b1, b2, a1, a2]` (Direct Form I) for an audio-EQ response. +#[pyfunction] +#[pyo3(signature = (typ, frequency_hz, sample_rate_hz, q, gain_db = 0.0))] +fn eq_coeffs( + typ: &str, + frequency_hz: f32, + sample_rate_hz: f32, + q: f32, + gain_db: f32, +) -> PyResult> { + let kind = biquad_type(typ)?; + EqFilter::new(frequency_hz, sample_rate_hz) + .q(q) + .gain_db(gain_db) + .try_build(kind) + .map(|c| c.to_vec()) + .map_err(|e| PyValueError::new_err(e.to_string())) +} + +/// FIR-filter `signal` with `coeffs` (zero initial state), returning the block. +#[pyfunction] +fn fir_f32(coeffs: Vec, signal: Vec) -> PyResult> { + let taps = coeffs.len(); + if taps == 0 || taps > u16::MAX as usize { + return Err(PyValueError::new_err("coeffs must hold 1..=65535 taps")); + } + let mut state = vec![0.0f32; taps]; + let mut dst = vec![0.0f32; signal.len()]; + let mut instance = FirInstance:: { + num_taps: taps as u16, + coeffs: &coeffs, + state: &mut state, + }; + fir(&mut instance, &signal, &mut dst); + Ok(dst) +} + +/// Direct Form I biquad cascade over `signal`. +/// +/// `coeffs` holds `5 * num_stages` values `[b0, b1, b2, a1, a2]` per stage. +#[pyfunction] +fn biquad_cascade_f32(coeffs: Vec, signal: Vec) -> PyResult> { + if coeffs.is_empty() || coeffs.len() % 5 != 0 { + return Err(PyValueError::new_err( + "coeffs must hold 5 * num_stages values", + )); + } + let stages = coeffs.len() / 5; + if stages > u8::MAX as usize { + return Err(PyValueError::new_err("too many stages")); + } + let mut state = vec![0.0f32; stages * 4]; + let mut dst = vec![0.0f32; signal.len()]; + let mut instance = BiquadCascadeInstance:: { + num_stages: stages as u8, + post_shift: 0, + coeffs: &coeffs, + state: &mut state, + }; + biquad_cascade_df1(&mut instance, &signal, &mut dst); + Ok(dst) +} + +/// The `embedded_dsp` Python module. +#[pymodule] +fn embedded_dsp(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(version, m)?)?; + m.add_function(wrap_pyfunction!(eq_coeffs, m)?)?; + m.add_function(wrap_pyfunction!(fir_f32, m)?)?; + m.add_function(wrap_pyfunction!(biquad_cascade_f32, m)?)?; + m.add( + "BIQUAD_TYPES", + [ + "lowpass", + "highpass", + "bandpass", + "allpass", + "notch", + "peaking", + "lowshelf", + "highshelf", + "iho", + ], + )?; + Ok(()) +} diff --git a/crates/embedded-dsp-py/tests/smoke.py b/crates/embedded-dsp-py/tests/smoke.py new file mode 100644 index 0000000..9f1b633 --- /dev/null +++ b/crates/embedded-dsp-py/tests/smoke.py @@ -0,0 +1,62 @@ +"""Smoke test for the embedded-dsp Python bindings (abi3). + +Run against a built extension module, e.g.: + + CARGO_TARGET_DIR=target/py cargo build --manifest-path crates/embedded-dsp-py/Cargo.toml + mkdir -p /tmp/eds_py && cp target/py/debug/libembedded_dsp.so /tmp/eds_py/embedded_dsp.so + PYTHONPATH=/tmp/eds_py python3 crates/embedded-dsp-py/tests/smoke.py +""" + +import math + +import embedded_dsp + +EXPECTED_TYPES = { + "lowpass", + "highpass", + "bandpass", + "allpass", + "notch", + "peaking", + "lowshelf", + "highshelf", + "iho", +} + + +def expect_value_error(call) -> None: + try: + call() + except ValueError: + return + raise AssertionError("expected ValueError") + + +def main() -> None: + assert embedded_dsp.version() + assert set(embedded_dsp.BIQUAD_TYPES) == EXPECTED_TYPES + + peaking = embedded_dsp.eq_coeffs("peaking", 1000.0, 48000.0, 0.707, gain_db=6.0) + assert len(peaking) == 5 and all(math.isfinite(c) for c in peaking) + + iho = embedded_dsp.eq_coeffs("iho", 2000.0, 48000.0, 0.707, gain_db=-6.0) + assert len(iho) == 5 and all(math.isfinite(c) for c in iho) + + expect_value_error(lambda: embedded_dsp.eq_coeffs("bogus", 1000.0, 48000.0, 0.707)) + expect_value_error(lambda: embedded_dsp.eq_coeffs("lowpass", 0.0, 48000.0, 0.707)) + + out = embedded_dsp.fir_f32([0.25, 0.5, 0.25], [1.0, 0.0, 0.0, 0.0]) + assert abs(out[0] - 0.25) < 1e-6 + assert abs(out[1] - 0.5) < 1e-6 + assert abs(out[2] - 0.25) < 1e-6 + + passthrough = embedded_dsp.biquad_cascade_f32( + [1.0, 0.0, 0.0, 0.0, 0.0], [1.0, 2.0, 3.0] + ) + assert all(abs(a - b) < 1e-6 for a, b in zip(passthrough, [1.0, 2.0, 3.0])) + + print(f"python smoke: ok, version {embedded_dsp.version()}") + + +if __name__ == "__main__": + main() diff --git a/crates/embedded-dsp-studio/Cargo.toml b/crates/embedded-dsp-studio/Cargo.toml index 638845e..e325261 100644 --- a/crates/embedded-dsp-studio/Cargo.toml +++ b/crates/embedded-dsp-studio/Cargo.toml @@ -7,6 +7,8 @@ authors.workspace = true license.workspace = true repository.workspace = true description = "Interactive DSP Workbench & Studio for embedded-dsp (Synthesis, Filter Design, Spectral Analysis, Snapshot & Benchmarking)" +# Desktop/WASM application, not a library dependency. +publish = false [lints] workspace = true @@ -15,6 +17,11 @@ workspace = true name = "embedded-dsp-studio" path = "src/main.rs" +[package.metadata.cargo-machete] +# Retained for upcoming studio UI work; not referenced yet, so the CI machete +# job must not fail on them. +ignored = ["egui_extras", "serde_json"] + [dependencies] embedded-dsp = { workspace = true, features = ["std", "full", "fixed", "serde"] } eframe = { workspace = true } diff --git a/crates/embedded-dsp-studio/src/state.rs b/crates/embedded-dsp-studio/src/state.rs index 5add775..430b454 100644 --- a/crates/embedded-dsp-studio/src/state.rs +++ b/crates/embedded-dsp-studio/src/state.rs @@ -5,7 +5,7 @@ use embedded_dsp::filter_design::{ biquad_bandpass_coeffs, biquad_highpass_coeffs, biquad_lowpass_coeffs, biquad_notch_coeffs, biquad_peaking_coeffs, }; -use embedded_dsp::filtering::{BiquadCascadeInstanceF32, biquad_cascade_df1_f32}; +use embedded_dsp::filtering::{BiquadCascadeInstance, biquad_cascade_df1}; use embedded_dsp::snapshot::{ImpulseResponseInfo, SnapshotBuffer, analyze_impulse_response}; use embedded_dsp::svf::StateVariableFilter; use embedded_dsp::synthesis::{ @@ -604,8 +604,8 @@ impl StudioState { let mut state = [0.0f32; 4]; let start = Instant::now(); for _ in 0..iterations { - let mut inst = BiquadCascadeInstanceF32::init(1, &coeffs, &mut state); - biquad_cascade_df1_f32(&mut inst, &in_buf, &mut out_buf); + let mut inst = BiquadCascadeInstance::::init(1, &coeffs, &mut state); + biquad_cascade_df1(&mut inst, &in_buf, &mut out_buf); } let dur_biquad = start.elapsed(); let biquad_per_block_us = dur_biquad.as_secs_f64() * 1e6 / (iterations as f64); @@ -811,32 +811,32 @@ fn run_dsp_processor(config: &ProcessorConfig, sample_rate: f32, src: &[f32], ds ProcessorMode::BiquadLowpass => { let coeffs = biquad_lowpass_coeffs(cutoff, sample_rate, q); let mut state = [0.0f32; 4]; - let mut inst = BiquadCascadeInstanceF32::init(1, &coeffs, &mut state); - biquad_cascade_df1_f32(&mut inst, &src[..n], &mut dst[..n]); + let mut inst = BiquadCascadeInstance::::init(1, &coeffs, &mut state); + biquad_cascade_df1(&mut inst, &src[..n], &mut dst[..n]); } ProcessorMode::BiquadHighpass => { let coeffs = biquad_highpass_coeffs(cutoff, sample_rate, q); let mut state = [0.0f32; 4]; - let mut inst = BiquadCascadeInstanceF32::init(1, &coeffs, &mut state); - biquad_cascade_df1_f32(&mut inst, &src[..n], &mut dst[..n]); + let mut inst = BiquadCascadeInstance::::init(1, &coeffs, &mut state); + biquad_cascade_df1(&mut inst, &src[..n], &mut dst[..n]); } ProcessorMode::BiquadBandpass => { let coeffs = biquad_bandpass_coeffs(cutoff, sample_rate, q); let mut state = [0.0f32; 4]; - let mut inst = BiquadCascadeInstanceF32::init(1, &coeffs, &mut state); - biquad_cascade_df1_f32(&mut inst, &src[..n], &mut dst[..n]); + let mut inst = BiquadCascadeInstance::::init(1, &coeffs, &mut state); + biquad_cascade_df1(&mut inst, &src[..n], &mut dst[..n]); } ProcessorMode::BiquadNotch => { let coeffs = biquad_notch_coeffs(cutoff, sample_rate, q); let mut state = [0.0f32; 4]; - let mut inst = BiquadCascadeInstanceF32::init(1, &coeffs, &mut state); - biquad_cascade_df1_f32(&mut inst, &src[..n], &mut dst[..n]); + let mut inst = BiquadCascadeInstance::::init(1, &coeffs, &mut state); + biquad_cascade_df1(&mut inst, &src[..n], &mut dst[..n]); } ProcessorMode::BiquadPeaking => { let coeffs = biquad_peaking_coeffs(cutoff, sample_rate, q, config.gain_db); let mut state = [0.0f32; 4]; - let mut inst = BiquadCascadeInstanceF32::init(1, &coeffs, &mut state); - biquad_cascade_df1_f32(&mut inst, &src[..n], &mut dst[..n]); + let mut inst = BiquadCascadeInstance::::init(1, &coeffs, &mut state); + biquad_cascade_df1(&mut inst, &src[..n], &mut dst[..n]); } ProcessorMode::SvfLowpass => { let mut svf = StateVariableFilter::new(sample_rate); diff --git a/crates/embedded-dsp-studio/src/tests.rs b/crates/embedded-dsp-studio/src/tests.rs index 2f1261c..554f26e 100644 --- a/crates/embedded-dsp-studio/src/tests.rs +++ b/crates/embedded-dsp-studio/src/tests.rs @@ -198,7 +198,7 @@ mod test_suite { let rust_code = crate::views::codegen::generate_rust_code(&state.proc_a, state.sample_rate); assert!(rust_code.contains("#![no_std]")); assert!(rust_code.contains("pub static BIQUAD_COEFFS: [f32; 5]")); - assert!(rust_code.contains("biquad_cascade_df1_f32")); + assert!(rust_code.contains("biquad_cascade_df1")); codegen.target_language = TargetLanguage::CmsisDspC; assert_eq!(codegen.target_language, TargetLanguage::CmsisDspC); diff --git a/crates/embedded-dsp-studio/src/views/codegen.rs b/crates/embedded-dsp-studio/src/views/codegen.rs index 09ef71f..e97884e 100644 --- a/crates/embedded-dsp-studio/src/views/codegen.rs +++ b/crates/embedded-dsp-studio/src/views/codegen.rs @@ -160,7 +160,7 @@ impl FilterPipeline {{ #![no_std] -use embedded_dsp::filtering::{{biquad_cascade_df1_f32, BiquadCascadeInstanceF32}}; +use embedded_dsp::filtering::{{biquad_cascade_df1, BiquadCascadeInstance}}; // Precomputed Direct Form I Biquad coefficients: [b0, b1, b2, a1, a2] pub static BIQUAD_COEFFS: [f32; 5] = [ @@ -183,8 +183,8 @@ impl FilterPipeline {{ }} pub fn process_block(&mut self, input: &[f32], output: &mut [f32]) {{ - let mut instance = BiquadCascadeInstanceF32::init(1, &BIQUAD_COEFFS, &mut self.state); - biquad_cascade_df1_f32(&mut instance, input, output); + let mut instance = BiquadCascadeInstance::init(1, &BIQUAD_COEFFS, &mut self.state); + biquad_cascade_df1(&mut instance, input, output); }} }} "#, diff --git a/crates/embedded-dsp/Cargo.toml b/crates/embedded-dsp/Cargo.toml index dac5243..e5fefa0 100644 --- a/crates/embedded-dsp/Cargo.toml +++ b/crates/embedded-dsp/Cargo.toml @@ -140,167 +140,25 @@ idsp = { workspace = true } [package.metadata.docs.rs] all-features = true +# Activates `#[cfg_attr(docsrs, doc(cfg(feature = "..."))) ]` in `src/lib.rs`, +# so every feature-gated module carries a badge saying which feature unlocks +# it. Requires nightly, which is what docs.rs builds with. +rustdoc-args = ["--cfg", "docsrs"] # Integration tests are declared explicitly so each can carry `required-features`. # Without this, `cargo test --no-default-features --features ` failed to # compile tests that reference modules outside the selected subset. -[[test]] -name = "basic_math_coverage" -path = "tests/basic_math_coverage.rs" -required-features = ["libm", "basic-math"] - -[[test]] -name = "filtering_coverage" -path = "tests/filtering_coverage.rs" -required-features = ["libm", "filtering"] - -[[test]] -name = "fixed_basic_types_transform" -path = "tests/fixed_basic_types_transform.rs" -required-features = ["libm", "basic-math", "transform"] - -[[test]] -name = "high_roi_features_test" -path = "tests/high_roi_features_test.rs" -required-features = [ - "libm", - "controller", - "fast-math", - "filter-analysis", - "filtering", - "pipeline", - "pll", - "resampling", - "synthesis", -] - [[test]] name = "matrix_window_support_fastmath" path = "tests/matrix_window_support_fastmath.rs" required-features = ["libm", "fast-math", "matrix", "support", "window"] -[[test]] -name = "push_to_95_coverage_b" -path = "tests/push_to_95_coverage_b.rs" -required-features = ["libm", "full"] - -[[test]] -name = "push_to_95_coverage_d" -path = "tests/push_to_95_coverage_d.rs" -required-features = ["libm", "full"] - -[[test]] -name = "split_process_biquad_quickcheck" -path = "tests/split_process_biquad_quickcheck.rs" -required-features = ["libm", "filtering", "pipeline"] - [[test]] name = "miniconf_config" path = "tests/miniconf_config.rs" required-features = ["libm", "miniconf"] -[[test]] -name = "stats_interp_distance_complex_coverage" -path = "tests/stats_interp_distance_complex_coverage.rs" -required-features = [ - "libm", - "complex-math", - "distance", - "interpolation", - "statistics", -] - # Broad coverage tests that glob-import the crate root need every module. -[[test]] -name = "coverage_boost_tests" -path = "tests/coverage_boost_tests.rs" -required-features = ["libm", "full"] - -[[test]] -name = "dsp_tests" -path = "tests/dsp_tests.rs" -required-features = ["libm", "full"] - -[[test]] -name = "final_90_plus_coverage_boost" -path = "tests/final_90_plus_coverage_boost.rs" -required-features = ["libm", "full"] - -[[test]] -name = "microcontroller_features_tests" -path = "tests/microcontroller_features_tests.rs" -required-features = ["libm", "full"] - -[[test]] -name = "more_coverage_boost" -path = "tests/more_coverage_boost.rs" -required-features = ["libm", "full"] - -[[test]] -name = "push_to_95_coverage_a" -path = "tests/push_to_95_coverage_a.rs" -required-features = ["libm", "full"] - -[[test]] -name = "push_to_95_coverage_c" -path = "tests/push_to_95_coverage_c.rs" -required-features = ["libm", "full"] - -[[test]] -name = "push_to_95_coverage_e" -path = "tests/push_to_95_coverage_e.rs" -required-features = ["libm", "full"] - -[[test]] -name = "snapshot_tests" -path = "tests/snapshot_tests.rs" -required-features = ["libm", "full"] - -[[test]] -name = "synthesis_tests" -path = "tests/synthesis_tests.rs" -required-features = ["libm", "full"] - -[[test]] -name = "validation_tests" -path = "tests/validation_tests.rs" -required-features = ["libm", "full"] - -[[test]] -name = "dither_dsm_pipeline_coverage" -path = "tests/dither_dsm_pipeline_coverage.rs" -required-features = ["libm", "full"] - -[[test]] -name = "biquad_and_cic_coverage" -path = "tests/biquad_and_cic_coverage.rs" -required-features = ["libm", "full"] - -[[test]] -name = "stats_window_matrix_coverage" -path = "tests/stats_window_matrix_coverage.rs" -required-features = ["libm", "full"] - -[[test]] -name = "pll_kalman_coverage" -path = "tests/pll_kalman_coverage.rs" -required-features = ["libm", "full"] - -[[test]] -name = "types_controller_coverage" -path = "tests/types_controller_coverage.rs" -required-features = ["libm", "full"] - -[[test]] -name = "misc_modules_coverage" -path = "tests/misc_modules_coverage.rs" -required-features = ["libm", "full"] - -[[test]] -name = "filtering_resampling_extras" -path = "tests/filtering_resampling_extras.rs" -required-features = ["libm", "full"] - [[test]] name = "kissfft_extract" path = "tests/kissfft_extract.rs" @@ -336,11 +194,6 @@ name = "analog_modem" path = "tests/analog_modem.rs" required-features = ["libm", "modem"] -[[test]] -name = "module_extras_coverage" -path = "tests/module_extras_coverage.rs" -required-features = ["libm", "full"] - [[test]] name = "costas_loop_tracking" path = "tests/costas_loop_tracking.rs" @@ -400,56 +253,127 @@ path = "benches/dsp_benchmarks.rs" required-features = ["libm", "full"] [[test]] -name = "edge_branch_coverage" -path = "tests/edge_branch_coverage.rs" -required-features = [ - "libm", - "beamforming", - "filter-analysis", - "resampling", - "support", - "validation", -] +name = "cordic_hyperbolic" +path = "tests/cordic_hyperbolic.rs" +required-features = ["libm", "cordic"] [[test]] -name = "utility_api_coverage" -path = "tests/utility_api_coverage.rs" -required-features = [ - "libm", - "companding", - "cordic", - "fast-math", - "filtering", - "pipeline", - "snapshot", -] +name = "kalman_compose" +path = "tests/kalman_compose.rs" +required-features = ["libm", "kalman"] [[test]] -name = "filter_design_eq_params" -path = "tests/filter_design_eq_params.rs" -required-features = ["libm", "filter-analysis", "filter-design"] +name = "cross_module" +path = "tests/cross_module.rs" +required-features = ["libm", "full"] + +[[test]] +name = "filters_and_resampling" +path = "tests/filters_and_resampling.rs" +required-features = ["libm", "full"] + +[[test]] +name = "control_and_estimation" +path = "tests/control_and_estimation.rs" +required-features = ["libm", "full"] + +[[test]] +name = "modules_misc" +path = "tests/modules_misc.rs" +required-features = ["libm", "full"] + +[[test]] +name = "statistics_and_windows" +path = "tests/statistics_and_windows.rs" +required-features = ["libm", "full"] + +[[test]] +name = "dither_dsm_pipeline" +path = "tests/dither_dsm_pipeline.rs" +required-features = ["libm", "full"] + +[[test]] +name = "microcontroller" +path = "tests/microcontroller.rs" +required-features = ["libm", "full"] [[test]] -name = "voxengo_oracle" -path = "tests/voxengo_oracle.rs" +name = "snapshot" +path = "tests/snapshot.rs" +required-features = ["libm", "full"] + +[[test]] +name = "synthesis" +path = "tests/synthesis.rs" +required-features = ["libm", "full"] + +[[test]] +name = "validation" +path = "tests/validation.rs" +required-features = ["libm", "full"] + +[[test]] +name = "dsp_core" +path = "tests/dsp_core.rs" +required-features = ["libm", "full"] + +[[test]] +name = "filter_design_eq" +path = "tests/filter_design_eq.rs" required-features = ["libm", "filter-analysis", "filter-design"] [[test]] -name = "swept_sine_inverse_filter" -path = "tests/swept_sine_inverse_filter.rs" -required-features = ["libm", "synthesis", "transform"] +name = "basic_math" +path = "tests/basic_math.rs" +required-features = ["libm", "basic-math"] [[test]] -name = "cordic_hyperbolic" -path = "tests/cordic_hyperbolic.rs" -required-features = ["libm", "cordic"] +name = "filtering" +path = "tests/filtering.rs" +required-features = ["libm", "filtering"] [[test]] -name = "kalman_compose" -path = "tests/kalman_compose.rs" -required-features = ["libm", "kalman"] +name = "stats_interp_distance_complex" +path = "tests/stats_interp_distance_complex.rs" +required-features = ["libm", "complex-math", "distance", "interpolation", "statistics"] + +[[test]] +name = "edge_cases" +path = "tests/edge_cases.rs" +required-features = ["libm", "beamforming", "filter-analysis", "resampling", "support", "validation"] [[test]] -name = "cfft_fixed_collapse_regression" -path = "tests/cfft_fixed_collapse_regression.rs" +name = "utility_api" +path = "tests/utility_api.rs" +required-features = ["libm", "companding", "cordic", "fast-math", "filtering", "pipeline", "snapshot"] + +[[test]] +name = "high_roi" +path = "tests/high_roi.rs" +required-features = ["libm", "controller", "fast-math", "filter-analysis", "filtering", "pipeline", "pll", "resampling", "synthesis"] + +[[test]] +name = "split_process_quickcheck" +path = "tests/split_process_quickcheck.rs" +required-features = ["libm", "dither", "dsm", "filtering", "pipeline"] + +[[test]] +name = "fixed_basic_types" +path = "tests/fixed_basic_types.rs" +required-features = ["libm", "basic-math", "transform"] + +[[test]] +name = "cfft_fixed_collapse" +path = "tests/cfft_fixed_collapse.rs" required-features = ["libm", "transform"] + +[[test]] +name = "swept_sine_inverse" +path = "tests/swept_sine_inverse.rs" +required-features = ["libm", "synthesis", "transform"] + +[[test]] +name = "differential_random" +path = "tests/differential_random.rs" +required-features = ["libm", "filtering", "filter-design"] + diff --git a/crates/embedded-dsp/LICENSE-APACHE b/crates/embedded-dsp/LICENSE-APACHE new file mode 100644 index 0000000..144004e --- /dev/null +++ b/crates/embedded-dsp/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 embedded-dsp contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/embedded-dsp/LICENSE-MIT b/crates/embedded-dsp/LICENSE-MIT new file mode 100644 index 0000000..ce19601 --- /dev/null +++ b/crates/embedded-dsp/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 embedded-dsp contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/embedded-dsp/benches/dsp_benchmarks.rs b/crates/embedded-dsp/benches/dsp_benchmarks.rs index ec1d700..0af15f3 100644 --- a/crates/embedded-dsp/benches/dsp_benchmarks.rs +++ b/crates/embedded-dsp/benches/dsp_benchmarks.rs @@ -219,10 +219,10 @@ fn bench_fir_q15(rec: &mut Recorder) { let mut state = [q15::ZERO; TAPS]; let mut dst = [q15::ZERO; SAMPLES]; - let mut fir = FirInstanceQ15::init(TAPS as u16, &coeffs, &mut state); + let mut instance = FirInstance::::init(TAPS as u16, &coeffs, &mut state); let start = Instant::now(); for _ in 0..iterations { - fir_q15(&mut fir, black_box(&src), &mut dst); + fir(&mut instance, black_box(&src), &mut dst); black_box(&dst); } let elapsed = start.elapsed(); @@ -317,7 +317,7 @@ fn bench_mult_q31(rec: &mut Recorder) { } fn bench_pid_q31(rec: &mut Recorder) { - let mut pid = PidInstanceQ31::new( + let mut pid = PidInstance::::new( q31::from_bits(i32::MAX / 4), q31::from_bits(i32::MAX / 20), q31::from_bits(i32::MAX / 100), @@ -333,7 +333,7 @@ fn bench_pid_q31(rec: &mut Recorder) { let elapsed = start.elapsed(); let ops_per_sec = iterations as f64 / elapsed.as_secs_f64(); println!( - "PidInstanceQ31::process: {:.2} MOps/s ({:?} for {} iterations, sum={})", + "PidInstance::::process: {:.2} MOps/s ({:?} for {} iterations, sum={})", ops_per_sec / 1e6, elapsed, iterations, diff --git a/crates/embedded-dsp/examples/audio_speech_pipeline.rs b/crates/embedded-dsp/examples/audio_speech_pipeline.rs index bfb56e6..b0d7613 100644 --- a/crates/embedded-dsp/examples/audio_speech_pipeline.rs +++ b/crates/embedded-dsp/examples/audio_speech_pipeline.rs @@ -99,7 +99,7 @@ fn main() { cascade_coeffs[5..].copy_from_slice(&peaking_coeffs); let mut eq_state = [0.0f32; 4 * 2]; // 4 state variables per biquad stage - let mut eq_cascade = BiquadCascadeInstanceF32 { + let mut eq_cascade = BiquadCascadeInstance:: { num_stages: 2, post_shift: 0, coeffs: &cascade_coeffs, @@ -107,7 +107,7 @@ fn main() { }; let mut equalized_audio = [0.0f32; NUM_SAMPLES]; - biquad_cascade_df1_f32(&mut eq_cascade, &dc_blocked_f32, &mut equalized_audio); + biquad_cascade_df1(&mut eq_cascade, &dc_blocked_f32, &mut equalized_audio); println!( " Notch Filter Coeffs: b0={:.4}, b1={:.4}, b2={:.4}, a1={:.4}, a2={:.4}", diff --git a/crates/embedded-dsp/examples/basic_usage.rs b/crates/embedded-dsp/examples/basic_usage.rs index c198581..11f1efd 100644 --- a/crates/embedded-dsp/examples/basic_usage.rs +++ b/crates/embedded-dsp/examples/basic_usage.rs @@ -25,15 +25,15 @@ fn main() { // 3. FIR Filtering let coeffs = [0.25f32, 0.5, 0.25]; // 3-tap moving average filter let mut state = [0.0f32; 3 + 4 - 1]; - let mut fir = FirInstanceF32::init(3, &coeffs, &mut state); + let mut fir_inst = FirInstance::::init(3, &coeffs, &mut state); let input_signal = [1.0f32, 2.0, 3.0, 4.0]; let mut filtered_signal = [0.0f32; 4]; - fir_f32(&mut fir, &input_signal, &mut filtered_signal); + fir(&mut fir_inst, &input_signal, &mut filtered_signal); println!("FIR Filter Output: {:?}", filtered_signal); // 4. PID Motor Controller - let mut pid = PidInstanceF32::new(2.0, 0.1, 0.05); + let mut pid = PidInstance::::new(2.0, 0.1, 0.05); let control_output = pid.process(10.0); println!("PID Control Signal: {}", control_output); diff --git a/crates/embedded-dsp/examples/filter_workbench_and_analysis.rs b/crates/embedded-dsp/examples/filter_workbench_and_analysis.rs index ee8ba92..cec148a 100644 --- a/crates/embedded-dsp/examples/filter_workbench_and_analysis.rs +++ b/crates/embedded-dsp/examples/filter_workbench_and_analysis.rs @@ -12,7 +12,7 @@ //! - FIR Group Delay Calculation (`fir_group_delay`) //! - IIR Pole Radius & Strict Stability Verification (`biquad_pole_radius`, `biquad_is_stable`, `biquad_cascade_is_stable`) //! 3. Filter Implementation & Topology Comparisons: -//! - Direct Form I (`biquad_cascade_df1_f32`) vs Transposed Direct Form II (`biquad_cascade_df2t_f32`) +//! - Direct Form I (`biquad_cascade_df1`) vs Transposed Direct Form II (`biquad_cascade_df2t`) //! - Const-Generic Fixed-Size Wrappers (`FirFilter<33>`, `BiquadCascade<10, 8>`) //! - Q15 Fixed-Point vs F32 Precision & Quantization Noise Analysis //! 4. Vector Distance Metrics (Euclidean, Cosine, Chebyshev, Manhattan, Minkowski, Canberra, Bray-Curtis) @@ -167,25 +167,25 @@ fn main() { // Direct Form I let mut df1_state = [0.0f32; 8]; - let mut df1_inst = BiquadCascadeInstanceF32 { + let mut df1_inst = BiquadCascadeInstance:: { num_stages: 2, post_shift: 0, coeffs: &butter_coeffs, state: &mut df1_state, }; let mut df1_out = [0.0f32; 10]; - biquad_cascade_df1_f32(&mut df1_inst, &input_signal, &mut df1_out); + biquad_cascade_df1(&mut df1_inst, &input_signal, &mut df1_out); // Transposed Direct Form II let mut df2t_state = [0.0f32; 4]; - let mut df2t_inst = BiquadCascadeDf2tInstanceF32 { + let mut df2t_inst = BiquadCascadeDf2tInstance:: { num_stages: 2, post_shift: 0, coeffs: &butter_coeffs, state: &mut df2t_state, }; let mut df2t_out = [0.0f32; 10]; - biquad_cascade_df2t_f32(&mut df2t_inst, &input_signal, &mut df2t_out); + biquad_cascade_df2t(&mut df2t_inst, &input_signal, &mut df2t_out); // Const-Generic BiquadCascade let mut cg_biquad = BiquadCascade::<10, 8>::new(butter_coeffs); diff --git a/crates/embedded-dsp/examples/motor_control_foc.rs b/crates/embedded-dsp/examples/motor_control_foc.rs index f99c12f..2eb2d35 100644 --- a/crates/embedded-dsp/examples/motor_control_foc.rs +++ b/crates/embedded-dsp/examples/motor_control_foc.rs @@ -183,7 +183,7 @@ fn main() { // ----------------------------------------------------------------------------------------- println!("\n--- 5. Vector Current Regulators (Dual PID Loops for Id & Iq) ---"); // Outer Velocity Loop: Target 3000 RPM, Actual 2950 RPM -> Error = 50 RPM - let mut speed_pid = PidInstanceF32::new(0.08, 0.005, 0.001); + let mut speed_pid = PidInstance::::new(0.08, 0.005, 0.001); let speed_error_rpm = 50.0f32; let demanded_iq = speed_pid.process(speed_error_rpm).clamp(-15.0, 15.0); println!( @@ -194,8 +194,8 @@ fn main() { // Inner Current Regulators: // Id controller: Setpoint = 0.0 A (Zero d-axis current for Maximum Torque Per Ampere) // Iq controller: Setpoint = demanded_iq - let mut id_pid = PidInstanceF32::new(2.5, 0.15, 0.0); - let mut iq_pid = PidInstanceF32::new(2.5, 0.15, 0.0); + let mut id_pid = PidInstance::::new(2.5, 0.15, 0.0); + let mut iq_pid = PidInstance::::new(2.5, 0.15, 0.0); let id_setpoint = 0.0f32; let iq_setpoint = demanded_iq; diff --git a/crates/embedded-dsp/examples/spectral_radar_transforms.rs b/crates/embedded-dsp/examples/spectral_radar_transforms.rs index e605d58..9c71430 100644 --- a/crates/embedded-dsp/examples/spectral_radar_transforms.rs +++ b/crates/embedded-dsp/examples/spectral_radar_transforms.rs @@ -3,7 +3,7 @@ //! Demonstrates: //! 1. Multitone RF Radar/SDR Signal Simulation (Chirps + Multi-tone + Narrowband Jammer + White Noise) //! 2. Multirate Signal Processing: Cascaded Integrator-Comb (CIC) Decimator & Interpolator (`CicDecimator<3>`, `CicInterpolator<3>`) and Fractional Linear Resampler (`resample_linear_f32`) -//! 3. Adaptive Filter Noise Cancellation: LMS (`LmsInstanceF32`, `lms_f32`) and Normalized LMS (`NlmsInstanceF32`, `nlms_f32`) for active interference suppression +//! 3. Adaptive Filter Noise Cancellation: LMS (`LmsInstance`, `lms`) and Normalized LMS (`NlmsInstance`, `nlms`) for active interference suppression //! 4. Windowing Comparison: Hanning, Hamming, Blackman, Blackman-Harris, and Flat-Top //! 5. Spectral Analysis: High-Resolution Complex FFT (`cfft_f32`, `cfft_q31`), Packed Real FFT (`rfft_f32`, `rfft_q15`), and Welch's PSD (`welch_psd_f32`) //! 6. Advanced DSP Transforms: @@ -109,14 +109,14 @@ fn main() { let mut lms_coeffs = [0.0f32; LMS_TAPS]; let mut lms_state = [0.0f32; LMS_TAPS]; let mut lms_filter = - LmsInstanceF32::init(LMS_TAPS as u16, &mut lms_coeffs, &mut lms_state, 0.005); + LmsInstance::::init(LMS_TAPS as u16, &mut lms_coeffs, &mut lms_state, 0.005); let mut lms_cancelled_out = [0.0f32; N_SAMPLES]; let mut lms_error = [0.0f32; N_SAMPLES]; // Reference signal x = jammer reference, Desired d = received signal (target + jammer + noise) // Error output e = d - y = target + noise (jammer cancelled!) - lms_f32( + lms( &mut lms_filter, &jammer_signal, &received_signal, diff --git a/crates/embedded-dsp/src/const_generics.rs b/crates/embedded-dsp/src/const_generics.rs index b1f1b46..9ea84c2 100644 --- a/crates/embedded-dsp/src/const_generics.rs +++ b/crates/embedded-dsp/src/const_generics.rs @@ -1,9 +1,6 @@ //! Const generic safe wrappers for compile-time sized FIR filters, Biquads, and Matrices. -use crate::filtering::{ - BiquadCascadeInstanceF32, BiquadCascadeInstanceQ15, FirInstanceF32, FirInstanceQ15, - biquad_cascade_df1_f32, biquad_cascade_df1_q15, fir_f32, fir_q15, -}; +use crate::filtering::{BiquadCascadeInstance, FirInstance, biquad_cascade_df1, fir}; use crate::matrix::{ MatrixInstance, MatrixInstanceMut, mat_add_f32, mat_mult_f32, mat_scale_f32, mat_sub_f32, mat_trans_f32, @@ -30,12 +27,12 @@ impl FirFilter { /// Process input slice `src` into output slice `dst`. pub fn process(&mut self, src: &[f32], dst: &mut [f32]) { - let mut instance = FirInstanceF32 { + let mut instance = FirInstance:: { num_taps: TAPS as u16, coeffs: &self.coeffs, state: &mut self.state, }; - fir_f32(&mut instance, src, dst); + fir(&mut instance, src, dst); } /// Reset filter state buffer. @@ -68,13 +65,13 @@ impl BiquadCascade { num_stages: self.num_stages, post_shift: 0, coeffs: &self.coeffs, state: &mut self.state, }; - biquad_cascade_df1_f32(&mut instance, src, dst); + biquad_cascade_df1(&mut instance, src, dst); } /// Reset internal filter delay state. @@ -103,12 +100,12 @@ impl FirFilterQ15 { /// Processes a single input sample. pub fn process(&mut self, src: &[q15], dst: &mut [q15]) { - let mut instance = FirInstanceQ15 { + let mut instance = FirInstance:: { num_taps: TAPS as u16, coeffs: &self.coeffs, state: &mut self.state, }; - fir_q15(&mut instance, src, dst); + fir(&mut instance, src, dst); } /// Resets the internal state. @@ -143,13 +140,13 @@ impl BiquadCascadeQ15 { num_stages: self.num_stages, post_shift: self.post_shift, coeffs: &self.coeffs, state: &mut self.state, }; - biquad_cascade_df1_q15(&mut instance, src, dst); + biquad_cascade_df1(&mut instance, src, dst); } /// Resets the internal state. diff --git a/crates/embedded-dsp/src/controller.rs b/crates/embedded-dsp/src/controller.rs index 1aaea1a..d7e7768 100644 --- a/crates/embedded-dsp/src/controller.rs +++ b/crates/embedded-dsp/src/controller.rs @@ -197,27 +197,7 @@ impl PidInstance { } } -/// `f32` PID instance (see [`PidInstance`]). -pub type PidInstanceF32 = PidInstance; -/// `q31` PID instance (see [`PidInstance`]). -pub type PidInstanceQ31 = PidInstance; -/// `q15` PID instance (see [`PidInstance`]). -pub type PidInstanceQ15 = PidInstance; - -/// PID control update (`f32`). -pub fn pid_f32(instance: &mut PidInstanceF32, in_val: f32) -> f32 { - instance.process(in_val) -} - -/// PID control update (`q31`). -pub fn pid_q31(instance: &mut PidInstanceQ31, in_val: q31) -> q31 { - instance.process(in_val) -} -/// PID control update (`q15`). -pub fn pid_q15(instance: &mut PidInstanceQ15, in_val: q15) -> q15 { - instance.process(in_val) -} /// The stateless-`SplitProcess` bridge for [`PidInstance`], kept next to the type so the pipeline /// layer does not have to reach outward to wrap it. `Process` and diff --git a/crates/embedded-dsp/src/dither.rs b/crates/embedded-dsp/src/dither.rs index c77d5c3..7e5fee5 100644 --- a/crates/embedded-dsp/src/dither.rs +++ b/crates/embedded-dsp/src/dither.rs @@ -38,6 +38,20 @@ impl XorShift32 { self.0 = x; x } + + /// Produce the next uniform float in `[0.0, 1.0)`. + #[inline] + pub fn next_f32(&mut self) -> f32 { + (self.next_u32() >> 8) as f32 * (1.0 / 16777216.0) + } + + /// Triangular Probability Density Function (TPDF) dither sample in `[-1.0, 1.0]`. + #[inline] + pub fn tpdf_dither_f32(&mut self) -> f32 { + let r1 = self.next_f32(); + let r2 = self.next_f32(); + r1 - r2 + } } impl Iterator for XorShift32 { diff --git a/crates/embedded-dsp/src/dsm.rs b/crates/embedded-dsp/src/dsm.rs index d769efa..d9914a1 100644 --- a/crates/embedded-dsp/src/dsm.rs +++ b/crates/embedded-dsp/src/dsm.rs @@ -42,6 +42,8 @@ impl Default for Dsm { impl Dsm { /// Process one input sample, returning the noise-shaped output. + /// + /// This is the name upstream `idsp` uses for the same operation. pub fn process(&mut self, x: u32) -> i8 { if K == 0 { return 0; diff --git a/crates/embedded-dsp/src/filter_analysis.rs b/crates/embedded-dsp/src/filter_analysis.rs index b90c060..851f156 100644 --- a/crates/embedded-dsp/src/filter_analysis.rs +++ b/crates/embedded-dsp/src/filter_analysis.rs @@ -28,7 +28,7 @@ pub fn fir_frequency_response(taps: &[f32], freq_norm: f32) -> Complex { /// Evaluates the frequency response `H(e^{jω})` of a single Direct Form I biquad section /// `[b0, b1, b2, a1, a2]` (as produced by [`crate::filter_design`] and consumed by -/// [`crate::filtering::biquad_cascade_df1_f32`], where `y(n) = b0 x(n) + b1 x(n-1) + b2 x(n-2) +/// [`crate::filtering::biquad_cascade_df1`], where `y(n) = b0 x(n) + b1 x(n-1) + b2 x(n-2) /// + a1 y(n-1) + a2 y(n-2)`) at a single normalized frequency `freq_norm` (cycles/sample, /// `0.0..=0.5`). pub fn biquad_frequency_response(coeffs: &[f32; 5], freq_norm: f32) -> Complex { diff --git a/crates/embedded-dsp/src/filter_design.rs b/crates/embedded-dsp/src/filter_design.rs index 794ce63..4f6bfb8 100644 --- a/crates/embedded-dsp/src/filter_design.rs +++ b/crates/embedded-dsp/src/filter_design.rs @@ -653,7 +653,7 @@ pub enum ScalingStrategy { /// Quantizes and scales floating-point biquad cascade coefficients into Q15. /// /// Returns `Ok(post_shift)` on success, which should be passed directly to -/// [`crate::filtering::BiquadCascadeInstanceQ15`]. +/// [`crate::filtering::BiquadCascadeInstance`]. pub fn biquad_quantize_and_scale_q15( sos_f32: &[f32], out_q15: &mut [q15], @@ -1281,3 +1281,406 @@ pub fn elliptic_lowpass_biquad( } Ok([b0, b1, b2, a1, a2]) } + +// ───────────────────────────────────────────────────────────────────────────── +// Unified audio-EQ builder (RBJ Audio EQ Cookbook) & WebAudio export +// ───────────────────────────────────────────────────────────────────────────── + +/// Transition/corner shape for the [`EqFilter`] audio-EQ builder. +/// +/// Defaults to `Q(1/√2)`, the maximally-flat (Butterworth) alignment. +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum EqShape { + /// Direct quality factor. + Q(f32), + /// −3 dB bandwidth in octaves, resolved with [`biquad_q_from_bw`]. + Bandwidth(f32), + /// Shelf slope `S` (RBJ; `S = 1` is the steepest monotonic slope), resolved with + /// [`biquad_q_from_shelf_slope`]. + Slope(f32), +} + +impl Default for EqShape { + fn default() -> Self { + Self::Q(core::f32::consts::FRAC_1_SQRT_2) + } +} + +/// Standard audio / WebAudio biquad response type. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum BiquadType { + /// Low-pass. + #[default] + Lowpass, + /// High-pass. + Highpass, + /// Band-pass (constant 0 dB peak gain). + Bandpass, + /// All-pass. + Allpass, + /// Band-stop / notch. + Notch, + /// Peaking EQ. + Peaking, + /// Low shelf. + Lowshelf, + /// High shelf. + Highshelf, + /// Integrator over harmonic oscillator: integrates below the critical frequency and + /// is flat at the shelf gain above it. + Iho, +} + +impl BiquadType { + /// The WebAudio `BiquadFilterNode.type` string. + /// + /// Returns `None` for [`BiquadType::Iho`], which has no native WebAudio node type. + pub const fn webaudio_name(self) -> Option<&'static str> { + match self { + BiquadType::Lowpass => Some("lowpass"), + BiquadType::Highpass => Some("highpass"), + BiquadType::Bandpass => Some("bandpass"), + BiquadType::Allpass => Some("allpass"), + BiquadType::Notch => Some("notch"), + BiquadType::Peaking => Some("peaking"), + BiquadType::Lowshelf => Some("lowshelf"), + BiquadType::Highshelf => Some("highshelf"), + BiquadType::Iho => None, + } + } +} + +/// Validation error for [`EqFilter`] and [`WebAudioFilter`]. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum EqError { + /// A parameter was NaN or infinite. + NonFinite(&'static str), + /// A parameter had to be strictly positive. + NonPositive(&'static str), + /// A parameter was outside its valid range. + OutOfRange(&'static str), +} + +impl core::fmt::Display for EqError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + EqError::NonFinite(k) => write!(f, "`{k}` must be finite"), + EqError::NonPositive(k) => write!(f, "`{k}` must be positive"), + EqError::OutOfRange(k) => write!(f, "`{k}` outside its valid range"), + } + } +} + +impl core::error::Error for EqError {} + +/// Fluent biquad designer covering every [`BiquadType`] in the RBJ Audio EQ Cookbook. +/// +/// One builder resolves the [`EqShape`] against the right RBJ parameter (`Q`, octave +/// bandwidth, or shelf slope), validates the result, and emits `[b0, b1, b2, a1, a2]` in +/// this crate's Direct Form I convention — directly usable with +/// [`BiquadCascadeInstance`](crate::filtering::BiquadCascadeInstance) or +/// [`Biquad`](crate::filtering::Biquad). +/// +/// `gain_db` is used by [`BiquadType::Peaking`], the shelves, and [`BiquadType::Iho`]. +/// +/// ``` +/// use embedded_dsp::filter_design::{BiquadType, EqFilter}; +/// +/// let peaking = EqFilter::new(1_000.0, 48_000.0).q(0.707).gain_db(6.0).peaking(); +/// assert!(peaking.iter().all(|c| c.is_finite())); +/// +/// // The same result through `try_build`, with validation: +/// let same = EqFilter::new(1_000.0, 48_000.0) +/// .q(0.707) +/// .gain_db(6.0) +/// .try_build(BiquadType::Peaking) +/// .unwrap(); +/// assert_eq!(peaking, same); +/// ``` +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct EqFilter { + /// Corner / center / critical frequency in Hz. + pub frequency_hz: f32, + /// Sample rate in Hz. + pub sample_rate_hz: f32, + /// Transition/corner shape. + pub shape: EqShape, + /// Gain in dB (peaking, shelves, `IHo`). + pub gain_db: f32, +} + +impl EqFilter { + /// Maximally-flat (`Q = 1/√2`), unity-gain designer at `frequency_hz`. + pub const fn new(frequency_hz: f32, sample_rate_hz: f32) -> Self { + Self { + frequency_hz, + sample_rate_hz, + shape: EqShape::Q(core::f32::consts::FRAC_1_SQRT_2), + gain_db: 0.0, + } + } + + /// Set a direct quality factor. + #[must_use] + pub const fn q(mut self, q: f32) -> Self { + self.shape = EqShape::Q(q); + self + } + + /// Set the −3 dB bandwidth in octaves (resolved with [`biquad_q_from_bw`]). + #[must_use] + pub const fn bandwidth_octaves(mut self, octaves: f32) -> Self { + self.shape = EqShape::Bandwidth(octaves); + self + } + + /// Set the shelf slope `S` (resolved with [`biquad_q_from_shelf_slope`]). + #[must_use] + pub const fn shelf_slope(mut self, slope: f32) -> Self { + self.shape = EqShape::Slope(slope); + self + } + + /// Set the gain in dB (peaking, shelves, `IHo`). + #[must_use] + pub const fn gain_db(mut self, db: f32) -> Self { + self.gain_db = db; + self + } + + /// Resolve the configured [`EqShape`] to a quality factor at this frequency. + pub fn q_value(&self) -> f32 { + match self.shape { + EqShape::Q(q) => q, + EqShape::Bandwidth(bw) => biquad_q_from_bw(bw, self.frequency_hz, self.sample_rate_hz), + EqShape::Slope(s) => biquad_q_from_shelf_slope(s, self.gain_db), + } + } + + /// Validate every parameter. + /// + /// `frequency_hz` must lie in `(0, sample_rate_hz / 2)`. + pub fn validate(&self) -> Result<(), EqError> { + let q = self.q_value(); + for (name, value) in [ + ("frequency_hz", self.frequency_hz), + ("sample_rate_hz", self.sample_rate_hz), + ("gain_db", self.gain_db), + ("q", q), + ] { + if !value.is_finite() { + return Err(EqError::NonFinite(name)); + } + } + if self.sample_rate_hz <= 0.0 { + return Err(EqError::NonPositive("sample_rate_hz")); + } + if q <= 0.0 { + return Err(EqError::NonPositive("q")); + } + if self.frequency_hz <= 0.0 || self.frequency_hz >= self.sample_rate_hz / 2.0 { + return Err(EqError::OutOfRange("frequency_hz")); + } + Ok(()) + } + + /// Validate, then build `[b0, b1, b2, a1, a2]` (Direct Form I). + pub fn try_build(&self, typ: BiquadType) -> Result<[f32; 5], EqError> { + self.validate()?; + Ok(self.build_unchecked(typ)) + } + + /// Build `[b0, b1, b2, a1, a2]`, sanitizing invalid input to the passthrough biquad + /// `[1, 0, 0, 0, 0]`. + /// + /// Use [`EqFilter::try_build`] to surface the error instead. + pub fn build(&self, typ: BiquadType) -> [f32; 5] { + if self.validate().is_err() { + [1.0, 0.0, 0.0, 0.0, 0.0] + } else { + self.build_unchecked(typ) + } + } + + /// Build without validating; the caller guarantees the parameters are in range. + pub fn build_unchecked(&self, typ: BiquadType) -> [f32; 5] { + let (f, fs, q, g) = ( + self.frequency_hz, + self.sample_rate_hz, + self.q_value(), + self.gain_db, + ); + match typ { + BiquadType::Lowpass => biquad_lowpass_coeffs(f, fs, q), + BiquadType::Highpass => biquad_highpass_coeffs(f, fs, q), + BiquadType::Bandpass => biquad_bandpass_coeffs(f, fs, q), + BiquadType::Allpass => biquad_allpass_coeffs(f, fs, q), + BiquadType::Notch => biquad_notch_coeffs(f, fs, q), + BiquadType::Peaking => biquad_peaking_coeffs(f, fs, q, g), + BiquadType::Lowshelf => biquad_lowshelf_coeffs(f, fs, q, g), + BiquadType::Highshelf => biquad_highshelf_coeffs(f, fs, q, g), + BiquadType::Iho => biquad_iho_coeffs(f, fs, q, g), + } + } + + /// Low-pass `[b0, b1, b2, a1, a2]`. + pub fn lowpass(&self) -> [f32; 5] { + self.build(BiquadType::Lowpass) + } + /// High-pass `[b0, b1, b2, a1, a2]`. + pub fn highpass(&self) -> [f32; 5] { + self.build(BiquadType::Highpass) + } + /// Band-pass `[b0, b1, b2, a1, a2]`. + pub fn bandpass(&self) -> [f32; 5] { + self.build(BiquadType::Bandpass) + } + /// All-pass `[b0, b1, b2, a1, a2]`. + pub fn allpass(&self) -> [f32; 5] { + self.build(BiquadType::Allpass) + } + /// Notch `[b0, b1, b2, a1, a2]`. + pub fn notch(&self) -> [f32; 5] { + self.build(BiquadType::Notch) + } + /// Peaking EQ `[b0, b1, b2, a1, a2]` (uses `gain_db`). + pub fn peaking(&self) -> [f32; 5] { + self.build(BiquadType::Peaking) + } + /// Low shelf `[b0, b1, b2, a1, a2]` (uses `gain_db`). + pub fn lowshelf(&self) -> [f32; 5] { + self.build(BiquadType::Lowshelf) + } + /// High shelf `[b0, b1, b2, a1, a2]` (uses `gain_db`). + pub fn highshelf(&self) -> [f32; 5] { + self.build(BiquadType::Highshelf) + } + /// Integrator-over-harmonic-oscillator `[b0, b1, b2, a1, a2]` (uses `gain_db`). + pub fn iho(&self) -> [f32; 5] { + self.build(BiquadType::Iho) + } +} + +/// WebAudio `BiquadFilterNode` parameters, convertible to this crate's coefficients. +/// +/// Mirrors the node's `type`, `frequency`, `detune`, `Q`, and `gain` properties. A +/// nonzero `detune_cents` is applied before coefficient design, exactly as the node +/// computes its effective frequency. +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct WebAudioFilter { + /// Node type. + pub typ: BiquadType, + /// Reference frequency in Hz. + pub frequency_hz: f32, + /// Sample rate in Hz. + pub sample_rate_hz: f32, + /// Detune in cents (applied as `frequency_hz * 2^(detune_cents / 1200)`). + pub detune_cents: f32, + /// Quality factor. + pub q: f32, + /// Gain in dB (peaking, shelves). + pub gain_db: f32, +} + +impl Default for WebAudioFilter { + fn default() -> Self { + Self { + typ: BiquadType::Lowpass, + frequency_hz: 350.0, + sample_rate_hz: 48_000.0, + detune_cents: 0.0, + q: 1.0, + gain_db: 0.0, + } + } +} + +impl WebAudioFilter { + /// Effective frequency after applying `detune_cents`. + pub fn effective_frequency_hz(&self) -> f32 { + self.frequency_hz * (2.0f32).powf(self.detune_cents / 1200.0) + } + + /// The WebAudio `BiquadFilterNode.type` string, or `None` for [`BiquadType::Iho`]. + pub const fn type_name(&self) -> Option<&'static str> { + self.typ.webaudio_name() + } + + /// The equivalent [`EqFilter`] (detune folded into the frequency). + pub fn filter(&self) -> EqFilter { + EqFilter { + frequency_hz: self.effective_frequency_hz(), + sample_rate_hz: self.sample_rate_hz, + shape: EqShape::Q(self.q), + gain_db: self.gain_db, + } + } + + /// Validate every parameter (after applying detune). + pub fn validate(&self) -> Result<(), EqError> { + for (name, value) in [ + ("frequency_hz", self.frequency_hz), + ("sample_rate_hz", self.sample_rate_hz), + ("detune_cents", self.detune_cents), + ("q", self.q), + ("gain_db", self.gain_db), + ] { + if !value.is_finite() { + return Err(EqError::NonFinite(name)); + } + } + if self.sample_rate_hz <= 0.0 { + return Err(EqError::NonPositive("sample_rate_hz")); + } + if self.q <= 0.0 { + return Err(EqError::NonPositive("q")); + } + let f = self.effective_frequency_hz(); + if f <= 0.0 || f >= self.sample_rate_hz / 2.0 { + return Err(EqError::OutOfRange("effective_frequency_hz")); + } + Ok(()) + } + + /// Validate, then build `[b0, b1, b2, a1, a2]`. + pub fn try_build(&self) -> Result<[f32; 5], EqError> { + self.validate()?; + Ok(self.filter().build_unchecked(self.typ)) + } + + /// Build `[b0, b1, b2, a1, a2]`, sanitizing invalid input to the passthrough biquad. + pub fn build(&self) -> [f32; 5] { + if self.validate().is_err() { + [1.0, 0.0, 0.0, 0.0, 0.0] + } else { + self.filter().build_unchecked(self.typ) + } + } +} + +/// Computes Direct Form I Biquad coefficients `[b0, b1, b2, a1, a2]` for an +/// integrator-over-harmonic-oscillator (IHO) section: a notch that integrates below the +/// critical frequency and is flat at `gain_db` above it. +/// +/// `gain_db` is the linear shelf gain in dB (`0 dB` leaves the high band at unity) and `q` +/// sets the notch width. Matches `idsp`'s `Type::IHo`. +pub fn biquad_iho_coeffs(frequency_hz: f32, sample_rate_hz: f32, q: f32, gain_db: f32) -> [f32; 5] { + let w0 = 2.0 * core::f32::consts::PI * frequency_hz / sample_rate_hz; + let cos_w0 = w0.cos(); + let sin_w0 = w0.sin(); + let alpha = sin_w0 / (2.0 * q); + let half_sin = 0.5 * sin_w0; + let shelf = (10.0f32).powf(gain_db / 20.0); + + // RBJ `[b, a]` form with unity passband gain: `b = [1+α, -2cos, 1-α]`, + // `a = [A + ½sin, -2A, A - ½sin]` with `A = (1+cos)/(2·shelf)`. + let a = (1.0 + cos_w0) / (2.0 * shelf); + let a0 = a + half_sin; + let b0 = (1.0 + alpha) / a0; + let b1 = (-2.0 * cos_w0) / a0; + let b2 = (1.0 - alpha) / a0; + let a1 = (2.0 * a) / a0; + let a2 = -(a - half_sin) / a0; + + [b0, b1, b2, a1, a2] +} diff --git a/crates/embedded-dsp/src/filtering.rs b/crates/embedded-dsp/src/filtering.rs deleted file mode 100644 index 80ab564..0000000 --- a/crates/embedded-dsp/src/filtering.rs +++ /dev/null @@ -1,2751 +0,0 @@ -//! Digital filtering functions (FIR, Biquad IIR Direct Form I & II, LMS Adaptive Filter, Convolution, Correlation). - -use crate::types::*; - -// --- FIR Filter --- - -/// Instance structure for the FIR filter, generic over the sample width. -/// -/// Coefficients are stored in the sample's coefficient type ([`DspSample::Coeff`]) and the state in -/// the sample type. Accumulation runs through [`DspSample::mul_high`] — the per-term narrow the -/// fixed-point kernels use — so `f32`, `q15`, and `q31` share one loop. -pub struct FirInstance<'a, T: DspSample> { - /// Number of filter taps. - pub num_taps: u16, - /// Filter coefficients. - pub coeffs: &'a [T::Coeff], - /// Filter state buffer. - pub state: &'a mut [T], -} - -impl<'a, T: DspSample> FirInstance<'a, T> { - /// Initializes the instance. - pub fn init(num_taps: u16, coeffs: &'a [T::Coeff], state: &'a mut [T]) -> Self { - state.fill(T::ZERO); - Self { - num_taps, - coeffs, - state, - } - } -} - -/// FIR filtering into `dst`, generic over the sample width. -pub fn fir(instance: &mut FirInstance<'_, T>, src: &[T], dst: &mut [T]) { - let num_taps = instance.num_taps as usize; - let block_size = src.len().min(dst.len()); - - for i in 0..block_size { - // Shift state - for k in (1..num_taps).rev() { - instance.state[k] = instance.state[k - 1]; - } - instance.state[0] = src[i]; - - // Compute the dot product with the per-term high product, exactly as the fixed-point - // kernels did: `acc += (state * coeff) >> FRAC`. - let mut acc = T::Accum::default(); - for k in 0..num_taps { - acc = acc + T::mul_high(instance.state[k], instance.coeffs[k]); - } - dst[i] = T::from_accum_shifted(acc, 0); - } -} - -/// `f32` FIR instance (see [`FirInstance`]). -pub type FirInstanceF32<'a> = FirInstance<'a, f32>; -/// `q31` FIR instance (see [`FirInstance`]). -pub type FirInstanceQ31<'a> = FirInstance<'a, q31>; -/// `q15` FIR instance (see [`FirInstance`]). -pub type FirInstanceQ15<'a> = FirInstance<'a, q15>; - -/// FIR filtering (`f32`) into `dst`. -#[inline(always)] -pub fn fir_f32(instance: &mut FirInstanceF32, src: &[f32], dst: &mut [f32]) { - fir(instance, src, dst) -} - -/// FIR filtering (`q31`) into `dst`. -#[inline(always)] -pub fn fir_q31(instance: &mut FirInstanceQ31, src: &[q31], dst: &mut [q31]) { - fir(instance, src, dst) -} - -/// FIR filtering (`q15`) into `dst`. -#[inline(always)] -pub fn fir_q15(instance: &mut FirInstanceQ15, src: &[q15], dst: &mut [q15]) { - fir(instance, src, dst) -} - -// --- Biquad Cascade Direct Form I Filter --- - -/// Instance structure for the biquad cascade, Direct Form I, generic over the sample width. -/// -/// Coefficients are `5 * num_stages` values `[b0, b1, b2, a1, a2]` per stage; state is -/// `4 * num_stages` values `[x[n-1], x[n-2], y[n-1], y[n-2]]` per stage. `post_shift` gives extra -/// coefficient headroom (CMSIS-style) and the MAC is narrowed by `FRAC - post_shift`; floats leave -/// it at `0` and take the plain per-stage sum. -pub struct BiquadCascadeInstance<'a, T: DspSample> { - /// Number of biquad stages. - pub num_stages: u8, - /// Output right-shift. - pub post_shift: u8, - /// Filter coefficients. - pub coeffs: &'a [T::Coeff], - /// Filter state buffer. - pub state: &'a mut [T], -} - -impl<'a, T: DspSample> BiquadCascadeInstance<'a, T> { - /// Initializes the instance with no coefficient headroom (`post_shift = 0`). - pub fn init(num_stages: u8, coeffs: &'a [T::Coeff], state: &'a mut [T]) -> Self { - state.fill(T::ZERO); - Self { - num_stages, - post_shift: 0, - coeffs, - state, - } - } - - /// Initializes the instance with a coefficient `post_shift` (fixed-point headroom). - pub fn with_post_shift( - num_stages: u8, - coeffs: &'a [T::Coeff], - state: &'a mut [T], - post_shift: u8, - ) -> Self { - state.fill(T::ZERO); - Self { - num_stages, - post_shift, - coeffs, - state, - } - } -} - -/// Biquad cascade, Direct Form I, generic over the sample width. -pub fn biquad_cascade_df1( - instance: &mut BiquadCascadeInstance<'_, T>, - src: &[T], - dst: &mut [T], -) { - let num_stages = instance.num_stages as usize; - let block_size = src.len().min(dst.len()); - let shift = T::FRAC.saturating_sub(instance.post_shift as u32).min(63); - - for i in 0..block_size { - let mut in_val = src[i]; - for stage in 0..num_stages { - let b0 = instance.coeffs[stage * 5]; - let b1 = instance.coeffs[stage * 5 + 1]; - let b2 = instance.coeffs[stage * 5 + 2]; - let a1 = instance.coeffs[stage * 5 + 3]; - let a2 = instance.coeffs[stage * 5 + 4]; - - let x1 = instance.state[stage * 4]; - let x2 = instance.state[stage * 4 + 1]; - let y1 = instance.state[stage * 4 + 2]; - let y2 = instance.state[stage * 4 + 3]; - - let acc = T::madd( - T::madd( - T::madd(T::madd(T::madd(T::Accum::default(), in_val, b0), x1, b1), x2, b2), - y1, - a1, - ), - y2, - a2, - ); - let out_val = T::from_accum_shifted(acc, shift); - - instance.state[stage * 4 + 1] = x1; - instance.state[stage * 4] = in_val; - instance.state[stage * 4 + 3] = y1; - instance.state[stage * 4 + 2] = out_val; - - in_val = out_val; - } - dst[i] = in_val; - } -} - -/// `f32` Direct Form I biquad cascade instance (see [`BiquadCascadeInstance`]). -pub type BiquadCascadeInstanceF32<'a> = BiquadCascadeInstance<'a, f32>; -/// `q15` Direct Form I biquad cascade instance (see [`BiquadCascadeInstance`]). -pub type BiquadCascadeInstanceQ15<'a> = BiquadCascadeInstance<'a, q15>; -/// `q31` Direct Form I biquad cascade instance (see [`BiquadCascadeInstance`]). -pub type BiquadCascadeInstanceQ31<'a> = BiquadCascadeInstance<'a, q31>; - -/// Biquad cascade, Direct Form I (`f32`). -#[inline(always)] -pub fn biquad_cascade_df1_f32( - instance: &mut BiquadCascadeInstanceF32, - src: &[f32], - dst: &mut [f32], -) { - biquad_cascade_df1(instance, src, dst) -} - -/// Biquad cascade, Direct Form I (`q15`). -#[inline(always)] -pub fn biquad_cascade_df1_q15( - instance: &mut BiquadCascadeInstanceQ15, - src: &[q15], - dst: &mut [q15], -) { - biquad_cascade_df1(instance, src, dst) -} - -/// Biquad cascade, Direct Form I (`q31`). -#[inline(always)] -pub fn biquad_cascade_df1_q31( - instance: &mut BiquadCascadeInstanceQ31, - src: &[q31], - dst: &mut [q31], -) { - biquad_cascade_df1(instance, src, dst) -} - -/// Instance structure for the biquad cascade, transposed Direct Form II, generic over the sample -/// width. -/// -/// Same `[b0, b1, b2, a1, a2]` and `post_shift` conventions as [`BiquadCascadeInstance`]; state is -/// two delays per stage (`[s1, s2, ...]`), which is better-conditioned for high-Q poles. -pub struct BiquadCascadeDf2tInstance<'a, T: DspSample> { - /// Number of biquad stages. - pub num_stages: u8, - /// Output right-shift. - pub post_shift: u8, - /// Filter coefficients. - pub coeffs: &'a [T::Coeff], - /// Filter state buffer. - pub state: &'a mut [T], -} - -impl<'a, T: DspSample> BiquadCascadeDf2tInstance<'a, T> { - /// Initializes the instance with no coefficient headroom (`post_shift = 0`). - pub fn init(num_stages: u8, coeffs: &'a [T::Coeff], state: &'a mut [T]) -> Self { - state.fill(T::ZERO); - Self { - num_stages, - post_shift: 0, - coeffs, - state, - } - } - - /// Initializes the instance with a coefficient `post_shift` (fixed-point headroom). - pub fn with_post_shift( - num_stages: u8, - coeffs: &'a [T::Coeff], - state: &'a mut [T], - post_shift: u8, - ) -> Self { - state.fill(T::ZERO); - Self { - num_stages, - post_shift, - coeffs, - state, - } - } -} - -/// Biquad cascade, transposed Direct Form II, generic over the sample width. -pub fn biquad_cascade_df2t( - instance: &mut BiquadCascadeDf2tInstance<'_, T>, - src: &[T], - dst: &mut [T], -) { - let num_stages = instance.num_stages as usize; - let block_size = src.len().min(dst.len()); - let shift = T::FRAC.saturating_sub(instance.post_shift as u32).min(63); - - for i in 0..block_size { - let mut in_val = src[i]; - for stage in 0..num_stages { - let b0 = instance.coeffs[stage * 5]; - let b1 = instance.coeffs[stage * 5 + 1]; - let b2 = instance.coeffs[stage * 5 + 2]; - let a1 = instance.coeffs[stage * 5 + 3]; - let a2 = instance.coeffs[stage * 5 + 4]; - - let s1 = instance.state[stage * 2]; - let s2 = instance.state[stage * 2 + 1]; - - // The accumulator is ordered to match the original float association exactly: - // `y = b0*in + s1`, `s1' = (b1*in + a1*y) + s2`, `s2' = b2*in + a2*y`. - let y_acc = - T::madd(T::Accum::default(), in_val, b0) + T::accum_from_shifted(s1, shift); - let y = T::from_accum_shifted(y_acc, shift); - let s1_acc = T::madd(T::madd(T::Accum::default(), in_val, b1), y, a1) - + T::accum_from_shifted(s2, shift); - let s1_new = T::from_accum_shifted(s1_acc, shift); - let s2_acc = T::madd(T::madd(T::Accum::default(), in_val, b2), y, a2); - let s2_new = T::from_accum_shifted(s2_acc, shift); - - instance.state[stage * 2] = s1_new; - instance.state[stage * 2 + 1] = s2_new; - in_val = y; - } - dst[i] = in_val; - } -} - -/// `f32` transposed Direct Form II biquad cascade instance (see [`BiquadCascadeDf2tInstance`]). -pub type BiquadCascadeDf2tInstanceF32<'a> = BiquadCascadeDf2tInstance<'a, f32>; -/// `q15` transposed Direct Form II biquad cascade instance (see [`BiquadCascadeDf2tInstance`]). -pub type BiquadCascadeDf2tInstanceQ15<'a> = BiquadCascadeDf2tInstance<'a, q15>; -/// `q31` transposed Direct Form II biquad cascade instance (see [`BiquadCascadeDf2tInstance`]). -pub type BiquadCascadeDf2tInstanceQ31<'a> = BiquadCascadeDf2tInstance<'a, q31>; - -/// Biquad cascade, transposed Direct Form II (`f32`). -#[inline(always)] -pub fn biquad_cascade_df2t_f32( - instance: &mut BiquadCascadeDf2tInstanceF32, - src: &[f32], - dst: &mut [f32], -) { - biquad_cascade_df2t(instance, src, dst) -} - -/// Biquad cascade, transposed Direct Form II (`q15`). -#[inline(always)] -pub fn biquad_cascade_df2t_q15( - instance: &mut BiquadCascadeDf2tInstanceQ15, - src: &[q15], - dst: &mut [q15], -) { - biquad_cascade_df2t(instance, src, dst) -} - -/// Biquad cascade, transposed Direct Form II (`q31`). -#[inline(always)] -pub fn biquad_cascade_df2t_q31( - instance: &mut BiquadCascadeDf2tInstanceQ31, - src: &[q31], - dst: &mut [q31], -) { - biquad_cascade_df2t(instance, src, dst) -} - -// --- LMS Adaptive Filter --- - -/// Scalar algebra for the adaptive filters, whose float and fixed-point coefficient updates are -/// genuinely different operations. Only the widths that ship an adaptive filter implement it; the -/// [`LmsInstance`]/[`NlmsInstance`] stages themselves are generic over it. -pub trait AdaptiveSample: DspSample { - /// Leaky-LMS retention factor `keep` for `w ← keep·w + alpha·x`. - fn lms_keep(leak: Self::Coeff) -> Self::Accum; - /// LMS step `2·μ·e`. - fn lms_alpha(mu: Self::Coeff, e: Self) -> Self::Accum; - /// One LMS coefficient update: `w ← keep·w + alpha·x`. - fn lms_apply(w: Self::Coeff, x: Self, alpha: Self::Accum, keep: Self::Accum) -> Self::Coeff; - /// NLMS power denominator seed from `eps` (fixed-point floors at one LSB). - fn nlms_power_seed(eps: Self::Coeff) -> Self::Accum; - /// NLMS step `μ·e / power`. - fn nlms_alpha(mu: Self::Coeff, e: Self, power: Self::Accum) -> Self::Accum; - /// One NLMS coefficient update: `w ← w + alpha·x`. - fn nlms_apply(w: Self::Coeff, x: Self, alpha: Self::Accum) -> Self::Coeff; -} - -impl AdaptiveSample for f32 { - #[inline(always)] - fn lms_keep(leak: f32) -> f32 { - 1.0 - leak - } - #[inline(always)] - fn lms_alpha(mu: f32, e: f32) -> f32 { - 2.0 * mu * e - } - #[inline(always)] - fn lms_apply(w: f32, x: f32, alpha: f32, keep: f32) -> f32 { - keep * w + alpha * x - } - #[inline(always)] - fn nlms_power_seed(eps: f32) -> f32 { - eps - } - #[inline(always)] - fn nlms_alpha(mu: f32, e: f32, power: f32) -> f32 { - mu * e / power - } - #[inline(always)] - fn nlms_apply(w: f32, x: f32, alpha: f32) -> f32 { - w + alpha * x - } -} - -impl AdaptiveSample for q15 { - #[inline(always)] - fn lms_keep(leak: q15) -> i64 { - 32_767i64 - leak.to_bits().max(0) as i64 - } - #[inline(always)] - fn lms_alpha(mu: q15, e: q15) -> i64 { - (2 * mu.to_bits() as i64 * e.to_bits() as i64) >> 15 - } - #[inline(always)] - fn lms_apply(w: q15, x: q15, alpha: i64, keep: i64) -> q15 { - let leaked = (keep * w.to_bits() as i64) >> 15; - let upd = leaked + ((alpha * x.to_bits() as i64) >> 15); - q15::from_bits(upd.clamp(i16::MIN as i64, i16::MAX as i64) as i16) - } - #[inline(always)] - fn nlms_power_seed(eps: q15) -> i64 { - eps.to_bits().max(1) as i64 - } - #[inline(always)] - fn nlms_alpha(mu: q15, e: q15, power: i64) -> i64 { - (mu.to_bits() as i64 * e.to_bits() as i64) / power - } - #[inline(always)] - fn nlms_apply(w: q15, x: q15, alpha: i64) -> q15 { - let upd = w.to_bits() as i64 + ((alpha * x.to_bits() as i64) >> 15); - q15::from_bits(upd.clamp(i16::MIN as i64, i16::MAX as i64) as i16) - } -} - -/// Instance structure for the LMS adaptive filter, generic over the sample width. -pub struct LmsInstance<'a, T: AdaptiveSample> { - /// Number of filter taps. - pub num_taps: u16, - /// Filter coefficients. - pub coeffs: &'a mut [T::Coeff], - /// Filter state buffer. - pub state: &'a mut [T], - /// Adaptation step size. - pub mu: T::Coeff, -} - -impl<'a, T: AdaptiveSample> LmsInstance<'a, T> { - /// Initializes the instance. - pub fn init( - num_taps: u16, - coeffs: &'a mut [T::Coeff], - state: &'a mut [T], - mu: T::Coeff, - ) -> Self { - state.fill(T::ZERO); - coeffs.fill(T::coeff_from_f32(0.0)); - Self { - num_taps, - coeffs, - state, - mu, - } - } -} - -/// LMS adaptive filtering, generic over the sample width. -pub fn lms( - instance: &mut LmsInstance<'_, T>, - src: &[T], - ref_signal: &[T], - out: &mut [T], - err: &mut [T], -) { - lms_leaky(instance, src, ref_signal, out, err, T::coeff_from_f32(0.0)); -} - -/// Leaky LMS: `w ← keep·w + 2 μ e x`. `leak = 0` matches [`lms`]. -pub fn lms_leaky( - instance: &mut LmsInstance<'_, T>, - src: &[T], - ref_signal: &[T], - out: &mut [T], - err: &mut [T], - leak: T::Coeff, -) { - let num_taps = instance.num_taps as usize; - let block_size = src - .len() - .min(ref_signal.len()) - .min(out.len()) - .min(err.len()); - let keep = T::lms_keep(leak); - - for i in 0..block_size { - for k in (1..num_taps).rev() { - instance.state[k] = instance.state[k - 1]; - } - instance.state[0] = src[i]; - - let mut acc = T::Accum::default(); - for k in 0..num_taps { - acc = T::madd(acc, instance.state[k], instance.coeffs[k]); - } - let y = T::from_accum(acc); - out[i] = y; - let e = T::sat_sub(ref_signal[i], y); - err[i] = e; - - let alpha = T::lms_alpha(instance.mu, e); - for k in 0..num_taps { - instance.coeffs[k] = T::lms_apply(instance.coeffs[k], instance.state[k], alpha, keep); - } - } -} - -/// Instance structure for the normalized LMS adaptive filter, generic over the sample width. -pub struct NlmsInstance<'a, T: AdaptiveSample> { - /// Number of filter taps. - pub num_taps: u16, - /// Filter coefficients. - pub coeffs: &'a mut [T::Coeff], - /// Filter state buffer. - pub state: &'a mut [T], - /// Adaptation step size. - pub mu: T::Coeff, - /// Regularization epsilon. - pub eps: T::Coeff, -} - -impl<'a, T: AdaptiveSample> NlmsInstance<'a, T> { - /// Initializes the instance. - pub fn init( - num_taps: u16, - coeffs: &'a mut [T::Coeff], - state: &'a mut [T], - mu: T::Coeff, - eps: T::Coeff, - ) -> Self { - state.fill(T::ZERO); - coeffs.fill(T::coeff_from_f32(0.0)); - Self { - num_taps, - coeffs, - state, - mu, - eps, - } - } -} - -/// Normalized LMS: `w ← w + μ e x / (eps + ‖x‖²)`, generic over the sample width. -pub fn nlms( - instance: &mut NlmsInstance<'_, T>, - src: &[T], - ref_signal: &[T], - out: &mut [T], - err: &mut [T], -) { - let num_taps = instance.num_taps as usize; - let block_size = src - .len() - .min(ref_signal.len()) - .min(out.len()) - .min(err.len()); - - for i in 0..block_size { - for k in (1..num_taps).rev() { - instance.state[k] = instance.state[k - 1]; - } - instance.state[0] = src[i]; - - let mut acc = T::Accum::default(); - let mut power = T::nlms_power_seed(instance.eps); - for k in 0..num_taps { - acc = T::madd(acc, instance.state[k], instance.coeffs[k]); - power = power + T::mul_high(instance.state[k], instance.state[k]); - } - let y = T::from_accum(acc); - out[i] = y; - let e = T::sat_sub(ref_signal[i], y); - err[i] = e; - - let alpha = T::nlms_alpha(instance.mu, e, power); - for k in 0..num_taps { - instance.coeffs[k] = T::nlms_apply(instance.coeffs[k], instance.state[k], alpha); - } - } -} - -/// `f32` LMS instance (see [`LmsInstance`]). -pub type LmsInstanceF32<'a> = LmsInstance<'a, f32>; -/// `q15` LMS instance (see [`LmsInstance`]). -pub type LmsInstanceQ15<'a> = LmsInstance<'a, q15>; -/// `f32` NLMS instance (see [`NlmsInstance`]). -pub type NlmsInstanceF32<'a> = NlmsInstance<'a, f32>; -/// `q15` NLMS instance (see [`NlmsInstance`]). -pub type NlmsInstanceQ15<'a> = NlmsInstance<'a, q15>; - -/// LMS adaptive filtering (`f32`). -#[inline(always)] -pub fn lms_f32( - instance: &mut LmsInstanceF32, - src: &[f32], - ref_signal: &[f32], - out: &mut [f32], - err: &mut [f32], -) { - lms(instance, src, ref_signal, out, err) -} - -/// Leaky LMS (`f32`). `leak = 0` matches [`lms_f32`]. -#[inline(always)] -pub fn lms_leaky_f32( - instance: &mut LmsInstanceF32, - src: &[f32], - ref_signal: &[f32], - out: &mut [f32], - err: &mut [f32], - leak: f32, -) { - lms_leaky(instance, src, ref_signal, out, err, leak) -} - -/// LMS adaptive filtering (`q15`). -#[inline(always)] -pub fn lms_q15( - instance: &mut LmsInstanceQ15, - src: &[q15], - ref_signal: &[q15], - out: &mut [q15], - err: &mut [q15], -) { - lms(instance, src, ref_signal, out, err) -} - -/// Leaky LMS (`q15`). `leak` is Q1.15 (`0` matches [`lms_q15`]). -#[inline(always)] -pub fn lms_leaky_q15( - instance: &mut LmsInstanceQ15, - src: &[q15], - ref_signal: &[q15], - out: &mut [q15], - err: &mut [q15], - leak: q15, -) { - lms_leaky(instance, src, ref_signal, out, err, leak) -} - -/// Normalized LMS adaptive filtering (`f32`). -#[inline(always)] -pub fn nlms_f32( - instance: &mut NlmsInstanceF32, - src: &[f32], - ref_signal: &[f32], - out: &mut [f32], - err: &mut [f32], -) { - nlms(instance, src, ref_signal, out, err) -} - -/// Normalized LMS adaptive filtering (`q15`). -#[inline(always)] -pub fn nlms_q15( - instance: &mut NlmsInstanceQ15, - src: &[q15], - ref_signal: &[q15], - out: &mut [q15], - err: &mut [q15], -) { - nlms(instance, src, ref_signal, out, err) -} - -// --- Convolution --- - -/// Convolution (`f32`) into `dst`. -pub fn conv_f32(src_a: &[f32], src_b: &[f32], dst: &mut [f32]) { - let len_a = src_a.len(); - let len_b = src_b.len(); - let out_len = (len_a + len_b - 1).min(dst.len()); - - dst[..out_len].fill(0.0); - for i in 0..len_a { - for j in 0..len_b { - if i + j < out_len { - dst[i + j] += src_a[i] * src_b[j]; - } - } - } -} - -/// Convolution (`q31`) into `dst`. -pub fn conv_q31(src_a: &[q31], src_b: &[q31], dst: &mut [q31]) { - let len_a = src_a.len(); - let len_b = src_b.len(); - let out_len = (len_a + len_b - 1).min(dst.len()); - - for n in 0..out_len { - let mut acc: i64 = 0; - let k_min = n.saturating_sub(len_b - 1); - let k_max = n.min(len_a - 1); - for k in k_min..=k_max { - acc += (src_a[k].to_bits() as i64 * src_b[n - k].to_bits() as i64) >> 31; - } - dst[n] = q31::from_bits(acc.clamp(i32::MIN as i64, i32::MAX as i64) as i32); - } -} - -/// Convolution (`q15`) into `dst`. -pub fn conv_q15(src_a: &[q15], src_b: &[q15], dst: &mut [q15]) { - let len_a = src_a.len(); - let len_b = src_b.len(); - let out_len = (len_a + len_b - 1).min(dst.len()); - - for n in 0..out_len { - let mut acc: i32 = 0; - let k_min = n.saturating_sub(len_b - 1); - let k_max = n.min(len_a - 1); - for k in k_min..=k_max { - acc += (src_a[k].to_bits() as i32 * src_b[n - k].to_bits() as i32) >> 15; - } - dst[n] = q15::from_bits(acc.clamp(i16::MIN as i32, i16::MAX as i32) as i16); - } -} - -/// Convolution (`q7`) into `dst`. -pub fn conv_q7(src_a: &[q7], src_b: &[q7], dst: &mut [q7]) { - let len_a = src_a.len(); - let len_b = src_b.len(); - let out_len = (len_a + len_b - 1).min(dst.len()); - - for n in 0..out_len { - let mut acc: i32 = 0; - let k_min = n.saturating_sub(len_b - 1); - let k_max = n.min(len_a - 1); - for k in k_min..=k_max { - acc += (src_a[k].to_bits() as i32 * src_b[n - k].to_bits() as i32) >> 7; - } - dst[n] = q7::from_bits(acc.clamp(i8::MIN as i32, i8::MAX as i32) as i8); - } -} - -// --- Correlation --- - -/// Correlation (`f32`) into `dst`. -pub fn correlate_f32(src_a: &[f32], src_b: &[f32], dst: &mut [f32]) { - let len_a = src_a.len(); - let len_b = src_b.len(); - let out_len = (len_a + len_b - 1).min(dst.len()); - - dst[..out_len].fill(0.0); - for n in 0..out_len { - let mut acc = 0.0f32; - for k in 0..len_a { - let idx_b = (k as isize) + (len_b as isize - 1) - (n as isize); - if idx_b >= 0 && (idx_b as usize) < len_b { - acc += src_a[k] * src_b[idx_b as usize]; - } - } - dst[n] = acc; - } -} - -/// Correlation (`q31`) into `dst`. -pub fn correlate_q31(src_a: &[q31], src_b: &[q31], dst: &mut [q31]) { - let len_a = src_a.len(); - let len_b = src_b.len(); - let out_len = (len_a + len_b - 1).min(dst.len()); - - for n in 0..out_len { - let mut acc: i64 = 0; - for k in 0..len_a { - let idx_b = (k as isize) + (len_b as isize - 1) - (n as isize); - if idx_b >= 0 && (idx_b as usize) < len_b { - acc += (src_a[k].to_bits() as i64 * src_b[idx_b as usize].to_bits() as i64) >> 31; - } - } - dst[n] = q31::from_bits(acc.clamp(i32::MIN as i64, i32::MAX as i64) as i32); - } -} - -/// Correlation (`q15`) into `dst`. -pub fn correlate_q15(src_a: &[q15], src_b: &[q15], dst: &mut [q15]) { - let len_a = src_a.len(); - let len_b = src_b.len(); - let out_len = (len_a + len_b - 1).min(dst.len()); - - for n in 0..out_len { - let mut acc: i32 = 0; - for k in 0..len_a { - let idx_b = (k as isize) + (len_b as isize - 1) - (n as isize); - if idx_b >= 0 && (idx_b as usize) < len_b { - acc += (src_a[k].to_bits() as i32 * src_b[idx_b as usize].to_bits() as i32) >> 15; - } - } - dst[n] = q15::from_bits(acc.clamp(i16::MIN as i32, i16::MAX as i32) as i16); - } -} - -// --- Non-linear Filtering (Median & Conditional Median) --- - -#[allow(unused_imports)] -use crate::math::FloatMath; -#[cfg(feature = "transform")] -use crate::transform::cfft_f32; - -/// 1D Conditional / Thresholded Median Filter for f32. -/// -/// Replaces sample `src[i]` with the local median only if `|src[i] - median| > threshold`. -/// When `threshold == 0.0`, performs standard median filtering. -/// -/// `window_len` must be odd and $\le 63$. -pub fn median_filter_1d_f32( - src: &[f32], - dst: &mut [f32], - window_len: usize, - threshold: f32, -) -> Status { - let n = src.len(); - if n == 0 || dst.len() < n { - return Status::LengthError; - } - if window_len == 0 || window_len.is_multiple_of(2) || window_len > 63 { - return Status::ArgumentError; - } - - let half = window_len / 2; - let mut sort_buf = [0.0f32; 64]; - - for i in 0..n { - // Populate window with boundary clamping - for j in 0..window_len { - let idx = (i as isize + j as isize - half as isize).clamp(0, (n - 1) as isize) as usize; - sort_buf[j] = src[idx]; - } - - // Insertion sort on small stack buffer - for a in 1..window_len { - let mut b = a; - while b > 0 && sort_buf[b - 1] > sort_buf[b] { - sort_buf.swap(b - 1, b); - b -= 1; - } - } - - let med = sort_buf[half]; - let center = src[i]; - if (center - med).abs() >= threshold { - dst[i] = med; - } else { - dst[i] = center; - } - } - - Status::Success -} - -/// 1D Conditional Median Filter for Q15. -pub fn median_filter_1d_q15( - src: &[q15], - dst: &mut [q15], - window_len: usize, - threshold: q15, -) -> Status { - let n = src.len(); - if n == 0 || dst.len() < n { - return Status::LengthError; - } - if window_len == 0 || window_len.is_multiple_of(2) || window_len > 63 { - return Status::ArgumentError; - } - - let half = window_len / 2; - let mut sort_buf = [q15::ZERO; 64]; - - for i in 0..n { - for j in 0..window_len { - let idx = (i as isize + j as isize - half as isize).clamp(0, (n - 1) as isize) as usize; - sort_buf[j] = src[idx]; - } - - for a in 1..window_len { - let mut b = a; - while b > 0 && sort_buf[b - 1] > sort_buf[b] { - sort_buf.swap(b - 1, b); - b -= 1; - } - } - - let med = sort_buf[half]; - let center = src[i]; - let diff = (center.to_bits() as i32 - med.to_bits() as i32).abs(); - if diff >= threshold.to_bits() as i32 { - dst[i] = med; - } else { - dst[i] = center; - } - } - - Status::Success -} - -/// 1D Conditional Median Filter for Q31. -pub fn median_filter_1d_q31( - src: &[q31], - dst: &mut [q31], - window_len: usize, - threshold: q31, -) -> Status { - let n = src.len(); - if n == 0 || dst.len() < n { - return Status::LengthError; - } - if window_len == 0 || window_len.is_multiple_of(2) || window_len > 63 { - return Status::ArgumentError; - } - - let half = window_len / 2; - let mut sort_buf = [q31::ZERO; 64]; - - for i in 0..n { - for j in 0..window_len { - let idx = (i as isize + j as isize - half as isize).clamp(0, (n - 1) as isize) as usize; - sort_buf[j] = src[idx]; - } - - for a in 1..window_len { - let mut b = a; - while b > 0 && sort_buf[b - 1] > sort_buf[b] { - sort_buf.swap(b - 1, b); - b -= 1; - } - } - - let med = sort_buf[half]; - let center = src[i]; - let diff = (center.to_bits() as i64 - med.to_bits() as i64).abs(); - if diff >= threshold.to_bits() as i64 { - dst[i] = med; - } else { - dst[i] = center; - } - } - - Status::Success -} - -// --- FFT Fast Convolution --- - -/// Performs fast linear convolution of `signal` and `kernel` via FFT multiplication. -/// Output length is `signal.len() + kernel.len() - 1`. -/// -/// Requires the `transform` feature (enabled by `full`). -#[cfg(feature = "transform")] -pub fn fast_convolve_f32(signal: &[f32], kernel: &[f32], dst: &mut [f32]) -> Status { - let len_sig = signal.len(); - let len_ker = kernel.len(); - if len_sig == 0 || len_ker == 0 { - return Status::LengthError; - } - let total_len = len_sig + len_ker - 1; - if dst.len() < total_len { - return Status::LengthError; - } - - // Find next power of 2 - let mut fft_n = 1; - while fft_n < total_len { - fft_n <<= 1; - } - - if fft_n > 512 { - // Fall back to time-domain convolution if size exceeds stack scratch buffer - conv_f32(signal, kernel, dst); - return Status::Success; - } - - let mut sig_buf = [0.0f32; 1024]; // 2 * fft_n - let mut ker_buf = [0.0f32; 1024]; - - for i in 0..len_sig { - sig_buf[2 * i] = signal[i]; - } - for i in 0..len_ker { - ker_buf[2 * i] = kernel[i]; - } - - cfft_f32(&mut sig_buf[..2 * fft_n], fft_n, 0, 1); - cfft_f32(&mut ker_buf[..2 * fft_n], fft_n, 0, 1); - - // Pointwise complex multiplication: (a + jb) * (c + jd) - for i in 0..fft_n { - let a = sig_buf[2 * i]; - let b = sig_buf[2 * i + 1]; - let c = ker_buf[2 * i]; - let d = ker_buf[2 * i + 1]; - sig_buf[2 * i] = a * c - b * d; - sig_buf[2 * i + 1] = a * d + b * c; - } - - // Inverse FFT - cfft_f32(&mut sig_buf[..2 * fft_n], fft_n, 1, 1); - - for i in 0..total_len { - dst[i] = sig_buf[2 * i]; - } - - Status::Success -} - -/// Streaming overlap-scrap FIR (`kiss_fastfir`): scrap at the tail of each -/// inverse FFT so consecutive hops overlap by `n_taps - 1` samples. -/// -/// `NFFT` is the real FFT size. It must be a length [`cfft_f32`] -/// accepts (`<= 512` because convolution uses a stack scratch of 1024 floats), -/// and must be `>=` the impulse length. Hop size is `NFFT - n_taps + 1`. -/// -/// History is primed with `n_taps - 1` zeros so the first hop aligns with -/// linear convolution (no extra delay). Call [`FastFirF32::flush`] after the -/// last input block to emit the filter tail. -#[cfg(feature = "transform")] -#[derive(Clone, Copy)] -pub struct FastFirF32 { - n_taps: usize, - ngood: usize, - fir_re: [f32; NFFT], - fir_im: [f32; NFFT], - pending: [f32; NFFT], - pending_len: usize, - spec_re: [f32; NFFT], -} - -#[cfg(feature = "transform")] -impl FastFirF32 { - /// Builds a streaming FIR from a real impulse response. - /// - /// Returns `None` if `impulse` is empty, longer than `NFFT`, or `NFFT` is - /// not a supported FFT length. - pub fn new(impulse: &[f32]) -> Option { - use crate::transform::cfft_f32_len_ok; - if impulse.is_empty() || impulse.len() > NFFT || !cfft_f32_len_ok(NFFT) { - return None; - } - - let n_taps = impulse.len(); - let ngood = NFFT - n_taps + 1; - let pending = [0.0f32; NFFT]; - let mut spec = [0.0f32; 1024]; - if 2 * NFFT > spec.len() { - return None; - } - - spec[0] = impulse[n_taps - 1]; - for i in 0..n_taps.saturating_sub(1) { - spec[2 * (ngood + i)] = impulse[i]; - } - cfft_f32(&mut spec[..2 * NFFT], NFFT, 0, 1); - - let mut fir_re = [0.0f32; NFFT]; - let mut fir_im = [0.0f32; NFFT]; - for i in 0..NFFT { - fir_re[i] = spec[2 * i]; - fir_im[i] = spec[2 * i + 1]; - } - - Some(Self { - n_taps, - ngood, - fir_re, - fir_im, - pending, - pending_len: n_taps.saturating_sub(1), - spec_re: [0.0f32; NFFT], - }) - } - - /// Valid samples produced per full FFT hop (`NFFT - n_taps + 1`). - #[inline] - pub const fn ngood(&self) -> usize { - self.ngood - } - - /// Impulse length used at construction. - #[inline] - pub const fn n_taps(&self) -> usize { - self.n_taps - } - - fn convolve_pending(&mut self) { - let mut spec = [0.0f32; 1024]; - for i in 0..NFFT { - spec[2 * i] = self.pending[i]; - } - cfft_f32(&mut spec[..2 * NFFT], NFFT, 0, 1); - for i in 0..NFFT { - let a = spec[2 * i]; - let b = spec[2 * i + 1]; - let c = self.fir_re[i]; - let d = self.fir_im[i]; - spec[2 * i] = a * c - b * d; - spec[2 * i + 1] = a * d + b * c; - } - cfft_f32(&mut spec[..2 * NFFT], NFFT, 1, 1); - for i in 0..NFFT { - self.spec_re[i] = spec[2 * i]; - } - } - - fn shift_scrap(&mut self) { - let scrap = NFFT - self.ngood; - for i in 0..scrap { - self.pending[i] = self.pending[self.ngood + i]; - } - self.pending_len = scrap; - } - - /// Consumes `input` and writes as many hop-aligned outputs as fit in - /// `output`. Returns the number of samples written. - /// - /// Provide `output.len() >= ngood` (ideally several hops) so full FFT - /// blocks are not stalled for lack of output space. - pub fn process(&mut self, input: &[f32], output: &mut [f32]) -> usize { - let mut in_i = 0; - let mut out_i = 0; - loop { - while self.pending_len < NFFT && in_i < input.len() { - self.pending[self.pending_len] = input[in_i]; - self.pending_len += 1; - in_i += 1; - } - if self.pending_len < NFFT || out_i + self.ngood > output.len() { - break; - } - self.convolve_pending(); - output[out_i..out_i + self.ngood].copy_from_slice(&self.spec_re[..self.ngood]); - out_i += self.ngood; - self.shift_scrap(); - } - out_i - } - - /// Appends `n_taps - 1` zeros and drains a final padded hop so a finite - /// input of length `L` yields the `L + n_taps - 1` linear-convolution samples. - pub fn flush(&mut self, output: &mut [f32]) -> usize { - let pad = self.n_taps.saturating_sub(1); - let mut written = 0; - let mut remaining_pad = pad; - while remaining_pad > 0 && written < output.len() { - let chunk = remaining_pad.min(32); - let zeros = [0.0f32; 32]; - let n = self.process(&zeros[..chunk], &mut output[written..]); - written += n; - remaining_pad -= chunk; - if n == 0 && self.pending_len < NFFT { - break; - } - } - - if self.pending_len == 0 || written >= output.len() { - return written; - } - - let n = self.pending_len; - let zpad = NFFT - n; - for i in n..NFFT { - self.pending[i] = 0.0; - } - self.pending_len = NFFT; - let nout = self.ngood.saturating_sub(zpad); - if nout == 0 || written + nout > output.len() { - self.pending_len = n; - return written; - } - self.convolve_pending(); - output[written..written + nout].copy_from_slice(&self.spec_re[..nout]); - self.pending_len = 0; - written + nout - } - - /// Clears history back to `n_taps - 1` zeros. - pub fn reset(&mut self) { - self.pending.fill(0.0); - self.pending_len = self.n_taps.saturating_sub(1); - } -} - -// --- Real-time Circular Buffer & Delay Line --- - -/// Const-generic zero-allocation circular buffer and delay line for real-time DSP sample streams. -#[derive(Debug, Clone, Copy)] -pub struct CircularBuffer { - buffer: [T; N], - head: usize, - count: usize, -} - -impl CircularBuffer { - /// Creates a new circular buffer initialized with `init_val`. - pub const fn new(init_val: T) -> Self { - Self { - buffer: [init_val; N], - head: 0, - count: 0, - } - } - - /// Pushes a new sample into the buffer, overwriting the oldest sample when full. - #[inline(always)] - pub fn push(&mut self, sample: T) { - if N == 0 { - return; - } - self.buffer[self.head] = sample; - self.head = (self.head + 1) % N; - if self.count < N { - self.count += 1; - } - } - - /// Gets sample with historical lag $k$, where $k = 0$ is the newest sample (`x[n]`), $k = 1$ is `x[n-1]`, etc. - /// Returns `None` if `lag >= self.len()`. - #[inline(always)] - pub fn get(&self, lag: usize) -> Option { - if lag >= self.count || N == 0 { - return None; - } - let idx = (self.head + N - 1 - (lag % N)) % N; - Some(self.buffer[idx]) - } - - /// Returns the most recently pushed sample (`x[n]`). - #[inline(always)] - pub fn latest(&self) -> Option { - self.get(0) - } - - /// Returns the oldest sample stored in the buffer. - #[inline(always)] - pub fn oldest(&self) -> Option { - if self.count == 0 { - None - } else { - self.get(self.count - 1) - } - } - - /// Returns the number of valid samples currently stored in the buffer. - #[inline(always)] - pub const fn len(&self) -> usize { - self.count - } - - /// Returns the capacity of the circular buffer (`N`). - #[inline(always)] - pub const fn capacity(&self) -> usize { - N - } - - /// Returns `true` if the buffer contains no samples. - #[inline(always)] - pub const fn is_empty(&self) -> bool { - self.count == 0 - } - - /// Returns `true` if the buffer is filled to capacity `N`. - #[inline(always)] - pub const fn is_full(&self) -> bool { - self.count == N - } - - /// Clears the circular buffer, resetting sample count and filling with `reset_val`. - pub fn clear(&mut self, reset_val: T) { - self.buffer = [reset_val; N]; - self.head = 0; - self.count = 0; - } -} - -// --- Single-Pole Recursive Filter (Steven W. Smith, Ch. 19) --- - -/// The cheapest possible IIR filter: a single-pole recursive low-pass or high-pass filter -/// (Steven W. Smith, Ch. 19, Eq. 19-2 / 19-3), needing only one or two multiplies per sample. -/// -/// This is the generic template: the recurrence is shared across every [`DspSample`] width and -/// runs through [`DspSample::madd`] / [`DspSample::from_accum`], so a `q15` stage keeps its wide -/// accumulator and an `f32` stage keeps its plain multiply without either being a separate type. -/// Coefficients are still built per width (a fixed-point decay quantizes differently from an `f32` -/// one); the constructors below are the per-width specializations that sit *behind* the generic -/// type. Decay factors come from -/// [`crate::filter_design::single_pole_decay_from_cutoff`] / -/// [`crate::filter_design::single_pole_decay_from_time_constant`]. -#[derive(Debug, Clone, Copy)] -#[cfg_attr(feature = "defmt", derive(defmt::Format))] -pub struct SinglePoleFilter { - b0: T::Coeff, - b1: T::Coeff, - a1: T::Coeff, - x1: T, - y1: T, -} - -impl SinglePoleFilter { - /// Creates a filter directly from the feed-forward coefficients `b0`/`b1` and the feed-back - /// coefficient `a1`. - pub fn new(b0: T::Coeff, b1: T::Coeff, a1: T::Coeff) -> Self { - Self { - b0, - b1, - a1, - x1: T::ZERO, - y1: T::ZERO, - } - } - - /// Processes a single input sample and returns the filtered output. - #[inline(always)] - pub fn process(&mut self, x: T) -> T { - let acc = T::madd( - T::madd(T::madd(T::Accum::default(), x, self.b0), self.x1, self.b1), - self.y1, - self.a1, - ); - let y = T::from_accum(acc); - self.x1 = x; - self.y1 = y; - y - } - - /// Resets the filter's delay state to zero. - pub fn reset(&mut self) { - self.x1 = T::ZERO; - self.y1 = T::ZERO; - } -} - -impl Default for SinglePoleFilter -where - T::Coeff: Default, -{ - fn default() -> Self { - Self { - b0: T::Coeff::default(), - b1: T::Coeff::default(), - a1: T::Coeff::default(), - x1: T::ZERO, - y1: T::ZERO, - } - } -} - -impl SinglePoleFilter { - /// Creates a single-pole low-pass filter from decay factor `x` (`0.0..1.0`); larger `x` - /// means slower decay (a lower cutoff frequency). - pub fn lowpass(decay: f32) -> Self { - Self::new(1.0 - decay, 0.0, decay) - } - - /// Creates a single-pole high-pass filter from the same decay factor `x` used by - /// [`SinglePoleFilter::lowpass`]. - pub fn highpass(decay: f32) -> Self { - let b0 = (1.0 + decay) / 2.0; - Self::new(b0, -b0, decay) - } -} - -impl SinglePoleFilter { - /// Creates a single-pole low-pass filter from Q15 decay `x` (larger → lower cutoff). - pub fn lowpass(decay: q15) -> Self { - let decay = decay.max(q15::ZERO); - Self::new( - q15::from_bits((32767i32 - decay.to_bits() as i32) as i16), - q15::ZERO, - decay, - ) - } - - /// Creates a single-pole high-pass filter from the same Q15 decay used by - /// [`SinglePoleFilter::lowpass`]. - pub fn highpass(decay: q15) -> Self { - let decay = decay.max(q15::ZERO); - let b0 = q15::from_bits(((32767i32 + decay.to_bits() as i32) / 2) as i16); - Self::new(b0, -b0, decay) - } - - /// Quantizes a floating-point decay in `0.0..1.0` to Q15 and builds a low-pass. - pub fn lowpass_from_f32(decay: f32) -> Self { - Self::lowpass(q15::saturating_from_num(decay.clamp(0.0, 1.0))) - } - - /// Quantizes a floating-point decay in `0.0..1.0` to Q15 and builds a high-pass. - pub fn highpass_from_f32(decay: f32) -> Self { - Self::highpass(q15::saturating_from_num(decay.clamp(0.0, 1.0))) - } -} - -/// The stateless-`SplitProcess` bridge for [`SinglePoleFilter`], kept next to the type so the -/// pipeline layer does not have to reach outward to wrap it. `Process` and -/// [`DspNode`](crate::pipeline::DspNode) follow from the pipeline blankets. -#[cfg(feature = "pipeline")] -impl crate::pipeline::SplitProcess for SinglePoleFilter { - #[inline(always)] - fn process_with_state(&mut self, _state: &mut (), input: T) -> T { - SinglePoleFilter::process(self, input) - } -} - -/// High-pass single-pole used as a DC blocker (Smith Ch. 19). -#[derive(Debug, Clone, Copy, Default)] -#[cfg_attr(feature = "defmt", derive(defmt::Format))] -pub struct DcBlockerQ15 { - inner: SinglePoleFilter, -} - -impl DcBlockerQ15 { - /// `decay` is the same Q15 factor as [`SinglePoleFilter::highpass`]. - pub fn new(decay: q15) -> Self { - Self { - inner: SinglePoleFilter::::highpass(decay), - } - } - - /// Quantizes a floating-point decay in `0.0..1.0`. - pub fn from_f32_decay(decay: f32) -> Self { - Self { - inner: SinglePoleFilter::::highpass_from_f32(decay), - } - } - - #[inline(always)] - /// Processes a single input sample. - pub fn process(&mut self, x: q15) -> q15 { - self.inner.process(x) - } - - /// Resets the internal state. - pub fn reset(&mut self) { - self.inner.reset(); - } -} - -// --- Recursive Moving Average Filter (Steven W. Smith, Ch. 15) --- - -/// Const-generic `N`-point moving average filter implemented recursively (Steven W. Smith, -/// Ch. 15, Eq. 15-3): each sample is updated with a single add and subtract instead of an -/// `O(N)` convolution sum. Generic over the sample width. -#[derive(Debug, Clone)] -#[cfg_attr(feature = "defmt", derive(defmt::Format))] -pub struct RecursiveMovingAverage { - history: CircularBuffer, - sum: T::Accum, -} - -impl RecursiveMovingAverage { - /// Creates a new `N`-point recursive moving average filter with empty history. - pub fn new() -> Self { - Self { - history: CircularBuffer::new(T::ZERO), - sum: T::Accum::default(), - } - } - - /// Pushes a new input sample and returns the updated moving average. While fewer than `N` - /// samples have been seen, the average is taken over the (growing) window received so far. - #[inline(always)] - pub fn process(&mut self, x: T) -> T { - let oldest = if self.history.is_full() { - self.history.oldest().unwrap_or(T::ZERO) - } else { - T::ZERO - }; - let delta = T::accum_from_shifted(x, 0) - T::accum_from_shifted(oldest, 0); - self.sum = self.sum + delta; - self.history.push(x); - if self.history.is_empty() { - T::ZERO - } else { - T::average_accum(self.sum, self.history.len()) - } - } - - /// Resets the filter to its initial, empty state. - pub fn reset(&mut self) { - self.history.clear(T::ZERO); - self.sum = T::Accum::default(); - } -} - -impl Default for RecursiveMovingAverage { - fn default() -> Self { - Self::new() - } -} - -/// `q15` recursive `N`-point moving average (see [`RecursiveMovingAverage`]). -pub type RecursiveMovingAverageQ15 = RecursiveMovingAverage; - -// ───────────────────────────────────────────────────────────────────────────── -// Robust Second-Order Sections (Biquads) with Anti-Windup & Clamping -// ───────────────────────────────────────────────────────────────────────────── - -/// Direct Form 1 filter history state holding delayed inputs and outputs `[x1, x2, y1, y2]`. -#[derive(Clone, Copy, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -#[cfg_attr(feature = "bytemuck", derive(bytemuck::Zeroable))] -pub struct DirectForm1 { - /// Input/output history. - pub xy: [T; 4], -} - -impl Default for DirectForm1 { - fn default() -> Self { - Self { - xy: [T::default(); 4], - } - } -} - -impl DirectForm1 { - /// Create a new zero-initialized Direct Form 1 state. - pub fn new() -> Self { - Self { - xy: [T::default(); 4], - } - } - - /// Reset internal state buffer. - pub fn reset(&mut self) { - self.xy = [T::default(); 4]; - } -} - -/// Direct Form 2 Transposed filter state holding accumulator registers `[s0, s1]`. -#[derive(Clone, Copy, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -#[cfg_attr(feature = "bytemuck", derive(bytemuck::Zeroable))] -pub struct DirectForm2Transposed { - /// S. - pub s: [T; 2], -} - -impl Default for DirectForm2Transposed { - fn default() -> Self { - Self { - s: [T::default(); 2], - } - } -} - -impl DirectForm2Transposed { - /// Create a new zero-initialized Direct Form 2 Transposed state. - pub fn new() -> Self { - Self { - s: [T::default(); 2], - } - } - - /// Reset internal state buffer. - pub fn reset(&mut self) { - self.s = [T::default(); 2]; - } -} - -/// Second-order section (SOS) biquadratic filter configuration. -/// -/// Contains coefficients `ba: [b0, b1, b2, a1, a2]` normalized such that `a0 = 1`. -/// Recurrence relation: -/// `y0 = b0*x0 + b1*x1 + b2*x2 + a1*y1 + a2*y2` -#[derive(Clone, Copy, Debug, Default, PartialEq)] -#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -pub struct Biquad { - /// Second-order-section coefficients `[b0, b1, b2, a1, a2]`. - pub ba: [T; 5], -} - -impl Biquad { - /// Create a new Biquad configuration from coefficients `[b0, b1, b2, a1, a2]`. - pub const fn new(b0: T, b1: T, b2: T, a1: T, a2: T) -> Self { - Self { - ba: [b0, b1, b2, a1, a2], - } - } -} - -impl Biquad { - /// Process a single input sample through Direct Form 1 state. - #[inline(always)] - pub fn process_df1(&self, state: &mut DirectForm1, x0: f32) -> f32 { - let [b0, b1, b2, a1, a2] = self.ba; - let [x1, x2, y1, y2] = state.xy; - let y0 = b0 * x0 + b1 * x1 + b2 * x2 + a1 * y1 + a2 * y2; - state.xy = [x0, x1, y0, y1]; - y0 - } - - /// Process a single input sample through Direct Form 2 Transposed state. - #[inline(always)] - pub fn process_df2t(&self, state: &mut DirectForm2Transposed, x0: f32) -> f32 { - let [b0, b1, b2, a1, a2] = self.ba; - let y0 = b0 * x0 + state.s[0]; - state.s[0] = b1 * x0 + a1 * y0 + state.s[1]; - state.s[1] = b2 * x0 + a2 * y0; - y0 - } -} - -impl Biquad { - /// Process a single input sample through Direct Form 1 state. - #[inline(always)] - pub fn process_df1(&self, state: &mut DirectForm1, x0: f64) -> f64 { - let [b0, b1, b2, a1, a2] = self.ba; - let [x1, x2, y1, y2] = state.xy; - let y0 = b0 * x0 + b1 * x1 + b2 * x2 + a1 * y1 + a2 * y2; - state.xy = [x0, x1, y0, y1]; - y0 - } - - /// Process a single input sample through Direct Form 2 Transposed state. - #[inline(always)] - pub fn process_df2t(&self, state: &mut DirectForm2Transposed, x0: f64) -> f64 { - let [b0, b1, b2, a1, a2] = self.ba; - let y0 = b0 * x0 + state.s[0]; - state.s[0] = b1 * x0 + a1 * y0 + state.s[1]; - state.s[1] = b2 * x0 + a2 * y0; - y0 - } -} - -/// Biquadratic filter configuration with summing junction offset and anti-windup output clamping. -/// -/// Clamps output between `[min, max]` at the summing junction before storing into feedback state, -/// preventing integrator windup and derivative kick when used in feedback control or PID applications. -#[derive(Clone, Copy, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -pub struct BiquadClamp { - /// Coeff. - pub coeff: Biquad, - /// Summing junction offset (setpoint) - pub u: T, - /// Minimum saturation clamp - pub min: T, - /// Maximum saturation clamp - pub max: T, -} - -impl BiquadClamp { - /// Create a new clamped Biquad with coefficients, offset, and clamp bounds. - pub const fn new(coeff: Biquad, min: T, max: T, u: T) -> Self { - Self { coeff, u, min, max } - } -} - -impl BiquadClamp { - /// Process a sample using Direct Form 1 with anti-windup clamping. - #[inline(always)] - pub fn process_df1(&self, state: &mut DirectForm1, x0: f32) -> f32 { - let [b0, b1, b2, a1, a2] = self.coeff.ba; - let [x1, x2, y1, y2] = state.xy; - let unclamped = b0 * x0 + b1 * x1 + b2 * x2 + a1 * y1 + a2 * y2 + self.u; - let y0 = unclamped.clamp(self.min, self.max); - state.xy = [x0, x1, y0, y1]; - y0 - } - - /// Process a sample using Direct Form 2 Transposed with anti-windup clamping. - #[inline(always)] - pub fn process_df2t(&self, state: &mut DirectForm2Transposed, x0: f32) -> f32 { - let [b0, b1, b2, a1, a2] = self.coeff.ba; - let y0 = (b0 * x0 + state.s[0] + self.u).clamp(self.min, self.max); - state.s[0] = b1 * x0 + a1 * y0 + state.s[1]; - state.s[1] = b2 * x0 + a2 * y0; - y0 - } -} - -impl BiquadClamp { - /// Process a sample using Direct Form 1 with anti-windup clamping. - #[inline(always)] - pub fn process_df1(&self, state: &mut DirectForm1, x0: f64) -> f64 { - let [b0, b1, b2, a1, a2] = self.coeff.ba; - let [x1, x2, y1, y2] = state.xy; - let unclamped = b0 * x0 + b1 * x1 + b2 * x2 + a1 * y1 + a2 * y2 + self.u; - let y0 = unclamped.clamp(self.min, self.max); - state.xy = [x0, x1, y0, y1]; - y0 - } - - /// Process a sample using Direct Form 2 Transposed with anti-windup clamping. - #[inline(always)] - pub fn process_df2t(&self, state: &mut DirectForm2Transposed, x0: f64) -> f64 { - let [b0, b1, b2, a1, a2] = self.coeff.ba; - let y0 = (b0 * x0 + state.s[0] + self.u).clamp(self.min, self.max); - state.s[0] = b1 * x0 + a1 * y0 + state.s[1]; - state.s[1] = b2 * x0 + a2 * y0; - y0 - } -} - -#[cfg(feature = "pipeline")] -impl crate::pipeline::SplitProcess> for Biquad { - #[inline(always)] - fn process_with_state(&mut self, state: &mut DirectForm1, x: f32) -> f32 { - self.process_df1(state, x) - } -} - -#[cfg(feature = "pipeline")] -impl crate::pipeline::SplitProcess> for Biquad { - #[inline(always)] - fn process_with_state(&mut self, state: &mut DirectForm2Transposed, x: f32) -> f32 { - self.process_df2t(state, x) - } -} - -#[cfg(feature = "pipeline")] -impl crate::pipeline::SplitProcess> for BiquadClamp { - #[inline(always)] - fn process_with_state(&mut self, state: &mut DirectForm1, x: f32) -> f32 { - self.process_df1(state, x) - } -} - -#[cfg(feature = "pipeline")] -impl crate::pipeline::SplitProcess> for BiquadClamp { - #[inline(always)] - fn process_with_state(&mut self, state: &mut DirectForm2Transposed, x: f32) -> f32 { - self.process_df2t(state, x) - } -} - -#[cfg(feature = "pipeline")] -impl crate::pipeline::SplitProcess> for Biquad { - #[inline(always)] - fn process_with_state(&mut self, state: &mut DirectForm1, x: f64) -> f64 { - self.process_df1(state, x) - } -} - -#[cfg(feature = "pipeline")] -impl crate::pipeline::SplitProcess> for Biquad { - #[inline(always)] - fn process_with_state(&mut self, state: &mut DirectForm2Transposed, x: f64) -> f64 { - self.process_df2t(state, x) - } -} - -#[cfg(feature = "pipeline")] -impl crate::pipeline::SplitProcess> for BiquadClamp { - #[inline(always)] - fn process_with_state(&mut self, state: &mut DirectForm1, x: f64) -> f64 { - self.process_df1(state, x) - } -} - -#[cfg(feature = "pipeline")] -impl crate::pipeline::SplitProcess> for BiquadClamp { - #[inline(always)] - fn process_with_state(&mut self, state: &mut DirectForm2Transposed, x: f64) -> f64 { - self.process_df2t(state, x) - } -} - -/// Direct Form 1 state with quantization error feedback for noise shaping. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -#[cfg_attr(feature = "bytemuck", derive(bytemuck::Zeroable))] -pub struct DirectForm1NoiseShaped { - /// Input/output history. - pub xy: [i32; 4], - /// Quantization error feedback accumulator. - pub err: i32, -} - -impl DirectForm1NoiseShaped { - /// Create a new zero-initialized state with zero error feedback. - pub const fn new() -> Self { - Self { - xy: [0; 4], - err: 0, - } - } - - /// Reset internal state and error accumulator. - pub fn reset(&mut self) { - self.xy = [0; 4]; - self.err = 0; - } -} - -/// Fixed-point 32-bit Biquad with parameterized fractional scaling and anti-windup clamping. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -pub struct BiquadFixed { - /// Fixed-point coefficients `[b0, b1, b2, a1, a2]`. - pub ba: [i32; 5], - /// Summing junction offset - pub u: i32, - /// Minimum saturation clamp - pub min: i32, - /// Maximum saturation clamp - pub max: i32, -} - -impl BiquadFixed { - /// Create a new fixed-point biquad configuration. - pub const fn new(ba: [i32; 5], min: i32, max: i32, u: i32) -> Self { - Self { ba, min, max, u } - } - - /// Process single sample with 1st-order noise shaping to eliminate limit cycles. - #[inline(always)] - pub fn process_noise_shaped(&self, state: &mut DirectForm1NoiseShaped, x0: i32) -> i32 { - let [b0, b1, b2, a1, a2] = self.ba; - let [x1, x2, y1, y2] = state.xy; - let acc = (b0 as i64 * x0 as i64) - + (b1 as i64 * x1 as i64) - + (b2 as i64 * x2 as i64) - + (a1 as i64 * y1 as i64) - + (a2 as i64 * y2 as i64) - + ((self.u as i64) << SHIFT) - - state.err as i64; // noise shaping feedback - let scaled = acc >> SHIFT; - let y0 = scaled.clamp(self.min as i64, self.max as i64) as i32; - state.err = (acc - ((y0 as i64) << SHIFT)) as i32; - state.xy = [x0, x1, y0, y1]; - y0 - } - - /// Process a single sample with a 64-bit (`Q32.32`) output accumulator. - /// - /// Compared with [`Self::process_noise_shaped`], the feedback path carries - /// 32 fractional bits instead of being rounded each sample, which removes - /// the need for dithering at the cost of a wider state. This is the - /// embedded-dsp equivalent of `idsp`'s `DirectForm1Wide` processing. - /// - /// # Panics - /// Fails to compile unless `1 <= SHIFT <= 32`. - #[inline(always)] - pub fn process_wide(&self, state: &mut DirectForm1Wide, x0: i32) -> i32 { - const { - assert!( - SHIFT >= 1 && SHIFT <= 32, - "BiquadFixed::process_wide requires 1 <= SHIFT <= 32" - ) - }; - let [b0, b1, b2, a1, a2] = self.ba; - let [x1, x2] = state.x; - let [y1, y2] = state.y; - - // Numerator: full-width products, no truncation. - let mut acc = (b0 as i64) - .wrapping_mul(x0 as i64) - .wrapping_add((b1 as i64).wrapping_mul(x1 as i64)) - .wrapping_add((b2 as i64).wrapping_mul(x2 as i64)); - - // Denominator: 32x32 split multiply of the wide states by the bits. - acc = acc.wrapping_add(((y1 as u32 as i64).wrapping_mul(a1 as i64)) >> 32); - acc = acc.wrapping_add(((y1 >> 32) as i32 as i64).wrapping_mul(a1 as i64)); - acc = acc.wrapping_add(((y2 as u32 as i64).wrapping_mul(a2 as i64)) >> 32); - acc = acc.wrapping_add(((y2 >> 32) as i32 as i64).wrapping_mul(a2 as i64)); - - // Promote from the `SHIFT`-bit coefficient scale to Q32.32. - acc <<= 32 - SHIFT; - - let y0 = ((acc >> 32) as i32 as i64) - .wrapping_add(self.u as i64) - .clamp(self.min as i64, self.max as i64) as i32; - - // Keep the fractional low word of the accumulator, overwrite the output word. - state.y = [((y0 as i64) << 32) | (acc as u32 as i64), y1]; - state.x = [x0, x1]; - y0 - } -} - -#[cfg(feature = "pipeline")] -impl crate::pipeline::SplitProcess - for BiquadFixed -{ - #[inline(always)] - fn process_with_state(&mut self, state: &mut DirectForm1NoiseShaped, x: i32) -> i32 { - self.process_noise_shaped(state, x) - } -} - -/// Direct Form 1 state with a 64-bit (`Q32.32`) output accumulator. -/// -/// This is the embedded-dsp equivalent of `idsp`'s `DirectForm1Wide`: the -/// recursion is carried at 32 fractional bits so coefficient rounding does not -/// accumulate inside the feedback path. Use it with -/// [`BiquadFixed::process_wide`]. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -pub struct DirectForm1Wide { - /// Input history `[x1, x2]`. - pub x: [i32; 2], - /// Output accumulator history `[y1, y2]` in `Q32.32`. - pub y: [i64; 2], -} - -impl DirectForm1Wide { - /// Create a new zero-initialized wide state. - pub const fn new() -> Self { - Self { - x: [0; 2], - y: [0; 2], - } - } - - /// Reset the state and accumulator history. - pub fn reset(&mut self) { - self.x = [0; 2]; - self.y = [0; 2]; - } -} - -#[cfg(feature = "pipeline")] -impl crate::pipeline::SplitProcess - for BiquadFixed -{ - #[inline(always)] - fn process_with_state(&mut self, state: &mut DirectForm1Wide, x: i32) -> i32 { - self.process_wide(state, x) - } -} - -/// Integer sample type usable with [`BiquadInt`]. -/// -/// Implemented for `i8`, `i16`, `i32` and `i64`, each with a wider accumulator -/// (`i16`, `i32`, `i64` and `i128` respectively). This mirrors `idsp`'s generic -/// integer `Biquad` over the primitive integer widths. -pub trait BiquadIntSample: Copy + PartialOrd { - /// Wider accumulator type used for the recursion. - type Wide: Copy + PartialOrd; - /// Most negative value. - const MIN: Self; - /// Most positive value. - const MAX: Self; - - /// Widen to the accumulator type. - fn widen(self) -> Self::Wide; - /// Saturate an accumulator value back to the sample range. - fn narrow(w: Self::Wide) -> Self; - /// Accumulator zero. - fn wide_zero() -> Self::Wide; - /// Wrapping accumulator multiply. - fn wide_mul(a: Self::Wide, b: Self::Wide) -> Self::Wide; - /// Wrapping accumulator add. - fn wide_add(a: Self::Wide, b: Self::Wide) -> Self::Wide; - /// Arithmetic right shift of the accumulator. - fn wide_shr(a: Self::Wide, n: u32) -> Self::Wide; -} - -macro_rules! impl_biquad_int_sample { - ($sample:ty, $wide:ty) => { - impl BiquadIntSample for $sample { - type Wide = $wide; - const MIN: Self = <$sample>::MIN; - const MAX: Self = <$sample>::MAX; - - #[inline(always)] - fn widen(self) -> $wide { - self as $wide - } - - #[inline(always)] - fn narrow(w: $wide) -> Self { - w.clamp(<$sample>::MIN as $wide, <$sample>::MAX as $wide) as $sample - } - - #[inline(always)] - fn wide_zero() -> $wide { - 0 - } - - #[inline(always)] - fn wide_mul(a: $wide, b: $wide) -> $wide { - a.wrapping_mul(b) - } - - #[inline(always)] - fn wide_add(a: $wide, b: $wide) -> $wide { - a.wrapping_add(b) - } - - #[inline(always)] - fn wide_shr(a: $wide, n: u32) -> $wide { - a >> n - } - } - }; -} - -impl_biquad_int_sample!(i8, i16); -impl_biquad_int_sample!(i16, i32); -impl_biquad_int_sample!(i32, i64); -impl_biquad_int_sample!(i64, i128); - -/// Direct Form 1 state for [`BiquadInt`]: `[x1, x2, y1, y2]`. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -pub struct DirectForm1Int { - /// `[x1, x2, y1, y2]`. - pub xy: [T; 4], -} - -impl Default for DirectForm1Int { - fn default() -> Self { - Self { - xy: [T::default(); 4], - } - } -} - -impl DirectForm1Int { - /// Create a new zeroed state. - pub fn new() -> Self { - Self::default() - } - - /// Reset the state to zero. - pub fn reset(&mut self) { - self.xy = [T::default(); 4]; - } -} - -/// Fixed-point second-order section generic over the integer sample type. -/// -/// Coefficients `ba = [b0, b1, b2, a1, a2]` are scaled by `2^SHIFT`, the -/// recurrence runs in the wider [`BiquadIntSample::Wide`] accumulator, and the -/// output is saturated to `[min, max]`. This closes the `idsp` gap of a biquad -/// that works over `i8`/`i16`/`i32`/`i64` samples. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -pub struct BiquadInt { - /// Fixed-point coefficients `[b0, b1, b2, a1, a2]`. - pub ba: [T; 5], - /// Summing-junction offset, in output units. - pub u: T, - /// Minimum saturation clamp. - pub min: T, - /// Maximum saturation clamp. - pub max: T, -} - -impl BiquadInt { - /// Create a new integer biquad configuration. - pub const fn new(ba: [T; 5], min: T, max: T, u: T) -> Self { - Self { ba, min, max, u } - } - - /// Process a single sample through Direct Form 1. - /// - /// # Panics - /// Fails to compile unless `SHIFT < 32`. - #[inline(always)] - pub fn process_df1(&self, state: &mut DirectForm1Int, x0: T) -> T { - const { assert!(SHIFT < 32, "BiquadInt requires SHIFT < 32") }; - let [b0, b1, b2, a1, a2] = self.ba; - let [x1, x2, y1, y2] = state.xy; - - let mut acc = T::wide_zero(); - for (c, s) in [(b0, x0), (b1, x1), (b2, x2), (a1, y1), (a2, y2)] { - acc = T::wide_add(acc, T::wide_mul(c.widen(), s.widen())); - } - - // Scale down, add the offset, then clamp to the configured output range. - let scaled = T::narrow(T::wide_shr(acc, SHIFT)); - let y_raw = T::narrow(T::wide_add(scaled.widen(), self.u.widen())); - let y0 = if y_raw < self.min { - self.min - } else if y_raw > self.max { - self.max - } else { - y_raw - }; - - state.xy = [x0, x1, y0, y1]; - y0 - } -} - -#[cfg(feature = "pipeline")] -impl - crate::pipeline::SplitProcess> for BiquadInt -{ - #[inline(always)] - fn process_with_state(&mut self, state: &mut DirectForm1Int, x: T) -> T { - self.process_df1(state, x) - } -} - -/// Delta-sigma modulator in MASH-(1)^K architecture. -/// -/// Converts a 32-bit unsigned input sample `x` into an integer stream with average -/// value `x / 2^32`, shaping quantization noise up by `K * 20 dB/decade`. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Dsm { - /// Accumulator state. - pub a: [u32; K], - /// Carry/coefficient state. - pub c: [i8; K], -} - -impl Default for Dsm { - fn default() -> Self { - Self { - a: [0; K], - c: [0; K], - } - } -} - -impl Dsm { - /// Create a new zeroed Delta-Sigma modulator. - pub const fn new() -> Self { - Self { - a: [0; K], - c: [0; K], - } - } - - /// Process a new 32-bit sample and return modulated output. - #[inline] - pub fn process_sample(&mut self, x: u32) -> i8 { - let mut d = 0i8; - for a in self.a.iter_mut() { - let (next_a, c) = a.overflowing_add(x); - *a = next_a; - d = (d << 1) | c as i8; - } - let mut y = d & 1; - for c in self.c.iter_mut().take(K.saturating_sub(1)) { - d >>= 1; - let next_y = (d & 1) + y - *c; - *c = y; - y = next_y; - } - y - } -} - -#[cfg(feature = "pipeline")] -impl crate::pipeline::Process for Dsm { - #[inline(always)] - fn process(&mut self, x: u32) -> i8 { - self.process_sample(x) - } -} - -/// Lightweight 32-bit XorShift pseudorandom generator for dither synthesis. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct XorShift32(pub u32); - -impl Default for XorShift32 { - fn default() -> Self { - Self::new(0x12345678) - } -} - -impl XorShift32 { - /// Create a new XorShift32 PRNG from non-zero seed. - #[inline(always)] - pub const fn new(seed: u32) -> Self { - Self(if seed == 0 { 0x12345678 } else { seed }) - } - - /// Produce next pseudorandom 32-bit word. - #[inline(always)] - pub fn next_u32(&mut self) -> u32 { - let mut x = self.0; - x ^= x << 13; - x ^= x >> 17; - x ^= x << 5; - self.0 = x; - x - } - - /// Produce next uniform float in `[0.0, 1.0)`. - #[inline(always)] - pub fn next_f32(&mut self) -> f32 { - (self.next_u32() >> 8) as f32 * (1.0 / 16777216.0) - } - - /// Triangular Probability Density Function (TPDF) dither sample in `[-1.0, 1.0]`. - #[inline(always)] - pub fn tpdf_dither_f32(&mut self) -> f32 { - let r1 = self.next_f32(); - let r2 = self.next_f32(); - r1 - r2 - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Lock-in Demodulation & Lock-in Amplifier -// ───────────────────────────────────────────────────────────────────────────── - -use crate::types::Complex; - -/// Dual-phase lock-in amplifier mixer and demodulator. -/// -/// Combines channel filters `C` with an IQ local oscillator reference to demodulate -/// a noisy input signal into in-phase $I$ and quadrature $Q$ components. -#[derive(Copy, Clone, Default, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -pub struct Lockin(pub C); - -impl Lockin { - /// Create a new lock-in demodulator with the given low-pass channel filter. - pub const fn new(filter: C) -> Self { - Self(filter) - } -} - -#[cfg(feature = "pipeline")] -impl crate::pipeline::SplitProcess<(X, Complex), Complex, [S; 2]> for Lockin -where - X: Copy + core::ops::Mul, - U: Copy, - C: crate::pipeline::SplitProcess, -{ - /// Demodulate a sample `x.0` against a local oscillator `x.1` (in-phase and quadrature). - #[inline] - fn process_with_state(&mut self, state: &mut [S; 2], x: (X, Complex)) -> Complex { - let (sample, lo) = x; - Complex::new( - self.0.process_with_state(&mut state[0], sample * lo.real), - self.0.process_with_state(&mut state[1], sample * lo.imag), - ) - } -} - -/// Standalone Lock-in Amplifier with integrated single-pole low-pass filtering. -/// -/// Multiplies an incoming signal with an internal or external quadrature reference, -/// and low-pass filters both channels to extract amplitude and phase. -#[derive(Clone, Copy, Debug)] -pub struct LockinAmplifier { - /// Filter i. - pub filter_i: SinglePoleFilter, - /// Filter q. - pub filter_q: SinglePoleFilter, - /// Phase. - pub phase: i32, - /// Phase inc. - pub phase_inc: i32, -} - -impl LockinAmplifier { - /// Create a new Lock-in Amplifier with carrier frequency, sample rate, and low-pass decay factor. - pub fn new(carrier_hz: f32, sample_rate: f32, filter_decay: f32) -> Self { - let phase_inc = ((carrier_hz / sample_rate) * 4294967296.0) as i32; - Self { - filter_i: SinglePoleFilter::::lowpass(filter_decay), - filter_q: SinglePoleFilter::::lowpass(filter_decay), - phase: 0, - phase_inc, - } - } - - /// Set carrier frequency in Hz. - pub fn set_frequency(&mut self, carrier_hz: f32, sample_rate: f32) { - self.phase_inc = ((carrier_hz / sample_rate) * 4294967296.0) as i32; - } - - /// Reset internal filter state and phase accumulator. - pub fn reset(&mut self) { - self.filter_i.reset(); - self.filter_q.reset(); - self.phase = 0; - } - - /// Ingest a sample and return demodulated IQ `Complex`. - #[inline] - pub fn process(&mut self, sample: f32) -> Complex { - let (cos_ref, sin_ref) = { - #[cfg(feature = "fast-math")] - { - let (c, s) = crate::fast_math::cossin(self.phase); - (c as f32 * (1.0 / 2147483648.0), s as f32 * (1.0 / 2147483648.0)) - } - #[cfg(not(feature = "fast-math"))] - { - let rad = self.phase as f32 * (core::f32::consts::PI / 2147483648.0); - (FloatMath::cos(rad), FloatMath::sin(rad)) - } - }; - - self.phase = self.phase.wrapping_add(self.phase_inc); - - let i_filt = self.filter_i.process(sample * cos_ref); - let q_filt = self.filter_q.process(sample * sin_ref); - Complex::new(i_filt, q_filt) - } - - /// Process a sample using an external reference phase angle (in radians). - #[inline] - pub fn process_with_phase(&mut self, sample: f32, phase_rad: f32) -> Complex { - let (cos_ref, sin_ref) = { - #[cfg(feature = "fast-math")] - { - crate::fast_math::cossin_f32(phase_rad) - } - #[cfg(not(feature = "fast-math"))] - { - (FloatMath::cos(phase_rad), FloatMath::sin(phase_rad)) - } - }; - - let i_filt = self.filter_i.process(sample * cos_ref); - let q_filt = self.filter_q.process(sample * sin_ref); - Complex::new(i_filt, q_filt) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Integer Lowpass Filter (ported from idsp) -// ───────────────────────────────────────────────────────────────────────────── - -/// Arbitrary-order integer lowpass filter with high dynamic range. DC gain is 1. -/// -/// Supports order `N = 1` (first-order) and `N = 2` (second-order Butterworth); -/// any other `N` is rejected at compile time. The filter saturates cleanly -/// towards the `i32` range. -/// -/// # Coefficient Calculation -/// -/// **First-order** (`N = 1`): `k[0] = π * (1 << 31) * f0 / fn` -/// where `f0` is the 3 dB corner frequency and `fn` is the Nyquist frequency. -/// -/// **Second-order Butterworth** (`N = 2`): `k = [k_sq >> 32, -k / q]` -/// where `q = 1/sqrt(2)` and `k` is as above. -/// -/// Both variants have zeros at Nyquist, optimised for Cortex-M7. -/// -/// ``` -/// # use embedded_dsp::filtering::IntLowpass; -/// let mut lp = IntLowpass::<1>::new([674_651_885]); -/// assert_eq!(lp.process(1 << 24), 2_635_358); -/// ``` -/// -/// Unsupported orders are a compile error rather than a runtime panic: -/// -/// ```compile_fail -/// # use embedded_dsp::filtering::IntLowpass; -/// let _ = IntLowpass::<3>::new([0, 0, 0]); -/// ``` -/// -/// Ported from the `idsp` crate by the Sinara/ARTIQ project. -#[derive(Clone, Debug)] -pub struct IntLowpass { - /// Lead/lag gain coefficients in Q1.31 fixed-point. - pub k: [i32; N], - /// Wide internal state accumulators. - state: [i64; N], -} - -impl Default for IntLowpass -where - [i32; N]: Default, -{ - fn default() -> Self { - const { assert!(N == 1 || N == 2, "IntLowpass supports only N = 1 or N = 2") }; - Self { k: Default::default(), state: [0i64; N] } - } -} - -impl IntLowpass { - /// Create a new filter from gain coefficients. - /// - /// # Panics - /// Fails to compile unless `N` is `1` or `2`. - pub fn new(k: [i32; N]) -> Self { - const { assert!(N == 1 || N == 2, "IntLowpass supports only N = 1 or N = 2") }; - Self { k, state: [0i64; N] } - } - - /// Reset internal state to zero. - pub fn reset(&mut self) { - self.state = [0i64; N]; - } - - /// Process a single sample and return the filtered output. - /// - /// # Panics - /// Fails to compile unless `N` is `1` or `2`. - pub fn process(&mut self, x: i32) -> i32 { - const { assert!(N == 1 || N == 2, "IntLowpass supports only N = 1 or N = 2") }; - if N == 1 { - let d = x.saturating_sub((self.state[0] >> 32) as i32) as i64 - * self.k[0] as i64; - self.state[0] += d; - let y = (self.state[0] >> 32) as i32; - self.state[0] += d; - y - } else { - let mut d = x.saturating_sub((self.state[0] >> 32) as i32) as i64 - * self.k[0] as i64; - d += (self.state[1] >> 32) * self.k[1] as i64; - self.state[1] += d; - self.state[0] += self.state[1]; - let y = (self.state[0] >> 32) as i32; - self.state[0] += self.state[1]; - self.state[1] += d; - y - } - } -} - -/// First-order integer lowpass (alias for `IntLowpass<1>`). -pub type IntLowpass1 = IntLowpass<1>; -/// Second-order integer lowpass (alias for `IntLowpass<2>`). -pub type IntLowpass2 = IntLowpass<2>; - -// ───────────────────────────────────────────────────────────────────────────── -// Normal Form Second-Order Section (Rader-Gold / Chamberlain oscillator) -// ───────────────────────────────────────────────────────────────────────────── - -/// Normal form (Rader-Gold / Chamberlain) second-order IIR section with an -/// **arbitrary numerator**. -/// -/// Unlike a standard direct-form biquad, the normal form has **constant pole -/// resolution** everywhere in the z-plane rather than clustering resolution -/// near the real axis. This makes it ideal for: -/// -/// - Precise narrow-band bandpass filters close to DC or Nyquist. -/// - Quadrature sinusoidal oscillators (the two state variables are -/// in-phase and 90°-shifted copies of the oscillation). -/// - Notch filters requiring very high Q. -/// -/// # Architecture -/// -/// The two state variables `(u, v)` are updated by a rotation through the -/// conjugate pole pair: -/// -/// ```text -/// u[n] = p.re * u[n-1] - p.im * v[n-1] + x[n] -/// v[n] = p.im * u[n-1] + p.re * v[n-1] -/// ``` -/// -/// The filtered output is an arbitrary linear combination of the states and -/// the current input: -/// -/// ```text -/// y[n] = c0 * u[n] + c1 * v[n] + c2 * x[n] -/// ``` -/// -/// With `c` chosen by [`NormalForm::from_ba`] this realizes **exactly** -/// `H(z) = (b0 + b1*z⁻¹ + b2*z⁻²) / (a0 + a1*z⁻¹ + a2*z⁻²)`, while keeping -/// the superior pole resolution of the normal form. (This is more general than -/// the `idsp` `Normal` form, whose numerator is forced to `p.im * z⁻¹ * B(z)`.) -/// -/// # Example: quadrature NCO -/// -/// ```rust -/// # use embedded_dsp::filtering::{NormalForm, NormalFormState}; -/// // 1 kHz oscillator at 48 kHz sample rate -/// let f = 1000.0_f32 / 48000.0; -/// let nco = NormalForm::oscillator(f); -/// let mut state = NormalFormState::default(); -/// // Kick the oscillator with a unit impulse -/// let (i_out, q_out) = nco.process_quadrature(&mut state, 1.0); -/// assert!(i_out.abs() > 0.0); -/// ``` -#[derive(Clone, Debug, Default)] -pub struct NormalForm { - /// Output combination coefficients `[c0, c1, c2]`: - /// `y = c0 * u + c1 * v + c2 * x`. - pub c: [f32; 3], - /// Conjugate pole pair: `p.re ± j·p.im`. - pub p: Complex, -} - -/// State for [`NormalForm`]: the two rotating state variables. -#[derive(Clone, Debug, Default)] -pub struct NormalFormState { - /// Real (in-phase) state variable `u`. - pub y_re: f32, - /// Imaginary (quadrature) state variable `v`. - pub y_im: f32, -} - -impl NormalForm { - /// Construct from raw output-combination coefficients `c` and pole `p`. - pub fn new(c: [f32; 3], p: Complex) -> Self { - Self { c, p } - } - - /// Construct from a standard `[b; a]` biquad coefficient matrix, exactly - /// realizing `H(z) = (b0 + b1*z⁻¹ + b2*z⁻²) / (a0 + a1*z⁻¹ + a2*z⁻²)`. - /// - /// `ba[0]` = `[b0, b1, b2]` numerator coefficients. - /// `ba[1]` = `[a0, a1, a2]` denominator coefficients (a0 usually 1.0). - /// - /// # Panics - /// - /// Panics if the poles are not a complex-conjugate pair (i.e. the - /// discriminant `a1² - 4*a0*a2` must be negative). - pub fn from_ba(ba: &[[f32; 3]; 2]) -> Self { - let a0_inv = ba[1][0].recip(); - let b = [ba[0][0] * a0_inv, ba[0][1] * a0_inv, ba[0][2] * a0_inv]; - // Roots of a0*z² + a1*z + a2: p = -a1/(2a0) ± sqrt((a1/(2a0))² - a2/a0) - let p_re = -0.5 * ba[1][1] * a0_inv; - let disc = p_re * p_re - ba[1][2] * a0_inv; - assert!( - disc < 0.0, - "NormalForm::from_ba: poles must be a complex-conjugate pair (use a direct-form biquad for real poles)" - ); - let p_im = (-disc).sqrt(); - let r2 = p_re * p_re + p_im * p_im; - - // Solve for the output combination that realizes B(z)/A(z). - // u = x*(1 - p_re*z⁻¹)/D, v = x*p_im*z⁻¹/D, D = 1 - 2p_re*z⁻¹ + r2*z⁻². - // y = c0*u + c1*v + c2*x has numerator - // c0 + c2 + (c1*p_im - c0*p_re - 2*c2*p_re)*z⁻¹ + c2*r2*z⁻². - let c2 = b[2] / r2; - let c0 = b[0] - c2; - let c1 = (b[1] + p_re * b[0] + p_re * c2) / p_im; - Self { - c: [c0, c1, c2], - p: Complex::new(p_re, p_im), - } - } - - /// Construct a pure quadrature sinusoidal oscillator at normalised - /// frequency `f` (0 < f < 0.5, where 0.5 is Nyquist). - /// - /// [`NormalForm::process`] returns the in-phase component (`cos`). - /// [`NormalForm::process_quadrature`] returns both in-phase and 90°-shifted - /// components. A unit impulse starts the oscillation. - /// - /// # Example - /// ```rust - /// # use embedded_dsp::filtering::{NormalForm, NormalFormState}; - /// let nco = NormalForm::oscillator(0.1); // 10% of sample rate - /// let mut s = NormalFormState::default(); - /// let _ = nco.process_quadrature(&mut s, 1.0); // impulse start - /// ``` - pub fn oscillator(f: f32) -> Self { - let theta = 2.0 * core::f32::consts::PI * f; - Self { - c: [1.0, 0.0, 0.0], - p: Complex::new(theta.cos(), theta.sin()), - } - } - - /// Construct a narrow-band bandpass filter centred at normalised frequency - /// `f` with quality factor `q`. - /// - /// Realizes `H(z) = g*(1 - z⁻²) / (1 - 2*r*cos(θ)*z⁻¹ + r²*z⁻²)` with - /// `θ = 2πf`, `r = 1 - πf/q`, and `g = 1 - r` (unity passband gain). - pub fn bandpass(f: f32, q: f32) -> Self { - let theta = 2.0 * core::f32::consts::PI * f; - let r = 1.0 - core::f32::consts::PI * f / q; // pole radius ≈ 1 - π·bw/fs - let g = 1.0 - r; // unity passband normalisation - let p_re = r * theta.cos(); - let p_im = r * theta.sin(); - let r2 = r * r; - // from_ba() solution with b = [g, 0, -g]: - let c2 = -g / r2; - let c0 = g - c2; - let c1 = p_re * g * (1.0 - 1.0 / r2) / p_im; - Self { - c: [c0, c1, c2], - p: Complex::new(p_re, p_im), - } - } - - /// Advance the internal state by one sample and return the two rotating - /// state variables `(u, v)`: the in-phase and quadrature components. - #[inline] - pub fn process_quadrature(&self, state: &mut NormalFormState, x0: f32) -> (f32, f32) { - // Normal-form feedback (conjugate pole pair rotation) - let u = self.p.re() * state.y_re - self.p.im() * state.y_im + x0; - let v = self.p.im() * state.y_re + self.p.re() * state.y_im; - state.y_re = u; - state.y_im = v; - (u, v) - } - - /// Process a single sample and return the filtered output `y`. - #[inline] - pub fn process(&self, state: &mut NormalFormState, x0: f32) -> f32 { - let (u, v) = self.process_quadrature(state, x0); - self.c[0] * u + self.c[1] * v + self.c[2] * x0 - } - - /// Reset state to zero. - pub fn reset(state: &mut NormalFormState) { - *state = NormalFormState::default(); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Wave Digital Filters (allpass chain) -// Ported from the `idsp` crate by the Sinara/ARTIQ project, with a corrected -// per-stage state update. -// ───────────────────────────────────────────────────────────────────────────── - -/// Two-port adapter architecture selector. -/// -/// Each architecture is a nibble in the const generic of [`Wdf`] and encodes -/// the optimal scaled form for a given allpass coefficient range. -#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -#[repr(u8)] -pub enum Tpa { - /// Terminate (coefficient 0). - Z = 0x0, - /// `1 > g > 1/2`: `a = g - 1`. - A = 0xA, - /// `1/2 >= g > 0`: `a = -g`. - B = 0xB, - /// Alternative to `B`. - B1 = 0xE, - /// `g = 0`. - X = 0x1, - /// `-1/2 <= g < 0`: `a = g`. - C = 0xC, - /// Alternative to `C`. - C1 = 0xF, - /// `-1 < g < -1/2`: `a = -(1 + g)`. - D = 0xD, -} - -impl From for Tpa { - #[inline] - fn from(value: u8) -> Self { - match value { - 0xa => Tpa::A, - 0xb => Tpa::B, - 0xe => Tpa::B1, - 0x1 => Tpa::X, - 0xc => Tpa::C, - 0xf => Tpa::C1, - 0xd => Tpa::D, - _ => Tpa::Z, - } - } -} - -impl Tpa { - /// Quantize the allpass coefficient `g` for this architecture. - /// - /// Returns the Q32.32 fixed-point adapter coefficient, or `None` if `g` - /// does not fit the architecture's scaled range. - fn quantize(self, g: f64) -> Option { - // Use -0.5 <= a <= 0 instead of the usual positive range so that -0.5 - // exactly fits the Q32.32 fixed-point range. - let a = match self { - Self::Z => 0.0, - Self::A => g - 1.0, - Self::B | Self::B1 => -g, - Self::X => 0.0, - Self::C | Self::C1 => g, - Self::D => -1.0 - g, - }; - (-0.5..=0.0).contains(&a).then_some((a * 4294967296.0) as i32) - } - - /// Fixed-point multiply: `(c * a) >> 32` with wrapping (Q32.32 coefficient). - #[cfg(feature = "pipeline")] - #[inline] - fn mul(self, c: i32, a: i32) -> i32 { - ((c as i64).wrapping_mul(a as i64) >> 32) as i32 - } - - /// Two-port adapter wave computation. - /// - /// Takes `[a1, a2]` (incident wave from the previous stage and the delay - /// state) and returns `[b1, b2]`: the output wave to the next stage and - /// the new delay state. - #[cfg(feature = "pipeline")] - #[inline] - fn adapt(&self, x: [i32; 2], a: i32) -> [i32; 2] { - match self { - Tpa::A => { - let c = x[1] - x[0]; - let y = self.mul(c, a).wrapping_add(x[1]); - [y.wrapping_add(c), y] - } - Tpa::B => { - let c = x[0] - x[1]; - let y = self.mul(c, a).wrapping_add(x[1]); - [y, y.wrapping_add(c)] - } - Tpa::B1 => { - let c = x[0] - x[1]; - let y = self.mul(c, a); - [y.wrapping_add(x[1]), y.wrapping_add(x[0])] - } - Tpa::X => [x[1], x[0]], - Tpa::C => { - let c = x[1] - x[0]; - let y = self.mul(c, a).wrapping_sub(x[1]); - [y, y.wrapping_add(c)] - } - Tpa::C1 => { - let c = x[1] - x[0]; - let y = self.mul(c, a); - [y.wrapping_sub(x[1]), y.wrapping_sub(x[0])] - } - Tpa::D => { - let c = x[0] - x[1]; - let y = self.mul(c, a).wrapping_sub(x[1]); - [y.wrapping_add(c), y] - } - Tpa::Z => x, - } - } -} - -/// Wave digital filter: a cascade of `N` first-order allpass sections. -/// -/// The `M` const generic encodes the two-port adapter architecture, one nibble -/// per stage (least significant nibble = first stage). All arithmetic is -/// wrapping 32-bit integer with Q32.32 coefficients — no floating point. -/// -/// # Ported from -/// The `idsp` crate by the Sinara/ARTIQ project. -#[derive(Debug, Clone)] -pub struct Wdf { - /// Q32.32 adapter coefficients, one per allpass section. - pub a: [i32; N], -} - -impl Default for Wdf { - fn default() -> Self { - Self { a: [0; N] } - } -} - -impl Wdf { - /// Quantize allpass pole coefficients `g` (one per section, `|g| < 1`) - /// using the architecture encoded in `M`. - pub fn quantize(g: &[f64; N]) -> Option { - let mut a = [0i32; N]; - let mut m = M; - for (a, g) in a.iter_mut().zip(g) { - *a = Tpa::from((m & 0xf) as u8).quantize(*g)?; - m >>= 4; - } - debug_assert_eq!(m, 0); - Some(Self { a }) - } -} - -/// State for [`Wdf`]: one delay element per allpass section. -#[derive(Clone, Debug)] -pub struct WdfState { - /// Section delay states. - pub z: [i32; N], -} - -impl Default for WdfState { - fn default() -> Self { - Self { z: [0; N] } - } -} - -#[cfg(feature = "pipeline")] -impl crate::pipeline::SplitProcess> - for Wdf -{ - #[inline] - fn process_with_state(&mut self, state: &mut WdfState, x: i32) -> i32 { - let mut x = x; - let mut m = M; - for (a, z) in self.a.iter().zip(state.z.iter_mut()) { - let [y, next] = Tpa::from((m & 0xf) as u8).adapt([x, *z], *a); - *z = next; // update this section's delay state - x = y; // output wave feeds the next section - m >>= 4; - } - debug_assert_eq!(m, 0); - x - } -} diff --git a/crates/embedded-dsp/src/filtering/adaptive.rs b/crates/embedded-dsp/src/filtering/adaptive.rs new file mode 100644 index 0000000..fa0586f --- /dev/null +++ b/crates/embedded-dsp/src/filtering/adaptive.rs @@ -0,0 +1,237 @@ +//! LMS / NLMS adaptive filters. + +use crate::types::*; + +// --- LMS Adaptive Filter --- + +/// Scalar algebra for the adaptive filters, whose float and fixed-point coefficient updates are +/// genuinely different operations. Only the widths that ship an adaptive filter implement it; the +/// [`LmsInstance`]/[`NlmsInstance`] stages themselves are generic over it. +pub trait AdaptiveSample: DspSample { + /// Leaky-LMS retention factor `keep` for `w ← keep·w + alpha·x`. + fn lms_keep(leak: Self::Coeff) -> Self::Accum; + /// LMS step `2·μ·e`. + fn lms_alpha(mu: Self::Coeff, e: Self) -> Self::Accum; + /// One LMS coefficient update: `w ← keep·w + alpha·x`. + fn lms_apply(w: Self::Coeff, x: Self, alpha: Self::Accum, keep: Self::Accum) -> Self::Coeff; + /// NLMS power denominator seed from `eps` (fixed-point floors at one LSB). + fn nlms_power_seed(eps: Self::Coeff) -> Self::Accum; + /// NLMS step `μ·e / power`. + fn nlms_alpha(mu: Self::Coeff, e: Self, power: Self::Accum) -> Self::Accum; + /// One NLMS coefficient update: `w ← w + alpha·x`. + fn nlms_apply(w: Self::Coeff, x: Self, alpha: Self::Accum) -> Self::Coeff; +} + +impl AdaptiveSample for f32 { + #[inline(always)] + fn lms_keep(leak: f32) -> f32 { + 1.0 - leak + } + #[inline(always)] + fn lms_alpha(mu: f32, e: f32) -> f32 { + 2.0 * mu * e + } + #[inline(always)] + fn lms_apply(w: f32, x: f32, alpha: f32, keep: f32) -> f32 { + keep * w + alpha * x + } + #[inline(always)] + fn nlms_power_seed(eps: f32) -> f32 { + eps + } + #[inline(always)] + fn nlms_alpha(mu: f32, e: f32, power: f32) -> f32 { + mu * e / power + } + #[inline(always)] + fn nlms_apply(w: f32, x: f32, alpha: f32) -> f32 { + w + alpha * x + } +} + +impl AdaptiveSample for q15 { + #[inline(always)] + fn lms_keep(leak: q15) -> i64 { + 32_767i64 - leak.to_bits().max(0) as i64 + } + #[inline(always)] + fn lms_alpha(mu: q15, e: q15) -> i64 { + (2 * mu.to_bits() as i64 * e.to_bits() as i64) >> 15 + } + #[inline(always)] + fn lms_apply(w: q15, x: q15, alpha: i64, keep: i64) -> q15 { + let leaked = (keep * w.to_bits() as i64) >> 15; + let upd = leaked + ((alpha * x.to_bits() as i64) >> 15); + q15::from_bits(upd.clamp(i16::MIN as i64, i16::MAX as i64) as i16) + } + #[inline(always)] + fn nlms_power_seed(eps: q15) -> i64 { + eps.to_bits().max(1) as i64 + } + #[inline(always)] + fn nlms_alpha(mu: q15, e: q15, power: i64) -> i64 { + (mu.to_bits() as i64 * e.to_bits() as i64) / power + } + #[inline(always)] + fn nlms_apply(w: q15, x: q15, alpha: i64) -> q15 { + let upd = w.to_bits() as i64 + ((alpha * x.to_bits() as i64) >> 15); + q15::from_bits(upd.clamp(i16::MIN as i64, i16::MAX as i64) as i16) + } +} + +/// Instance structure for the LMS adaptive filter, generic over the sample width. +pub struct LmsInstance<'a, T: AdaptiveSample> { + /// Number of filter taps. + pub num_taps: u16, + /// Filter coefficients. + pub coeffs: &'a mut [T::Coeff], + /// Filter state buffer. + pub state: &'a mut [T], + /// Adaptation step size. + pub mu: T::Coeff, +} + +impl<'a, T: AdaptiveSample> LmsInstance<'a, T> { + /// Initializes the instance. + pub fn init( + num_taps: u16, + coeffs: &'a mut [T::Coeff], + state: &'a mut [T], + mu: T::Coeff, + ) -> Self { + state.fill(T::ZERO); + coeffs.fill(T::coeff_from_f32(0.0)); + Self { + num_taps, + coeffs, + state, + mu, + } + } +} + +/// LMS adaptive filtering, generic over the sample width. +pub fn lms( + instance: &mut LmsInstance<'_, T>, + src: &[T], + ref_signal: &[T], + out: &mut [T], + err: &mut [T], +) { + lms_leaky(instance, src, ref_signal, out, err, T::coeff_from_f32(0.0)); +} + +/// Leaky LMS: `w ← keep·w + 2 μ e x`. `leak = 0` matches [`lms`]. +pub fn lms_leaky( + instance: &mut LmsInstance<'_, T>, + src: &[T], + ref_signal: &[T], + out: &mut [T], + err: &mut [T], + leak: T::Coeff, +) { + let num_taps = instance.num_taps as usize; + let block_size = src + .len() + .min(ref_signal.len()) + .min(out.len()) + .min(err.len()); + let keep = T::lms_keep(leak); + + for i in 0..block_size { + for k in (1..num_taps).rev() { + instance.state[k] = instance.state[k - 1]; + } + instance.state[0] = src[i]; + + let mut acc = T::Accum::default(); + for k in 0..num_taps { + acc = T::madd(acc, instance.state[k], instance.coeffs[k]); + } + let y = T::from_accum(acc); + out[i] = y; + let e = T::sat_sub(ref_signal[i], y); + err[i] = e; + + let alpha = T::lms_alpha(instance.mu, e); + for k in 0..num_taps { + instance.coeffs[k] = T::lms_apply(instance.coeffs[k], instance.state[k], alpha, keep); + } + } +} + +/// Instance structure for the normalized LMS adaptive filter, generic over the sample width. +pub struct NlmsInstance<'a, T: AdaptiveSample> { + /// Number of filter taps. + pub num_taps: u16, + /// Filter coefficients. + pub coeffs: &'a mut [T::Coeff], + /// Filter state buffer. + pub state: &'a mut [T], + /// Adaptation step size. + pub mu: T::Coeff, + /// Regularization epsilon. + pub eps: T::Coeff, +} + +impl<'a, T: AdaptiveSample> NlmsInstance<'a, T> { + /// Initializes the instance. + pub fn init( + num_taps: u16, + coeffs: &'a mut [T::Coeff], + state: &'a mut [T], + mu: T::Coeff, + eps: T::Coeff, + ) -> Self { + state.fill(T::ZERO); + coeffs.fill(T::coeff_from_f32(0.0)); + Self { + num_taps, + coeffs, + state, + mu, + eps, + } + } +} + +/// Normalized LMS: `w ← w + μ e x / (eps + ‖x‖²)`, generic over the sample width. +pub fn nlms( + instance: &mut NlmsInstance<'_, T>, + src: &[T], + ref_signal: &[T], + out: &mut [T], + err: &mut [T], +) { + let num_taps = instance.num_taps as usize; + let block_size = src + .len() + .min(ref_signal.len()) + .min(out.len()) + .min(err.len()); + + for i in 0..block_size { + for k in (1..num_taps).rev() { + instance.state[k] = instance.state[k - 1]; + } + instance.state[0] = src[i]; + + let mut acc = T::Accum::default(); + let mut power = T::nlms_power_seed(instance.eps); + for k in 0..num_taps { + acc = T::madd(acc, instance.state[k], instance.coeffs[k]); + power = power + T::mul_high(instance.state[k], instance.state[k]); + } + let y = T::from_accum(acc); + out[i] = y; + let e = T::sat_sub(ref_signal[i], y); + err[i] = e; + + let alpha = T::nlms_alpha(instance.mu, e, power); + for k in 0..num_taps { + instance.coeffs[k] = T::nlms_apply(instance.coeffs[k], instance.state[k], alpha); + } + } +} + + diff --git a/crates/embedded-dsp/src/filtering/biquad.rs b/crates/embedded-dsp/src/filtering/biquad.rs new file mode 100644 index 0000000..33d3ba6 --- /dev/null +++ b/crates/embedded-dsp/src/filtering/biquad.rs @@ -0,0 +1,782 @@ +//! Biquad and direct-form sections: cascades, clamping, and fixed/integer forms. + +use crate::types::*; + +// --- Biquad Cascade Direct Form I Filter --- + +/// Instance structure for the biquad cascade, Direct Form I, generic over the sample width. +/// +/// Coefficients are `5 * num_stages` values `[b0, b1, b2, a1, a2]` per stage; state is +/// `4 * num_stages` values `[x[n-1], x[n-2], y[n-1], y[n-2]]` per stage. `post_shift` gives extra +/// coefficient headroom (CMSIS-style) and the MAC is narrowed by `FRAC - post_shift`; floats leave +/// it at `0` and take the plain per-stage sum. +pub struct BiquadCascadeInstance<'a, T: DspSample> { + /// Number of biquad stages. + pub num_stages: u8, + /// Output right-shift. + pub post_shift: u8, + /// Filter coefficients. + pub coeffs: &'a [T::Coeff], + /// Filter state buffer. + pub state: &'a mut [T], +} + +impl<'a, T: DspSample> BiquadCascadeInstance<'a, T> { + /// Initializes the instance with no coefficient headroom (`post_shift = 0`). + pub fn init(num_stages: u8, coeffs: &'a [T::Coeff], state: &'a mut [T]) -> Self { + state.fill(T::ZERO); + Self { + num_stages, + post_shift: 0, + coeffs, + state, + } + } + + /// Initializes the instance with a coefficient `post_shift` (fixed-point headroom). + pub fn with_post_shift( + num_stages: u8, + coeffs: &'a [T::Coeff], + state: &'a mut [T], + post_shift: u8, + ) -> Self { + state.fill(T::ZERO); + Self { + num_stages, + post_shift, + coeffs, + state, + } + } +} + +/// Biquad cascade, Direct Form I, generic over the sample width. +pub fn biquad_cascade_df1( + instance: &mut BiquadCascadeInstance<'_, T>, + src: &[T], + dst: &mut [T], +) { + let num_stages = instance.num_stages as usize; + let block_size = src.len().min(dst.len()); + let shift = T::FRAC.saturating_sub(instance.post_shift as u32).min(63); + + for i in 0..block_size { + let mut in_val = src[i]; + for stage in 0..num_stages { + let b0 = instance.coeffs[stage * 5]; + let b1 = instance.coeffs[stage * 5 + 1]; + let b2 = instance.coeffs[stage * 5 + 2]; + let a1 = instance.coeffs[stage * 5 + 3]; + let a2 = instance.coeffs[stage * 5 + 4]; + + let x1 = instance.state[stage * 4]; + let x2 = instance.state[stage * 4 + 1]; + let y1 = instance.state[stage * 4 + 2]; + let y2 = instance.state[stage * 4 + 3]; + + let acc = T::madd( + T::madd( + T::madd(T::madd(T::madd(T::Accum::default(), in_val, b0), x1, b1), x2, b2), + y1, + a1, + ), + y2, + a2, + ); + let out_val = T::from_accum_shifted(acc, shift); + + instance.state[stage * 4 + 1] = x1; + instance.state[stage * 4] = in_val; + instance.state[stage * 4 + 3] = y1; + instance.state[stage * 4 + 2] = out_val; + + in_val = out_val; + } + dst[i] = in_val; + } +} + + + +/// Instance structure for the biquad cascade, transposed Direct Form II, generic over the sample +/// width. +/// +/// Same `[b0, b1, b2, a1, a2]` and `post_shift` conventions as [`BiquadCascadeInstance`]; state is +/// two delays per stage (`[s1, s2, ...]`), which is better-conditioned for high-Q poles. +pub struct BiquadCascadeDf2tInstance<'a, T: DspSample> { + /// Number of biquad stages. + pub num_stages: u8, + /// Output right-shift. + pub post_shift: u8, + /// Filter coefficients. + pub coeffs: &'a [T::Coeff], + /// Filter state buffer. + pub state: &'a mut [T], +} + +impl<'a, T: DspSample> BiquadCascadeDf2tInstance<'a, T> { + /// Initializes the instance with no coefficient headroom (`post_shift = 0`). + pub fn init(num_stages: u8, coeffs: &'a [T::Coeff], state: &'a mut [T]) -> Self { + state.fill(T::ZERO); + Self { + num_stages, + post_shift: 0, + coeffs, + state, + } + } + + /// Initializes the instance with a coefficient `post_shift` (fixed-point headroom). + pub fn with_post_shift( + num_stages: u8, + coeffs: &'a [T::Coeff], + state: &'a mut [T], + post_shift: u8, + ) -> Self { + state.fill(T::ZERO); + Self { + num_stages, + post_shift, + coeffs, + state, + } + } +} + +/// Biquad cascade, transposed Direct Form II, generic over the sample width. +pub fn biquad_cascade_df2t( + instance: &mut BiquadCascadeDf2tInstance<'_, T>, + src: &[T], + dst: &mut [T], +) { + let num_stages = instance.num_stages as usize; + let block_size = src.len().min(dst.len()); + let shift = T::FRAC.saturating_sub(instance.post_shift as u32).min(63); + + for i in 0..block_size { + let mut in_val = src[i]; + for stage in 0..num_stages { + let b0 = instance.coeffs[stage * 5]; + let b1 = instance.coeffs[stage * 5 + 1]; + let b2 = instance.coeffs[stage * 5 + 2]; + let a1 = instance.coeffs[stage * 5 + 3]; + let a2 = instance.coeffs[stage * 5 + 4]; + + let s1 = instance.state[stage * 2]; + let s2 = instance.state[stage * 2 + 1]; + + // The accumulator is ordered to match the original float association exactly: + // `y = b0*in + s1`, `s1' = (b1*in + a1*y) + s2`, `s2' = b2*in + a2*y`. + let y_acc = + T::madd(T::Accum::default(), in_val, b0) + T::accum_from_shifted(s1, shift); + let y = T::from_accum_shifted(y_acc, shift); + let s1_acc = T::madd(T::madd(T::Accum::default(), in_val, b1), y, a1) + + T::accum_from_shifted(s2, shift); + let s1_new = T::from_accum_shifted(s1_acc, shift); + let s2_acc = T::madd(T::madd(T::Accum::default(), in_val, b2), y, a2); + let s2_new = T::from_accum_shifted(s2_acc, shift); + + instance.state[stage * 2] = s1_new; + instance.state[stage * 2 + 1] = s2_new; + in_val = y; + } + dst[i] = in_val; + } +} + + + +// ───────────────────────────────────────────────────────────────────────────── +// Robust Second-Order Sections (Biquads) with Anti-Windup & Clamping +// ───────────────────────────────────────────────────────────────────────────── + +/// Direct Form 1 filter history state holding delayed inputs and outputs `[x1, x2, y1, y2]`. +#[derive(Clone, Copy, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] +#[cfg_attr(feature = "bytemuck", derive(bytemuck::Zeroable))] +pub struct DirectForm1 { + /// Input/output history. + pub xy: [T; 4], +} + +impl Default for DirectForm1 { + fn default() -> Self { + Self { + xy: [T::default(); 4], + } + } +} + +impl DirectForm1 { + /// Create a new zero-initialized Direct Form 1 state. + pub fn new() -> Self { + Self { + xy: [T::default(); 4], + } + } + + /// Reset internal state buffer. + pub fn reset(&mut self) { + self.xy = [T::default(); 4]; + } +} + +/// Direct Form 2 Transposed filter state holding accumulator registers `[s0, s1]`. +#[derive(Clone, Copy, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] +#[cfg_attr(feature = "bytemuck", derive(bytemuck::Zeroable))] +pub struct DirectForm2Transposed { + /// S. + pub s: [T; 2], +} + +impl Default for DirectForm2Transposed { + fn default() -> Self { + Self { + s: [T::default(); 2], + } + } +} + +impl DirectForm2Transposed { + /// Create a new zero-initialized Direct Form 2 Transposed state. + pub fn new() -> Self { + Self { + s: [T::default(); 2], + } + } + + /// Reset internal state buffer. + pub fn reset(&mut self) { + self.s = [T::default(); 2]; + } +} + +/// Second-order section (SOS) biquadratic filter configuration. +/// +/// Contains coefficients `ba: [b0, b1, b2, a1, a2]` normalized such that `a0 = 1`. +/// Recurrence relation: +/// `y0 = b0*x0 + b1*x1 + b2*x2 + a1*y1 + a2*y2` +#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] +pub struct Biquad { + /// Second-order-section coefficients `[b0, b1, b2, a1, a2]`. + pub ba: [T; 5], +} + +impl Biquad { + /// Create a new Biquad configuration from coefficients `[b0, b1, b2, a1, a2]`. + pub const fn new(b0: T, b1: T, b2: T, a1: T, a2: T) -> Self { + Self { + ba: [b0, b1, b2, a1, a2], + } + } +} + +impl Biquad { + /// Process a single input sample through Direct Form 1 state. + #[inline(always)] + pub fn process_df1(&self, state: &mut DirectForm1, x0: f32) -> f32 { + let [b0, b1, b2, a1, a2] = self.ba; + let [x1, x2, y1, y2] = state.xy; + let y0 = b0 * x0 + b1 * x1 + b2 * x2 + a1 * y1 + a2 * y2; + state.xy = [x0, x1, y0, y1]; + y0 + } + + /// Process a single input sample through Direct Form 2 Transposed state. + #[inline(always)] + pub fn process_df2t(&self, state: &mut DirectForm2Transposed, x0: f32) -> f32 { + let [b0, b1, b2, a1, a2] = self.ba; + let y0 = b0 * x0 + state.s[0]; + state.s[0] = b1 * x0 + a1 * y0 + state.s[1]; + state.s[1] = b2 * x0 + a2 * y0; + y0 + } +} + +impl Biquad { + /// Process a single input sample through Direct Form 1 state. + #[inline(always)] + pub fn process_df1(&self, state: &mut DirectForm1, x0: f64) -> f64 { + let [b0, b1, b2, a1, a2] = self.ba; + let [x1, x2, y1, y2] = state.xy; + let y0 = b0 * x0 + b1 * x1 + b2 * x2 + a1 * y1 + a2 * y2; + state.xy = [x0, x1, y0, y1]; + y0 + } + + /// Process a single input sample through Direct Form 2 Transposed state. + #[inline(always)] + pub fn process_df2t(&self, state: &mut DirectForm2Transposed, x0: f64) -> f64 { + let [b0, b1, b2, a1, a2] = self.ba; + let y0 = b0 * x0 + state.s[0]; + state.s[0] = b1 * x0 + a1 * y0 + state.s[1]; + state.s[1] = b2 * x0 + a2 * y0; + y0 + } +} + +/// Biquadratic filter configuration with summing junction offset and anti-windup output clamping. +/// +/// Clamps output between `[min, max]` at the summing junction before storing into feedback state, +/// preventing integrator windup and derivative kick when used in feedback control or PID applications. +#[derive(Clone, Copy, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] +pub struct BiquadClamp { + /// Coeff. + pub coeff: Biquad, + /// Summing junction offset (setpoint) + pub u: T, + /// Minimum saturation clamp + pub min: T, + /// Maximum saturation clamp + pub max: T, +} + +impl BiquadClamp { + /// Create a new clamped Biquad with coefficients, offset, and clamp bounds. + pub const fn new(coeff: Biquad, min: T, max: T, u: T) -> Self { + Self { coeff, u, min, max } + } +} + +impl BiquadClamp { + /// Process a sample using Direct Form 1 with anti-windup clamping. + #[inline(always)] + pub fn process_df1(&self, state: &mut DirectForm1, x0: f32) -> f32 { + let [b0, b1, b2, a1, a2] = self.coeff.ba; + let [x1, x2, y1, y2] = state.xy; + let unclamped = b0 * x0 + b1 * x1 + b2 * x2 + a1 * y1 + a2 * y2 + self.u; + let y0 = unclamped.clamp(self.min, self.max); + state.xy = [x0, x1, y0, y1]; + y0 + } + + /// Process a sample using Direct Form 2 Transposed with anti-windup clamping. + #[inline(always)] + pub fn process_df2t(&self, state: &mut DirectForm2Transposed, x0: f32) -> f32 { + let [b0, b1, b2, a1, a2] = self.coeff.ba; + let y0 = (b0 * x0 + state.s[0] + self.u).clamp(self.min, self.max); + state.s[0] = b1 * x0 + a1 * y0 + state.s[1]; + state.s[1] = b2 * x0 + a2 * y0; + y0 + } +} + +impl BiquadClamp { + /// Process a sample using Direct Form 1 with anti-windup clamping. + #[inline(always)] + pub fn process_df1(&self, state: &mut DirectForm1, x0: f64) -> f64 { + let [b0, b1, b2, a1, a2] = self.coeff.ba; + let [x1, x2, y1, y2] = state.xy; + let unclamped = b0 * x0 + b1 * x1 + b2 * x2 + a1 * y1 + a2 * y2 + self.u; + let y0 = unclamped.clamp(self.min, self.max); + state.xy = [x0, x1, y0, y1]; + y0 + } + + /// Process a sample using Direct Form 2 Transposed with anti-windup clamping. + #[inline(always)] + pub fn process_df2t(&self, state: &mut DirectForm2Transposed, x0: f64) -> f64 { + let [b0, b1, b2, a1, a2] = self.coeff.ba; + let y0 = (b0 * x0 + state.s[0] + self.u).clamp(self.min, self.max); + state.s[0] = b1 * x0 + a1 * y0 + state.s[1]; + state.s[1] = b2 * x0 + a2 * y0; + y0 + } +} + +#[cfg(feature = "pipeline")] +impl crate::pipeline::SplitProcess> for Biquad { + #[inline(always)] + fn process_with_state(&mut self, state: &mut DirectForm1, x: f32) -> f32 { + self.process_df1(state, x) + } +} + +#[cfg(feature = "pipeline")] +impl crate::pipeline::SplitProcess> for Biquad { + #[inline(always)] + fn process_with_state(&mut self, state: &mut DirectForm2Transposed, x: f32) -> f32 { + self.process_df2t(state, x) + } +} + +#[cfg(feature = "pipeline")] +impl crate::pipeline::SplitProcess> for BiquadClamp { + #[inline(always)] + fn process_with_state(&mut self, state: &mut DirectForm1, x: f32) -> f32 { + self.process_df1(state, x) + } +} + +#[cfg(feature = "pipeline")] +impl crate::pipeline::SplitProcess> for BiquadClamp { + #[inline(always)] + fn process_with_state(&mut self, state: &mut DirectForm2Transposed, x: f32) -> f32 { + self.process_df2t(state, x) + } +} + +#[cfg(feature = "pipeline")] +impl crate::pipeline::SplitProcess> for Biquad { + #[inline(always)] + fn process_with_state(&mut self, state: &mut DirectForm1, x: f64) -> f64 { + self.process_df1(state, x) + } +} + +#[cfg(feature = "pipeline")] +impl crate::pipeline::SplitProcess> for Biquad { + #[inline(always)] + fn process_with_state(&mut self, state: &mut DirectForm2Transposed, x: f64) -> f64 { + self.process_df2t(state, x) + } +} + +#[cfg(feature = "pipeline")] +impl crate::pipeline::SplitProcess> for BiquadClamp { + #[inline(always)] + fn process_with_state(&mut self, state: &mut DirectForm1, x: f64) -> f64 { + self.process_df1(state, x) + } +} + +#[cfg(feature = "pipeline")] +impl crate::pipeline::SplitProcess> for BiquadClamp { + #[inline(always)] + fn process_with_state(&mut self, state: &mut DirectForm2Transposed, x: f64) -> f64 { + self.process_df2t(state, x) + } +} + +/// Direct Form 1 state with quantization error feedback for noise shaping. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] +#[cfg_attr(feature = "bytemuck", derive(bytemuck::Zeroable))] +pub struct DirectForm1NoiseShaped { + /// Input/output history. + pub xy: [i32; 4], + /// Quantization error feedback accumulator. + pub err: i32, +} + +impl DirectForm1NoiseShaped { + /// Create a new zero-initialized state with zero error feedback. + pub const fn new() -> Self { + Self { + xy: [0; 4], + err: 0, + } + } + + /// Reset internal state and error accumulator. + pub fn reset(&mut self) { + self.xy = [0; 4]; + self.err = 0; + } +} + +/// Fixed-point 32-bit Biquad with parameterized fractional scaling and anti-windup clamping. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] +pub struct BiquadFixed { + /// Fixed-point coefficients `[b0, b1, b2, a1, a2]`. + pub ba: [i32; 5], + /// Summing junction offset + pub u: i32, + /// Minimum saturation clamp + pub min: i32, + /// Maximum saturation clamp + pub max: i32, +} + +impl BiquadFixed { + /// Create a new fixed-point biquad configuration. + pub const fn new(ba: [i32; 5], min: i32, max: i32, u: i32) -> Self { + Self { ba, min, max, u } + } + + /// Process single sample with 1st-order noise shaping to eliminate limit cycles. + #[inline(always)] + pub fn process_noise_shaped(&self, state: &mut DirectForm1NoiseShaped, x0: i32) -> i32 { + let [b0, b1, b2, a1, a2] = self.ba; + let [x1, x2, y1, y2] = state.xy; + let acc = (b0 as i64 * x0 as i64) + + (b1 as i64 * x1 as i64) + + (b2 as i64 * x2 as i64) + + (a1 as i64 * y1 as i64) + + (a2 as i64 * y2 as i64) + + ((self.u as i64) << SHIFT) + - state.err as i64; // noise shaping feedback + let scaled = acc >> SHIFT; + let y0 = scaled.clamp(self.min as i64, self.max as i64) as i32; + state.err = (acc - ((y0 as i64) << SHIFT)) as i32; + state.xy = [x0, x1, y0, y1]; + y0 + } + + /// Process a single sample with a 64-bit (`Q32.32`) output accumulator. + /// + /// Compared with [`Self::process_noise_shaped`], the feedback path carries + /// 32 fractional bits instead of being rounded each sample, which removes + /// the need for dithering at the cost of a wider state. This is the + /// embedded-dsp equivalent of `idsp`'s `DirectForm1Wide` processing. + /// + /// # Panics + /// Fails to compile unless `1 <= SHIFT <= 32`. + #[inline(always)] + pub fn process_wide(&self, state: &mut DirectForm1Wide, x0: i32) -> i32 { + const { + assert!( + SHIFT >= 1 && SHIFT <= 32, + "BiquadFixed::process_wide requires 1 <= SHIFT <= 32" + ) + }; + let [b0, b1, b2, a1, a2] = self.ba; + let [x1, x2] = state.x; + let [y1, y2] = state.y; + + // Numerator: full-width products, no truncation. + let mut acc = (b0 as i64) + .wrapping_mul(x0 as i64) + .wrapping_add((b1 as i64).wrapping_mul(x1 as i64)) + .wrapping_add((b2 as i64).wrapping_mul(x2 as i64)); + + // Denominator: 32x32 split multiply of the wide states by the bits. + acc = acc.wrapping_add(((y1 as u32 as i64).wrapping_mul(a1 as i64)) >> 32); + acc = acc.wrapping_add(((y1 >> 32) as i32 as i64).wrapping_mul(a1 as i64)); + acc = acc.wrapping_add(((y2 as u32 as i64).wrapping_mul(a2 as i64)) >> 32); + acc = acc.wrapping_add(((y2 >> 32) as i32 as i64).wrapping_mul(a2 as i64)); + + // Promote from the `SHIFT`-bit coefficient scale to Q32.32. + acc <<= 32 - SHIFT; + + let y0 = ((acc >> 32) as i32 as i64) + .wrapping_add(self.u as i64) + .clamp(self.min as i64, self.max as i64) as i32; + + // Keep the fractional low word of the accumulator, overwrite the output word. + state.y = [((y0 as i64) << 32) | (acc as u32 as i64), y1]; + state.x = [x0, x1]; + y0 + } +} + +#[cfg(feature = "pipeline")] +impl crate::pipeline::SplitProcess + for BiquadFixed +{ + #[inline(always)] + fn process_with_state(&mut self, state: &mut DirectForm1NoiseShaped, x: i32) -> i32 { + self.process_noise_shaped(state, x) + } +} + +/// Direct Form 1 state with a 64-bit (`Q32.32`) output accumulator. +/// +/// This is the embedded-dsp equivalent of `idsp`'s `DirectForm1Wide`: the +/// recursion is carried at 32 fractional bits so coefficient rounding does not +/// accumulate inside the feedback path. Use it with +/// [`BiquadFixed::process_wide`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] +pub struct DirectForm1Wide { + /// Input history `[x1, x2]`. + pub x: [i32; 2], + /// Output accumulator history `[y1, y2]` in `Q32.32`. + pub y: [i64; 2], +} + +impl DirectForm1Wide { + /// Create a new zero-initialized wide state. + pub const fn new() -> Self { + Self { + x: [0; 2], + y: [0; 2], + } + } + + /// Reset the state and accumulator history. + pub fn reset(&mut self) { + self.x = [0; 2]; + self.y = [0; 2]; + } +} + +#[cfg(feature = "pipeline")] +impl crate::pipeline::SplitProcess + for BiquadFixed +{ + #[inline(always)] + fn process_with_state(&mut self, state: &mut DirectForm1Wide, x: i32) -> i32 { + self.process_wide(state, x) + } +} + +/// Integer sample type usable with [`BiquadInt`]. +/// +/// Implemented for `i8`, `i16`, `i32` and `i64`, each with a wider accumulator +/// (`i16`, `i32`, `i64` and `i128` respectively). This mirrors `idsp`'s generic +/// integer `Biquad` over the primitive integer widths. +pub trait BiquadIntSample: Copy + PartialOrd { + /// Wider accumulator type used for the recursion. + type Wide: Copy + PartialOrd; + /// Most negative value. + const MIN: Self; + /// Most positive value. + const MAX: Self; + + /// Widen to the accumulator type. + fn widen(self) -> Self::Wide; + /// Saturate an accumulator value back to the sample range. + fn narrow(w: Self::Wide) -> Self; + /// Accumulator zero. + fn wide_zero() -> Self::Wide; + /// Wrapping accumulator multiply. + fn wide_mul(a: Self::Wide, b: Self::Wide) -> Self::Wide; + /// Wrapping accumulator add. + fn wide_add(a: Self::Wide, b: Self::Wide) -> Self::Wide; + /// Arithmetic right shift of the accumulator. + fn wide_shr(a: Self::Wide, n: u32) -> Self::Wide; +} + +macro_rules! impl_biquad_int_sample { + ($sample:ty, $wide:ty) => { + impl BiquadIntSample for $sample { + type Wide = $wide; + const MIN: Self = <$sample>::MIN; + const MAX: Self = <$sample>::MAX; + + #[inline(always)] + fn widen(self) -> $wide { + self as $wide + } + + #[inline(always)] + fn narrow(w: $wide) -> Self { + w.clamp(<$sample>::MIN as $wide, <$sample>::MAX as $wide) as $sample + } + + #[inline(always)] + fn wide_zero() -> $wide { + 0 + } + + #[inline(always)] + fn wide_mul(a: $wide, b: $wide) -> $wide { + a.wrapping_mul(b) + } + + #[inline(always)] + fn wide_add(a: $wide, b: $wide) -> $wide { + a.wrapping_add(b) + } + + #[inline(always)] + fn wide_shr(a: $wide, n: u32) -> $wide { + a >> n + } + } + }; +} + +impl_biquad_int_sample!(i8, i16); +impl_biquad_int_sample!(i16, i32); +impl_biquad_int_sample!(i32, i64); +impl_biquad_int_sample!(i64, i128); + +/// Direct Form 1 state for [`BiquadInt`]: `[x1, x2, y1, y2]`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] +pub struct DirectForm1Int { + /// `[x1, x2, y1, y2]`. + pub xy: [T; 4], +} + +impl Default for DirectForm1Int { + fn default() -> Self { + Self { + xy: [T::default(); 4], + } + } +} + +impl DirectForm1Int { + /// Create a new zeroed state. + pub fn new() -> Self { + Self::default() + } + + /// Reset the state to zero. + pub fn reset(&mut self) { + self.xy = [T::default(); 4]; + } +} + +/// Fixed-point second-order section generic over the integer sample type. +/// +/// Coefficients `ba = [b0, b1, b2, a1, a2]` are scaled by `2^SHIFT`, the +/// recurrence runs in the wider [`BiquadIntSample::Wide`] accumulator, and the +/// output is saturated to `[min, max]`. This closes the `idsp` gap of a biquad +/// that works over `i8`/`i16`/`i32`/`i64` samples. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] +pub struct BiquadInt { + /// Fixed-point coefficients `[b0, b1, b2, a1, a2]`. + pub ba: [T; 5], + /// Summing-junction offset, in output units. + pub u: T, + /// Minimum saturation clamp. + pub min: T, + /// Maximum saturation clamp. + pub max: T, +} + +impl BiquadInt { + /// Create a new integer biquad configuration. + pub const fn new(ba: [T; 5], min: T, max: T, u: T) -> Self { + Self { ba, min, max, u } + } + + /// Process a single sample through Direct Form 1. + /// + /// # Panics + /// Fails to compile unless `SHIFT < 32`. + #[inline(always)] + pub fn process_df1(&self, state: &mut DirectForm1Int, x0: T) -> T { + const { assert!(SHIFT < 32, "BiquadInt requires SHIFT < 32") }; + let [b0, b1, b2, a1, a2] = self.ba; + let [x1, x2, y1, y2] = state.xy; + + let mut acc = T::wide_zero(); + for (c, s) in [(b0, x0), (b1, x1), (b2, x2), (a1, y1), (a2, y2)] { + acc = T::wide_add(acc, T::wide_mul(c.widen(), s.widen())); + } + + // Scale down, add the offset, then clamp to the configured output range. + let scaled = T::narrow(T::wide_shr(acc, SHIFT)); + let y_raw = T::narrow(T::wide_add(scaled.widen(), self.u.widen())); + let y0 = if y_raw < self.min { + self.min + } else if y_raw > self.max { + self.max + } else { + y_raw + }; + + state.xy = [x0, x1, y0, y1]; + y0 + } +} + +#[cfg(feature = "pipeline")] +impl + crate::pipeline::SplitProcess> for BiquadInt +{ + #[inline(always)] + fn process_with_state(&mut self, state: &mut DirectForm1Int, x: T) -> T { + self.process_df1(state, x) + } +} diff --git a/crates/embedded-dsp/src/filtering/convolution.rs b/crates/embedded-dsp/src/filtering/convolution.rs new file mode 100644 index 0000000..6588b9c --- /dev/null +++ b/crates/embedded-dsp/src/filtering/convolution.rs @@ -0,0 +1,338 @@ +//! Convolution, correlation, median, and FFT-based convolution. + +use crate::types::*; +// `fast_convolve_f32` is `transform`-gated, so is its import. +#[cfg(feature = "transform")] +use crate::transform::cfft_f32; + +// --- Convolution --- + +/// Convolution (`f32`) into `dst`. +pub fn conv_f32(src_a: &[f32], src_b: &[f32], dst: &mut [f32]) { + let len_a = src_a.len(); + let len_b = src_b.len(); + let out_len = (len_a + len_b - 1).min(dst.len()); + + dst[..out_len].fill(0.0); + for i in 0..len_a { + for j in 0..len_b { + if i + j < out_len { + dst[i + j] += src_a[i] * src_b[j]; + } + } + } +} + +/// Convolution (`q31`) into `dst`. +pub fn conv_q31(src_a: &[q31], src_b: &[q31], dst: &mut [q31]) { + let len_a = src_a.len(); + let len_b = src_b.len(); + let out_len = (len_a + len_b - 1).min(dst.len()); + + for n in 0..out_len { + let mut acc: i64 = 0; + let k_min = n.saturating_sub(len_b - 1); + let k_max = n.min(len_a - 1); + for k in k_min..=k_max { + acc += (src_a[k].to_bits() as i64 * src_b[n - k].to_bits() as i64) >> 31; + } + dst[n] = q31::from_bits(acc.clamp(i32::MIN as i64, i32::MAX as i64) as i32); + } +} + +/// Convolution (`q15`) into `dst`. +pub fn conv_q15(src_a: &[q15], src_b: &[q15], dst: &mut [q15]) { + let len_a = src_a.len(); + let len_b = src_b.len(); + let out_len = (len_a + len_b - 1).min(dst.len()); + + for n in 0..out_len { + let mut acc: i32 = 0; + let k_min = n.saturating_sub(len_b - 1); + let k_max = n.min(len_a - 1); + for k in k_min..=k_max { + acc += (src_a[k].to_bits() as i32 * src_b[n - k].to_bits() as i32) >> 15; + } + dst[n] = q15::from_bits(acc.clamp(i16::MIN as i32, i16::MAX as i32) as i16); + } +} + +/// Convolution (`q7`) into `dst`. +pub fn conv_q7(src_a: &[q7], src_b: &[q7], dst: &mut [q7]) { + let len_a = src_a.len(); + let len_b = src_b.len(); + let out_len = (len_a + len_b - 1).min(dst.len()); + + for n in 0..out_len { + let mut acc: i32 = 0; + let k_min = n.saturating_sub(len_b - 1); + let k_max = n.min(len_a - 1); + for k in k_min..=k_max { + acc += (src_a[k].to_bits() as i32 * src_b[n - k].to_bits() as i32) >> 7; + } + dst[n] = q7::from_bits(acc.clamp(i8::MIN as i32, i8::MAX as i32) as i8); + } +} + +// --- Correlation --- + +/// Correlation (`f32`) into `dst`. +pub fn correlate_f32(src_a: &[f32], src_b: &[f32], dst: &mut [f32]) { + let len_a = src_a.len(); + let len_b = src_b.len(); + let out_len = (len_a + len_b - 1).min(dst.len()); + + dst[..out_len].fill(0.0); + for n in 0..out_len { + let mut acc = 0.0f32; + for k in 0..len_a { + let idx_b = (k as isize) + (len_b as isize - 1) - (n as isize); + if idx_b >= 0 && (idx_b as usize) < len_b { + acc += src_a[k] * src_b[idx_b as usize]; + } + } + dst[n] = acc; + } +} + +/// Correlation (`q31`) into `dst`. +pub fn correlate_q31(src_a: &[q31], src_b: &[q31], dst: &mut [q31]) { + let len_a = src_a.len(); + let len_b = src_b.len(); + let out_len = (len_a + len_b - 1).min(dst.len()); + + for n in 0..out_len { + let mut acc: i64 = 0; + for k in 0..len_a { + let idx_b = (k as isize) + (len_b as isize - 1) - (n as isize); + if idx_b >= 0 && (idx_b as usize) < len_b { + acc += (src_a[k].to_bits() as i64 * src_b[idx_b as usize].to_bits() as i64) >> 31; + } + } + dst[n] = q31::from_bits(acc.clamp(i32::MIN as i64, i32::MAX as i64) as i32); + } +} + +/// Correlation (`q15`) into `dst`. +pub fn correlate_q15(src_a: &[q15], src_b: &[q15], dst: &mut [q15]) { + let len_a = src_a.len(); + let len_b = src_b.len(); + let out_len = (len_a + len_b - 1).min(dst.len()); + + for n in 0..out_len { + let mut acc: i32 = 0; + for k in 0..len_a { + let idx_b = (k as isize) + (len_b as isize - 1) - (n as isize); + if idx_b >= 0 && (idx_b as usize) < len_b { + acc += (src_a[k].to_bits() as i32 * src_b[idx_b as usize].to_bits() as i32) >> 15; + } + } + dst[n] = q15::from_bits(acc.clamp(i16::MIN as i32, i16::MAX as i32) as i16); + } +} + +// --- Non-linear Filtering (Median & Conditional Median) --- + +/// 1D Conditional / Thresholded Median Filter for f32. +/// +/// Replaces sample `src[i]` with the local median only if `|src[i] - median| > threshold`. +/// When `threshold == 0.0`, performs standard median filtering. +/// +/// `window_len` must be odd and $\le 63$. +pub fn median_filter_1d_f32( + src: &[f32], + dst: &mut [f32], + window_len: usize, + threshold: f32, +) -> Status { + let n = src.len(); + if n == 0 || dst.len() < n { + return Status::LengthError; + } + if window_len == 0 || window_len.is_multiple_of(2) || window_len > 63 { + return Status::ArgumentError; + } + + let half = window_len / 2; + let mut sort_buf = [0.0f32; 64]; + + for i in 0..n { + // Populate window with boundary clamping + for j in 0..window_len { + let idx = (i as isize + j as isize - half as isize).clamp(0, (n - 1) as isize) as usize; + sort_buf[j] = src[idx]; + } + + // Insertion sort on small stack buffer + for a in 1..window_len { + let mut b = a; + while b > 0 && sort_buf[b - 1] > sort_buf[b] { + sort_buf.swap(b - 1, b); + b -= 1; + } + } + + let med = sort_buf[half]; + let center = src[i]; + if (center - med).abs() >= threshold { + dst[i] = med; + } else { + dst[i] = center; + } + } + + Status::Success +} + +/// 1D Conditional Median Filter for Q15. +pub fn median_filter_1d_q15( + src: &[q15], + dst: &mut [q15], + window_len: usize, + threshold: q15, +) -> Status { + let n = src.len(); + if n == 0 || dst.len() < n { + return Status::LengthError; + } + if window_len == 0 || window_len.is_multiple_of(2) || window_len > 63 { + return Status::ArgumentError; + } + + let half = window_len / 2; + let mut sort_buf = [q15::ZERO; 64]; + + for i in 0..n { + for j in 0..window_len { + let idx = (i as isize + j as isize - half as isize).clamp(0, (n - 1) as isize) as usize; + sort_buf[j] = src[idx]; + } + + for a in 1..window_len { + let mut b = a; + while b > 0 && sort_buf[b - 1] > sort_buf[b] { + sort_buf.swap(b - 1, b); + b -= 1; + } + } + + let med = sort_buf[half]; + let center = src[i]; + let diff = (center.to_bits() as i32 - med.to_bits() as i32).abs(); + if diff >= threshold.to_bits() as i32 { + dst[i] = med; + } else { + dst[i] = center; + } + } + + Status::Success +} + +/// 1D Conditional Median Filter for Q31. +pub fn median_filter_1d_q31( + src: &[q31], + dst: &mut [q31], + window_len: usize, + threshold: q31, +) -> Status { + let n = src.len(); + if n == 0 || dst.len() < n { + return Status::LengthError; + } + if window_len == 0 || window_len.is_multiple_of(2) || window_len > 63 { + return Status::ArgumentError; + } + + let half = window_len / 2; + let mut sort_buf = [q31::ZERO; 64]; + + for i in 0..n { + for j in 0..window_len { + let idx = (i as isize + j as isize - half as isize).clamp(0, (n - 1) as isize) as usize; + sort_buf[j] = src[idx]; + } + + for a in 1..window_len { + let mut b = a; + while b > 0 && sort_buf[b - 1] > sort_buf[b] { + sort_buf.swap(b - 1, b); + b -= 1; + } + } + + let med = sort_buf[half]; + let center = src[i]; + let diff = (center.to_bits() as i64 - med.to_bits() as i64).abs(); + if diff >= threshold.to_bits() as i64 { + dst[i] = med; + } else { + dst[i] = center; + } + } + + Status::Success +} + +// --- FFT Fast Convolution --- + +/// Performs fast linear convolution of `signal` and `kernel` via FFT multiplication. +/// Output length is `signal.len() + kernel.len() - 1`. +/// +/// Requires the `transform` feature (enabled by `full`). +#[cfg(feature = "transform")] +pub fn fast_convolve_f32(signal: &[f32], kernel: &[f32], dst: &mut [f32]) -> Status { + let len_sig = signal.len(); + let len_ker = kernel.len(); + if len_sig == 0 || len_ker == 0 { + return Status::LengthError; + } + let total_len = len_sig + len_ker - 1; + if dst.len() < total_len { + return Status::LengthError; + } + + // Find next power of 2 + let mut fft_n = 1; + while fft_n < total_len { + fft_n <<= 1; + } + + if fft_n > 512 { + // Fall back to time-domain convolution if size exceeds stack scratch buffer + conv_f32(signal, kernel, dst); + return Status::Success; + } + + let mut sig_buf = [0.0f32; 1024]; // 2 * fft_n + let mut ker_buf = [0.0f32; 1024]; + + for i in 0..len_sig { + sig_buf[2 * i] = signal[i]; + } + for i in 0..len_ker { + ker_buf[2 * i] = kernel[i]; + } + + cfft_f32(&mut sig_buf[..2 * fft_n], fft_n, 0, 1); + cfft_f32(&mut ker_buf[..2 * fft_n], fft_n, 0, 1); + + // Pointwise complex multiplication: (a + jb) * (c + jd) + for i in 0..fft_n { + let a = sig_buf[2 * i]; + let b = sig_buf[2 * i + 1]; + let c = ker_buf[2 * i]; + let d = ker_buf[2 * i + 1]; + sig_buf[2 * i] = a * c - b * d; + sig_buf[2 * i + 1] = a * d + b * c; + } + + // Inverse FFT + cfft_f32(&mut sig_buf[..2 * fft_n], fft_n, 1, 1); + + for i in 0..total_len { + dst[i] = sig_buf[2 * i]; + } + + Status::Success +} diff --git a/crates/embedded-dsp/src/filtering/fir.rs b/crates/embedded-dsp/src/filtering/fir.rs new file mode 100644 index 0000000..1a17f65 --- /dev/null +++ b/crates/embedded-dsp/src/filtering/fir.rs @@ -0,0 +1,326 @@ +//! Finite impulse response filters (FIR, overlap-scrap, circular buffer). + +use crate::types::*; +// `fast_convolve_f32` and `FastFirF32` are `transform`-gated, so is their import. +#[cfg(feature = "transform")] +use crate::transform::cfft_f32; + +// --- FIR Filter --- + +/// Instance structure for the FIR filter, generic over the sample width. +/// +/// Coefficients are stored in the sample's coefficient type ([`DspSample::Coeff`]) and the state in +/// the sample type. Accumulation runs through [`DspSample::mul_high`] — the per-term narrow the +/// fixed-point kernels use — so `f32`, `q15`, and `q31` share one loop. +pub struct FirInstance<'a, T: DspSample> { + /// Number of filter taps. + pub num_taps: u16, + /// Filter coefficients. + pub coeffs: &'a [T::Coeff], + /// Filter state buffer. + pub state: &'a mut [T], +} + +impl<'a, T: DspSample> FirInstance<'a, T> { + /// Initializes the instance. + pub fn init(num_taps: u16, coeffs: &'a [T::Coeff], state: &'a mut [T]) -> Self { + state.fill(T::ZERO); + Self { + num_taps, + coeffs, + state, + } + } +} + +/// FIR filtering into `dst`, generic over the sample width. +pub fn fir(instance: &mut FirInstance<'_, T>, src: &[T], dst: &mut [T]) { + let num_taps = instance.num_taps as usize; + let block_size = src.len().min(dst.len()); + + for i in 0..block_size { + // Shift state + for k in (1..num_taps).rev() { + instance.state[k] = instance.state[k - 1]; + } + instance.state[0] = src[i]; + + // Compute the dot product with the per-term high product, exactly as the fixed-point + // kernels did: `acc += (state * coeff) >> FRAC`. + let mut acc = T::Accum::default(); + for k in 0..num_taps { + acc = acc + T::mul_high(instance.state[k], instance.coeffs[k]); + } + dst[i] = T::from_accum_shifted(acc, 0); + } +} + + + +/// Streaming overlap-scrap FIR (`kiss_fastfir`): scrap at the tail of each +/// inverse FFT so consecutive hops overlap by `n_taps - 1` samples. +/// +/// `NFFT` is the real FFT size. It must be a length [`cfft_f32`] +/// accepts (`<= 512` because convolution uses a stack scratch of 1024 floats), +/// and must be `>=` the impulse length. Hop size is `NFFT - n_taps + 1`. +/// +/// History is primed with `n_taps - 1` zeros so the first hop aligns with +/// linear convolution (no extra delay). Call [`FastFirF32::flush`] after the +/// last input block to emit the filter tail. +#[cfg(feature = "transform")] +#[derive(Clone, Copy)] +pub struct FastFirF32 { + n_taps: usize, + ngood: usize, + fir_re: [f32; NFFT], + fir_im: [f32; NFFT], + pending: [f32; NFFT], + pending_len: usize, + spec_re: [f32; NFFT], +} + +#[cfg(feature = "transform")] +impl FastFirF32 { + /// Builds a streaming FIR from a real impulse response. + /// + /// Returns `None` if `impulse` is empty, longer than `NFFT`, or `NFFT` is + /// not a supported FFT length. + pub fn new(impulse: &[f32]) -> Option { + use crate::transform::cfft_f32_len_ok; + if impulse.is_empty() || impulse.len() > NFFT || !cfft_f32_len_ok(NFFT) { + return None; + } + + let n_taps = impulse.len(); + let ngood = NFFT - n_taps + 1; + let pending = [0.0f32; NFFT]; + let mut spec = [0.0f32; 1024]; + if 2 * NFFT > spec.len() { + return None; + } + + spec[0] = impulse[n_taps - 1]; + for i in 0..n_taps.saturating_sub(1) { + spec[2 * (ngood + i)] = impulse[i]; + } + cfft_f32(&mut spec[..2 * NFFT], NFFT, 0, 1); + + let mut fir_re = [0.0f32; NFFT]; + let mut fir_im = [0.0f32; NFFT]; + for i in 0..NFFT { + fir_re[i] = spec[2 * i]; + fir_im[i] = spec[2 * i + 1]; + } + + Some(Self { + n_taps, + ngood, + fir_re, + fir_im, + pending, + pending_len: n_taps.saturating_sub(1), + spec_re: [0.0f32; NFFT], + }) + } + + /// Valid samples produced per full FFT hop (`NFFT - n_taps + 1`). + #[inline] + pub const fn ngood(&self) -> usize { + self.ngood + } + + /// Impulse length used at construction. + #[inline] + pub const fn n_taps(&self) -> usize { + self.n_taps + } + + fn convolve_pending(&mut self) { + let mut spec = [0.0f32; 1024]; + for i in 0..NFFT { + spec[2 * i] = self.pending[i]; + } + cfft_f32(&mut spec[..2 * NFFT], NFFT, 0, 1); + for i in 0..NFFT { + let a = spec[2 * i]; + let b = spec[2 * i + 1]; + let c = self.fir_re[i]; + let d = self.fir_im[i]; + spec[2 * i] = a * c - b * d; + spec[2 * i + 1] = a * d + b * c; + } + cfft_f32(&mut spec[..2 * NFFT], NFFT, 1, 1); + for i in 0..NFFT { + self.spec_re[i] = spec[2 * i]; + } + } + + fn shift_scrap(&mut self) { + let scrap = NFFT - self.ngood; + for i in 0..scrap { + self.pending[i] = self.pending[self.ngood + i]; + } + self.pending_len = scrap; + } + + /// Consumes `input` and writes as many hop-aligned outputs as fit in + /// `output`. Returns the number of samples written. + /// + /// Provide `output.len() >= ngood` (ideally several hops) so full FFT + /// blocks are not stalled for lack of output space. + pub fn process(&mut self, input: &[f32], output: &mut [f32]) -> usize { + let mut in_i = 0; + let mut out_i = 0; + loop { + while self.pending_len < NFFT && in_i < input.len() { + self.pending[self.pending_len] = input[in_i]; + self.pending_len += 1; + in_i += 1; + } + if self.pending_len < NFFT || out_i + self.ngood > output.len() { + break; + } + self.convolve_pending(); + output[out_i..out_i + self.ngood].copy_from_slice(&self.spec_re[..self.ngood]); + out_i += self.ngood; + self.shift_scrap(); + } + out_i + } + + /// Appends `n_taps - 1` zeros and drains a final padded hop so a finite + /// input of length `L` yields the `L + n_taps - 1` linear-convolution samples. + pub fn flush(&mut self, output: &mut [f32]) -> usize { + let pad = self.n_taps.saturating_sub(1); + let mut written = 0; + let mut remaining_pad = pad; + while remaining_pad > 0 && written < output.len() { + let chunk = remaining_pad.min(32); + let zeros = [0.0f32; 32]; + let n = self.process(&zeros[..chunk], &mut output[written..]); + written += n; + remaining_pad -= chunk; + if n == 0 && self.pending_len < NFFT { + break; + } + } + + if self.pending_len == 0 || written >= output.len() { + return written; + } + + let n = self.pending_len; + let zpad = NFFT - n; + for i in n..NFFT { + self.pending[i] = 0.0; + } + self.pending_len = NFFT; + let nout = self.ngood.saturating_sub(zpad); + if nout == 0 || written + nout > output.len() { + self.pending_len = n; + return written; + } + self.convolve_pending(); + output[written..written + nout].copy_from_slice(&self.spec_re[..nout]); + self.pending_len = 0; + written + nout + } + + /// Clears history back to `n_taps - 1` zeros. + pub fn reset(&mut self) { + self.pending.fill(0.0); + self.pending_len = self.n_taps.saturating_sub(1); + } +} + +// --- Real-time Circular Buffer & Delay Line --- + +/// Const-generic zero-allocation circular buffer and delay line for real-time DSP sample streams. +#[derive(Debug, Clone, Copy)] +pub struct CircularBuffer { + buffer: [T; N], + head: usize, + count: usize, +} + +impl CircularBuffer { + /// Creates a new circular buffer initialized with `init_val`. + pub const fn new(init_val: T) -> Self { + Self { + buffer: [init_val; N], + head: 0, + count: 0, + } + } + + /// Pushes a new sample into the buffer, overwriting the oldest sample when full. + #[inline(always)] + pub fn push(&mut self, sample: T) { + if N == 0 { + return; + } + self.buffer[self.head] = sample; + self.head = (self.head + 1) % N; + if self.count < N { + self.count += 1; + } + } + + /// Gets sample with historical lag $k$, where $k = 0$ is the newest sample (`x[n]`), $k = 1$ is `x[n-1]`, etc. + /// Returns `None` if `lag >= self.len()`. + #[inline(always)] + pub fn get(&self, lag: usize) -> Option { + if lag >= self.count || N == 0 { + return None; + } + let idx = (self.head + N - 1 - (lag % N)) % N; + Some(self.buffer[idx]) + } + + /// Returns the most recently pushed sample (`x[n]`). + #[inline(always)] + pub fn latest(&self) -> Option { + self.get(0) + } + + /// Returns the oldest sample stored in the buffer. + #[inline(always)] + pub fn oldest(&self) -> Option { + if self.count == 0 { + None + } else { + self.get(self.count - 1) + } + } + + /// Returns the number of valid samples currently stored in the buffer. + #[inline(always)] + pub const fn len(&self) -> usize { + self.count + } + + /// Returns the capacity of the circular buffer (`N`). + #[inline(always)] + pub const fn capacity(&self) -> usize { + N + } + + /// Returns `true` if the buffer contains no samples. + #[inline(always)] + pub const fn is_empty(&self) -> bool { + self.count == 0 + } + + /// Returns `true` if the buffer is filled to capacity `N`. + #[inline(always)] + pub const fn is_full(&self) -> bool { + self.count == N + } + + /// Clears the circular buffer, resetting sample count and filling with `reset_val`. + pub fn clear(&mut self, reset_val: T) { + self.buffer = [reset_val; N]; + self.head = 0; + self.count = 0; + } +} diff --git a/crates/embedded-dsp/src/filtering/int_filters.rs b/crates/embedded-dsp/src/filtering/int_filters.rs new file mode 100644 index 0000000..629953e --- /dev/null +++ b/crates/embedded-dsp/src/filtering/int_filters.rs @@ -0,0 +1,100 @@ +//! Integer lowpass filters ported from `idsp`. + +// ───────────────────────────────────────────────────────────────────────────── +// Integer Lowpass Filter (ported from idsp) +// ───────────────────────────────────────────────────────────────────────────── + +/// Arbitrary-order integer lowpass filter with high dynamic range. DC gain is 1. +/// +/// Supports order `N = 1` (first-order) and `N = 2` (second-order Butterworth); +/// any other `N` is rejected at compile time. The filter saturates cleanly +/// towards the `i32` range. +/// +/// # Coefficient Calculation +/// +/// **First-order** (`N = 1`): `k[0] = π * (1 << 31) * f0 / fn` +/// where `f0` is the 3 dB corner frequency and `fn` is the Nyquist frequency. +/// +/// **Second-order Butterworth** (`N = 2`): `k = [k_sq >> 32, -k / q]` +/// where `q = 1/sqrt(2)` and `k` is as above. +/// +/// Both variants have zeros at Nyquist, optimised for Cortex-M7. +/// +/// ``` +/// # use embedded_dsp::filtering::IntLowpass; +/// let mut lp = IntLowpass::<1>::new([674_651_885]); +/// assert_eq!(lp.process(1 << 24), 2_635_358); +/// ``` +/// +/// Unsupported orders are a compile error rather than a runtime panic: +/// +/// ```compile_fail +/// # use embedded_dsp::filtering::IntLowpass; +/// let _ = IntLowpass::<3>::new([0, 0, 0]); +/// ``` +/// +/// Ported from the `idsp` crate by the Sinara/ARTIQ project. +#[derive(Clone, Debug)] +pub struct IntLowpass { + /// Lead/lag gain coefficients in Q1.31 fixed-point. + pub k: [i32; N], + /// Wide internal state accumulators. + state: [i64; N], +} + +impl Default for IntLowpass +where + [i32; N]: Default, +{ + fn default() -> Self { + const { assert!(N == 1 || N == 2, "IntLowpass supports only N = 1 or N = 2") }; + Self { k: Default::default(), state: [0i64; N] } + } +} + +impl IntLowpass { + /// Create a new filter from gain coefficients. + /// + /// # Panics + /// Fails to compile unless `N` is `1` or `2`. + pub fn new(k: [i32; N]) -> Self { + const { assert!(N == 1 || N == 2, "IntLowpass supports only N = 1 or N = 2") }; + Self { k, state: [0i64; N] } + } + + /// Reset internal state to zero. + pub fn reset(&mut self) { + self.state = [0i64; N]; + } + + /// Process a single sample and return the filtered output. + /// + /// # Panics + /// Fails to compile unless `N` is `1` or `2`. + pub fn process(&mut self, x: i32) -> i32 { + const { assert!(N == 1 || N == 2, "IntLowpass supports only N = 1 or N = 2") }; + if N == 1 { + let d = x.saturating_sub((self.state[0] >> 32) as i32) as i64 + * self.k[0] as i64; + self.state[0] += d; + let y = (self.state[0] >> 32) as i32; + self.state[0] += d; + y + } else { + let mut d = x.saturating_sub((self.state[0] >> 32) as i32) as i64 + * self.k[0] as i64; + d += (self.state[1] >> 32) * self.k[1] as i64; + self.state[1] += d; + self.state[0] += self.state[1]; + let y = (self.state[0] >> 32) as i32; + self.state[0] += self.state[1]; + self.state[1] += d; + y + } + } +} + +/// First-order integer lowpass (alias for `IntLowpass<1>`). +pub type IntLowpass1 = IntLowpass<1>; +/// Second-order integer lowpass (alias for `IntLowpass<2>`). +pub type IntLowpass2 = IntLowpass<2>; diff --git a/crates/embedded-dsp/src/filtering/lockin.rs b/crates/embedded-dsp/src/filtering/lockin.rs new file mode 100644 index 0000000..e731b6f --- /dev/null +++ b/crates/embedded-dsp/src/filtering/lockin.rs @@ -0,0 +1,129 @@ +//! Lock-in demodulation and the standalone lock-in amplifier. + +use crate::types::*; +// Only the `not(fast-math)` branch calls `FloatMath::{cos, sin}` directly; with +// `fast-math` on it uses `fast_math::cossin*` and this import would be unused. +#[cfg(not(feature = "fast-math"))] +use crate::math::FloatMath; +use super::recursive::SinglePoleFilter; + +// ───────────────────────────────────────────────────────────────────────────── +// Lock-in Demodulation & Lock-in Amplifier +// ───────────────────────────────────────────────────────────────────────────── + + +/// Dual-phase lock-in amplifier mixer and demodulator. +/// +/// Combines channel filters `C` with an IQ local oscillator reference to demodulate +/// a noisy input signal into in-phase $I$ and quadrature $Q$ components. +#[derive(Copy, Clone, Default, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] +pub struct Lockin(pub C); + +impl Lockin { + /// Create a new lock-in demodulator with the given low-pass channel filter. + pub const fn new(filter: C) -> Self { + Self(filter) + } +} + +#[cfg(feature = "pipeline")] +impl crate::pipeline::SplitProcess<(X, Complex), Complex, [S; 2]> for Lockin +where + X: Copy + core::ops::Mul, + U: Copy, + C: crate::pipeline::SplitProcess, +{ + /// Demodulate a sample `x.0` against a local oscillator `x.1` (in-phase and quadrature). + #[inline] + fn process_with_state(&mut self, state: &mut [S; 2], x: (X, Complex)) -> Complex { + let (sample, lo) = x; + Complex::new( + self.0.process_with_state(&mut state[0], sample * lo.real), + self.0.process_with_state(&mut state[1], sample * lo.imag), + ) + } +} + +/// Standalone Lock-in Amplifier with integrated single-pole low-pass filtering. +/// +/// Multiplies an incoming signal with an internal or external quadrature reference, +/// and low-pass filters both channels to extract amplitude and phase. +#[derive(Clone, Copy, Debug)] +pub struct LockinAmplifier { + /// Filter i. + pub filter_i: SinglePoleFilter, + /// Filter q. + pub filter_q: SinglePoleFilter, + /// Phase. + pub phase: i32, + /// Phase inc. + pub phase_inc: i32, +} + +impl LockinAmplifier { + /// Create a new Lock-in Amplifier with carrier frequency, sample rate, and low-pass decay factor. + pub fn new(carrier_hz: f32, sample_rate: f32, filter_decay: f32) -> Self { + let phase_inc = ((carrier_hz / sample_rate) * 4294967296.0) as i32; + Self { + filter_i: SinglePoleFilter::::lowpass(filter_decay), + filter_q: SinglePoleFilter::::lowpass(filter_decay), + phase: 0, + phase_inc, + } + } + + /// Set carrier frequency in Hz. + pub fn set_frequency(&mut self, carrier_hz: f32, sample_rate: f32) { + self.phase_inc = ((carrier_hz / sample_rate) * 4294967296.0) as i32; + } + + /// Reset internal filter state and phase accumulator. + pub fn reset(&mut self) { + self.filter_i.reset(); + self.filter_q.reset(); + self.phase = 0; + } + + /// Ingest a sample and return demodulated IQ `Complex`. + #[inline] + pub fn process(&mut self, sample: f32) -> Complex { + let (cos_ref, sin_ref) = { + #[cfg(feature = "fast-math")] + { + let (c, s) = crate::fast_math::cossin(self.phase); + (c as f32 * (1.0 / 2147483648.0), s as f32 * (1.0 / 2147483648.0)) + } + #[cfg(not(feature = "fast-math"))] + { + let rad = self.phase as f32 * (core::f32::consts::PI / 2147483648.0); + (FloatMath::cos(rad), FloatMath::sin(rad)) + } + }; + + self.phase = self.phase.wrapping_add(self.phase_inc); + + let i_filt = self.filter_i.process(sample * cos_ref); + let q_filt = self.filter_q.process(sample * sin_ref); + Complex::new(i_filt, q_filt) + } + + /// Process a sample using an external reference phase angle (in radians). + #[inline] + pub fn process_with_phase(&mut self, sample: f32, phase_rad: f32) -> Complex { + let (cos_ref, sin_ref) = { + #[cfg(feature = "fast-math")] + { + crate::fast_math::cossin_f32(phase_rad) + } + #[cfg(not(feature = "fast-math"))] + { + (FloatMath::cos(phase_rad), FloatMath::sin(phase_rad)) + } + }; + + let i_filt = self.filter_i.process(sample * cos_ref); + let q_filt = self.filter_q.process(sample * sin_ref); + Complex::new(i_filt, q_filt) + } +} diff --git a/crates/embedded-dsp/src/filtering/mod.rs b/crates/embedded-dsp/src/filtering/mod.rs new file mode 100644 index 0000000..1baa124 --- /dev/null +++ b/crates/embedded-dsp/src/filtering/mod.rs @@ -0,0 +1,26 @@ +//! Digital filtering: FIR, IIR/biquad, adaptive, convolution, and recursive +//! filters. +//! +//! Split into one submodule per filter family; every public item is +//! re-exported here, so `embedded_dsp::filtering::Foo` and the crate-root +//! glob are unchanged. + +mod adaptive; +mod biquad; +mod convolution; +mod fir; +mod int_filters; +mod lockin; +mod normal_form; +mod recursive; +mod wdf; + +pub use adaptive::*; +pub use biquad::*; +pub use convolution::*; +pub use fir::*; +pub use int_filters::*; +pub use lockin::*; +pub use normal_form::*; +pub use recursive::*; +pub use wdf::*; diff --git a/crates/embedded-dsp/src/filtering/normal_form.rs b/crates/embedded-dsp/src/filtering/normal_form.rs new file mode 100644 index 0000000..0b588a8 --- /dev/null +++ b/crates/embedded-dsp/src/filtering/normal_form.rs @@ -0,0 +1,186 @@ +//! Normal-form (Rader-Gold / Chamberlain) second-order sections. + +use crate::types::*; +// With `std` linked, `f32::{sqrt, cos, sin}` are inherent; without it the +// `FloatMath` trait supplies them. +#[cfg(not(feature = "std"))] +use crate::math::FloatMath; + +// ───────────────────────────────────────────────────────────────────────────── +// Normal Form Second-Order Section (Rader-Gold / Chamberlain oscillator) +// ───────────────────────────────────────────────────────────────────────────── + +/// Normal form (Rader-Gold / Chamberlain) second-order IIR section with an +/// **arbitrary numerator**. +/// +/// Unlike a standard direct-form biquad, the normal form has **constant pole +/// resolution** everywhere in the z-plane rather than clustering resolution +/// near the real axis. This makes it ideal for: +/// +/// - Precise narrow-band bandpass filters close to DC or Nyquist. +/// - Quadrature sinusoidal oscillators (the two state variables are +/// in-phase and 90°-shifted copies of the oscillation). +/// - Notch filters requiring very high Q. +/// +/// # Architecture +/// +/// The two state variables `(u, v)` are updated by a rotation through the +/// conjugate pole pair: +/// +/// ```text +/// u[n] = p.re * u[n-1] - p.im * v[n-1] + x[n] +/// v[n] = p.im * u[n-1] + p.re * v[n-1] +/// ``` +/// +/// The filtered output is an arbitrary linear combination of the states and +/// the current input: +/// +/// ```text +/// y[n] = c0 * u[n] + c1 * v[n] + c2 * x[n] +/// ``` +/// +/// With `c` chosen by [`NormalForm::from_ba`] this realizes **exactly** +/// `H(z) = (b0 + b1*z⁻¹ + b2*z⁻²) / (a0 + a1*z⁻¹ + a2*z⁻²)`, while keeping +/// the superior pole resolution of the normal form. (This is more general than +/// the `idsp` `Normal` form, whose numerator is forced to `p.im * z⁻¹ * B(z)`.) +/// +/// # Example: quadrature NCO +/// +/// ```rust +/// # use embedded_dsp::filtering::{NormalForm, NormalFormState}; +/// // 1 kHz oscillator at 48 kHz sample rate +/// let f = 1000.0_f32 / 48000.0; +/// let nco = NormalForm::oscillator(f); +/// let mut state = NormalFormState::default(); +/// // Kick the oscillator with a unit impulse +/// let (i_out, q_out) = nco.process_quadrature(&mut state, 1.0); +/// assert!(i_out.abs() > 0.0); +/// ``` +#[derive(Clone, Debug, Default)] +pub struct NormalForm { + /// Output combination coefficients `[c0, c1, c2]`: + /// `y = c0 * u + c1 * v + c2 * x`. + pub c: [f32; 3], + /// Conjugate pole pair: `p.re ± j·p.im`. + pub p: Complex, +} + +/// State for [`NormalForm`]: the two rotating state variables. +#[derive(Clone, Debug, Default)] +pub struct NormalFormState { + /// Real (in-phase) state variable `u`. + pub y_re: f32, + /// Imaginary (quadrature) state variable `v`. + pub y_im: f32, +} + +impl NormalForm { + /// Construct from raw output-combination coefficients `c` and pole `p`. + pub fn new(c: [f32; 3], p: Complex) -> Self { + Self { c, p } + } + + /// Construct from a standard `[b; a]` biquad coefficient matrix, exactly + /// realizing `H(z) = (b0 + b1*z⁻¹ + b2*z⁻²) / (a0 + a1*z⁻¹ + a2*z⁻²)`. + /// + /// `ba[0]` = `[b0, b1, b2]` numerator coefficients. + /// `ba[1]` = `[a0, a1, a2]` denominator coefficients (a0 usually 1.0). + /// + /// # Panics + /// + /// Panics if the poles are not a complex-conjugate pair (i.e. the + /// discriminant `a1² - 4*a0*a2` must be negative). + pub fn from_ba(ba: &[[f32; 3]; 2]) -> Self { + let a0_inv = ba[1][0].recip(); + let b = [ba[0][0] * a0_inv, ba[0][1] * a0_inv, ba[0][2] * a0_inv]; + // Roots of a0*z² + a1*z + a2: p = -a1/(2a0) ± sqrt((a1/(2a0))² - a2/a0) + let p_re = -0.5 * ba[1][1] * a0_inv; + let disc = p_re * p_re - ba[1][2] * a0_inv; + assert!( + disc < 0.0, + "NormalForm::from_ba: poles must be a complex-conjugate pair (use a direct-form biquad for real poles)" + ); + let p_im = (-disc).sqrt(); + let r2 = p_re * p_re + p_im * p_im; + + // Solve for the output combination that realizes B(z)/A(z). + // u = x*(1 - p_re*z⁻¹)/D, v = x*p_im*z⁻¹/D, D = 1 - 2p_re*z⁻¹ + r2*z⁻². + // y = c0*u + c1*v + c2*x has numerator + // c0 + c2 + (c1*p_im - c0*p_re - 2*c2*p_re)*z⁻¹ + c2*r2*z⁻². + let c2 = b[2] / r2; + let c0 = b[0] - c2; + let c1 = (b[1] + p_re * b[0] + p_re * c2) / p_im; + Self { + c: [c0, c1, c2], + p: Complex::new(p_re, p_im), + } + } + + /// Construct a pure quadrature sinusoidal oscillator at normalised + /// frequency `f` (0 < f < 0.5, where 0.5 is Nyquist). + /// + /// [`NormalForm::process`] returns the in-phase component (`cos`). + /// [`NormalForm::process_quadrature`] returns both in-phase and 90°-shifted + /// components. A unit impulse starts the oscillation. + /// + /// # Example + /// ```rust + /// # use embedded_dsp::filtering::{NormalForm, NormalFormState}; + /// let nco = NormalForm::oscillator(0.1); // 10% of sample rate + /// let mut s = NormalFormState::default(); + /// let _ = nco.process_quadrature(&mut s, 1.0); // impulse start + /// ``` + pub fn oscillator(f: f32) -> Self { + let theta = 2.0 * core::f32::consts::PI * f; + Self { + c: [1.0, 0.0, 0.0], + p: Complex::new(theta.cos(), theta.sin()), + } + } + + /// Construct a narrow-band bandpass filter centred at normalised frequency + /// `f` with quality factor `q`. + /// + /// Realizes `H(z) = g*(1 - z⁻²) / (1 - 2*r*cos(θ)*z⁻¹ + r²*z⁻²)` with + /// `θ = 2πf`, `r = 1 - πf/q`, and `g = 1 - r` (unity passband gain). + pub fn bandpass(f: f32, q: f32) -> Self { + let theta = 2.0 * core::f32::consts::PI * f; + let r = 1.0 - core::f32::consts::PI * f / q; // pole radius ≈ 1 - π·bw/fs + let g = 1.0 - r; // unity passband normalisation + let p_re = r * theta.cos(); + let p_im = r * theta.sin(); + let r2 = r * r; + // from_ba() solution with b = [g, 0, -g]: + let c2 = -g / r2; + let c0 = g - c2; + let c1 = p_re * g * (1.0 - 1.0 / r2) / p_im; + Self { + c: [c0, c1, c2], + p: Complex::new(p_re, p_im), + } + } + + /// Advance the internal state by one sample and return the two rotating + /// state variables `(u, v)`: the in-phase and quadrature components. + #[inline] + pub fn process_quadrature(&self, state: &mut NormalFormState, x0: f32) -> (f32, f32) { + // Normal-form feedback (conjugate pole pair rotation) + let u = self.p.re() * state.y_re - self.p.im() * state.y_im + x0; + let v = self.p.im() * state.y_re + self.p.re() * state.y_im; + state.y_re = u; + state.y_im = v; + (u, v) + } + + /// Process a single sample and return the filtered output `y`. + #[inline] + pub fn process(&self, state: &mut NormalFormState, x0: f32) -> f32 { + let (u, v) = self.process_quadrature(state, x0); + self.c[0] * u + self.c[1] * v + self.c[2] * x0 + } + + /// Reset state to zero. + pub fn reset(state: &mut NormalFormState) { + *state = NormalFormState::default(); + } +} diff --git a/crates/embedded-dsp/src/filtering/recursive.rs b/crates/embedded-dsp/src/filtering/recursive.rs new file mode 100644 index 0000000..0cd515f --- /dev/null +++ b/crates/embedded-dsp/src/filtering/recursive.rs @@ -0,0 +1,221 @@ +//! Single-pole, DC-blocker, and recursive moving-average filters. + +use crate::types::*; +use super::fir::CircularBuffer; + +// --- Single-Pole Recursive Filter (Steven W. Smith, Ch. 19) --- + +/// The cheapest possible IIR filter: a single-pole recursive low-pass or high-pass filter +/// (Steven W. Smith, Ch. 19, Eq. 19-2 / 19-3), needing only one or two multiplies per sample. +/// +/// This is the generic template: the recurrence is shared across every [`DspSample`] width and +/// runs through [`DspSample::madd`] / [`DspSample::from_accum`], so a `q15` stage keeps its wide +/// accumulator and an `f32` stage keeps its plain multiply without either being a separate type. +/// Coefficients are still built per width (a fixed-point decay quantizes differently from an `f32` +/// one); the constructors below are the per-width specializations that sit *behind* the generic +/// type. Decay factors come from +/// [`crate::filter_design::single_pole_decay_from_cutoff`] / +/// [`crate::filter_design::single_pole_decay_from_time_constant`]. +#[derive(Debug, Clone, Copy)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +pub struct SinglePoleFilter { + b0: T::Coeff, + b1: T::Coeff, + a1: T::Coeff, + x1: T, + y1: T, +} + +impl SinglePoleFilter { + /// Creates a filter directly from the feed-forward coefficients `b0`/`b1` and the feed-back + /// coefficient `a1`. + pub fn new(b0: T::Coeff, b1: T::Coeff, a1: T::Coeff) -> Self { + Self { + b0, + b1, + a1, + x1: T::ZERO, + y1: T::ZERO, + } + } + + /// Processes a single input sample and returns the filtered output. + #[inline(always)] + pub fn process(&mut self, x: T) -> T { + let acc = T::madd( + T::madd(T::madd(T::Accum::default(), x, self.b0), self.x1, self.b1), + self.y1, + self.a1, + ); + let y = T::from_accum(acc); + self.x1 = x; + self.y1 = y; + y + } + + /// Resets the filter's delay state to zero. + pub fn reset(&mut self) { + self.x1 = T::ZERO; + self.y1 = T::ZERO; + } +} + +impl Default for SinglePoleFilter +where + T::Coeff: Default, +{ + fn default() -> Self { + Self { + b0: T::Coeff::default(), + b1: T::Coeff::default(), + a1: T::Coeff::default(), + x1: T::ZERO, + y1: T::ZERO, + } + } +} + +impl SinglePoleFilter { + /// Creates a single-pole low-pass filter from decay factor `x` (`0.0..1.0`); larger `x` + /// means slower decay (a lower cutoff frequency). + pub fn lowpass(decay: f32) -> Self { + Self::new(1.0 - decay, 0.0, decay) + } + + /// Creates a single-pole high-pass filter from the same decay factor `x` used by + /// [`SinglePoleFilter::lowpass`]. + pub fn highpass(decay: f32) -> Self { + let b0 = (1.0 + decay) / 2.0; + Self::new(b0, -b0, decay) + } +} + +impl SinglePoleFilter { + /// Creates a single-pole low-pass filter from Q15 decay `x` (larger → lower cutoff). + pub fn lowpass(decay: q15) -> Self { + let decay = decay.max(q15::ZERO); + Self::new( + q15::from_bits((32767i32 - decay.to_bits() as i32) as i16), + q15::ZERO, + decay, + ) + } + + /// Creates a single-pole high-pass filter from the same Q15 decay used by + /// [`SinglePoleFilter::lowpass`]. + pub fn highpass(decay: q15) -> Self { + let decay = decay.max(q15::ZERO); + let b0 = q15::from_bits(((32767i32 + decay.to_bits() as i32) / 2) as i16); + Self::new(b0, -b0, decay) + } + + /// Quantizes a floating-point decay in `0.0..1.0` to Q15 and builds a low-pass. + pub fn lowpass_from_f32(decay: f32) -> Self { + Self::lowpass(q15::saturating_from_num(decay.clamp(0.0, 1.0))) + } + + /// Quantizes a floating-point decay in `0.0..1.0` to Q15 and builds a high-pass. + pub fn highpass_from_f32(decay: f32) -> Self { + Self::highpass(q15::saturating_from_num(decay.clamp(0.0, 1.0))) + } +} + +/// The stateless-`SplitProcess` bridge for [`SinglePoleFilter`], kept next to the type so the +/// pipeline layer does not have to reach outward to wrap it. `Process` and +/// [`DspNode`](crate::pipeline::DspNode) follow from the pipeline blankets. +#[cfg(feature = "pipeline")] +impl crate::pipeline::SplitProcess for SinglePoleFilter { + #[inline(always)] + fn process_with_state(&mut self, _state: &mut (), input: T) -> T { + SinglePoleFilter::process(self, input) + } +} + +/// High-pass single-pole used as a DC blocker (Smith Ch. 19). +#[derive(Debug, Clone, Copy, Default)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +pub struct DcBlockerQ15 { + inner: SinglePoleFilter, +} + +impl DcBlockerQ15 { + /// `decay` is the same Q15 factor as [`SinglePoleFilter::highpass`]. + pub fn new(decay: q15) -> Self { + Self { + inner: SinglePoleFilter::::highpass(decay), + } + } + + /// Quantizes a floating-point decay in `0.0..1.0`. + pub fn from_f32_decay(decay: f32) -> Self { + Self { + inner: SinglePoleFilter::::highpass_from_f32(decay), + } + } + + #[inline(always)] + /// Processes a single input sample. + pub fn process(&mut self, x: q15) -> q15 { + self.inner.process(x) + } + + /// Resets the internal state. + pub fn reset(&mut self) { + self.inner.reset(); + } +} + +// --- Recursive Moving Average Filter (Steven W. Smith, Ch. 15) --- + +/// Const-generic `N`-point moving average filter implemented recursively (Steven W. Smith, +/// Ch. 15, Eq. 15-3): each sample is updated with a single add and subtract instead of an +/// `O(N)` convolution sum. Generic over the sample width. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +pub struct RecursiveMovingAverage { + history: CircularBuffer, + sum: T::Accum, +} + +impl RecursiveMovingAverage { + /// Creates a new `N`-point recursive moving average filter with empty history. + pub fn new() -> Self { + Self { + history: CircularBuffer::new(T::ZERO), + sum: T::Accum::default(), + } + } + + /// Pushes a new input sample and returns the updated moving average. While fewer than `N` + /// samples have been seen, the average is taken over the (growing) window received so far. + #[inline(always)] + pub fn process(&mut self, x: T) -> T { + let oldest = if self.history.is_full() { + self.history.oldest().unwrap_or(T::ZERO) + } else { + T::ZERO + }; + let delta = T::accum_from_shifted(x, 0) - T::accum_from_shifted(oldest, 0); + self.sum = self.sum + delta; + self.history.push(x); + if self.history.is_empty() { + T::ZERO + } else { + T::average_accum(self.sum, self.history.len()) + } + } + + /// Resets the filter to its initial, empty state. + pub fn reset(&mut self) { + self.history.clear(T::ZERO); + self.sum = T::Accum::default(); + } +} + +impl Default for RecursiveMovingAverage { + fn default() -> Self { + Self::new() + } +} + + diff --git a/crates/embedded-dsp/src/filtering/wdf.rs b/crates/embedded-dsp/src/filtering/wdf.rs new file mode 100644 index 0000000..65a7cc6 --- /dev/null +++ b/crates/embedded-dsp/src/filtering/wdf.rs @@ -0,0 +1,186 @@ +//! Wave digital filters (allpass chain). + +// ───────────────────────────────────────────────────────────────────────────── +// Wave Digital Filters (allpass chain) +// Ported from the `idsp` crate by the Sinara/ARTIQ project, with a corrected +// per-stage state update. +// ───────────────────────────────────────────────────────────────────────────── + +/// Two-port adapter architecture selector. +/// +/// Each architecture is a nibble in the const generic of [`Wdf`] and encodes +/// the optimal scaled form for a given allpass coefficient range. +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[repr(u8)] +pub enum Tpa { + /// Terminate (coefficient 0). + Z = 0x0, + /// `1 > g > 1/2`: `a = g - 1`. + A = 0xA, + /// `1/2 >= g > 0`: `a = -g`. + B = 0xB, + /// Alternative to `B`. + B1 = 0xE, + /// `g = 0`. + X = 0x1, + /// `-1/2 <= g < 0`: `a = g`. + C = 0xC, + /// Alternative to `C`. + C1 = 0xF, + /// `-1 < g < -1/2`: `a = -(1 + g)`. + D = 0xD, +} + +impl From for Tpa { + #[inline] + fn from(value: u8) -> Self { + match value { + 0xa => Tpa::A, + 0xb => Tpa::B, + 0xe => Tpa::B1, + 0x1 => Tpa::X, + 0xc => Tpa::C, + 0xf => Tpa::C1, + 0xd => Tpa::D, + _ => Tpa::Z, + } + } +} + +impl Tpa { + /// Quantize the allpass coefficient `g` for this architecture. + /// + /// Returns the Q32.32 fixed-point adapter coefficient, or `None` if `g` + /// does not fit the architecture's scaled range. + fn quantize(self, g: f64) -> Option { + // Use -0.5 <= a <= 0 instead of the usual positive range so that -0.5 + // exactly fits the Q32.32 fixed-point range. + let a = match self { + Self::Z => 0.0, + Self::A => g - 1.0, + Self::B | Self::B1 => -g, + Self::X => 0.0, + Self::C | Self::C1 => g, + Self::D => -1.0 - g, + }; + (-0.5..=0.0).contains(&a).then_some((a * 4294967296.0) as i32) + } + + /// Fixed-point multiply: `(c * a) >> 32` with wrapping (Q32.32 coefficient). + #[cfg(feature = "pipeline")] + #[inline] + fn mul(self, c: i32, a: i32) -> i32 { + ((c as i64).wrapping_mul(a as i64) >> 32) as i32 + } + + /// Two-port adapter wave computation. + /// + /// Takes `[a1, a2]` (incident wave from the previous stage and the delay + /// state) and returns `[b1, b2]`: the output wave to the next stage and + /// the new delay state. + #[cfg(feature = "pipeline")] + #[inline] + fn adapt(&self, x: [i32; 2], a: i32) -> [i32; 2] { + match self { + Tpa::A => { + let c = x[1] - x[0]; + let y = self.mul(c, a).wrapping_add(x[1]); + [y.wrapping_add(c), y] + } + Tpa::B => { + let c = x[0] - x[1]; + let y = self.mul(c, a).wrapping_add(x[1]); + [y, y.wrapping_add(c)] + } + Tpa::B1 => { + let c = x[0] - x[1]; + let y = self.mul(c, a); + [y.wrapping_add(x[1]), y.wrapping_add(x[0])] + } + Tpa::X => [x[1], x[0]], + Tpa::C => { + let c = x[1] - x[0]; + let y = self.mul(c, a).wrapping_sub(x[1]); + [y, y.wrapping_add(c)] + } + Tpa::C1 => { + let c = x[1] - x[0]; + let y = self.mul(c, a); + [y.wrapping_sub(x[1]), y.wrapping_sub(x[0])] + } + Tpa::D => { + let c = x[0] - x[1]; + let y = self.mul(c, a).wrapping_sub(x[1]); + [y.wrapping_add(c), y] + } + Tpa::Z => x, + } + } +} + +/// Wave digital filter: a cascade of `N` first-order allpass sections. +/// +/// The `M` const generic encodes the two-port adapter architecture, one nibble +/// per stage (least significant nibble = first stage). All arithmetic is +/// wrapping 32-bit integer with Q32.32 coefficients — no floating point. +/// +/// # Ported from +/// The `idsp` crate by the Sinara/ARTIQ project. +#[derive(Debug, Clone)] +pub struct Wdf { + /// Q32.32 adapter coefficients, one per allpass section. + pub a: [i32; N], +} + +impl Default for Wdf { + fn default() -> Self { + Self { a: [0; N] } + } +} + +impl Wdf { + /// Quantize allpass pole coefficients `g` (one per section, `|g| < 1`) + /// using the architecture encoded in `M`. + pub fn quantize(g: &[f64; N]) -> Option { + let mut a = [0i32; N]; + let mut m = M; + for (a, g) in a.iter_mut().zip(g) { + *a = Tpa::from((m & 0xf) as u8).quantize(*g)?; + m >>= 4; + } + debug_assert_eq!(m, 0); + Some(Self { a }) + } +} + +/// State for [`Wdf`]: one delay element per allpass section. +#[derive(Clone, Debug)] +pub struct WdfState { + /// Section delay states. + pub z: [i32; N], +} + +impl Default for WdfState { + fn default() -> Self { + Self { z: [0; N] } + } +} + +#[cfg(feature = "pipeline")] +impl crate::pipeline::SplitProcess> + for Wdf +{ + #[inline] + fn process_with_state(&mut self, state: &mut WdfState, x: i32) -> i32 { + let mut x = x; + let mut m = M; + for (a, z) in self.a.iter().zip(state.z.iter_mut()) { + let [y, next] = Tpa::from((m & 0xf) as u8).adapt([x, *z], *a); + *z = next; // update this section's delay state + x = y; // output wave feeds the next section + m >>= 4; + } + debug_assert_eq!(m, 0); + x + } +} diff --git a/crates/embedded-dsp/src/lib.rs b/crates/embedded-dsp/src/lib.rs index a522776..61ba048 100644 --- a/crates/embedded-dsp/src/lib.rs +++ b/crates/embedded-dsp/src/lib.rs @@ -1,5 +1,8 @@ #![no_std] #![warn(missing_docs)] +// `doc(cfg(...))` badges are a nightly feature; `docsrs` is only set by the +// `rustdoc-args` in `[package.metadata.docs.rs]`, so stable builds are inert. +#![cfg_attr(docsrs, feature(doc_cfg))] //! # embedded-dsp //! @@ -47,6 +50,7 @@ extern crate std; macro_rules! gated_mod { ($feature:literal, $module:ident) => { #[cfg(feature = $feature)] + #[cfg_attr(docsrs, doc(cfg(feature = $feature)))] #[doc = concat!("The `", stringify!($module), "` module.")] pub mod $module; #[cfg(feature = $feature)] @@ -54,6 +58,10 @@ macro_rules! gated_mod { }; (math $feature:literal, $module:ident) => { #[cfg(all(feature = $feature, any(feature = "std", feature = "libm")))] + #[cfg_attr( + docsrs, + doc(cfg(all(feature = $feature, any(feature = "std", feature = "libm")))) + )] #[doc = concat!("The `", stringify!($module), "` module.")] pub mod $module; #[cfg(all(feature = $feature, any(feature = "std", feature = "libm")))] @@ -70,10 +78,8 @@ gated_mod!("const-generics", const_generics); gated_mod!("controller", controller); gated_mod!("cordic", cordic); gated_mod!("distance", distance); -#[cfg(feature = "dither")] -pub mod dither; -#[cfg(feature = "dsm")] -pub mod dsm; +gated_mod!("dither", dither); +gated_mod!("dsm", dsm); gated_mod!(math "dynamics", dynamics); gated_mod!("fec", fec); gated_mod!("fast-math", fast_math); diff --git a/crates/embedded-dsp/src/modem.rs b/crates/embedded-dsp/src/modem.rs index 7b57cd3..4ec003f 100644 --- a/crates/embedded-dsp/src/modem.rs +++ b/crates/embedded-dsp/src/modem.rs @@ -1,12 +1,12 @@ //! Analog-style AM, FM, and SSB at complex baseband. //! //! Frequency modulation uses a phase accumulator (no sine LUT). AM-DSB is -//! envelope modulation. SSB reuses [`HilbertTransformF32`] for the analytic +//! envelope modulation. SSB reuses [`HilbertTransform`] for the analytic //! signal (USB = `I + jQ`, LSB = `I − jQ`). #[allow(unused_imports)] use crate::math::FloatMath; -use crate::transform::HilbertTransformF32; +use crate::transform::HilbertTransform; use crate::types::{Complex, Status}; /// Frequency modulator: `s = exp(j · φ)`, `φ += 2π kf m`. @@ -141,9 +141,9 @@ pub enum SsbSideband { Lsb, } -/// SSB modulator using a caller-owned [`HilbertTransformF32`]. +/// SSB modulator using a caller-owned [`HilbertTransform`]. pub struct SsbMod<'a> { - ht: HilbertTransformF32<'a>, + ht: HilbertTransform<'a, f32>, mod_index: f32, sideband: SsbSideband, suppressed_carrier: bool, @@ -152,7 +152,7 @@ pub struct SsbMod<'a> { impl<'a> SsbMod<'a> { /// Wraps an existing Hilbert transformer. `mod_index` must be `> 0`. pub fn new( - ht: HilbertTransformF32<'a>, + ht: HilbertTransform<'a, f32>, sideband: SsbSideband, mod_index: f32, suppressed_carrier: bool, @@ -202,7 +202,7 @@ impl<'a> SsbMod<'a> { /// /// `i_delay` must be the same length as the Hilbert tap/state buffers. pub struct SsbDemod<'a> { - ht: HilbertTransformF32<'a>, + ht: HilbertTransform<'a, f32>, i_delay: &'a mut [f32], mod_index: f32, sideband: SsbSideband, @@ -211,7 +211,7 @@ pub struct SsbDemod<'a> { impl<'a> SsbDemod<'a> { /// Wraps a Hilbert transformer and an I-channel delay line of equal length. pub fn new( - ht: HilbertTransformF32<'a>, + ht: HilbertTransform<'a, f32>, i_delay: &'a mut [f32], sideband: SsbSideband, mod_index: f32, diff --git a/crates/embedded-dsp/src/support.rs b/crates/embedded-dsp/src/support.rs index 4d80759..916954a 100644 --- a/crates/embedded-dsp/src/support.rs +++ b/crates/embedded-dsp/src/support.rs @@ -297,7 +297,7 @@ pub fn gaussian_noise_f32(dst: &mut [f32], mean: f32, std_dev: f32, seed: &mut u /// Quantize f32 biquad SOS coeffs (`[b0,b1,b2,a1,a2]` per stage) to Q15. /// /// Stores `coeff / 2^{post_shift} * 2^{15}` so values with magnitude `>= 1` fit in Q15. -/// [`crate::filtering::BiquadCascadeInstanceQ15`]. +/// [`crate::filtering::BiquadCascadeInstance`]. pub fn biquad_coeffs_f32_to_q15(src: &[f32], dst: &mut [q15], post_shift: u8) -> Status { if src.len() != dst.len() || src.is_empty() || !src.len().is_multiple_of(5) { return Status::LengthError; diff --git a/crates/embedded-dsp/src/transform.rs b/crates/embedded-dsp/src/transform.rs index 34142af..78f5319 100644 --- a/crates/embedded-dsp/src/transform.rs +++ b/crates/embedded-dsp/src/transform.rs @@ -1233,7 +1233,7 @@ pub fn hilbert_fir_design_f32(dst_coeffs: &mut [f32]) -> Status { /// Accumulates via [`DspSample::madd`] (the full-width raw product, summed before a single /// narrowing shift in [`DspSample::from_accum`]) — the same shape [`crate::filtering::fir`] /// itself was built from before Stage 3 moved it to the per-term-shifted [`DspSample::mul_high`]. -/// This matters here: the hand-written `HilbertTransformQ15` this type replaces accumulated in +/// This matters here: the hand-written `HilbertTransform` this type replaces accumulated in /// `i32`, which a filter with more than two or three near-full-scale taps can overflow (the same /// risk Stage 1's `Accum = i64` for both fixed widths exists to close). Genericizing widens the /// accumulator to `i64` and removes that overflow — a deliberate fix, not a silent behavior @@ -1344,10 +1344,7 @@ impl<'a, T: DspSample> crate::pipeline::SplitProcess for HilbertT } } -/// `f32` Hilbert transformer (see [`HilbertTransform`]). -pub type HilbertTransformF32<'a> = HilbertTransform<'a, f32>; -/// `q15` Hilbert transformer (see [`HilbertTransform`]). -pub type HilbertTransformQ15<'a> = HilbertTransform<'a, q15>; + /// Computes the instantaneous envelope (magnitude) of an analytic signal: `sqrt(I^2 + Q^2)`. pub fn analytic_envelope_f32(analytic: &[Complex], dst_env: &mut [f32]) { diff --git a/crates/embedded-dsp/src/types.rs b/crates/embedded-dsp/src/types.rs index 7bfa0ca..cdeba52 100644 --- a/crates/embedded-dsp/src/types.rs +++ b/crates/embedded-dsp/src/types.rs @@ -560,7 +560,7 @@ fn saturating_div_q31(a: q31, b: q31) -> q31 { /// The arithmetic surface here used to be entirely `Self -> Self -> Self`: a coefficient had the /// same type as a sample and a product never widened. That makes two of the most common DSP designs /// inexpressible, and it is the reason the fixed-point stages historically shipped a hand-written -/// twin per width (`SinglePoleFilterQ15`, `FirInstanceQ15`, ...): +/// twin per width (`SinglePoleFilterQ15`, `FirInstance`, ...): /// /// 1. **Wider accumulators.** A Q15 recurrence sums several Q30 products before shifting back down, /// which neither a Q15 nor a single Q30 word holds. [`Accum`](Self::Accum) is the domain those diff --git a/crates/embedded-dsp/tests/analog_modem.rs b/crates/embedded-dsp/tests/analog_modem.rs index a1515ea..65c3e95 100644 --- a/crates/embedded-dsp/tests/analog_modem.rs +++ b/crates/embedded-dsp/tests/analog_modem.rs @@ -1,7 +1,7 @@ //! AM, FM, and Hilbert-based SSB modem tests. use embedded_dsp::modem::{AmDsb, FmDemod, FmMod, SsbDemod, SsbMod, SsbSideband}; -use embedded_dsp::transform::{HilbertTransformF32, hilbert_fir_design_f32}; +use embedded_dsp::transform::{HilbertTransform, hilbert_fir_design_f32}; use embedded_dsp::types::Status; #[test] @@ -54,8 +54,8 @@ fn ssb_usb_roundtrip_lsb_cancelled() { let mut st_tx = [0.0f32; N]; let mut st_rx = [0.0f32; N]; let mut i_delay = [0.0f32; N]; - let ht_tx = HilbertTransformF32::new(&h, &mut st_tx).unwrap(); - let ht_rx = HilbertTransformF32::new(&h, &mut st_rx).unwrap(); + let ht_tx = HilbertTransform::::new(&h, &mut st_tx).unwrap(); + let ht_rx = HilbertTransform::::new(&h, &mut st_rx).unwrap(); let mut tx = SsbMod::new(ht_tx, SsbSideband::Usb, 0.7, true).unwrap(); let mut rx = SsbDemod::new(ht_rx, &mut i_delay, SsbSideband::Usb, 0.7).unwrap(); let delay = tx.group_delay() + rx.group_delay(); @@ -80,8 +80,8 @@ fn ssb_usb_roundtrip_lsb_cancelled() { let mut st_tx = [0.0f32; N]; let mut st_lsb = [0.0f32; N]; let mut i_delay = [0.0f32; N]; - let ht_tx = HilbertTransformF32::new(&h, &mut st_tx).unwrap(); - let ht_lsb = HilbertTransformF32::new(&h, &mut st_lsb).unwrap(); + let ht_tx = HilbertTransform::::new(&h, &mut st_tx).unwrap(); + let ht_lsb = HilbertTransform::::new(&h, &mut st_lsb).unwrap(); let mut tx = SsbMod::new(ht_tx, SsbSideband::Usb, 0.7, true).unwrap(); let mut rx_lsb = SsbDemod::new(ht_lsb, &mut i_delay, SsbSideband::Lsb, 0.7).unwrap(); let mut leak = 0.0; @@ -102,14 +102,14 @@ fn ssb_usb_roundtrip_lsb_cancelled() { let mut st = [0.0f32; N]; let mut short = [0.0f32; 3]; - let ht = HilbertTransformF32::new(&h, &mut st).unwrap(); + let ht = HilbertTransform::::new(&h, &mut st).unwrap(); assert_eq!( SsbDemod::new(ht, &mut short, SsbSideband::Usb, 0.7).err(), Some(Status::LengthError) ); let mut st2 = [0.0f32; N]; - let ht2 = HilbertTransformF32::new(&h, &mut st2).unwrap(); + let ht2 = HilbertTransform::::new(&h, &mut st2).unwrap(); assert_eq!( SsbMod::new(ht2, SsbSideband::Lsb, 0.0, false).err(), Some(Status::ArgumentError) @@ -117,14 +117,14 @@ fn ssb_usb_roundtrip_lsb_cancelled() { let mut st3 = [0.0f32; N]; let mut i_delay = [0.0f32; N]; - let ht3 = HilbertTransformF32::new(&h, &mut st3).unwrap(); + let ht3 = HilbertTransform::::new(&h, &mut st3).unwrap(); assert_eq!( SsbDemod::new(ht3, &mut i_delay, SsbSideband::Usb, 0.0).err(), Some(Status::ArgumentError) ); let mut st_lsb = [0.0f32; N]; - let ht_lsb = HilbertTransformF32::new(&h, &mut st_lsb).unwrap(); + let ht_lsb = HilbertTransform::::new(&h, &mut st_lsb).unwrap(); let mut lsb = SsbMod::new(ht_lsb, SsbSideband::Lsb, 0.5, false).unwrap(); let y = lsb.modulate(0.2); assert!(y.real.is_finite() && y.imag.is_finite()); diff --git a/crates/embedded-dsp/tests/basic_math_coverage.rs b/crates/embedded-dsp/tests/basic_math.rs similarity index 100% rename from crates/embedded-dsp/tests/basic_math_coverage.rs rename to crates/embedded-dsp/tests/basic_math.rs diff --git a/crates/embedded-dsp/tests/biquad_and_cic_coverage.rs b/crates/embedded-dsp/tests/biquad_and_cic_coverage.rs deleted file mode 100644 index 0f814af..0000000 --- a/crates/embedded-dsp/tests/biquad_and_cic_coverage.rs +++ /dev/null @@ -1,216 +0,0 @@ -//! Coverage for the streaming biquad state APIs and the CIC resampler. -//! -//! `DirectForm1`/`DirectForm2Transposed` state, the `Biquad`/`BiquadClamp` -//! sample processors, and the generic `CicFilter` were only reachable through -//! paths exercised elsewhere; these tests drive them directly. - -use embedded_dsp::filtering::{Biquad, BiquadClamp, DirectForm1, DirectForm2Transposed}; -use embedded_dsp::resampling::{CicDec3, CicFilter, CicInt3}; - -// ───────────────────────────────────────────────────────────────────────────── -// Direct form state -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn direct_form_state_defaults_new_and_reset() { - let mut df1 = DirectForm1::::default(); - assert_eq!(df1.xy, [0.0; 4]); - assert_eq!(df1, DirectForm1::new()); - - df1.xy = [1.0, 2.0, 3.0, 4.0]; - df1.reset(); - assert_eq!(df1.xy, [0.0; 4]); - - let mut df2 = DirectForm2Transposed::::default(); - assert_eq!(df2.s, [0.0; 2]); - assert_eq!(df2, DirectForm2Transposed::new()); - - df2.s = [1.0, 2.0]; - df2.reset(); - assert_eq!(df2.s, [0.0; 2]); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Biquad: direct form 1 and direct form 2 transposed -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn biquad_df1_follows_the_documented_recurrence() { - // y0 = 0.5*x0 + 0.5*x1, i.e. a two-point moving average (a1 = a2 = 0). - let bq = Biquad::new(0.5f32, 0.5, 0.0, 0.0, 0.0); - let mut state = DirectForm1::::new(); - - assert_eq!(bq.process_df1(&mut state, 1.0), 0.5); - assert_eq!(bq.process_df1(&mut state, 2.0), 1.5); - assert_eq!(bq.process_df1(&mut state, 4.0), 3.0); - // History is [x0, x1, y0, y1] from the most recent call. - assert_eq!(state.xy, [4.0, 2.0, 3.0, 1.5]); -} - -#[test] -fn biquad_df2t_agrees_with_df1() { - // Direct Form 1 and Direct Form 2 Transposed are equivalent realisations - // of the same transfer function, so they must agree sample for sample. - let ba = Biquad::new(0.5f32, 0.25, 0.125, 0.5, -0.25); - let mut df1 = DirectForm1::::new(); - let mut df2t = DirectForm2Transposed::::new(); - - for n in 0..32 { - let x = (n as f32) * 0.25 - 2.0; - let y1 = ba.process_df1(&mut df1, x); - let y2 = ba.process_df2t(&mut df2t, x); - assert!((y1 - y2).abs() < 1e-5, "step {n}: df1={y1} df2t={y2}"); - } -} - -#[test] -fn biquad_f64_forms_agree() { - let ba = Biquad::new(0.5f64, 0.25, 0.125, 0.5, -0.25); - let mut df1 = DirectForm1::::new(); - let mut df2t = DirectForm2Transposed::::new(); - - for n in 0..32 { - let x = (n as f64) * 0.25 - 2.0; - let y1 = ba.process_df1(&mut df1, x); - let y2 = ba.process_df2t(&mut df2t, x); - assert!((y1 - y2).abs() < 1e-12, "step {n}: df1={y1} df2t={y2}"); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// BiquadClamp: anti-windup clamping -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn biquad_clamp_f32_limits_output() { - // Pure gain of 2, clamped to +/-1. - let clamp = BiquadClamp::new(Biquad::new(2.0f32, 0.0, 0.0, 0.0, 0.0), -1.0, 1.0, 0.0); - - let mut df1 = DirectForm1::::new(); - assert_eq!(clamp.process_df1(&mut df1, 0.25), 0.5); - assert_eq!(clamp.process_df1(&mut df1, 5.0), 1.0); - assert_eq!(clamp.process_df1(&mut df1, -5.0), -1.0); - - let mut df2t = DirectForm2Transposed::::new(); - assert_eq!(clamp.process_df2t(&mut df2t, 0.25), 0.5); - assert_eq!(clamp.process_df2t(&mut df2t, 5.0), 1.0); - assert_eq!(clamp.process_df2t(&mut df2t, -5.0), -1.0); -} - -#[test] -fn biquad_clamp_f32_applies_the_summing_offset() { - // Zero coefficients: the output is just the `u` offset, then clamped. - let clamp = BiquadClamp::new(Biquad::new(0.0f32, 0.0, 0.0, 0.0, 0.0), -0.25, 0.25, 0.5); - - let mut df1 = DirectForm1::::new(); - assert_eq!(clamp.process_df1(&mut df1, 0.0), 0.25); - - let mut df2t = DirectForm2Transposed::::new(); - assert_eq!(clamp.process_df2t(&mut df2t, 0.0), 0.25); -} - -#[test] -fn biquad_clamp_f64_limits_output() { - let clamp = BiquadClamp::new(Biquad::new(2.0f64, 0.0, 0.0, 0.0, 0.0), -1.0, 1.0, 0.0); - - let mut df1 = DirectForm1::::new(); - assert_eq!(clamp.process_df1(&mut df1, 5.0), 1.0); - assert_eq!(clamp.process_df1(&mut df1, -5.0), -1.0); - - let mut df2t = DirectForm2Transposed::::new(); - assert_eq!(clamp.process_df2t(&mut df2t, 5.0), 1.0); - assert_eq!(clamp.process_df2t(&mut df2t, -5.0), -1.0); -} - -// ───────────────────────────────────────────────────────────────────────────── -// CIC filter -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn cic_accessors_report_configuration_and_clear_keeps_rate() { - let mut filter = CicFilter::::new(2); - assert_eq!(filter.order(), 3); - assert_eq!(filter.comb_delay(), 1); - assert_eq!(filter.rate(), 2); - assert!(filter.tick(), "index starts at zero"); - assert_eq!(filter.get_decimate(), 0); - assert_eq!(filter.get_interpolate(), 0); - - filter.set_rate(4); - assert_eq!(filter.rate(), 4); - - // A decimation call advances the phase, so the next tick is not due. - let _ = filter.process_decimate(1); - assert!(!filter.tick()); - - filter.clear(); - assert!(filter.tick(), "clear resets the phase"); - assert_eq!(filter.rate(), 4, "clear preserves the configured rate"); - assert_eq!(filter.get_decimate(), 0); -} - -#[test] -fn cic_decimator_zero_input_stays_zero() { - let mut dec = CicDec3::::new(2); - for _ in 0..30 { - if let Some(y) = dec.process_decimate(0) { - assert_eq!(y, 0); - } - } -} - -#[test] -fn cic_decimator_emits_one_output_per_rate_plus_one_samples() { - let rate = 2u32; - let mut dec = CicFilter::::new(rate); - let mut outputs = 0; - for _ in 0..(3 * (rate as usize + 1)) { - if dec.process_decimate(1).is_some() { - outputs += 1; - } - } - assert_eq!(outputs, 3, "9 inputs at rate 2 decimate to 3 outputs"); -} - -#[test] -fn cic_decimator_is_linear_and_reports_last_output() { - let mut one = CicFilter::::new(2); - let mut three = CicFilter::::new(2); - let mut last = 0; - - for n in 0..24 { - let x = n % 5 - 2; - let y1 = one.process_decimate(x); - let y3 = three.process_decimate(x * 3); - // Linear, zero-state filter: tripling the input triples the output. - assert_eq!(y3, y1.map(|v| v * 3), "step {n}"); - if let Some(y) = y1 { - last = y; - } - } - assert_eq!(one.get_decimate(), last); -} - -#[test] -fn cic_interpolator_emits_rate_plus_one_samples_per_input() { - let rate = 3u32; - let mut interp: CicInt3 = CicInt3::new(rate); - let period = rate as usize + 1; - - let mut inputs = 0; - let mut last_output = 0; - for _ in 0..(period * 4) { - let slow = interp.tick(); - if slow { - inputs += 1; - } - last_output = interp.process_interpolate(if slow { Some(1) } else { None }); - } - - assert_eq!(inputs, 4, "one slow input every {period} fast cycles"); - assert_eq!( - interp.get_interpolate(), - last_output, - "accessor must expose the most recent integrator value" - ); -} diff --git a/crates/embedded-dsp/tests/cfft_fixed_collapse_regression.rs b/crates/embedded-dsp/tests/cfft_fixed_collapse.rs similarity index 100% rename from crates/embedded-dsp/tests/cfft_fixed_collapse_regression.rs rename to crates/embedded-dsp/tests/cfft_fixed_collapse.rs diff --git a/crates/embedded-dsp/tests/pll_kalman_coverage.rs b/crates/embedded-dsp/tests/control_and_estimation.rs similarity index 57% rename from crates/embedded-dsp/tests/pll_kalman_coverage.rs rename to crates/embedded-dsp/tests/control_and_estimation.rs index 7166161..f968968 100644 --- a/crates/embedded-dsp/tests/pll_kalman_coverage.rs +++ b/crates/embedded-dsp/tests/control_and_estimation.rs @@ -1,14 +1,233 @@ -//! Coverage for the PLL accessors / integer-PLL helpers and the EKF model -//! input-forwarding defaults. -//! -//! The PLL loop bodies were already exercised by the main test suite, but the -//! reporting accessors, the RPLL timestamp path, `ClampWrap`'s low-rail -//! branch, and the `EkfModel::*_with_input` defaults were not. +//! Consolidated tests: types_controller_coverage, pll_kalman_coverage. +use embedded_dsp::controller::{PidAction, PidBuilder, PidError}; use embedded_dsp::kalman::EkfModel; use embedded_dsp::pipeline::{DspNode, Process, Split, SplitProcess}; use embedded_dsp::pll::{ClampWrap, CostasLoop, IntPll, IntPllState, Rpll, RpllConfig, SogiPll}; +use embedded_dsp::types::{DspSample, q15, q31}; +// ─── from types_controller_coverage.rs ──────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// DspSample impls (present in both `fixed` configurations) +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn dsp_sample_saturating_ops_for_float_types() { + assert_eq!(::sat_div(1.0, 4.0), 0.25); + assert_eq!(::to_f32(2.5), 2.5); +} + +#[test] +fn dsp_sample_saturating_ops_for_q15_and_q31() { + let one = q15::from_bits(32_767); + let half = q15::from_bits(16_384); + let _ = ::sat_mul(half, one); + let _ = ::sat_div(half, one); + assert!((::to_f32(half) - 0.5).abs() < 1e-3); + + let one31 = q31::from_bits(i32::MAX); + let half31 = q31::from_bits(1 << 30); + let _ = ::sat_mul(half31, one31); + let _ = ::sat_div(half31, one31); + assert!((::to_f32(half31) - 0.5).abs() < 1e-6); +} + +// ───────────────────────────────────────────────────────────────────────────── +// PID builder +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn pid_builder_accessors_chain_into_a_valid_configuration() { + let builder = PidBuilder::default() + .gain(PidAction::P, 1.0) + .limit(PidAction::I, 0.5) + .kd2(0.25) + .limit_d2(0.125) + .offset(0.75) + .output_limits(-1.0, 1.0); + + assert!(builder.validate(0.001).is_ok()); +} + +#[test] +fn pid_builder_validate_rejects_non_finite_period() { + assert!(matches!( + PidBuilder::default().validate(f32::NAN), + Err(PidError::NonFinite("period")) + )); +} + +#[test] +fn pid_builder_validate_rejects_non_finite_limit() { + assert!(matches!( + PidBuilder::default() + .limit(PidAction::I, f32::NAN) + .validate(0.001), + Err(PidError::NonFinite("limit")) + )); +} + +#[test] +fn pid_builder_validate_rejects_zero_limit() { + assert!(matches!( + PidBuilder::default() + .limit(PidAction::D, 0.0) + .validate(0.001), + Err(PidError::NonPositive("limit")) + )); +} + +#[test] +fn pid_builder_validate_rejects_inverted_output_limits() { + assert!(matches!( + PidBuilder::default() + .output_limits(1.0, -1.0) + .validate(0.001), + Err(PidError::InvertedRange("output_limits")) + )); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Fallback fixed-point types (`fixed` feature off) +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(not(feature = "fixed"))] +mod fallback_fixed_point { + use embedded_dsp::types::{FixedNum, I16F16, q15}; + + #[test] + fn fixed_num_f64_conversion_edge_cases() { + // NaN maps to zero. + assert_eq!( + ::to_raw_fixed(f64::NAN, 8, -1000, 1000, false), + 0 + ); + + // Saturating conversion clamps to the low rail. + assert_eq!( + ::to_raw_fixed(-1.0e9, 8, -100, 100, true), + -100 + ); + assert_eq!( + ::to_raw_fixed(1.0e9, 8, -100, 100, true), + 100 + ); + + // Exact .5 tie with an odd integer part rounds away from zero. + // 1.5 / 256 scaled by 256 gives abs_int = 1 (odd) -> rounds to 2. + let tie = 1.5f64 / 256.0; + assert_eq!( + ::to_raw_fixed(tie, 8, -1000, 1000, true), + 2 + ); + + assert_eq!(::from_raw_fixed(256, 8), 1.0); + } + + #[test] + fn fixed_num_integer_conversion_saturates() { + assert_eq!( + ::to_raw_fixed(1000, 8, -100, 100, true), + 100 + ); + assert_eq!( + ::to_raw_fixed(-1000, 8, -100, 100, true), + -100 + ); + assert_eq!(::from_raw_fixed(256, 8), 1); + } + + #[test] + fn q15_wrapping_div_and_recip_edge_cases() { + let half = q15::from_bits(16_384); + let zero = q15::from_bits(0); + + // Division by zero short-circuits to zero instead of dividing. + assert_eq!(half.wrapping_div(zero), zero); + assert_eq!(half.wrapping_div_int(0), zero); + + // Reciprocal of zero saturates to MAX. + assert_eq!(zero.recip(), q15::MAX); + } + + #[test] + fn q15_checked_div_reports_zero_and_overflow() { + let zero = q15::from_bits(0); + assert_eq!(q15::from_bits(16_384).checked_div(zero), None); + + // 1.0 / tiny overflows the i16 backing store. + assert_eq!(q15::from_bits(32_767).checked_div(q15::from_bits(1)), None); + + // 0.25 / 0.5 = 0.5, which fits. + assert_eq!( + q15::from_bits(8_192).checked_div(q15::from_bits(16_384)), + Some(q15::from_bits(16_384)) + ); + } + + #[test] + fn fallback_fixed_num_scaling_saturates() { + // q15 has 15 fractional bits; converting into a narrow window clamps. + assert_eq!( + q15::from_bits(32_767).to_raw_fixed(15, -100, 100, true), + 100 + ); + assert_eq!( + q15::from_bits(-32_768).to_raw_fixed(15, -100, 100, true), + -100 + ); + + // Raw values at or below the type's own precision shift left... + assert_eq!(::from_raw_fixed(1, 15), q15::from_bits(1)); + assert_eq!( + ::from_raw_fixed(128, 8), + q15::from_bits(16_384) + ); + // ...and coarser inputs shift right. + assert_eq!(::from_raw_fixed(32, 20), q15::from_bits(1)); + } + + #[test] + fn fallback_arithmetic_operators() { + let a = q15::from_bits(1_000); + let b = q15::from_bits(200); + + assert_eq!(a + b, q15::from_bits(1_200)); + assert_eq!(a - b, q15::from_bits(800)); + + // Q15 multiply: (1000 * 200) >> 15 == 6. + assert_eq!(a * b, q15::from_bits(6)); + + let mut sub = a; + sub -= b; + assert_eq!(sub, q15::from_bits(800)); + + let mut mul = a; + mul *= b; + assert_eq!(mul, q15::from_bits(6)); + } + + #[test] + fn fallback_display_shows_raw_bits() { + assert_eq!(format!("{}", q15::from_bits(1_000)), "1000"); + assert_eq!(format!("{}", q15::from_bits(-1)), "-1"); + } + + #[test] + fn fallback_integer_comparisons() { + // I16F16 is backed by i32 with 16 fractional bits, so 65536 raw == 1. + let one = I16F16::from_bits(1 << 16); + + assert!(one == 1i32); + assert!(1i32 == one); + assert_eq!(1i32.partial_cmp(&one), Some(core::cmp::Ordering::Equal)); + + let two = I16F16::from_bits(2 << 16); + assert!(1i32 < two); + } +} + +// ─── from pll_kalman_coverage.rs ──────────────────────────────────────── // ───────────────────────────────────────────────────────────────────────────── // SOGI-PLL // ───────────────────────────────────────────────────────────────────────────── diff --git a/crates/embedded-dsp/tests/coverage_boost_tests.rs b/crates/embedded-dsp/tests/coverage_boost_tests.rs deleted file mode 100644 index a174519..0000000 --- a/crates/embedded-dsp/tests/coverage_boost_tests.rs +++ /dev/null @@ -1,178 +0,0 @@ -use embedded_dsp::*; - -#[test] -fn test_pipeline_thorough_coverage() { - let gain_f32 = Gain::new(2.0f32); - let limiter_f32 = Limiter::new(-1.0f32, 1.0f32); - let mut chain = gain_f32.then(limiter_f32); - - assert_eq!(chain.process_sample(0.25), 0.5); - assert_eq!(chain.process_sample(1.0), 1.0); // Limiter clamped - assert_eq!(chain.process_sample(-1.0), -1.0); // Limiter clamped - - let input_block = [0.1f32, 0.6, -0.8]; - let mut output_block = [0.0f32; 3]; - chain.process_block(&input_block, &mut output_block); - assert_eq!(output_block[0], 0.2); - assert_eq!(output_block[1], 1.0); - - let mut in_place = [0.1f32, 0.6, -0.8]; - chain.process_in_place(&mut in_place); - assert_eq!(in_place[0], 0.2); - assert_eq!(in_place[1], 1.0); - - // Gain i16 and i32 - let mut gain_i16 = Gain::new(16384i16); // 0.5 in Q15 - assert_eq!(gain_i16.process_sample(10000i16), 5000i16); - - let mut gain_i32 = Gain::new(1073741824i32); // 0.5 in Q31 - assert_eq!(gain_i32.process_sample(100000i32), 50000i32); - - // Limiter i16, i32, f32 - let mut lim_i16 = Limiter::new(-100i16, 100i16); - assert_eq!(lim_i16.process_sample(150i16), 100i16); - assert_eq!(lim_i16.process_sample(-150i16), -100i16); - assert_eq!(lim_i16.process_sample(50i16), 50i16); - - let mut lim_i32 = Limiter::new(-100i32, 100i32); - assert_eq!(lim_i32.process_sample(150i32), 100i32); -} - -#[test] -fn test_quaternion_coverage() { - let q = [1.0f32, 0.0, 0.0, 0.0]; - assert_eq!(quaternion_norm_f32(&q), 1.0); - - let mut zero_q = [0.0f32; 4]; - assert_eq!(quaternion_normalize_f32(&mut zero_q), Status::ArgumentError); - assert_eq!( - quaternion_inverse_f32(&zero_q, &mut [0.0; 4]), - Status::ArgumentError - ); - - let q1 = [ - core::f32::consts::FRAC_1_SQRT_2, - core::f32::consts::FRAC_1_SQRT_2, - 0.0, - 0.0, - ]; - let q2 = [ - core::f32::consts::FRAC_1_SQRT_2, - 0.0, - core::f32::consts::FRAC_1_SQRT_2, - 0.0, - ]; - let mut q_prod = [0.0f32; 4]; - quaternion_product_f32(&q1, &q2, &mut q_prod); - assert!(q_prod[0].is_finite()); - - let mut q_conj = [0.0f32; 4]; - quaternion_conjugate_f32(&q1, &mut q_conj); - assert_eq!(q_conj[0], q1[0]); - assert_eq!(q_conj[1], -q1[1]); - - let mut q_inv = [0.0f32; 4]; - assert_eq!(quaternion_inverse_f32(&q1, &mut q_inv), Status::Success); - - let mut rot_mat = [0.0f32; 9]; - quaternion_to_rotmat_f32(&q1, &mut rot_mat); - assert_eq!(rot_mat.len(), 9); -} - -#[test] -fn test_fast_math_and_approximations() { - assert!((fast_tanh_f32(0.0)).abs() < 1e-4); - assert!((fast_tanh_f32(5.0) - 1.0).abs() < 1e-4); - assert!((fast_tanh_f32(-5.0) + 1.0).abs() < 1e-4); - - assert!((fast_exp_f32(0.0) - 1.0).abs() < 1e-3); - assert_eq!(fast_exp_f32(-15.0), 0.0); - assert_eq!(fast_exp_f32(15.0), 22026.465); - - let mut res_atan = 0.0f32; - assert_eq!(atan2_f32(1.0, 1.0, &mut res_atan), Status::Success); - assert!((res_atan - core::f32::consts::FRAC_PI_4).abs() < 1e-3); - - let mut q31_out = q31::ZERO; - assert_eq!( - atan2_q31(q31::from_bits(10000), q31::from_bits(10000), &mut q31_out), - Status::Success - ); - - let mut q15_out = q15::ZERO; - assert_eq!( - atan2_q15(q15::from_bits(1000), q15::from_bits(1000), &mut q15_out), - Status::Success - ); - - let mut div_out_q15 = q15::ZERO; - let mut shift_q15 = 0i16; - assert_eq!( - divide_q15( - q15::from_bits(100), - q15::ZERO, - &mut div_out_q15, - &mut shift_q15 - ), - Status::ArgumentError - ); - assert_eq!( - divide_q15( - q15::from_bits(100), - q15::from_bits(200), - &mut div_out_q15, - &mut shift_q15 - ), - Status::Success - ); -} - -#[test] -fn test_safety_limiter_and_dynamics() { - let mut limiter = SafetyLimiter::new(0.9, 0.05, 48000.0); - assert_eq!(limiter.current_gain(), 1.0); - - let limited = limiter.process(1.5); - assert!((-0.9..=0.9).contains(&limited)); - assert!(limiter.current_gain() < 1.0); - - limiter.reset(); - assert_eq!(limiter.current_gain(), 1.0); - - let mut comp = DynamicsCompressor::new(-20.0, 4.0, 6.0, 0.005, 0.1, 4.0, 48000.0); - let _out = comp.process(0.8); - comp.reset(); - - let mut gate = NoiseGate::new(-40.0, -30.0, 0.002, 0.05, 48000.0); - let _g_out = gate.process(0.0001); - gate.reset(); -} - -#[test] -fn test_math_trait_floats() { - let x: f32 = 0.5; - assert!(FloatMath::abs(x) > 0.0); - assert!(FloatMath::sin(x) > 0.0); - assert!(FloatMath::cos(x) > 0.0); - assert!(FloatMath::tan(x) > 0.0); - assert!(FloatMath::sqrt(x) > 0.0); - assert!(FloatMath::ln(x) < 0.0); - assert!(FloatMath::log10(x) < 0.0); - assert!(FloatMath::exp(x) > 1.0); - assert!(FloatMath::atan2(x, 1.0) > 0.0); - assert!(FloatMath::powf(x, 2.0) == 0.25); - assert!(FloatMath::tanh(x) > 0.0); - - let y: f64 = 0.5; - assert!(FloatMath::abs(y) > 0.0); - assert!(FloatMath::sin(y) > 0.0); - assert!(FloatMath::cos(y) > 0.0); - assert!(FloatMath::tan(y) > 0.0); - assert!(FloatMath::sqrt(y) > 0.0); - assert!(FloatMath::ln(y) < 0.0); - assert!(FloatMath::log10(y) < 0.0); - assert!(FloatMath::exp(y) > 1.0); - assert!(FloatMath::atan2(y, 1.0) > 0.0); - assert!(FloatMath::powf(y, 2.0) == 0.25); - assert!(FloatMath::tanh(y) > 0.0); -} diff --git a/crates/embedded-dsp/tests/cross_module.rs b/crates/embedded-dsp/tests/cross_module.rs new file mode 100644 index 0000000..39cb3c1 --- /dev/null +++ b/crates/embedded-dsp/tests/cross_module.rs @@ -0,0 +1,1847 @@ +//! Consolidated tests: push_to_95_coverage_a, push_to_95_coverage_b, push_to_95_coverage_c, push_to_95_coverage_d, push_to_95_coverage_e, coverage_boost_tests, more_coverage_boost, final_90_plus_coverage_boost. + +use embedded_dsp::pipeline::DspNode; +use embedded_dsp::pipeline::*; +use embedded_dsp::*; + +// ─── from push_to_95_coverage_a.rs ──────────────────────────────────────── +#[test] +fn test_audio_exhaustive() { + let mut detector = GoertzelDetector::new(1000.0, 16000.0); + let sample_block = [0.1f32; 160]; + for &s in &sample_block { + detector.process_sample(s); + } + assert!(detector.magnitude().is_finite()); + detector.reset(); + + let mut q15_detector = GoertzelDetectorQ15::new(1000.0, 16000.0); + let q15_block = [q15::from_bits(1000); 160]; + for &s in &q15_block { + q15_detector.process_sample(s); + } + assert!(q15_detector.magnitude().to_bits() >= 0); + q15_detector.reset(); + + let mut peak_env = PeakEnvelopeFollower::new(10.0, 100.0); + assert!(peak_env.process(0.5).is_finite()); + peak_env.reset(); + + let mut rms_env = RmsEnvelopeFollower::new(100.0); + assert!(rms_env.process(0.5).is_finite()); + rms_env.reset(); + + let mut peak_env_q15 = PeakEnvelopeFollowerQ15::new(10.0, 100.0); + assert!(peak_env_q15.process(q15::from_bits(10000)).to_bits() >= 0); + peak_env_q15.reset(); + + let mut rms_env_q15 = RmsEnvelopeFollowerQ15::new(100.0); + assert!(rms_env_q15.process(q15::from_bits(10000)).to_bits() >= 0); + rms_env_q15.reset(); + + assert!(hz_to_mel(1000.0).is_finite()); + assert!(mel_to_hz(1000.0).is_finite()); + + let fft_mag = [1.0f32; 64]; + let mut filterbank_energies = [0.0f32; 10]; + let status = mel_filterbank_f32( + &fft_mag, + 64, + 16000.0, + 100.0, + 8000.0, + &mut filterbank_energies, + ); + assert_eq!(status, Status::Success); + + let frame = [0.1f32; 64]; + let mut mel_scratch = [0.0f32; 16]; + let mut mfcc_coeffs = [0.0f32; 10]; + let status = mfcc_f32( + &frame, + 16000.0, + 100.0, + 8000.0, + &mut mel_scratch, + &mut mfcc_coeffs, + ); + assert_eq!(status, Status::Success); + + let left = [0usize, 1, 2]; + let center = [1usize, 2, 3]; + let right = [2usize, 3, 4]; + let mut tri_energies = [0.0f32; 3]; + let status = + generalized_triangular_filterbank(&fft_mag, &left, ¢er, &right, &mut tri_energies); + assert_eq!(status, Status::Success); + + let q15_val = fast_log2_q15(q15::from_bits(4096)); + assert!(q15_val.to_bits() != 0); + + let vad = VadDetectorQ15::new(100, 2); + let q15_frame = [q15::from_bits(1000); 16]; + let _ = vad.is_active(&q15_frame); +} + +#[test] +fn test_beamforming_exhaustive() { + let mut bf = DelayAndSumBeamformer::<4, 64>::new(); + bf.set_delays(&[0.0, 1.0, 2.0, 3.0]); + bf.set_weights(&[0.25, 0.25, 0.25, 0.25]); + let mic_sample = [1.0, 1.0, 1.0, 1.0]; + let out = bf.process_sample(&mic_sample); + assert!(out.is_finite()); + bf.reset(); + + let sig_a = [1.0f32; 64]; + let sig_b = [1.0f32; 64]; + let result = gcc_phat_tdoa_f32(&sig_a, &sig_b, 10); + assert!(result.is_ok()); +} + +#[test] +fn test_controller_exhaustive() { + let mut inst_f32 = PidInstance::::new(1.0, 0.1, 0.01); + assert!(inst_f32.process(1.0).is_finite()); + assert!(PidInstance::process(&mut inst_f32, 1.0).is_finite()); + inst_f32.reset(); + + let mut inst_q31 = PidInstance::::new( + q31::from_bits(1_000_000_000), + q31::from_bits(100_000_000), + q31::from_bits(10_000_000), + ); + let _res_q31_1 = inst_q31.process(q31::from_bits(1_000_000_000)); + let _res_q31_2 = PidInstance::process(&mut inst_q31, q31::from_bits(1_000_000_000)); + inst_q31.reset(); + + let mut inst_q15 = PidInstance::::new( + q15::from_bits(10000), + q15::from_bits(1000), + q15::from_bits(100), + ); + let _res_q15_1 = inst_q15.process(q15::from_bits(10000)); + let _res_q15_2 = PidInstance::process(&mut inst_q15, q15::from_bits(10000)); + inst_q15.reset(); + + let (mut alpha, mut beta) = (0.0f32, 0.0f32); + clarke_f32(1.0, 0.0, &mut alpha, &mut beta); + let (mut d, mut q) = (0.0f32, 0.0f32); + park_f32(alpha, beta, 0.5, &mut d, &mut q); + let (mut ia, mut ib) = (0.0f32, 0.0f32); + inv_park_f32(d, q, 0.5, &mut alpha, &mut beta); + inv_clarke_f32(alpha, beta, &mut ia, &mut ib); + assert!(ia.is_finite() && ib.is_finite()); + + let (mut alpha_q, mut beta_q) = (q15::ZERO, q15::ZERO); + clarke_q15(q15::from_bits(1000), q15::ZERO, &mut alpha_q, &mut beta_q); + let (mut d_q, mut q_q) = (q15::ZERO, q15::ZERO); + park_q15( + alpha_q, + beta_q, + q15::from_bits(500), + q15::from_bits(1000), + &mut d_q, + &mut q_q, + ); + inv_park_q15( + d_q, + q_q, + q15::from_bits(500), + q15::from_bits(1000), + &mut alpha_q, + &mut beta_q, + ); + let (mut ia_q, mut ib_q) = (q15::ZERO, q15::ZERO); + inv_clarke_q15(alpha_q, beta_q, &mut ia_q, &mut ib_q); +} + +#[test] +fn test_intrinsics_lut_pll_psd() { + let _d1 = intrinsics::dual_mac_q15(0x00010002, 0x00030004, 0); + let _d2 = intrinsics::dual_mac_q63(0x00010002, 0x00030004, 0); + let _a1 = intrinsics::dual_saturating_add_q15(0x00010002, 0x00030004); + let _s1 = intrinsics::dual_saturating_sub_q15(0x00030004, 0x00010002); + let _sq = intrinsics::saturate_q15(40000); + let _sq31 = intrinsics::saturate_q31(3000000000); + + let src_a = [q15::from_bits(100); 4]; + let src_b = [q15::from_bits(200); 4]; + let mut dst = [q15::ZERO; 4]; + let _dot = intrinsics::simd_dot_prod_q15(&src_a, &src_b); + intrinsics::simd_add_q15(&src_a, &src_b, &mut dst); + intrinsics::simd_sub_q15(&src_a, &src_b, &mut dst); + intrinsics::simd_mult_q15(&src_a, &src_b, &mut dst); + + assert_ne!(lut::fast_sin_i16(1.0), 0); + assert_ne!(lut::fast_cos_i16(1.0), 0); + assert_ne!(lut::sin_q16(10000), 0); + assert_ne!(lut::cos_q16(10000), 0); + + let mut pll = SogiPll::new(50.0, 1000.0, 1.414, 60.0, 1400.0); + assert!(pll.process(1.0).is_finite()); + assert!(pll.frequency_hz().is_finite()); + assert!(pll.phase().is_finite()); + let _ortho = pll.orthogonal_components(); + pll.reset(); + + let mut costas = CostasLoop::new(1000.0, 10000.0, 10.0, 0.707); + let (i_out, q_out) = costas.process_sample(1.0); + assert!(i_out.is_finite() && q_out.is_finite()); + assert!(costas.frequency_hz().is_finite()); + assert!(costas.center_frequency_hz().is_finite()); + + let psd_data = [1.0f32; 128]; + let mut psd_out = [0.0f32; 32]; + assert_eq!( + welch_psd_f32( + &psd_data, + &mut psd_out, + 64, + 32, + 1000.0, + WelchWindow::Hamming, + true + ), + Status::Success + ); + + let mut pgram_out = [0.0f32; 32]; + assert_eq!( + periodogram_f32( + &psd_data[..64], + &mut pgram_out, + 64, + 1000.0, + WelchWindow::Rectangular, + false + ), + Status::Success + ); + + let mut ar_coeffs = [0.0f32; 4]; + if let Ok(noise_var) = ar_burg_f32(&psd_data[..32], 4, &mut ar_coeffs) { + let _ = ar_psd_f32(&ar_coeffs, noise_var, 32, &mut psd_out, false); + } +} + +// ─── from push_to_95_coverage_b.rs ──────────────────────────────────────── +#[test] +fn test_math_exhaustive() { + assert_eq!(isqrt_u32(0), 0); + assert_eq!(isqrt_u32(1), 1); + assert_eq!(isqrt_u32(2), 1); + assert_eq!(isqrt_u32(3), 1); + assert_eq!(isqrt_u32(4), 2); + assert_eq!(isqrt_u32(15), 3); + assert_eq!(isqrt_u32(16), 4); + assert_eq!(isqrt_u32(100), 10); + assert_eq!(isqrt_u32(100000), 316); + + assert_eq!(isqrt_u64(0), 0); + assert_eq!(isqrt_u64(1), 1); + assert_eq!(isqrt_u64(2), 1); + assert_eq!(isqrt_u64(3), 1); + assert_eq!(isqrt_u64(4), 2); + assert_eq!(isqrt_u64(15), 3); + assert_eq!(isqrt_u64(16), 4); + assert_eq!(isqrt_u64(100), 10); + assert_eq!(isqrt_u64(1_000_000_000), 31622); + + let x32 = 0.5f32; + assert!(FloatMath::abs(x32).is_finite()); + assert!(FloatMath::sin(x32).is_finite()); + assert!(FloatMath::cos(x32).is_finite()); + assert!(FloatMath::tan(x32).is_finite()); + assert!(FloatMath::sqrt(x32).is_finite()); + assert!(FloatMath::ln(x32).is_finite()); + assert!(FloatMath::log10(x32).is_finite()); + assert!(FloatMath::exp(x32).is_finite()); + assert!(FloatMath::atan2(x32, 1.0f32).is_finite()); + assert!(FloatMath::powf(x32, 2.0f32).is_finite()); + assert!(FloatMath::tanh(x32).is_finite()); + + let x64 = 0.5f64; + assert!(FloatMath::abs(x64).is_finite()); + assert!(FloatMath::sin(x64).is_finite()); + assert!(FloatMath::cos(x64).is_finite()); + assert!(FloatMath::tan(x64).is_finite()); + assert!(FloatMath::sqrt(x64).is_finite()); + assert!(FloatMath::ln(x64).is_finite()); + assert!(FloatMath::log10(x64).is_finite()); + assert!(FloatMath::exp(x64).is_finite()); + assert!(FloatMath::atan2(x64, 1.0f64).is_finite()); + assert!(FloatMath::powf(x64, 2.0f64).is_finite()); + assert!(FloatMath::tanh(x64).is_finite()); +} + +#[test] +fn test_types_and_dspsample_exhaustive() { + assert_eq!( + q15_mult(q15::from_bits(1000), q15::from_bits(2000)).to_bits(), + q15::from_bits(1000) + .saturating_mul(q15::from_bits(2000)) + .to_bits() + ); + assert_eq!( + q31_mult(q31::from_bits(10000), q31::from_bits(20000)).to_bits(), + q31::from_bits(10000) + .saturating_mul(q31::from_bits(20000)) + .to_bits() + ); + assert_eq!( + q7_mult(q7::from_bits(10), q7::from_bits(20)).to_bits(), + q7::from_bits(10) + .saturating_mul(q7::from_bits(20)) + .to_bits() + ); + + // DspSample for f32 + assert_eq!(f32::ZERO, 0.0); + assert_eq!(f32::ONE, 1.0); + assert_eq!(DspSample::sat_add(1.0f32, 2.0f32), 3.0f32); + assert_eq!(DspSample::sat_sub(3.0f32, 1.0f32), 2.0f32); + assert_eq!(DspSample::sat_mul(2.0f32, 3.0f32), 6.0f32); + assert_eq!(DspSample::sat_div(6.0f32, 2.0f32), 3.0f32); + assert_eq!(DspSample::abs_val(-5.0f32), 5.0f32); + assert_eq!(DspSample::abs_val(5.0f32), 5.0f32); + assert_eq!(DspSample::to_f32(4.5f32), 4.5f32); + assert_eq!(::from_f32(4.5f32), 4.5f32); + let _: ::Accum = 0.0f32; + let _: ::Coeff = 0.0f32; + assert_eq!(::madd(0.5, 2.0, 3.0), 6.5); + assert_eq!(::from_accum(6.5), 6.5); + assert_eq!(::coeff_from_f32(0.25), 0.25); + + // DspSample for f64 + assert_eq!(f64::ZERO, 0.0); + assert_eq!(f64::ONE, 1.0); + assert_eq!(DspSample::sat_add(1.0f64, 2.0f64), 3.0f64); + assert_eq!(DspSample::sat_sub(3.0f64, 1.0f64), 2.0f64); + assert_eq!(DspSample::sat_mul(2.0f64, 3.0f64), 6.0f64); + assert_eq!(DspSample::sat_div(6.0f64, 2.0f64), 3.0f64); + assert_eq!(DspSample::abs_val(-5.0f64), 5.0f64); + assert_eq!(DspSample::abs_val(5.0f64), 5.0f64); + assert_eq!(DspSample::to_f32(4.5f64), 4.5f32); + assert_eq!(::from_f32(4.5f32), 4.5f64); + let _: ::Accum = 0.0f64; + let _: ::Coeff = 0.0f64; + assert_eq!(::madd(0.5, 2.0, 3.0), 6.5); + assert_eq!(::from_accum(6.5), 6.5); + assert_eq!(::coeff_from_f32(0.25), 0.25); + + // DspSample for q15 + let a_q15 = q15::from_bits(1000); + let b_q15 = q15::from_bits(500); + assert_eq!( + DspSample::sat_add(a_q15, b_q15), + a_q15.saturating_add(b_q15) + ); + assert_eq!( + DspSample::sat_sub(a_q15, b_q15), + a_q15.saturating_sub(b_q15) + ); + assert_eq!( + DspSample::sat_mul(a_q15, b_q15), + a_q15.saturating_mul(b_q15) + ); + let _div_q15 = DspSample::sat_div(a_q15, b_q15); + let _div_zero15 = DspSample::sat_div(a_q15, q15::ZERO); + let _div_neg_zero15 = DspSample::sat_div(-a_q15, q15::ZERO); + assert_eq!(DspSample::abs_val(-a_q15), a_q15); + let _f_q15 = DspSample::to_f32(a_q15); + let _q15_from_f = ::from_f32(0.5); + + // The Q15 accumulator is wide enough to hold several Q30 products, then narrows once. + let _: ::Accum = 0i64; + let _: ::Coeff = q15::ZERO; + assert_eq!( + ::madd(0, q15::from_bits(1000), q15::from_bits(2000)), + 1000i64 * 2000 + ); + let wide_q15 = ::madd( + ::madd( + ::madd(0, q15::MAX, q15::MAX), + q15::MAX, + q15::MAX, + ), + q15::MAX, + q15::MAX, + ); + assert_eq!(wide_q15, 3 * (32767i64 * 32767)); + assert_eq!(::from_accum(wide_q15), q15::MAX); + assert_eq!(::from_accum(i64::MIN), q15::MIN); + assert_eq!(::from_accum(1 << 15), q15::from_bits(1)); + assert_eq!( + ::coeff_from_f32(0.5), + q15::saturating_from_num(0.5) + ); + assert_eq!(::coeff_from_f32(2.0), q15::MAX); + assert_eq!(::coeff_from_f32(-2.0), q15::MIN); + + // DspSample for q31 + let a_q31 = q31::from_bits(100000); + let b_q31 = q31::from_bits(50000); + assert_eq!( + DspSample::sat_add(a_q31, b_q31), + a_q31.saturating_add(b_q31) + ); + assert_eq!( + DspSample::sat_sub(a_q31, b_q31), + a_q31.saturating_sub(b_q31) + ); + assert_eq!( + DspSample::sat_mul(a_q31, b_q31), + a_q31.saturating_mul(b_q31) + ); + let _div_q31 = DspSample::sat_div(a_q31, b_q31); + let _div_zero31 = DspSample::sat_div(a_q31, q31::ZERO); + let _div_neg_zero31 = DspSample::sat_div(-a_q31, q31::ZERO); + assert_eq!(DspSample::abs_val(-a_q31), a_q31); + let _f_q31 = DspSample::to_f32(a_q31); + let _q31_from_f = ::from_f32(0.5); + + let _: ::Accum = 0i64; + let _: ::Coeff = q31::ZERO; + assert_eq!( + ::madd(0, a_q31, a_q31), + a_q31.to_bits() as i64 * a_q31.to_bits() as i64 + ); + assert_eq!(::from_accum(i64::MAX), q31::MAX); + assert_eq!(::from_accum(i64::MIN), q31::MIN); + assert_eq!(::from_accum(1 << 31), q31::from_bits(1)); + assert_eq!( + ::coeff_from_f32(0.5), + q31::saturating_from_num(0.5) + ); + assert_eq!(::coeff_from_f32(2.0), q31::MAX); + assert_eq!(::coeff_from_f32(-2.0), q31::MIN); + + // Complex operations + let c1 = Complex::new(1.0f32, 2.0f32); + let c2 = Complex::new(3.0f32, 4.0f32); + let c_add = c1 + c2; + let c_sub = c1 - c2; + let c_mul = c1 * c2; + let c_scale = c1 * 2.0f32; + let c_neg = -c1; + assert_eq!(c_add.real, 4.0); + assert_eq!(c_sub.real, -2.0); + assert!(c_mul.real.is_finite()); + assert_eq!(c_scale.real, 2.0); + assert_eq!(c_neg.real, -1.0); +} + +#[test] +fn test_dspsample_stage6_primitives_exhaustive() { + // `sat_neg`: plain negation for floats (preserving signed zero), saturating for fixed widths. + assert_eq!(::sat_neg(1.5), -1.5); + assert!(::sat_neg(0.0).is_sign_negative()); + assert_eq!(::sat_neg(1.5), -1.5); + assert!(::sat_neg(0.0).is_sign_negative()); + assert_eq!( + ::sat_neg(q15::from_bits(i16::MIN)), + q15::from_bits(i16::MAX) + ); + assert_eq!( + ::sat_neg(q15::from_bits(100)), + q15::from_bits(-100) + ); + assert_eq!( + ::sat_neg(q31::from_bits(i32::MIN)), + q31::from_bits(i32::MAX) + ); + assert_eq!( + ::sat_neg(q31::from_bits(100)), + q31::from_bits(-100) + ); + + // `wrapping_madd`: wrap the fixed-point product at native width before widening; identical to + // `madd` for floats since there's nothing to wrap. + assert_eq!(::wrapping_madd(0.5, 2.0, 3.0), 6.5); + assert_eq!(::wrapping_madd(0.5, 2.0, 3.0), 6.5); + assert_eq!( + ::wrapping_madd(0, q15::from_bits(i16::MIN), q15::from_bits(i16::MIN)), + i16::MIN as i64, + "MIN*MIN must wrap, not saturate" + ); + assert_eq!( + ::wrapping_madd(0, q31::from_bits(i32::MIN), q31::from_bits(i32::MIN)), + i32::MIN as i64, + "MIN*MIN must wrap, not saturate" + ); + + // `mul_shifted`: `mul_high` generalized to an explicit shift instead of the fixed `FRAC`. + assert_eq!(::mul_shifted(2.0, 3.0, 5), 6.0); + assert_eq!(::mul_shifted(2.0, 3.0, 5), 6.0); + assert_eq!( + ::mul_shifted(q15::from_bits(1000), q15::from_bits(2000), 17), + (1000i64 * 2000) >> 17 + ); + assert_eq!( + ::mul_shifted(q31::from_bits(1000), q31::from_bits(2000), 33), + (1000i64 * 2000) >> 33 + ); + + // `accum_shift`: shift a value already in the accumulator domain, staying there. + assert_eq!(::accum_shift(6.5, 3), 6.5); + assert_eq!(::accum_shift(6.5, 3), 6.5); + assert_eq!(::accum_shift(1000i64, 3), 1000i64 >> 3); + assert_eq!(::accum_shift(1000i64, 3), 1000i64 >> 3); + + // `coeff_from_q15_bits`: promote a shared Q15-precision twiddle-table entry to this sample's + // native coefficient width. + assert_eq!(::coeff_from_q15_bits(16384), 0.5); + assert_eq!(::coeff_from_q15_bits(16384), 0.5); + assert_eq!( + ::coeff_from_q15_bits(1000), + q15::from_bits(1000) + ); + assert_eq!( + ::coeff_from_q15_bits(1000), + q31::from_bits(1000 << 16) + ); + + // `f64`'s remaining `DspSample` methods (pre-existing since Stage 3, but never directly + // exercised anywhere else in the suite). + assert_eq!(::average_accum(6.0, 3), 2.0); + assert_eq!(::mul_high(2.0, 3.0), 6.0); + assert_eq!(::from_accum_shifted(6.5, 3), 6.5); + assert_eq!(::accum_from_shifted(6.5, 3), 6.5); + assert_eq!(::abs_val(-5.0), 5.0); + assert_eq!(::abs_val(5.0), 5.0); +} + +#[test] +fn test_pid_instance_derived_trait_impls() { + // `PidInstance`'s manual Clone/Copy/Debug/PartialEq/Default (the derive macros can't add + // bounds on associated types like `T::Coeff`, so these are hand-written). + let a = PidInstance::::new(1.0, 0.1, 0.01); + let b = a; + #[allow(clippy::clone_on_copy)] + let c = a.clone(); + assert_eq!(a, b); + assert_eq!(a, c); + assert!(format!("{a:?}").contains("PidInstance")); + + let mut d = PidInstance::::default(); + assert_ne!(a, d); + d.kp = a.kp; + d.ki = a.ki; + d.kd = a.kd; + d.init(1); + assert_eq!(a, d); + + let e = PidInstance::::new(q15::from_bits(100), q15::from_bits(10), q15::from_bits(1)); + let f = e; + assert_eq!(e, f); + assert!(format!("{e:?}").contains("PidInstance")); + assert_eq!(PidInstance::::default(), PidInstance::::default()); + + let g = PidInstance::::new(q31::from_bits(100), q31::from_bits(10), q31::from_bits(1)); + let h = g; + assert_eq!(g, h); + assert!(format!("{g:?}").contains("PidInstance")); + assert_eq!(PidInstance::::default(), PidInstance::::default()); +} + +#[test] +fn test_resampling_exhaustive() { + let mut cic_dec = CicDecimator::<3>::new(4); + assert!(cic_dec.gain() > 0); + assert!(cic_dec.gain_bits() > 0); + for i in 0..10 { + let _out = cic_dec.process_sample(i * 10); + let _out_s = cic_dec.process_sample_scaled(i * 10); + } + + let mut cic_interp = CicInterpolator::<3>::new(4); + assert!(cic_interp.gain() > 0); + assert!(cic_interp.gain_bits() > 0); + let mut interp_buf = [0i32; 4]; + for i in 0..5 { + cic_interp.process_sample(i * 10, &mut interp_buf); + } + + let coeffs_q15 = [q15::from_bits(4096); 8]; + let src_q15 = [q15::from_bits(1000); 16]; + let mut dst_dec = [q15::ZERO; 8]; + let count_dec = polyphase_decimate_q15(&src_q15, &coeffs_q15, 2, &mut dst_dec); + assert!(count_dec > 0); + + let mut dst_interp = [q15::ZERO; 32]; + let count_interp = polyphase_interpolate_q15(&src_q15, &coeffs_q15, 2, &mut dst_interp); + assert!(count_interp > 0); + + let mut dst_lin_q15 = [q15::ZERO; 32]; + resample_linear_q15(&src_q15, &mut dst_lin_q15, 0x00008000); // 0.5 ratio + + let src_f32 = [1.0f32; 16]; + let mut dst_lin_f32 = [0.0f32; 32]; + resample_linear_f32(&src_f32, &mut dst_lin_f32, 0.5); + + let mut dst_spec = [0.0f32; 32]; + let status_spec = spectral_interpolate_2x_f32(&src_f32, &mut dst_spec); + assert_eq!(status_spec, Status::Success); +} + +#[test] +fn test_spatial_exhaustive() { + let src_img = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]; + let mut dst_img = [0.0f32; 9]; + assert_eq!(dct2d_f32(&src_img, &mut dst_img, 3, 3), Status::Success); + let mut recovered = [0.0f32; 9]; + assert_eq!(idct2d_f32(&dst_img, &mut recovered, 3, 3), Status::Success); + + let kernel = [0.0f32, 1.0, 0.0, 1.0, -4.0, 1.0, 0.0, 1.0, 0.0]; + let mut convolved = [0.0f32; 9]; + assert_eq!( + convolve2d_f32(&src_img, &mut convolved, 3, 3, &kernel, 3, 3, true), + Status::Success + ); + // `normalize = false` skips the kernel-weight-sum normalization branch. + assert_eq!( + convolve2d_f32(&src_img, &mut convolved, 3, 3, &kernel, 3, 3, false), + Status::Success + ); + + let mut nonlin_out = [0.0f32; 9]; + assert_eq!( + nonlin2d_filter_f32(&src_img, &mut nonlin_out, 3, 3, 3, NonlinFilterType::Min), + Status::Success + ); + assert_eq!( + nonlin2d_filter_f32(&src_img, &mut nonlin_out, 3, 3, 3, NonlinFilterType::Max), + Status::Success + ); + assert_eq!( + nonlin2d_filter_f32(&src_img, &mut nonlin_out, 3, 3, 3, NonlinFilterType::Median), + Status::Success + ); + // Descending image reorders where the extrema land relative to the kernel tap-visitation + // order, exercising the "found a new min/max after the first tap" branches. + let src_img_desc = [9.0f32, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0]; + assert_eq!( + nonlin2d_filter_f32( + &src_img_desc, + &mut nonlin_out, + 3, + 3, + 3, + NonlinFilterType::Min + ), + Status::Success + ); + assert_eq!( + nonlin2d_filter_f32( + &src_img_desc, + &mut nonlin_out, + 3, + 3, + 3, + NonlinFilterType::Max + ), + Status::Success + ); + // ArgumentError: even k_size. + assert_eq!( + nonlin2d_filter_f32(&src_img, &mut nonlin_out, 3, 3, 2, NonlinFilterType::Min), + Status::ArgumentError + ); + // LengthError: declared dims exceed what the buffers hold. + assert_eq!( + nonlin2d_filter_f32( + &src_img[..4], + &mut nonlin_out, + 3, + 3, + 3, + NonlinFilterType::Min + ), + Status::LengthError + ); + + let mut edges = [0.0f32; 9]; + assert_eq!( + sobel_edge_detection_f32(&src_img, &mut edges, 3, 3, 2.0), + Status::Success + ); + // LengthError: declared dims exceed what the buffers hold. + assert_eq!( + sobel_edge_detection_f32(&src_img[..4], &mut edges, 3, 3, 2.0), + Status::LengthError + ); + + let mut hist_bins = [0usize; 5]; + assert_eq!( + histogram_2d_f32(&src_img, &mut hist_bins, 0.0, 10.0), + Status::Success + ); + + let mse = mse_2d_f32(&src_img, &recovered); + assert!(mse.is_finite()); + + let psnr = psnr_2d_f32(&src_img, &recovered, 9.0); + assert!(psnr.is_finite()); +} + +#[test] +fn test_fast_math_and_dynamics_exhaustive() { + assert!(fast_exp_f32(1.0).is_finite()); + assert!(fast_tanh_f32(1.0).is_finite()); + assert!(log_f32(2.0).is_finite()); + assert!(exp_f32(1.0).is_finite()); + + let mut s_f32 = 0.0f32; + let mut c_f32 = 0.0f32; + fast_math::sin_cos_f32(45.0, &mut s_f32, &mut c_f32); + assert_ne!(fast_math::sin_f32(1.0), 0.0); + assert_ne!(fast_math::cos_f32(1.0), 0.0); + + let mut s_q31 = q31::ZERO; + let mut c_q31 = q31::ZERO; + fast_math::sin_cos_q31(q31::from_bits(10000), &mut s_q31, &mut c_q31); + assert_ne!(fast_math::sin_q31(q31::from_bits(10000)).to_bits(), 0); + assert_ne!(fast_math::cos_q31(q31::from_bits(10000)).to_bits(), 0); + + // LUT negative inputs + assert_ne!(lut::fast_sin_i16(-1.0), 0); + assert_ne!(lut::fast_cos_i16(-1.0), 0); + assert_ne!(lut::sin_q16(-10000), 0); + assert_ne!(lut::cos_q16(-10000), 0); + + // CORDIC / Atan2 all 4 quadrants + let mut res_f32 = 0.0f32; + assert_eq!(atan2_f32(1.0, 1.0, &mut res_f32), Status::Success); + + let mut res_q31 = q31::ZERO; + assert_eq!( + atan2_q31(q31::from_bits(10000), q31::from_bits(10000), &mut res_q31), + Status::Success + ); + assert_eq!( + atan2_q31(q31::from_bits(10000), q31::from_bits(-10000), &mut res_q31), + Status::Success + ); + assert_eq!( + atan2_q31(q31::from_bits(-10000), q31::from_bits(10000), &mut res_q31), + Status::Success + ); + assert_eq!( + atan2_q31(q31::from_bits(-10000), q31::from_bits(-10000), &mut res_q31), + Status::Success + ); + assert_eq!( + atan2_q31(q31::ZERO, q31::ZERO, &mut res_q31), + Status::Success + ); + assert_eq!( + atan2_q31(q31::from_bits(10000), q31::ZERO, &mut res_q31), + Status::Success + ); + + let mut res_q15 = q15::ZERO; + assert_eq!( + atan2_q15(q15::from_bits(1000), q15::from_bits(1000), &mut res_q15), + Status::Success + ); + assert_eq!( + atan2_q15(q15::from_bits(1000), q15::from_bits(-1000), &mut res_q15), + Status::Success + ); + assert_eq!( + atan2_q15(q15::from_bits(-1000), q15::from_bits(1000), &mut res_q15), + Status::Success + ); + assert_eq!( + atan2_q15(q15::from_bits(-1000), q15::from_bits(-1000), &mut res_q15), + Status::Success + ); + + let mut out_f32 = 0.0f32; + assert_eq!(sqrt_f32(4.0, &mut out_f32), Status::Success); + assert_eq!(sqrt_f32(-1.0, &mut out_f32), Status::ArgumentError); + + let mut out_q31 = q31::ZERO; + assert_eq!( + sqrt_q31(q31::from_bits(1000000), &mut out_q31), + Status::Success + ); + assert_eq!( + sqrt_q31(q31::from_bits(-100), &mut out_q31), + Status::ArgumentError + ); + + let mut out_q15 = q15::ZERO; + assert_eq!( + sqrt_q15(q15::from_bits(10000), &mut out_q15), + Status::Success + ); + assert_eq!( + sqrt_q15(q15::from_bits(-100), &mut out_q15), + Status::ArgumentError + ); + + let src_v = [1.0f32, 4.0, 9.0, 16.0]; + let mut dst_v = [0.0f32; 4]; + vsqrt_f32(&src_v, &mut dst_v); + + let mut quot_q31 = q31::ZERO; + let mut shift_q31 = 0i16; + assert_eq!( + divide_q31( + q31::from_bits(5000), + q31::from_bits(10000), + &mut quot_q31, + &mut shift_q31 + ), + Status::Success + ); + assert_eq!( + divide_q31( + q31::from_bits(5000), + q31::ZERO, + &mut quot_q31, + &mut shift_q31 + ), + Status::ArgumentError + ); + + let mut quot_q15 = q15::ZERO; + let mut shift_q15 = 0i16; + assert_eq!( + divide_q15( + q15::from_bits(500), + q15::from_bits(1000), + &mut quot_q15, + &mut shift_q15 + ), + Status::Success + ); + assert_eq!( + divide_q15( + q15::from_bits(500), + q15::ZERO, + &mut quot_q15, + &mut shift_q15 + ), + Status::ArgumentError + ); + + // SIMD odd lengths (remainder loops) + let src_odd_a = [q15::from_bits(100); 5]; + let src_odd_b = [q15::from_bits(200); 5]; + let mut dst_odd = [q15::ZERO; 5]; + let _dot_odd = intrinsics::simd_dot_prod_q15(&src_odd_a, &src_odd_b); + intrinsics::simd_add_q15(&src_odd_a, &src_odd_b, &mut dst_odd); + intrinsics::simd_sub_q15(&src_odd_a, &src_odd_b, &mut dst_odd); + intrinsics::simd_mult_q15(&src_odd_a, &src_odd_b, &mut dst_odd); + + // SafetyLimiter + let mut limiter = SafetyLimiter::new(0.9, 0.01, 16000.0); + assert!(limiter.process(0.5).is_finite()); + assert!(limiter.process(1.5).is_finite()); + assert!(limiter.process_sample(0.2).is_finite()); + assert!(limiter.current_gain() <= 1.0); + limiter.reset(); + + // DynamicsCompressor + let mut comp = DynamicsCompressor::new(-10.0, 4.0, 6.0, 0.001, 0.05, 3.0, 16000.0); + assert!(comp.process(0.1).is_finite()); + assert!(comp.process(0.8).is_finite()); + assert!(comp.process_sample(0.8).is_finite()); + comp.reset(); + + // NoiseGate + let mut gate = NoiseGate::new(-40.0, -30.0, 0.001, 0.05, 16000.0); + assert!(gate.process(0.001).is_finite()); + assert!(gate.process(0.5).is_finite()); + assert!(gate.process_sample(0.5).is_finite()); + gate.reset(); +} + +// ─── from push_to_95_coverage_c.rs ──────────────────────────────────────── +#[test] +fn test_transforms_inverse_and_flags() { + let mut data_q31 = [q31::from_bits(10000); 16]; + cfft_q31(&mut data_q31, 8, 1, 0); + cfft_q31(&mut data_q31, 8, 1, 1); + cfft_q31(&mut data_q31, 8, 0, 0); + + let mut data_q15 = [q15::from_bits(1000); 16]; + cfft_q15(&mut data_q15, 8, 1, 0); + cfft_q15(&mut data_q15, 8, 1, 1); + cfft_q15(&mut data_q15, 8, 0, 0); + + let mut bfp_q31 = [q31::from_bits(10000); 16]; + let _s_q31_1 = cfft_bfp_q31(&mut bfp_q31, 8, 1, 0); + let _s_q31_2 = cfft_bfp_q31(&mut bfp_q31, 8, 1, 1); + + let mut bfp_q15 = [q15::from_bits(1000); 16]; + let _s_q15_1 = cfft_bfp_q15(&mut bfp_q15, 8, 1, 0); + let _s_q15_2 = cfft_bfp_q15(&mut bfp_q15, 8, 1, 1); + + let src_q31 = [q31::from_bits(5000); 8]; + let mut dst_q31_a = [q31::ZERO; 16]; + let mut dst_q31_b = [q31::ZERO; 8]; + rfft_q31(&src_q31, &mut dst_q31_a, 8, 0); // packed_rfft_q31_forward + irfft_q31(&dst_q31_a, &mut dst_q31_b, 8); // packed_irfft_q31 + rfft_q31(&src_q31, &mut dst_q31_a, 8, 1); // fallback unpack branch + + let src_q15 = [q15::from_bits(500); 8]; + let mut dst_q15_a = [q15::ZERO; 16]; + let mut dst_q15_b = [q15::ZERO; 8]; + rfft_q15(&src_q15, &mut dst_q15_a, 8, 0); // packed_rfft_q15_forward + irfft_q15(&dst_q15_a, &mut dst_q15_b, 8); // packed_irfft_q15 + rfft_q15(&src_q15, &mut dst_q15_a, 8, 1); // fallback unpack branch + + let mut wht_f32 = [1.0f32; 8]; + assert_eq!(ifwht_f32(&mut wht_f32), Status::Success); + + let mut wht_i32 = [10i32; 8]; + assert_eq!(fwht_i32(&mut wht_i32), Status::Success); + + let mut haar_f32 = [1.0f32; 8]; + assert_eq!(haar_transform_f32(&mut haar_f32), Status::Success); + assert_eq!(inverse_haar_transform_f32(&mut haar_f32), Status::Success); + + let mut haar_i32 = [10i32; 8]; + assert_eq!(haar_transform_i32(&mut haar_i32), Status::Success); + + let mut hartley = [1.0f32; 8]; + assert_eq!(hartley_transform_f32(&mut hartley), Status::Success); + + let db4_h = [0.482_962_9, 0.836_516_3, 0.224_143_86, -0.129_409_52]; + let mut wav_data = [1.0f32; 8]; + assert_eq!(wavelet_step_f32(&mut wav_data, 8, &db4_h), Status::Success); + assert_eq!( + inverse_wavelet_step_f32(&mut wav_data, 8, &db4_h), + Status::Success + ); + assert_eq!( + wavelet_transform_f32(&mut wav_data, &db4_h), + Status::Success + ); + assert_eq!( + inverse_wavelet_transform_f32(&mut wav_data, &db4_h), + Status::Success + ); +} + +#[test] +fn test_transforms_error_branches() { + let mut bad_buf = [0.0f32; 3]; + assert_ne!(fwht_f32(&mut bad_buf), Status::Success); + assert_ne!(ifwht_f32(&mut bad_buf), Status::Success); + assert_ne!(haar_transform_f32(&mut bad_buf), Status::Success); + assert_ne!(inverse_haar_transform_f32(&mut bad_buf), Status::Success); + assert_ne!(hartley_transform_f32(&mut bad_buf), Status::Success); + + let mut bad_buf_i32 = [0i32; 3]; + assert_ne!(fwht_i32(&mut bad_buf_i32), Status::Success); + assert_ne!(haar_transform_i32(&mut bad_buf_i32), Status::Success); + + let db4_h = [0.482_962_9, 0.836_516_3, 0.224_143_86, -0.129_409_52]; + assert_ne!(wavelet_step_f32(&mut bad_buf, 3, &db4_h), Status::Success); + assert_ne!( + inverse_wavelet_step_f32(&mut bad_buf, 3, &db4_h), + Status::Success + ); + assert_ne!(wavelet_transform_f32(&mut bad_buf, &db4_h), Status::Success); + assert_ne!( + inverse_wavelet_transform_f32(&mut bad_buf, &db4_h), + Status::Success + ); + + let mut cep_out = [0.0f32; 2]; + assert_ne!(real_cepstrum_f32(&bad_buf, &mut cep_out), Status::Success); + + let src_short = [q31::ZERO; 2]; + let mut dst_short = [q31::ZERO; 2]; + irfft_q31(&src_short, &mut dst_short, 8); + let src_short_q15 = [q15::ZERO; 2]; + let mut dst_short_q15 = [q15::ZERO; 2]; + irfft_q15(&src_short_q15, &mut dst_short_q15, 8); +} + +#[test] +fn test_types_fixed_and_enums() { + let q = q15::from_bits(1000); + assert_eq!(q15::ZERO.to_bits(), 0); + assert_eq!(q15::MIN.to_bits(), i16::MIN); + assert_eq!(q15::MAX.to_bits(), i16::MAX); + assert_eq!(q.to_bits(), 1000); + + let _q_sat_add = q.saturating_add(q15::from_bits(500)); + let _q_sat_sub = q.saturating_sub(q15::from_bits(500)); + let _q_sat_mul = q.saturating_mul(q15::from_bits(500)); + let _q_sat_neg = q.saturating_neg(); + let _q_sat_abs = q.saturating_abs(); + let _q_abs = q.abs(); + let _q_wrap_add = q.wrapping_add(q15::from_bits(500)); + let _q_wrap_sub = q.wrapping_sub(q15::from_bits(500)); + let _q_wrap_neg = q.wrapping_neg(); + let _q_wrap_mul = q.wrapping_mul(q15::from_bits(500)); + let _q_wrap_mul_int = q.wrapping_mul_int(2); + let _q_chk_div = q.checked_div(q15::from_bits(500)); + let _q_chk_div_zero = q.checked_div(q15::ZERO); + + // Status enum variants + assert_eq!(Status::Success as i8, 0); + assert_eq!(Status::ArgumentError as i8, -1); + assert_eq!(Status::LengthError as i8, -2); + assert_eq!(Status::SizeMismatch as i8, -3); + assert_eq!(Status::NanInf as i8, -4); + assert_eq!(Status::Singular as i8, -5); + assert_eq!(Status::TestFailure as i8, -6); + assert_eq!(Status::DecompositionFailure as i8, -7); +} + +#[test] +fn test_windows_and_statistics_extra() { + let mut win_buf = [0.0f32; 16]; + hanning_f32(&mut win_buf); + hamming_f32(&mut win_buf); + blackman_f32(&mut win_buf); + blackman_harris_f32(&mut win_buf); + bartlett_f32(&mut win_buf); + welch_f32(&mut win_buf); + flattop_f32(&mut win_buf); + kaiser_f32(&mut win_buf, 5.0); + apply_window_f32(&mut win_buf, &[1.0f32; 16]); + + let mut q15_win = [q15::ZERO; 16]; + hanning_q15(&mut q15_win); + hamming_q15(&mut q15_win); + blackman_q15(&mut q15_win); + bartlett_q15(&mut q15_win); + apply_window_q15(&mut q15_win, &[q15::from_bits(1000); 16]); + + let src_q7 = [q7::from_bits(10); 8]; + let mut q7_res = q7::ZERO; + let mut idx = 0usize; + assert_eq!(mean_q7(&src_q7, &mut q7_res), Status::Success); + assert_eq!(var_q7(&src_q7, &mut q7_res), Status::Success); + assert_eq!(std_q7(&src_q7, &mut q7_res), Status::Success); + assert_eq!(min_q7(&src_q7, &mut q7_res, &mut idx), Status::Success); + assert_eq!(max_q7(&src_q7, &mut q7_res, &mut idx), Status::Success); + + let data = [0.1f32, 0.2, 0.3, 0.4]; + assert!(entropy_f32(&data).is_finite()); + assert!(kullback_leibler_f32(&data, &data).is_finite()); + assert!(logsumexp_f32(&data).is_finite()); + + let mut f_res = 0.0f32; + assert_eq!(absmax_f32(&data, &mut f_res, &mut idx), Status::Success); + assert_eq!(absmin_f32(&data, &mut f_res, &mut idx), Status::Success); +} + +#[test] +fn test_matrix_extra() { + let data_a = [1.0f32, 2.0, 3.0, 4.0]; + let data_b = [5.0f32, 6.0, 7.0, 8.0]; + let mut data_out = [0.0f32; 4]; + + let mat_a = MatrixInstance::new(2, 2, &data_a); + let mat_b = MatrixInstance::new(2, 2, &data_b); + let mut mat_out = MatrixInstanceMut::new(2, 2, &mut data_out); + + assert_eq!(mat_add_f32(&mat_a, &mat_b, &mut mat_out), Status::Success); + assert_eq!(mat_sub_f32(&mat_a, &mat_b, &mut mat_out), Status::Success); + assert_eq!(mat_scale_f32(&mat_a, 2.0, &mut mat_out), Status::Success); + assert_eq!(mat_mult_f32(&mat_a, &mat_b, &mut mat_out), Status::Success); + assert_eq!(mat_trans_f32(&mat_a, &mut mat_out), Status::Success); + assert_eq!(mat_inverse_f32(&mat_a, &mut mat_out), Status::Success); +} + +// ─── from push_to_95_coverage_d.rs ──────────────────────────────────────── +#[test] +fn test_filter_analysis_exhaustive() { + let coeffs = [0.1f32, 0.2, 0.3, 0.4, 0.5]; + let freq = 0.1f32; + let resp = biquad_frequency_response(&coeffs, freq); + assert!(response_magnitude(resp).is_finite()); + assert!(response_magnitude_db(resp).is_finite()); + assert!(response_phase(resp).is_finite()); + + let fir_taps = [0.1f32, 0.2, 0.3, 0.4]; + let fir_resp = fir_frequency_response(&fir_taps, freq); + assert!(response_magnitude(fir_resp).is_finite()); + + let cascade_coeffs = [0.1f32, 0.2, 0.3, 0.4, 0.5, 0.1, 0.2, 0.3, 0.4, 0.5]; + let cascade_resp = biquad_cascade_frequency_response(&cascade_coeffs, freq); + assert!(response_magnitude(cascade_resp).is_finite()); + + assert!(fir_group_delay(&fir_taps, freq).is_finite()); + assert!(biquad_pole_radius(&coeffs).is_finite()); + assert!(biquad_is_stable(&coeffs)); + assert!(biquad_cascade_is_stable(&cascade_coeffs)); + + assert!(biquad_peak_gain(&coeffs, 32).is_finite()); + assert!(biquad_l2_norm(&coeffs, 32).is_finite()); + + let (headroom, gain) = estimate_biquad_headroom_bits(&coeffs); + assert!(gain.is_finite()); + assert!(headroom <= 32); + + let biquad_q15_resp = biquad_q15_frequency_response(&[q15::from_bits(1000); 5], 0, freq); + assert!(response_magnitude(biquad_q15_resp).is_finite()); + + let snr_biquad = biquad_quantization_snr_db(&coeffs, &[q15::from_bits(1000); 5], 0, 32); + assert!(snr_biquad.is_finite()); + + let fir_taps_q15 = [q15::from_bits(1000); 4]; + let snr_fir = fir_quantization_snr_db(&fir_taps, &fir_taps_q15, 32); + assert!(snr_fir.is_finite()); +} + +#[test] +fn test_const_generics_exhaustive() { + let fir_taps = [0.1f32, 0.2, 0.3, 0.4]; + let mut fir = FirFilter::<4>::new(fir_taps); + let src = [1.0f32; 8]; + let mut dst = [0.0f32; 8]; + fir.process(&src, &mut dst); + fir.reset(); + + let biquad_coeffs = [0.1f32, 0.2, 0.3, 0.4, 0.5]; + let mut biquad = BiquadCascade::<5, 4>::new(biquad_coeffs); + biquad.process(&src, &mut dst); + biquad.reset(); + + let fir_taps_q15 = [q15::from_bits(1000); 4]; + let mut fir_q15 = FirFilterQ15::<4>::new(fir_taps_q15); + let src_q15 = [q15::from_bits(1000); 8]; + let mut dst_q15 = [q15::ZERO; 8]; + fir_q15.process(&src_q15, &mut dst_q15); + fir_q15.reset(); + + let biquad_coeffs_q15 = [q15::from_bits(1000); 5]; + let mut biquad_q15 = BiquadCascadeQ15::<5, 4>::new(biquad_coeffs_q15, 0); + biquad_q15.process(&src_q15, &mut dst_q15); + biquad_q15.reset(); + + let m1 = Matrix::<2, 2, 4>::new([1.0, 2.0, 3.0, 4.0]); + let m2 = Matrix::<2, 2, 4>::new([5.0, 6.0, 7.0, 8.0]); + let _m_add = m1.add(&m2); + let _m_sub = m1.sub(&m2); + let _m_scale = m1.scale(2.0); + let _m_trans = m1.transpose(); + let _m_mul = m1.mul_mat::<2, 4, 4>(&m2); +} + +#[test] +fn test_quaternion_exhaustive() { + let mut q = [1.0f32, 2.0, 3.0, 4.0]; + assert!(quaternion_norm_f32(&q) > 0.0); + assert_eq!(quaternion_normalize_f32(&mut q), Status::Success); + + let q1 = [1.0f32, 0.0, 0.0, 0.0]; + let q2 = [0.0f32, 1.0, 0.0, 0.0]; + let mut out = [0.0f32; 4]; + quaternion_product_f32(&q1, &q2, &mut out); + quaternion_conjugate_f32(&q1, &mut out); + assert_eq!(quaternion_inverse_f32(&q1, &mut out), Status::Success); + + let mut rot_mat = [0.0f32; 9]; + quaternion_to_rotmat_f32(&q, &mut rot_mat); + + // Error branches + let mut q_zero = [0.0f32; 4]; + assert_eq!(quaternion_normalize_f32(&mut q_zero), Status::ArgumentError); + assert_eq!( + quaternion_inverse_f32(&q_zero, &mut out), + Status::ArgumentError + ); +} + +#[test] +fn test_pipeline_nodes_exhaustive() { + let mut gain_i16 = Gain::::new(16384); + assert_eq!(gain_i16.process_sample(1000i16), 500i16); + + let mut gain_i32 = Gain::::new(1073741824); + let _g_i32 = gain_i32.process_sample(1000i32); + + let mut limiter = Limiter::new(-1.0f32, 1.0f32); + let mut block_in = [0.5f32, 1.5, -2.0]; + let mut block_out = [0.0f32; 3]; + limiter.process_block(&block_in, &mut block_out); + limiter.process_in_place(&mut block_in); + + let mut pid_f32 = PidInstance::::new(1.0, 0.1, 0.01); + assert!(pid_f32.process_sample(1.0).is_finite()); + + let mut pid_q15 = PidInstance::::new( + q15::from_bits(1000), + q15::from_bits(100), + q15::from_bits(10), + ); + let _p_q15 = pid_q15.process_sample(q15::from_bits(500)); + + let mut filter_f32 = SinglePoleFilter::::lowpass(0.1); + assert!(filter_f32.process_sample(1.0).is_finite()); + + let mut filter_q15 = SinglePoleFilter::::lowpass(q15::from_bits(3000)); + let _f_q15 = filter_q15.process_sample(q15::from_bits(1000)); + + let mut dc_blocker = DcBlockerQ15::new(q15::from_bits(32000)); + let _dc_out = dc_blocker.process_sample(q15::from_bits(1000)); +} + +// ─── from push_to_95_coverage_e.rs ──────────────────────────────────────── +#[test] +fn test_square_root_kalman_filter_exhaustive() { + let x0 = [0.0f32, 0.0]; + let s0 = [[1.0f32, 0.0], [0.0, 1.0]]; + let f = [[1.0f32, 1.0], [0.0, 1.0]]; + let s_q = [[0.1f32, 0.0], [0.0, 0.1]]; + let h = [[1.0f32, 0.0]]; + let s_r = [[0.5f32]]; + + let mut sr_kf = SquareRootKalmanFilter::<2, 1>::new(x0, s0, f, s_q, h, s_r); + sr_kf.predict(); + let status = sr_kf.update(&[1.0f32]); + assert_eq!(status, Status::Success); + + let cov = sr_kf.covariance(); + assert!(cov[0][0] > 0.0); +} + +#[test] +fn test_psd_error_branches_and_db() { + let src = [1.0f32; 128]; + let mut dst = [0.0f32; 32]; + + // Invalid arguments for welch_psd_f32 + assert_eq!( + welch_psd_f32( + &src, + &mut dst, + 3, + 0, + 1000.0, + WelchWindow::Rectangular, + false + ), + Status::ArgumentError + ); + assert_eq!( + welch_psd_f32( + &src, + &mut dst, + 64, + 64, + 1000.0, + WelchWindow::Rectangular, + false + ), + Status::ArgumentError + ); + assert_eq!( + welch_psd_f32( + &src, + &mut dst, + 64, + 16, + -100.0, + WelchWindow::Rectangular, + false + ), + Status::ArgumentError + ); + assert_eq!( + welch_psd_f32( + &src[..10], + &mut dst, + 64, + 16, + 1000.0, + WelchWindow::Rectangular, + false + ), + Status::LengthError + ); + + // ar_burg_f32 invalid arguments + let mut ar_coeffs = [0.0f32; 4]; + assert_eq!( + ar_burg_f32(&src[..4], 4, &mut ar_coeffs), + Err(Status::ArgumentError) + ); + assert_eq!( + ar_burg_f32(&src, 0, &mut ar_coeffs), + Err(Status::ArgumentError) + ); + + // ar_psd_f32 db and linear + if let Ok(noise_var) = ar_burg_f32(&src[..32], 4, &mut ar_coeffs) { + assert_eq!( + ar_psd_f32(&ar_coeffs, noise_var, 32, &mut dst, true), + Status::Success + ); + assert_eq!( + ar_psd_f32(&ar_coeffs, noise_var, 32, &mut dst, false), + Status::Success + ); + } +} + +#[test] +fn test_quantization_and_scaling_strategies() { + let sos_f32 = [0.1f32, 0.2, 0.3, 0.4, 0.5]; + let mut q15_out = [q15::ZERO; 5]; + let mut q31_out = [q31::ZERO; 5]; + + assert!( + biquad_quantize_and_scale_q15(&sos_f32, &mut q15_out, ScalingStrategy::LInfNorm).is_ok() + ); + assert!(biquad_quantize_and_scale_q15(&sos_f32, &mut q15_out, ScalingStrategy::L2Norm).is_ok()); + assert!(biquad_quantize_and_scale_q15(&sos_f32, &mut q15_out, ScalingStrategy::Direct).is_ok()); + + assert!( + biquad_quantize_and_scale_q31(&sos_f32, &mut q31_out, ScalingStrategy::LInfNorm).is_ok() + ); + assert!(biquad_quantize_and_scale_q31(&sos_f32, &mut q31_out, ScalingStrategy::L2Norm).is_ok()); + assert!(biquad_quantize_and_scale_q31(&sos_f32, &mut q31_out, ScalingStrategy::Direct).is_ok()); + + let taps_f32 = [0.1f32, 0.2, 0.3, 0.4]; + let mut taps_q15 = [q15::ZERO; 4]; + assert!(fir_quantize_q15(&taps_f32, &mut taps_q15).is_ok()); + + // Error length tests + let mut bad_q15 = [q15::ZERO; 4]; + assert_eq!( + biquad_quantize_and_scale_q15(&sos_f32, &mut bad_q15, ScalingStrategy::Direct), + Err(Status::LengthError) + ); + assert_eq!( + fir_quantize_q15(&taps_f32, &mut bad_q15[..2]), + Err(Status::LengthError) + ); + + // More than 26 biquad stages (130 elements) overflows the internal 128-element scratch + // buffer, an ArgumentError distinct from the length-mismatch case above. + let big_sos = [0.1f32; 130]; + let mut big_q15 = [q15::ZERO; 130]; + let mut big_q31 = [q31::ZERO; 130]; + assert_eq!( + biquad_quantize_and_scale_q15(&big_sos, &mut big_q15, ScalingStrategy::Direct), + Err(Status::ArgumentError) + ); + assert_eq!( + biquad_quantize_and_scale_q31(&big_sos, &mut big_q31, ScalingStrategy::Direct), + Err(Status::ArgumentError) + ); +} + +// ─── from coverage_boost_tests.rs ──────────────────────────────────────── +#[test] +fn test_pipeline_thorough_coverage() { + let gain_f32 = Gain::new(2.0f32); + let limiter_f32 = Limiter::new(-1.0f32, 1.0f32); + let mut chain = gain_f32.then(limiter_f32); + + assert_eq!(chain.process_sample(0.25), 0.5); + assert_eq!(chain.process_sample(1.0), 1.0); // Limiter clamped + assert_eq!(chain.process_sample(-1.0), -1.0); // Limiter clamped + + let input_block = [0.1f32, 0.6, -0.8]; + let mut output_block = [0.0f32; 3]; + chain.process_block(&input_block, &mut output_block); + assert_eq!(output_block[0], 0.2); + assert_eq!(output_block[1], 1.0); + + let mut in_place = [0.1f32, 0.6, -0.8]; + chain.process_in_place(&mut in_place); + assert_eq!(in_place[0], 0.2); + assert_eq!(in_place[1], 1.0); + + // Gain i16 and i32 + let mut gain_i16 = Gain::new(16384i16); // 0.5 in Q15 + assert_eq!(gain_i16.process_sample(10000i16), 5000i16); + + let mut gain_i32 = Gain::new(1073741824i32); // 0.5 in Q31 + assert_eq!(gain_i32.process_sample(100000i32), 50000i32); + + // Limiter i16, i32, f32 + let mut lim_i16 = Limiter::new(-100i16, 100i16); + assert_eq!(lim_i16.process_sample(150i16), 100i16); + assert_eq!(lim_i16.process_sample(-150i16), -100i16); + assert_eq!(lim_i16.process_sample(50i16), 50i16); + + let mut lim_i32 = Limiter::new(-100i32, 100i32); + assert_eq!(lim_i32.process_sample(150i32), 100i32); +} + +#[test] +fn test_quaternion_coverage() { + let q = [1.0f32, 0.0, 0.0, 0.0]; + assert_eq!(quaternion_norm_f32(&q), 1.0); + + let mut zero_q = [0.0f32; 4]; + assert_eq!(quaternion_normalize_f32(&mut zero_q), Status::ArgumentError); + assert_eq!( + quaternion_inverse_f32(&zero_q, &mut [0.0; 4]), + Status::ArgumentError + ); + + let q1 = [ + core::f32::consts::FRAC_1_SQRT_2, + core::f32::consts::FRAC_1_SQRT_2, + 0.0, + 0.0, + ]; + let q2 = [ + core::f32::consts::FRAC_1_SQRT_2, + 0.0, + core::f32::consts::FRAC_1_SQRT_2, + 0.0, + ]; + let mut q_prod = [0.0f32; 4]; + quaternion_product_f32(&q1, &q2, &mut q_prod); + assert!(q_prod[0].is_finite()); + + let mut q_conj = [0.0f32; 4]; + quaternion_conjugate_f32(&q1, &mut q_conj); + assert_eq!(q_conj[0], q1[0]); + assert_eq!(q_conj[1], -q1[1]); + + let mut q_inv = [0.0f32; 4]; + assert_eq!(quaternion_inverse_f32(&q1, &mut q_inv), Status::Success); + + let mut rot_mat = [0.0f32; 9]; + quaternion_to_rotmat_f32(&q1, &mut rot_mat); + assert_eq!(rot_mat.len(), 9); +} + +#[test] +fn test_fast_math_and_approximations() { + assert!((fast_tanh_f32(0.0)).abs() < 1e-4); + assert!((fast_tanh_f32(5.0) - 1.0).abs() < 1e-4); + assert!((fast_tanh_f32(-5.0) + 1.0).abs() < 1e-4); + + assert!((fast_exp_f32(0.0) - 1.0).abs() < 1e-3); + assert_eq!(fast_exp_f32(-15.0), 0.0); + assert_eq!(fast_exp_f32(15.0), 22026.465); + + let mut res_atan = 0.0f32; + assert_eq!(atan2_f32(1.0, 1.0, &mut res_atan), Status::Success); + assert!((res_atan - core::f32::consts::FRAC_PI_4).abs() < 1e-3); + + let mut q31_out = q31::ZERO; + assert_eq!( + atan2_q31(q31::from_bits(10000), q31::from_bits(10000), &mut q31_out), + Status::Success + ); + + let mut q15_out = q15::ZERO; + assert_eq!( + atan2_q15(q15::from_bits(1000), q15::from_bits(1000), &mut q15_out), + Status::Success + ); + + let mut div_out_q15 = q15::ZERO; + let mut shift_q15 = 0i16; + assert_eq!( + divide_q15( + q15::from_bits(100), + q15::ZERO, + &mut div_out_q15, + &mut shift_q15 + ), + Status::ArgumentError + ); + assert_eq!( + divide_q15( + q15::from_bits(100), + q15::from_bits(200), + &mut div_out_q15, + &mut shift_q15 + ), + Status::Success + ); +} + +#[test] +fn test_safety_limiter_and_dynamics() { + let mut limiter = SafetyLimiter::new(0.9, 0.05, 48000.0); + assert_eq!(limiter.current_gain(), 1.0); + + let limited = limiter.process(1.5); + assert!((-0.9..=0.9).contains(&limited)); + assert!(limiter.current_gain() < 1.0); + + limiter.reset(); + assert_eq!(limiter.current_gain(), 1.0); + + let mut comp = DynamicsCompressor::new(-20.0, 4.0, 6.0, 0.005, 0.1, 4.0, 48000.0); + let _out = comp.process(0.8); + comp.reset(); + + let mut gate = NoiseGate::new(-40.0, -30.0, 0.002, 0.05, 48000.0); + let _g_out = gate.process(0.0001); + gate.reset(); +} + +#[test] +fn test_math_trait_floats() { + let x: f32 = 0.5; + assert!(FloatMath::abs(x) > 0.0); + assert!(FloatMath::sin(x) > 0.0); + assert!(FloatMath::cos(x) > 0.0); + assert!(FloatMath::tan(x) > 0.0); + assert!(FloatMath::sqrt(x) > 0.0); + assert!(FloatMath::ln(x) < 0.0); + assert!(FloatMath::log10(x) < 0.0); + assert!(FloatMath::exp(x) > 1.0); + assert!(FloatMath::atan2(x, 1.0) > 0.0); + assert!(FloatMath::powf(x, 2.0) == 0.25); + assert!(FloatMath::tanh(x) > 0.0); + + let y: f64 = 0.5; + assert!(FloatMath::abs(y) > 0.0); + assert!(FloatMath::sin(y) > 0.0); + assert!(FloatMath::cos(y) > 0.0); + assert!(FloatMath::tan(y) > 0.0); + assert!(FloatMath::sqrt(y) > 0.0); + assert!(FloatMath::ln(y) < 0.0); + assert!(FloatMath::log10(y) < 0.0); + assert!(FloatMath::exp(y) > 1.0); + assert!(FloatMath::atan2(y, 1.0) > 0.0); + assert!(FloatMath::powf(y, 2.0) == 0.25); + assert!(FloatMath::tanh(y) > 0.0); +} + +// ─── from more_coverage_boost.rs ──────────────────────────────────────── +#[test] +fn test_dsp_sample_all_primitives() { + let a_f32: f32 = 0.5; + let b_f32: f32 = 0.5; + assert_eq!(a_f32.sat_add(b_f32), 1.0); + assert_eq!(a_f32.sat_sub(b_f32), 0.0); + assert_eq!(a_f32.sat_mul(b_f32), 0.25); + + let a_f64: f64 = 0.5; + let b_f64: f64 = 0.5; + assert_eq!(a_f64.sat_add(b_f64), 1.0); + assert_eq!(a_f64.sat_sub(b_f64), 0.0); + assert_eq!(a_f64.sat_mul(b_f64), 0.25); + + let c1 = Complex::new(1.0f32, 2.0f32); + let c2 = Complex::new(3.0f32, 4.0f32); + assert_eq!(c1.real + c2.real, 4.0); + assert_eq!(c1.imag + c2.imag, 6.0); +} + +#[test] +fn test_const_generics_and_cordic() { + let mut fir = FirFilter::<4>::new([0.25f32, 0.25, 0.25, 0.25]); + let in_buf = [1.0f32, 0.5, 0.2, 0.1]; + let mut out_buf = [0.0f32; 4]; + fir.process(&in_buf, &mut out_buf); + fir.reset(); + + let mut fir_q15 = FirFilterQ15::<4>::new([q15::from_bits(1000); 4]); + let in_q15 = [q15::from_bits(2000); 4]; + let mut out_q15 = [q15::ZERO; 4]; + fir_q15.process(&in_q15, &mut out_q15); + fir_q15.reset(); + + let mut biquad = BiquadCascade::<5, 4>::new([1.0, 0.0, 0.0, 1.0, 0.0]); + biquad.process(&in_buf, &mut out_buf); + biquad.reset(); + + let mut biquad_q15 = BiquadCascadeQ15::<5, 4>::new([q15::from_bits(1000); 5], 0); + biquad_q15.process(&in_q15, &mut out_q15); + biquad_q15.reset(); + + let mat = Matrix::<2, 2, 4>::new([1.0, 2.0, 3.0, 4.0]); + let t = mat.transpose(); + assert_eq!(t.data[1], 3.0); + + // CORDIC engine + let (s, c) = cordic_sin_cos_q31(q31::from_bits(1000000)); + assert!(s.to_bits() != 0); + assert!(c.to_bits() != 0); + + let atan_q15 = cordic_atan2_q15(q15::from_bits(1000), q15::from_bits(1000)); + assert!(atan_q15.to_bits() > 0); + + let sqrt_q15 = cordic_sqrt_q15(q15::from_bits(10000)); + assert!(sqrt_q15.to_bits() > 0); +} + +#[test] +fn test_complex_math_extended() { + let a = [1.0f32, 2.0, 3.0, 4.0]; + let b = [2.0f32, 1.0, 1.0, 2.0]; + let mut out = [0.0f32; 4]; + + cmplx_add_f32(&a, &b, &mut out); + cmplx_sub_f32(&a, &b, &mut out); + cmplx_mult_cmplx_f32(&a, &b, &mut out); + cmplx_mult_real_f32(&a, &b, &mut out); + cmplx_conj_f32(&a, &mut out); + + let mut mag = [0.0f32; 2]; + cmplx_mag_f32(&a, &mut mag); + cmplx_mag_squared_f32(&a, &mut mag); + + let dot = cmplx_dot_prod_f32(&a, &b); + assert!(dot.real.is_finite()); +} + +#[test] +fn test_status_codes() { + let s1 = Status::Success; + let s2 = Status::ArgumentError; + let s3 = Status::LengthError; + assert_ne!(s1, s2); + assert_ne!(s2, s3); +} + +#[test] +fn test_transforms_extended() { + let mut data = [1.0f32, 2.0, 3.0, 4.0]; + let mut out = [0.0f32; 4]; + assert_eq!(haar_transform_f32(&mut data), Status::Success); + hartley_transform_f32(&mut data); + dct4_f32(&data, &mut out, 4); +} + +// ─── from final_90_plus_coverage_boost.rs ──────────────────────────────────────── +#[test] +fn test_complex_math_q31_q15_all() { + let a_q31 = [q31::from_bits(10000), q31::from_bits(20000)]; + let b_q31 = [q31::from_bits(5000), q31::from_bits(10000)]; + let mut out_q31 = [q31::ZERO; 2]; + + cmplx_add_q31(&a_q31, &b_q31, &mut out_q31); + cmplx_sub_q31(&a_q31, &b_q31, &mut out_q31); + cmplx_mult_cmplx_q31(&a_q31, &b_q31, &mut out_q31); + cmplx_mult_real_q31(&a_q31, &b_q31, &mut out_q31); + cmplx_conj_q31(&a_q31, &mut out_q31); + + let mut mag_q31 = [q31::ZERO; 1]; + cmplx_mag_q31(&a_q31, &mut mag_q31); + cmplx_mag_squared_q31(&a_q31, &mut mag_q31); + let _dot_q31 = cmplx_dot_prod_q31(&a_q31, &b_q31); + + let a_q15 = [q15::from_bits(1000), q15::from_bits(2000)]; + let b_q15 = [q15::from_bits(500), q15::from_bits(1000)]; + let mut out_q15 = [q15::ZERO; 2]; + + cmplx_add_q15(&a_q15, &b_q15, &mut out_q15); + cmplx_sub_q15(&a_q15, &b_q15, &mut out_q15); + cmplx_mult_cmplx_q15(&a_q15, &b_q15, &mut out_q15); + cmplx_mult_real_q15(&a_q15, &b_q15, &mut out_q15); + cmplx_conj_q15(&a_q15, &mut out_q15); + + let mut mag_q15 = [q15::ZERO; 1]; + cmplx_mag_q15(&a_q15, &mut mag_q15); + cmplx_mag_squared_q15(&a_q15, &mut mag_q15); + let _dot_q15 = cmplx_dot_prod_q15(&a_q15, &b_q15); +} + +#[test] +fn test_transforms_q31_q15_and_wavelets() { + let mut q31_buf = [q31::from_bits(1000); 32]; + let mut out_q31 = [q31::ZERO; 32]; + cfft_q31(&mut q31_buf[..32], 16, 0, 1); + cfft_q31(&mut q31_buf[..32], 16, 1, 1); + let _scale_q31 = cfft_bfp_q31(&mut q31_buf[..32], 16, 0, 1); + rfft_q31(&q31_buf[..16], &mut out_q31[..32], 16, 0); + rfft_q31(&q31_buf[..16], &mut out_q31[..32], 16, 1); + irfft_q31(&out_q31[..32], &mut q31_buf[..16], 16); + + let mut q15_buf = [q15::from_bits(100); 32]; + let mut out_q15 = [q15::ZERO; 32]; + cfft_q15(&mut q15_buf[..32], 16, 0, 1); + cfft_q15(&mut q15_buf[..32], 16, 1, 1); + let _scale_q15 = cfft_bfp_q15(&mut q15_buf[..32], 16, 0, 1); + rfft_q15(&q15_buf[..16], &mut out_q15[..32], 16, 0); + rfft_q15(&q15_buf[..16], &mut out_q15[..32], 16, 1); + irfft_q15(&out_q15[..32], &mut q15_buf[..16], 16); + + let f32_buf = [1.0f32; 16]; + let mut out_f32 = [0.0f32; 32]; + rfft_f32(&f32_buf, &mut out_f32, 16, 0); + rfft_f32(&f32_buf, &mut out_f32, 16, 1); + + let mut cep_out = [0.0f32; 16]; + assert_eq!(real_cepstrum_f32(&f32_buf, &mut cep_out), Status::Success); + + // FWHT and Haar + let mut fwht_buf = [1.0f32, 2.0, 3.0, 4.0]; + assert_eq!(fwht_f32(&mut fwht_buf), Status::Success); + assert_eq!(ifwht_f32(&mut fwht_buf), Status::Success); + + let mut fwht_i32_buf = [10i32, 20, 30, 40]; + assert_eq!(fwht_i32(&mut fwht_i32_buf), Status::Success); + + let mut haar_i32_buf = [10i32, 20, 30, 40]; + assert_eq!(haar_transform_i32(&mut haar_i32_buf), Status::Success); + + let mut haar_f32_buf = [1.0f32, 2.0, 3.0, 4.0]; + assert_eq!( + inverse_haar_transform_f32(&mut haar_f32_buf), + Status::Success + ); + + // Wavelets + let daub4 = [0.482_962_9, 0.836_516_3, 0.224_143_86, -0.129_409_52]; + let mut wave_buf = [1.0f32, 2.0, 3.0, 4.0]; + assert_eq!(wavelet_step_f32(&mut wave_buf, 4, &daub4), Status::Success); + assert_eq!( + inverse_wavelet_step_f32(&mut wave_buf, 4, &daub4), + Status::Success + ); + assert_eq!( + wavelet_transform_f32(&mut wave_buf, &daub4), + Status::Success + ); + assert_eq!( + inverse_wavelet_transform_f32(&mut wave_buf, &daub4), + Status::Success + ); +} + +#[test] +fn test_filter_design_all_windowed_sinc() { + let mut hp_taps = [0.0f32; 15]; + assert_eq!( + fir_windowed_sinc_highpass(0.2, &mut hp_taps), + Status::Success + ); + + let mut bp_taps = [0.0f32; 15]; + assert_eq!( + fir_windowed_sinc_bandpass(0.1, 0.3, &mut bp_taps), + Status::Success + ); + + let mut bs_taps = [0.0f32; 15]; + assert_eq!( + fir_windowed_sinc_bandstop(0.1, 0.3, &mut bs_taps), + Status::Success + ); + + // Invalid tap-length/cutoff arguments propagate as ArgumentError, including through + // highpass/bandstop's delegation into lowpass/bandpass. + let mut even_taps = [0.0f32; 4]; + assert_eq!( + fir_windowed_sinc_highpass(0.2, &mut even_taps), + Status::ArgumentError + ); + assert_eq!( + fir_windowed_sinc_bandpass(0.3, 0.1, &mut bp_taps), + Status::ArgumentError + ); + assert_eq!( + fir_windowed_sinc_bandstop(0.3, 0.1, &mut bs_taps), + Status::ArgumentError + ); + assert_eq!( + fir_windowed_sinc_bandstop(0.1, 0.3, &mut even_taps), + Status::ArgumentError + ); + + let mut biquad_q31 = [q31::ZERO; 5]; + assert!( + biquad_quantize_and_scale_q31( + &[1.0, -0.5, 0.25, 0.1, -0.05], + &mut biquad_q31, + ScalingStrategy::Direct + ) + .is_ok() + ); + + let mut biquad_q15 = [q15::ZERO; 5]; + assert!( + biquad_quantize_and_scale_q15( + &[1.0, -0.5, 0.25, 0.1, -0.05], + &mut biquad_q15, + ScalingStrategy::Direct + ) + .is_ok() + ); + + let mut fir_q15_taps = [q15::ZERO; 15]; + assert!(fir_quantize_q15(&hp_taps, &mut fir_q15_taps).is_ok()); + + assert_eq!( + single_pole_decay_from_time_constant(10.0), + (-1.0f32 / 10.0).exp() + ); + assert_eq!( + single_pole_decay_from_cutoff(0.1), + (-2.0f32 * core::f32::consts::PI * 0.1).exp() + ); + + assert!(biquad_lowpass_coeffs(1000.0, 48000.0, 0.707)[0].is_finite()); + assert!(biquad_highpass_coeffs(1000.0, 48000.0, 0.707)[0].is_finite()); + assert!(biquad_bandpass_coeffs(1000.0, 48000.0, 0.707)[0].is_finite()); + assert!(biquad_notch_coeffs(1000.0, 48000.0, 0.707)[0].is_finite()); + assert!(biquad_peaking_coeffs(1000.0, 48000.0, 0.707, 3.0)[0].is_finite()); + assert!(biquad_allpass_coeffs(1000.0, 48000.0, 0.707)[0].is_finite()); + + let mut biquads_bw = [0.0f32; 10]; + butterworth_lowpass_biquads(1000.0, 48000.0, 4, &mut biquads_bw); + + let mut biquads_cheb = [0.0f32; 10]; + chebyshev_lowpass_biquads(0.1, 0.5, 4, &mut biquads_cheb); + chebyshev_highpass_biquads(0.1, 0.5, 4, &mut biquads_cheb); + + let pw = prewarp_cutoff_f32(1000.0, 48000.0); + let biquad_out = bilinear_transform_biquad(pw, 0.0, 0.0, 1.0, 0.0, 0.0, 48000.0); + assert_eq!(biquad_out.len(), 5); +} + +#[test] +fn test_kalman_all_methods() { + let mut kf = KalmanFilter::<2, 1>::from_variances([0.0, 0.0], 1.0, 0.01, 0.1); + let f = [[1.0, 1.0], [0.0, 1.0]]; + kf.predict(&f); + let b = [[0.1], [0.05]]; + let u = [1.0]; + kf.predict_with_control(&f, &b, &u); + assert_eq!(kf.update(&[[1.0, 0.0]], &[1.0]), Status::Success); + + let mut sr_kf = SquareRootKalmanFilter::<2, 1>::new( + [0.0, 0.0], + [[1.0, 0.0], [0.0, 1.0]], + [[1.0, 0.0], [0.0, 1.0]], + [[0.1, 0.0], [0.0, 0.1]], + [[1.0, 0.0]], + [[0.1]], + ); + sr_kf.predict(); + assert_eq!(sr_kf.update(&[1.0]), Status::Success); + assert_eq!(sr_kf.covariance().len(), 2); +} + +#[test] +fn test_math_f64_full_coverage() { + let f: f64 = 0.5; + assert!((FloatMath::abs(f) - 0.5).abs() < 1e-6); + assert!(FloatMath::sin(f) > 0.0); + assert!(FloatMath::cos(f) > 0.0); + assert!(FloatMath::tan(f) > 0.0); + assert!(FloatMath::sqrt(f) > 0.0); + assert!(FloatMath::ln(f) < 0.0); + assert!(FloatMath::log10(f) < 0.0); + assert!(FloatMath::exp(f) > 1.0); + assert!(FloatMath::atan2(f, 1.0) > 0.0); + assert!((FloatMath::powf(f, 2.0) - 0.25).abs() < 1e-6); + assert!(FloatMath::tanh(f) > 0.0); + + assert_eq!(isqrt_u32(0), 0); + assert_eq!(isqrt_u32(1), 1); + assert_eq!(isqrt_u32(16), 4); + assert_eq!(isqrt_u32(100), 10); + + assert_eq!(isqrt_u64(0), 0); + assert_eq!(isqrt_u64(1), 1); + assert_eq!(isqrt_u64(144), 12); +} diff --git a/crates/embedded-dsp/tests/differential_random.rs b/crates/embedded-dsp/tests/differential_random.rs new file mode 100644 index 0000000..50d2b1f --- /dev/null +++ b/crates/embedded-dsp/tests/differential_random.rs @@ -0,0 +1,131 @@ +//! Deterministic randomized differential checks: the `f32` kernels against +//! in-process `f64` references. +//! +//! The libFuzzer targets in `fuzz/` run the same comparisons with coverage +//! guidance; this keeps a fast, always-on version in the normal test suite. + +use embedded_dsp::filter_design::{BiquadType, EqFilter}; +use embedded_dsp::filtering::{BiquadCascadeInstance, FirInstance, biquad_cascade_df1, fir}; + +const LEN: usize = 64; + +/// Tiny deterministic LCG, so the sweep is reproducible without a dev-dependency. +struct Lcg(u64); + +impl Lcg { + fn new(seed: u64) -> Self { + Self(seed) + } + + fn next_u32(&mut self) -> u32 { + self.0 = self + .0 + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + (self.0 >> 33) as u32 + } + + /// A value in `[-1, 1)`. + fn unit(&mut self) -> f32 { + self.next_u32() as f32 / 2_147_483_648.0 + } + + /// A value in `[0, 1)`. + fn frac(&mut self) -> f32 { + self.next_u32() as f32 / 4_294_967_296.0 + } +} + +#[test] +fn fir_f32_tracks_f64_reference() { + let mut rng = Lcg::new(0x1234_5678_9abc_def0); + let mut worst = 0.0f64; + + for _ in 0..500 { + let taps = 1 + (rng.next_u32() as usize % 32); + let coeffs: Vec = (0..taps).map(|_| rng.unit()).collect(); + let src: Vec = (0..LEN).map(|_| rng.unit()).collect(); + let mut state = vec![0.0f32; taps]; + let mut dst = vec![0.0f32; LEN]; + + let mut instance = FirInstance:: { + num_taps: taps as u16, + coeffs: &coeffs, + state: &mut state, + }; + fir(&mut instance, &src, &mut dst); + assert!(dst.iter().all(|v| v.is_finite())); + + for i in 0..LEN { + let mut reference = 0.0f64; + for k in 0..taps { + if i >= k { + reference += coeffs[k] as f64 * src[i - k] as f64; + } + } + let rel = (dst[i] as f64 - reference).abs() / (1.0 + reference.abs()); + worst = worst.max(rel); + } + } + + assert!(worst < 1e-5, "worst FIR relative gap {worst}"); +} + +#[test] +fn biquad_f32_tracks_f64_reference() { + let mut rng = Lcg::new(0xdead_beef_0bad_f00d); + let fs = 48_000.0f32; + let types = [ + BiquadType::Lowpass, + BiquadType::Highpass, + BiquadType::Bandpass, + BiquadType::Allpass, + BiquadType::Notch, + BiquadType::Peaking, + BiquadType::Lowshelf, + BiquadType::Highshelf, + BiquadType::Iho, + ]; + let mut worst = 0.0f64; + + for _ in 0..500 { + let typ = types[rng.next_u32() as usize % types.len()]; + let f0 = 20.0 + rng.frac() * (fs * 0.45 - 20.0); + let q = 0.1 + rng.frac() * 9.9; + let gain_db = rng.unit() * 24.0; + let coeffs = EqFilter::new(f0, fs) + .q(q) + .gain_db(gain_db) + .try_build(typ) + .expect("in-range design must validate"); + + let src: Vec = (0..LEN).map(|_| rng.unit()).collect(); + let mut state = [0.0f32; 4]; + let mut dst = vec![0.0f32; LEN]; + + let mut instance = BiquadCascadeInstance:: { + num_stages: 1, + post_shift: 0, + coeffs: &coeffs, + state: &mut state, + }; + biquad_cascade_df1(&mut instance, &src, &mut dst); + assert!(dst.iter().all(|v| v.is_finite())); + + let (b0, b1, b2) = (coeffs[0] as f64, coeffs[1] as f64, coeffs[2] as f64); + let (a1, a2) = (coeffs[3] as f64, coeffs[4] as f64); + let (mut x1, mut x2, mut y1, mut y2) = (0.0f64, 0.0, 0.0, 0.0); + for i in 0..LEN { + let x = src[i] as f64; + let y = b0 * x + b1 * x1 + b2 * x2 + a1 * y1 + a2 * y2; + x2 = x1; + x1 = x; + y2 = y1; + y1 = y; + let rel = (dst[i] as f64 - y).abs() / (1.0 + y.abs()); + worst = worst.max(rel); + } + } + + assert!(worst < 5e-4, "worst biquad relative gap {worst}"); +} diff --git a/crates/embedded-dsp/tests/dither_dsm_pipeline_coverage.rs b/crates/embedded-dsp/tests/dither_dsm_pipeline.rs similarity index 96% rename from crates/embedded-dsp/tests/dither_dsm_pipeline_coverage.rs rename to crates/embedded-dsp/tests/dither_dsm_pipeline.rs index 6d61489..06d0088 100644 --- a/crates/embedded-dsp/tests/dither_dsm_pipeline_coverage.rs +++ b/crates/embedded-dsp/tests/dither_dsm_pipeline.rs @@ -1,8 +1,4 @@ -//! Coverage for the dithering, delta-sigma, and pipeline/streaming APIs. -//! -//! `dither` and `dsm` were previously reachable only from their doc examples -//! (which are not instrumented), and the `pipeline` trait surface was largely -//! unexercised. These tests drive the public API directly. +//! Consolidated tests: dither_dsm_pipeline_coverage. use embedded_dsp::audio::{ PeakEnvelopeFollower, PeakEnvelopeFollowerQ15, RmsEnvelopeFollower, RmsEnvelopeFollowerQ15, @@ -17,6 +13,7 @@ use embedded_dsp::pipeline::{ use embedded_dsp::resampling::{CicDecimator, CicFilter, GardnerSymbolSync, HbfDec, HbfInt}; use embedded_dsp::types::q15; +// ─── from dither_dsm_pipeline_coverage.rs ──────────────────────────────────────── // ───────────────────────────────────────────────────────────────────────────── // dither // ───────────────────────────────────────────────────────────────────────────── @@ -614,30 +611,30 @@ fn limiter_clamps_both_rails() { #[test] fn built_in_nodes_delegate_to_their_inherent_process() { - use embedded_dsp::controller::{PidInstanceF32, PidInstanceQ15, PidInstanceQ31}; + use embedded_dsp::controller::PidInstance; use embedded_dsp::filtering::{DcBlockerQ15, SinglePoleFilter}; use embedded_dsp::types::{q15, q31}; - let mut via_node = PidInstanceF32::new(1.0, 0.1, 0.01); - let mut direct = PidInstanceF32::new(1.0, 0.1, 0.01); + let mut via_node = PidInstance::::new(1.0, 0.1, 0.01); + let mut direct = PidInstance::::new(1.0, 0.1, 0.01); assert_eq!( DspNode::process_sample(&mut via_node, 0.5), direct.process(0.5) ); let mut via_node = - PidInstanceQ15::new(q15::from_bits(100), q15::from_bits(10), q15::from_bits(1)); + PidInstance::::new(q15::from_bits(100), q15::from_bits(10), q15::from_bits(1)); let mut direct = - PidInstanceQ15::new(q15::from_bits(100), q15::from_bits(10), q15::from_bits(1)); + PidInstance::::new(q15::from_bits(100), q15::from_bits(10), q15::from_bits(1)); assert_eq!( DspNode::process_sample(&mut via_node, q15::from_bits(1_000)), direct.process(q15::from_bits(1_000)) ); let mut via_node = - PidInstanceQ31::new(q31::from_bits(100), q31::from_bits(10), q31::from_bits(1)); + PidInstance::::new(q31::from_bits(100), q31::from_bits(10), q31::from_bits(1)); let mut direct = - PidInstanceQ31::new(q31::from_bits(100), q31::from_bits(10), q31::from_bits(1)); + PidInstance::::new(q31::from_bits(100), q31::from_bits(10), q31::from_bits(1)); assert_eq!( DspNode::process_sample(&mut via_node, q31::from_bits(1_000)), direct.process(q31::from_bits(1_000)) diff --git a/crates/embedded-dsp/tests/dsp_tests.rs b/crates/embedded-dsp/tests/dsp_core.rs similarity index 97% rename from crates/embedded-dsp/tests/dsp_tests.rs rename to crates/embedded-dsp/tests/dsp_core.rs index 0255f1a..5b060ad 100644 --- a/crates/embedded-dsp/tests/dsp_tests.rs +++ b/crates/embedded-dsp/tests/dsp_core.rs @@ -1,5 +1,8 @@ +//! Consolidated tests: dsp_tests. + use embedded_dsp::*; +// ─── from dsp_tests.rs ──────────────────────────────────────── // ========================================================================================= // 1. BASIC MATH & BITWISE TESTS // ========================================================================================= @@ -180,11 +183,11 @@ fn test_fast_trig_and_roots() { fn test_fir_filter_impulse_response() { let coeffs = [0.25f32, 0.5, 0.25]; let mut state = [0.0f32; 3]; - let mut fir = FirInstanceF32::init(3, &coeffs, &mut state); + let mut fir_inst = FirInstance::::init(3, &coeffs, &mut state); let src = [1.0f32, 0.0, 0.0, 0.0]; let mut dst = [0.0f32; 4]; - fir_f32(&mut fir, &src, &mut dst); + fir(&mut fir_inst, &src, &mut dst); assert_eq!(dst, [0.25, 0.5, 0.25, 0.0]); } @@ -193,11 +196,11 @@ fn test_fir_filter_impulse_response() { fn test_biquad_cascade_iir() { let coeffs = [1.0f32, 0.0, 0.0, 0.0, 0.0]; let mut state = [0.0f32; 4]; - let mut iir = BiquadCascadeInstanceF32::init(1, &coeffs, &mut state); + let mut iir = BiquadCascadeInstance::::init(1, &coeffs, &mut state); let src = [1.0f32, 2.0, 3.0, 4.0]; let mut dst = [0.0f32; 4]; - biquad_cascade_df1_f32(&mut iir, &src, &mut dst); + biquad_cascade_df1(&mut iir, &src, &mut dst); assert_eq!(dst, [1.0, 2.0, 3.0, 4.0]); } @@ -206,19 +209,19 @@ fn test_biquad_cascade_iir() { fn test_lms_adaptive_filter() { let mut coeffs = [0.0f32; 2]; let mut state = [0.0f32; 2]; - let mut lms = LmsInstanceF32::init(2, &mut coeffs, &mut state, 0.01); + let mut lms_inst = LmsInstance::::init(2, &mut coeffs, &mut state, 0.01); let src = [1.0f32, 2.0]; let ref_sig = [1.0f32, 2.0]; let mut out = [0.0f32; 2]; let mut err = [0.0f32; 2]; - lms_f32(&mut lms, &src, &ref_sig, &mut out, &mut err); + lms(&mut lms_inst, &src, &ref_sig, &mut out, &mut err); assert_eq!(out.len(), 2); let mut ncoeffs = [0.0f32; 4]; let mut nstate = [0.0f32; 4]; - let mut nlms = NlmsInstanceF32::init(4, &mut ncoeffs, &mut nstate, 0.5, 1e-6); + let mut nlms_inst = NlmsInstance::::init(4, &mut ncoeffs, &mut nstate, 0.5, 1e-6); let mut nout = [0.0f32; 64]; let mut nerr = [0.0f32; 64]; let mut nsrc = [0.0f32; 64]; @@ -227,13 +230,13 @@ fn test_lms_adaptive_filter() { nsrc[i] = ((i * 17) % 10) as f32 / 10.0 - 0.45; nref[i] = 0.5 * nsrc[i]; } - nlms_f32(&mut nlms, &nsrc, &nref, &mut nout, &mut nerr); + nlms(&mut nlms_inst, &nsrc, &nref, &mut nout, &mut nerr); let last_err = nerr[63].abs(); assert!(last_err < 0.15, "nlms err {last_err}"); let mut qcoeffs = [q15::ZERO; 4]; let mut qstate = [q15::ZERO; 4]; - let mut qlms = LmsInstanceQ15::init(4, &mut qcoeffs, &mut qstate, q15::from_bits(1024)); + let mut qlms = LmsInstance::::init(4, &mut qcoeffs, &mut qstate, q15::from_bits(1024)); let mut qsrc = [q15::ZERO; 64]; let mut qref = [q15::ZERO; 64]; for i in 0..64 { @@ -242,8 +245,8 @@ fn test_lms_adaptive_filter() { } let mut qout = [q15::ZERO; 64]; let mut qerr = [q15::ZERO; 64]; - lms_q15(&mut qlms, &qsrc, &qref, &mut qout, &mut qerr); - lms_leaky_q15( + lms(&mut qlms, &qsrc, &qref, &mut qout, &mut qerr); + lms_leaky( &mut qlms, &qsrc, &qref, @@ -254,14 +257,14 @@ fn test_lms_adaptive_filter() { let mut nqcoeffs = [q15::ZERO; 4]; let mut nqstate = [q15::ZERO; 4]; - let mut qnlms = NlmsInstanceQ15::init( + let mut qnlms = NlmsInstance::::init( 4, &mut nqcoeffs, &mut nqstate, q15::from_bits(16384), q15::from_bits(8), ); - nlms_q15(&mut qnlms, &qsrc, &qref, &mut qout, &mut qerr); + nlms(&mut qnlms, &qsrc, &qref, &mut qout, &mut qerr); } #[test] @@ -362,7 +365,7 @@ fn test_matrix_inverse() { #[test] fn test_pid_and_clarke_park() { - let mut pid = PidInstanceF32::new(1.0, 0.1, 0.01); + let mut pid = PidInstance::::new(1.0, 0.1, 0.01); let out1 = pid.process(10.0); assert!((out1 - 11.1).abs() < 1e-3); @@ -409,7 +412,7 @@ fn test_pid_and_clarke_park() { #[test] fn test_pid_q31_and_q15_process() { - let mut pid31 = PidInstanceQ31::new( + let mut pid31 = PidInstance::::new( q31::from_bits(i32::MAX / 4), q31::from_bits(i32::MAX / 20), q31::from_bits(i32::MAX / 100), @@ -420,7 +423,7 @@ fn test_pid_q31_and_q15_process() { "positive input with positive gains must give positive output" ); - let mut pid15 = PidInstanceQ15::new( + let mut pid15 = PidInstance::::new( q15::from_bits(i16::MAX / 4), q15::from_bits(i16::MAX / 20), q15::from_bits(i16::MAX / 100), @@ -433,8 +436,8 @@ fn test_pid_q31_and_q15_process() { // Extreme edge: coefficient and input both exactly Q31::MIN (-1.0). The // MAC term wraps to MIN instead of saturating to +2^31, since only the - // final sum saturates, not each term (see PidInstanceQ31::process docs). - let mut pid31_edge = PidInstanceQ31 { + // final sum saturates, not each term (see PidInstance::process docs). + let mut pid31_edge = PidInstance:: { a0: q31::from_bits(i32::MIN), a1: q31::ZERO, a2: q31::ZERO, @@ -450,8 +453,8 @@ fn test_pid_q31_and_q15_process() { "MIN*MIN term must wrap, not saturate" ); - // Same edge case at Q15 width (see PidInstanceQ15::process docs). - let mut pid15_edge = PidInstanceQ15 { + // Same edge case at Q15 width (see PidInstance::process docs). + let mut pid15_edge = PidInstance:: { a0: q15::from_bits(i16::MIN), a1: q15::ZERO, a2: q15::ZERO, @@ -1697,7 +1700,7 @@ fn test_recursive_moving_average_matches_naive_average() { ); } - let mut rma_q = RecursiveMovingAverageQ15::<4>::new(); + let mut rma_q = RecursiveMovingAverage::::new(); let input_q = [ q15::from_bits(1000), q15::from_bits(2000), @@ -2248,8 +2251,9 @@ fn test_biquad_q15_matches_f32_lowpass() { let mut state_f = [0.0f32; 4]; let mut state_q = [q15::ZERO; 4]; - let mut bq_f = BiquadCascadeInstanceF32::init(1, &coeffs, &mut state_f); - let mut bq_q = BiquadCascadeInstanceQ15::with_post_shift(1, &qcoeffs, &mut state_q, post_shift); + let mut bq_f = BiquadCascadeInstance::::init(1, &coeffs, &mut state_f); + let mut bq_q = + BiquadCascadeInstance::::with_post_shift(1, &qcoeffs, &mut state_q, post_shift); let mut max_abs_err = 0i32; for n in 0..128 { @@ -2257,8 +2261,8 @@ fn test_biquad_q15_matches_f32_lowpass() { let mut yf = [0.0f32; 1]; let mut yq = [q15::ZERO; 1]; let xq = [q15::from_bits((x * 32767.0) as i16)]; - biquad_cascade_df1_f32(&mut bq_f, &[x], &mut yf); - biquad_cascade_df1_q15(&mut bq_q, &xq, &mut yq); + biquad_cascade_df1(&mut bq_f, &[x], &mut yf); + biquad_cascade_df1(&mut bq_q, &xq, &mut yq); let expected_q = (yf[0] * 32767.0) as i32; let err = (yq[0].to_bits() as i32 - expected_q).abs(); if err > max_abs_err { @@ -2283,10 +2287,10 @@ fn test_biquad_df2t_q15_matches_df1() { let mut state_df1 = [q15::ZERO; 4]; let mut df1 = - BiquadCascadeInstanceQ15::with_post_shift(1, &qcoeffs, &mut state_df1, post_shift); + BiquadCascadeInstance::::with_post_shift(1, &qcoeffs, &mut state_df1, post_shift); let mut state_df2t = [q15::ZERO; 2]; let mut df2t = - BiquadCascadeDf2tInstanceQ15::with_post_shift(1, &qcoeffs, &mut state_df2t, post_shift); + BiquadCascadeDf2tInstance::::with_post_shift(1, &qcoeffs, &mut state_df2t, post_shift); let mut max_err = 0i32; for n in 0..64 { @@ -2294,24 +2298,24 @@ fn test_biquad_df2t_q15_matches_df1() { let xq = [q15::from_bits((x * 32767.0) as i16)]; let mut y1 = [q15::ZERO; 1]; let mut y2 = [q15::ZERO; 1]; - biquad_cascade_df1_q15(&mut df1, &xq, &mut y1); - biquad_cascade_df2t_q15(&mut df2t, &xq, &mut y2); + biquad_cascade_df1(&mut df1, &xq, &mut y1); + biquad_cascade_df2t(&mut df2t, &xq, &mut y2); max_err = max_err.max((y1[0].to_bits() as i32 - y2[0].to_bits() as i32).abs()); } assert!(max_err < 2500, "DF1 vs DF2T max abs {max_err}"); let coeffs = biquad_lowpass_coeffs(800.0, 8000.0, core::f32::consts::FRAC_1_SQRT_2); let mut state_f = [0.0f32; 4]; - let mut df1f = BiquadCascadeInstanceF32::init(1, &coeffs, &mut state_f); + let mut df1f = BiquadCascadeInstance::::init(1, &coeffs, &mut state_f); let mut state_t = [0.0f32; 2]; - let mut df2tf = BiquadCascadeDf2tInstanceF32::init(1, &coeffs, &mut state_t); + let mut df2tf = BiquadCascadeDf2tInstance::::init(1, &coeffs, &mut state_t); let mut max_f = 0.0f32; for n in 0..64 { let x = (2.0 * core::f32::consts::PI * 200.0 * n as f32 / 8000.0).sin() * 0.5; let mut y1 = [0.0f32; 1]; let mut y2 = [0.0f32; 1]; - biquad_cascade_df1_f32(&mut df1f, &[x], &mut y1); - biquad_cascade_df2t_f32(&mut df2tf, &[x], &mut y2); + biquad_cascade_df1(&mut df1f, &[x], &mut y1); + biquad_cascade_df2t(&mut df2tf, &[x], &mut y2); max_f = max_f.max((y1[0] - y2[0]).abs()); } assert!(max_f < 1e-4, "f32 DF1 vs DF2T {max_f}"); diff --git a/crates/embedded-dsp/tests/edge_branch_coverage.rs b/crates/embedded-dsp/tests/edge_cases.rs similarity index 100% rename from crates/embedded-dsp/tests/edge_branch_coverage.rs rename to crates/embedded-dsp/tests/edge_cases.rs diff --git a/crates/embedded-dsp/tests/feature_manifest.rs b/crates/embedded-dsp/tests/feature_manifest.rs new file mode 100644 index 0000000..e175e66 --- /dev/null +++ b/crates/embedded-dsp/tests/feature_manifest.rs @@ -0,0 +1,99 @@ +//! Guards the hand-maintained `full` feature against the module list in +//! `src/lib.rs`. +//! +//! `full` is the "every algorithm module" aggregator, and the per-module +//! features are listed by hand in `Cargo.toml`. Nothing at compile time notices +//! when a new `pub mod` lands without a matching `full` entry, so this test +//! reads both files and fails with the offenders. Modules wired through +//! `gated_mod!` are the only ones that can be gated: `math`, `types`, and +//! `intrinsics` are always available and have no feature to check. + +use std::collections::BTreeSet; + +/// Features that gate a module but are deliberately *not* part of `full`, +/// because they pull in an optional third-party dependency instead of an +/// in-crate algorithm module. Keep this list tiny and justified. +const OPT_IN: &[&str] = &[ + // `nalgebra_interop`: bridges `quaternion` to the `nalgebra` crate. + "nalgebra", + // `config`: `miniconf` control-plane settings for runtime-tunable filters. + "miniconf", +]; + +fn read(relative: &str) -> String { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(relative); + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())) +} + +/// Every `gated_mod!("feature", module)` / `gated_mod!(math "feature", module)` +/// invocation in `lib.rs`, as `(feature, module)` pairs. +fn gated_modules(src: &str) -> Vec<(String, String)> { + let mut out = Vec::new(); + let mut rest = src; + while let Some(start) = rest.find("gated_mod!(") { + rest = &rest[start + "gated_mod!(".len()..]; + let end = rest.find(");").expect("unterminated gated_mod! invocation"); + let call = &rest[..end]; + + let open = call.find('"').expect("gated_mod! feature literal"); + let close = call[open + 1..] + .find('"') + .expect("gated_mod! closing quote") + + open + + 1; + let feature = call[open + 1..close].to_string(); + + let module = call + .rsplit(',') + .next() + .expect("gated_mod! module argument") + .trim() + .to_string(); + assert!( + !module.is_empty() && !module.contains('"'), + "could not parse the module name out of `gated_mod!({call})`" + ); + + out.push((feature, module)); + rest = &rest[end..]; + } + out +} + +/// The entries of the `full = [...]` array in `Cargo.toml`. +fn full_features(manifest: &str) -> BTreeSet { + let start = manifest + .find("full = [") + .expect("a `full = [...]` feature in Cargo.toml") + + "full = [".len(); + let end = manifest[start..].find(']').expect("closing `]` for `full`") + start; + manifest[start..end] + .split(',') + .map(|entry| entry.trim().trim_matches('"')) + .filter(|entry| !entry.is_empty()) + .map(str::to_string) + .collect() +} + +#[test] +fn every_gated_module_is_in_full_or_an_opt_in_feature() { + let modules = gated_modules(&read("src/lib.rs")); + assert!(!modules.is_empty(), "found no `gated_mod!` invocations"); + + let full = full_features(&read("Cargo.toml")); + let mut missing = Vec::new(); + for (feature, module) in &modules { + if !full.contains(feature) && !OPT_IN.contains(&feature.as_str()) { + missing.push(format!(" {module} (feature `{feature}`)")); + } + } + + assert!( + missing.is_empty(), + "these feature-gated modules are neither in the `full` feature nor the \ + OPT_IN allowlist in tests/feature_manifest.rs:\n{}\n\n\ + Add the feature to `full = [...]` in crates/embedded-dsp/Cargo.toml, or \ + add it to OPT_IN with a comment explaining why it is opt-in.", + missing.join("\n") + ); +} diff --git a/crates/embedded-dsp/tests/filter_design_eq.rs b/crates/embedded-dsp/tests/filter_design_eq.rs new file mode 100644 index 0000000..8822e25 --- /dev/null +++ b/crates/embedded-dsp/tests/filter_design_eq.rs @@ -0,0 +1,794 @@ +//! Consolidated tests: filter_design_eq_params, voxengo_oracle. + +use embedded_dsp::filter_analysis::{biquad_frequency_response, response_magnitude_db}; +use embedded_dsp::filter_design::{ + biquad_bandpass_coeffs, biquad_bandpass_skirt_coeffs, biquad_highshelf_coeffs, + biquad_lowshelf_coeffs, biquad_notch_coeffs, biquad_peaking_coeffs, biquad_q_from_bw, + biquad_q_from_shelf_slope, +}; + +// ─── from filter_design_eq_params.rs ──────────────────────────────────────── +/// -3.0103 dB, the half-power point. +const MINUS_3_DB: f32 = -3.010_3; + +fn mag_db(coeffs: [f32; 5], freq: f32, sample_rate: f32) -> f32 { + response_magnitude_db(biquad_frequency_response(&coeffs, freq / sample_rate)) +} + +/// Bisects for the frequency in `[lo, hi]` where the magnitude crosses `target_db`. +/// The caller must ensure the endpoints bracket a single crossing. +fn crossing_freq( + coeffs: [f32; 5], + sample_rate: f32, + mut lo: f32, + mut hi: f32, + target_db: f32, +) -> f32 { + let mut g_lo = mag_db(coeffs, lo, sample_rate) - target_db; + for _ in 0..100 { + let mid = 0.5 * (lo + hi); + let g_mid = mag_db(coeffs, mid, sample_rate) - target_db; + if g_lo * g_mid <= 0.0 { + hi = mid; + } else { + lo = mid; + g_lo = g_mid; + } + } + 0.5 * (lo + hi) +} + +/// Measured octave span of a symmetric band: `log2(f_hi / f_lo)` at the given threshold. +fn measured_octave_span(coeffs: [f32; 5], sample_rate: f32, center: f32, target_db: f32) -> f32 { + let f_lo = crossing_freq( + coeffs, + sample_rate, + sample_rate * 1e-4, + center * 0.999, + target_db, + ); + let f_hi = crossing_freq( + coeffs, + sample_rate, + center * 1.001, + sample_rate * 0.4999, + target_db, + ); + (f_hi / f_lo).log2() +} + +#[test] +fn bw_sets_bandpass_and_notch_octave_bandwidth() { + let fs = 48_000.0; + + for (f0, bw) in [ + (200.0f32, 0.5f32), + (1_000.0, 1.0), + (6_000.0, 1.0), + (12_000.0, 1.0), + (300.0, 3.0), + ] { + let q = biquad_q_from_bw(bw, f0, fs); + assert!(q.is_finite() && q > 0.0, "q={q} for f0={f0} bw={bw}"); + + let cases = [ + ("band-pass", biquad_bandpass_coeffs(f0, fs, q)), + ("notch", biquad_notch_coeffs(f0, fs, q)), + ]; + for (name, coeffs) in cases { + let actual = measured_octave_span(coeffs, fs, f0, MINUS_3_DB); + assert!( + (actual - bw).abs() < 0.05, + "{name} f0={f0} requested {bw} oct, measured {actual:.4} oct" + ); + } + } +} + +#[test] +fn bw_sets_peaking_half_gain_bandwidth() { + let fs = 48_000.0; + + for (f0, bw, gain_db) in [ + (1_000.0f32, 1.0f32, 6.0f32), + (2_000.0, 2.0, 12.0), + (6_000.0, 0.5, -9.0), + (12_000.0, 1.0, 6.0), + ] { + let q = biquad_q_from_bw(bw, f0, fs); + let coeffs = biquad_peaking_coeffs(f0, fs, q, gain_db); + + // Centred exactly on the requested gain; the bandwidth is measured at half of it. + assert!( + (mag_db(coeffs, f0, fs) - gain_db).abs() < 0.05, + "f0={f0}: centre gain {} dB != {gain_db} dB", + mag_db(coeffs, f0, fs) + ); + + let actual = measured_octave_span(coeffs, fs, f0, gain_db * 0.5); + assert!( + (actual - bw).abs() < 0.05, + "peaking f0={f0} gain={gain_db} dB requested {bw} oct, measured {actual:.4} oct" + ); + } +} + +/// The bilinear-transform correction is what keeps the bandwidth honest near Nyquist; the +/// uncorrected analog-prototype shorthand collapses the band as `f0` rises. +#[test] +fn digital_bw_correction_preserves_width_near_nyquist() { + let fs = 48_000.0; + let (f0, bw) = (12_000.0f32, 1.0f32); + + let digital_q = biquad_q_from_bw(bw, f0, fs); + let analog_q = 1.0 / (2.0 * (0.5 * core::f32::consts::LN_2 * bw).sinh()); + + let digital_span = measured_octave_span( + biquad_bandpass_coeffs(f0, fs, digital_q), + fs, + f0, + MINUS_3_DB, + ); + let analog_span = + measured_octave_span(biquad_bandpass_coeffs(f0, fs, analog_q), fs, f0, MINUS_3_DB); + + assert!( + (digital_span - bw).abs() < 0.05, + "digital relation should hold near Nyquist, measured {digital_span:.4} oct" + ); + assert!( + analog_span < bw - 0.2, + "analog shorthand should under-state the width, measured {analog_span:.4} oct" + ); +} + +#[test] +fn shelf_slope_one_is_butterworth_and_centres_at_half_gain() { + let fs = 48_000.0; + let f0 = 1_000.0; + + for gain_db in [6.0f32, 12.0, -6.0, -12.0] { + let q = biquad_q_from_shelf_slope(1.0, gain_db); + assert!( + (q - core::f32::consts::FRAC_1_SQRT_2).abs() < 1e-5, + "S=1 should give Q=1/sqrt(2), got {q} for {gain_db} dB" + ); + + let low = biquad_lowshelf_coeffs(f0, fs, q, gain_db); + let high = biquad_highshelf_coeffs(f0, fs, q, gain_db); + + // The shelf midpoint at f0 is half the shelf gain. + assert!((mag_db(low, f0, fs) - gain_db * 0.5).abs() < 0.05); + assert!((mag_db(high, f0, fs) - gain_db * 0.5).abs() < 0.05); + + // A low shelf acts at DC and is transparent at Nyquist; a high shelf is the reverse. + assert!((mag_db(low, 0.0, fs) - gain_db).abs() < 0.05); + assert!(mag_db(low, fs * 0.5, fs).abs() < 0.05); + assert!(mag_db(high, 0.0, fs).abs() < 0.05); + assert!((mag_db(high, fs * 0.5, fs) - gain_db).abs() < 0.05); + } +} + +#[test] +fn shelf_slope_controls_steepness_and_stays_monotonic() { + let fs = 48_000.0; + let f0 = 1_000.0; + let gain_db = 6.0f32; + + let mut previous_drop = None; + for slope in [0.3f32, 0.6, 1.0] { + let q = biquad_q_from_shelf_slope(slope, gain_db); + let coeffs = biquad_lowshelf_coeffs(f0, fs, q, gain_db); + + // A boost low shelf must fall monotonically from DC to Nyquist for S <= 1. + let mut previous = f32::INFINITY; + let mut f = 20.0f32; + while f < fs * 0.5 { + let m = mag_db(coeffs, f, fs); + assert!( + m <= previous + 1e-3, + "slope={slope}: non-monotonic at {f} Hz ({m} dB > {previous} dB)" + ); + previous = m; + f *= 1.1; + } + + // Steeper slope => a larger gain drop across the octave around f0. + let drop = mag_db(coeffs, f0 * 0.5, fs) - mag_db(coeffs, f0 * 2.0, fs); + if let Some(prev) = previous_drop { + assert!( + drop > prev, + "slope {slope} should drop more than the gentler slope ({drop} vs {prev} dB)" + ); + } + previous_drop = Some(drop); + } +} + +/// The cookbook's peaking definition (`A*Q`) makes +N dB and -N dB at identical `Q`/`f0` an +/// exact unity "wire". +#[test] +fn peaking_boost_then_cut_is_a_unity_wire() { + let fs = 48_000.0; + let q = 1.2f32; + + // The identity is exact mathematically but only approximate in `f32`: at low `f0` the poles + // sit close to z = 1 and rounding in the near-cancelling coefficients costs a little + // accuracy. The bound below covers the sampled grid. + let mut worst_db = 0.0f32; + + for (f0, gain_db) in [(60.0f32, 6.0f32), (1_000.0, 6.0), (10_000.0, 12.0)] { + let boost = biquad_peaking_coeffs(f0, fs, q, gain_db); + let cut = biquad_peaking_coeffs(f0, fs, q, -gain_db); + + let mut f = 5.0f32; + while f < fs * 0.5 { + let combined_db = mag_db(boost, f, fs) + mag_db(cut, f, fs); + worst_db = worst_db.max(combined_db.abs()); + f *= 1.07; + } + } + + assert!( + worst_db < 0.05, + "worst boost/cut cascade deviation was {worst_db} dB, expected unity" + ); +} + +/// Locks the two band-pass variants to the cookbook's distinct gain conventions. +#[test] +fn bandpass_variants_match_their_rbj_conventions() { + let fs = 48_000.0; + let f0 = 1_000.0; + + for q in [0.5f32, 1.0, 2.0, 5.0] { + // Constant 0 dB peak gain. + let peak = biquad_bandpass_coeffs(f0, fs, q); + assert!( + mag_db(peak, f0, fs).abs() < 0.01, + "q={q}: constant-peak band-pass is {} dB at f0", + mag_db(peak, f0, fs) + ); + + // Constant skirt gain, so the peak gain equals Q. + let skirt = biquad_bandpass_skirt_coeffs(f0, fs, q); + let expected = 20.0 * q.log10(); + assert!( + (mag_db(skirt, f0, fs) - expected).abs() < 0.01, + "q={q}: skirt band-pass is {} dB at f0, expected {expected} dB", + mag_db(skirt, f0, fs) + ); + } +} + +#[test] +fn parameter_converters_stay_finite_on_degenerate_inputs() { + for (bw, f0, fs) in [ + (0.0f32, 0.0f32, 48_000.0f32), + (1.0, 24_000.0, 48_000.0), // Nyquist + (-1.0, 1_000.0, 48_000.0), + (1.0, -5.0, 48_000.0), + ] { + let q = biquad_q_from_bw(bw, f0, fs); + assert!( + q.is_finite() && q > 0.0, + "q_from_bw({bw}, {f0}, {fs}) = {q}" + ); + } + + for (slope, gain_db) in [(0.0f32, 6.0f32), (-1.0, 6.0), (1.0, 0.0), (0.5, -60.0)] { + let q = biquad_q_from_shelf_slope(slope, gain_db); + assert!( + q.is_finite() && q > 0.0, + "q_from_shelf_slope({slope}, {gain_db}) = {q}" + ); + } +} + +// ─── from voxengo_oracle.rs ──────────────────────────────────────── +/// Filter family understood by [`cook_biquad_voxengo`]. +#[derive(Clone, Copy, Debug)] +enum Kind { + /// Bell (`gain > 1`) or notch (`gain < 1`); the reference's `BT_PEQ`. + Peq, + /// Constant-peak-gain band-pass; the reference's `BT_BPF`. + Bpf, +} + +/// A biquad with an explicit, unnormalised `a0`, as the reference emits it. +#[derive(Clone, Copy, Debug)] +struct Biquad6 { + b0: f64, + b1: f64, + b2: f64, + a0: f64, + a1: f64, + a2: f64, +} + +impl Biquad6 { + /// Magnitude `|H(e^{jω})|` at normalised frequency `freq_norm` (cycles/sample). + fn magnitude(&self, freq_norm: f64) -> f64 { + let omega = 2.0 * core::f64::consts::PI * freq_norm; + let (s1, c1) = omega.sin_cos(); + let (s2, c2) = (2.0 * omega).sin_cos(); + + let num_re = self.b0 + self.b1 * c1 + self.b2 * c2; + let num_im = -(self.b1 * s1 + self.b2 * s2); + let den_re = self.a0 + self.a1 * c1 + self.a2 * c2; + let den_im = -(self.a1 * s1 + self.a2 * s2); + + ((num_re * num_re + num_im * num_im) / (den_re * den_re + den_im * den_im)).sqrt() + } + + /// Normalises to this crate's Direct Form I `[b0, b1, b2, a1, a2]` convention, i.e. + /// `H(z) = (b0 + b1 z^-1 + b2 z^-2) / (1 - a1 z^-1 - a2 z^-2)`. + fn to_df1_f32(self) -> [f32; 5] { + let inv = 1.0 / self.a0; + [ + (self.b0 * inv) as f32, + (self.b1 * inv) as f32, + (self.b2 * inv) as f32, + (-self.a1 * inv) as f32, + (-self.a2 * inv) as f32, + ] + } + + /// Largest pole modulus of `a0 + a1 z^-1 + a2 z^-2 = 0`. + fn max_pole_radius(&self) -> f64 { + let disc = self.a1 * self.a1 - 4.0 * self.a0 * self.a2; + if disc >= 0.0 { + let root = disc.sqrt(); + let p1 = (-self.a1 + root) / (2.0 * self.a0); + let p2 = (-self.a1 - root) / (2.0 * self.a0); + p1.abs().max(p2.abs()) + } else { + (self.a2 / self.a0).sqrt() + } + } +} + +/// Faithful `f64` transcription of `cookBiquadVoxengo` (see module docs). +/// +/// `gain` is linear (`2.0` is +6 dB) and `bw` is the -3 dB bandwidth in octaves. +fn cook_biquad_voxengo(kind: Kind, sample_rate: f64, freq: f64, gain: f64, bw: f64) -> Biquad6 { + // Normalised centre frequency, clamped away from 0 and Nyquist (`tan` blows up otherwise). + let fp = (freq / sample_rate).clamp(1e-9, 0.499_999_9); + let fb = fp * 2f64.powf(-bw * 0.5); + + // `rs` is a shift parameter with 2.0 yielding the intended design (the reference notes a + // value of 1.7 tracks the analog prototype more closely). + const RS: f64 = 2.0; + let r = (fp * fp - fb * fb) / (RS * fb * (0.25 - fp * fp)); + let y = r * r; + + // Family anchors: `gn` (Nyquist), `g0` (DC), `gb` (band edge), `gp` (peak), and the skew `v2`. + let (gn, g0, gb, gp, v2): (f64, f64, f64, f64, f64) = match kind { + Kind::Peq => { + if (gain - 1.0).abs() < 1e-9 { + return Biquad6 { + b0: 1.0, + b1: 0.0, + b2: 0.0, + a0: 1.0, + a1: 0.0, + a2: 0.0, + }; + } + ( + (1.0 + gain * y) / (1.0 + y / gain), + 1.0, + gain, + gain * gain, + gain / (gain + y), + ) + } + Kind::Bpf => (y / (1.0 + y), 0.0, 0.5, 1.0, 1.0 / (1.0 + y)), + }; + + // Warped frequency axis. + let xp = (core::f64::consts::PI * fp).tan().powi(2); + let xb = (core::f64::consts::PI * fb).tan().powi(2); + + let w = xp * v2.sqrt(); + let gn_sqrt = gn.sqrt(); + let g0w = g0.sqrt() * w; + + // 2x2 linear solve for the numerator/denominator quadratic terms. + let t = w - xp; + let u = g0w - gn_sqrt * xp; + let r1 = (gp * t * t - u * u) / xp; + + let t = w - xb; + let u = g0w - gn_sqrt * xb; + let r2 = (gb * t * t - u * u) / xb; + + let den = gb - gp; + let a_sq = (r1 - r2) / den; + let b_sq = (gb * r1 - gp * r2) / den; + let a = a_sq.sqrt(); + let b = b_sq.sqrt(); + + Biquad6 { + b0: gn_sqrt + g0w + b, + b1: 2.0 * (g0w - gn_sqrt), + b2: gn_sqrt + g0w - b, + a0: 1.0 + w + a, + a1: 2.0 * (w - 1.0), + a2: 1.0 + w - a, + } +} + +/// `Q` that yields a -3 dB octave bandwidth `bw`, per the reference's own relation +/// `BW = 2/ln(2) * asinh(1/(2Q))`. +fn q_from_bw(bw: f64) -> f64 { + 1.0 / (2.0 * (bw * core::f64::consts::LN_2 / 2.0).sinh()) +} + +/// Linear magnitude -> decibels. +fn db(mag: f64) -> f64 { + 20.0 * mag.log10() +} + +/// Magnitude in dB of a crate-format Direct Form I section, via the crate's own analyzer. +fn rbj_mag_db(coeffs: [f32; 5], freq_norm: f32) -> f64 { + response_magnitude_db(biquad_frequency_response(&coeffs, freq_norm)) as f64 +} + +#[test] +fn oracle_bell_hits_center_gain_exactly_and_targets_octave_edges() { + // (sample_rate, centre_hz, linear_gain, bandwidth_octaves) + let cases = [ + (48_000.0, 1_000.0, 2.0, 1.0), + (48_000.0, 1_000.0, 3.981_071_7, 3.0), + (48_000.0, 250.0, 10.0, 0.5), + (44_100.0, 5_000.0, 0.5, 1.5), + ]; + + for (fs, f0, gain, bw) in cases { + let f = cook_biquad_voxengo(Kind::Peq, fs, f0, gain, bw); + + let center = f.magnitude(f0 / fs); + assert!( + (center - gain).abs() / gain < 1e-9, + "centre gain {center} != {gain}" + ); + + // Band edges target -3 dB below the peak, i.e. sqrt(gain) in linear terms. The + // reference is approximate here: residual edge error is small (well under 0.2 dB for + // these mid-band cases) but grows for very high-frequency, narrow, deep bands. + let edge_target_db = db(gain.sqrt()); + let lo = f0 * 2f64.powf(-bw / 2.0); + let hi = f0 * 2f64.powf(bw / 2.0); + for freq in [lo, hi] { + let m_db = db(f.magnitude(freq / fs)); + assert!( + (m_db - edge_target_db).abs() < 0.25, + "f0={f0} bw={bw} gain={gain}: edge {freq} Hz at {m_db:.3} dB, \ + expected {edge_target_db:.3} dB" + ); + } + + // A bell is transparent at DC. + assert!((f.magnitude(0.0) - 1.0).abs() < 1e-9); + } +} + +#[test] +fn oracle_bandpass_is_unity_peak_with_minus_3db_edges() { + let (fs, f0, bw) = (48_000.0, 1_000.0, 1.0); + let f = cook_biquad_voxengo(Kind::Bpf, fs, f0, 1.0, bw); + + assert!((f.magnitude(f0 / fs) - 1.0).abs() < 1e-9); + + let lo = f0 * 2f64.powf(-bw / 2.0); + let hi = f0 * 2f64.powf(bw / 2.0); + for freq in [lo, hi] { + let m_db = db(f.magnitude(freq / fs)); + assert!( + (m_db + 3.010_3).abs() < 0.1, + "edge {freq} Hz at {m_db:.3} dB, expected -3.01 dB" + ); + } + + // A band-pass rejects DC. + assert!(f.magnitude(0.0) < 1e-6); +} + +#[test] +fn oracle_df1_conversion_agrees_with_crate_frequency_response() { + let f = cook_biquad_voxengo(Kind::Peq, 48_000.0, 2_000.0, 2.0, 1.5); + let df1 = f.to_df1_f32(); + + for k in 0..=100 { + let freq_norm = 0.5 * f64::from(k) / 100.0; + let oracle_db = db(f.magnitude(freq_norm)); + let crate_db = rbj_mag_db(df1, freq_norm as f32); + assert!( + (oracle_db - crate_db).abs() < 0.01, + "at {freq_norm} cyc/sample: oracle {oracle_db:.4} dB vs crate {crate_db:.4} dB" + ); + } +} + +#[test] +fn rbj_peaking_tracks_oracle_for_midband_bands() { + let fs = 48_000.0; + + for f0 in [200.0, 1_000.0, 2_000.0] { + for bw in [0.5, 1.0, 2.0] { + for gain_db in [-12.0, -6.0, 6.0, 12.0] { + let gain = 10f64.powf(gain_db / 20.0); + let oracle = cook_biquad_voxengo(Kind::Peq, fs, f0, gain, bw); + let rbj = biquad_peaking_coeffs( + f0 as f32, + fs as f32, + q_from_bw(bw) as f32, + gain_db as f32, + ); + + let lo = f0 * 2f64.powf(-bw / 2.0); + let hi = f0 * 2f64.powf(bw / 2.0); + for freq in [lo, f0, hi] { + let oracle_db = db(oracle.magnitude(freq / fs)); + let rbj_db = rbj_mag_db(rbj, (freq / fs) as f32); + assert!( + (oracle_db - rbj_db).abs() < 0.2, + "f0={f0} bw={bw} gain={gain_db} dB at {freq} Hz: \ + oracle {oracle_db:.3} dB vs RBJ {rbj_db:.3} dB" + ); + } + } + } + } +} + +/// Characterization test: documents *where* RBJ and the oracle diverge, so a future change in +/// either design is caught rather than silently absorbed. +#[test] +fn rbj_nyquist_anchor_pulls_wide_high_band_edges_off_spec() { + // A 2-octave bell at 16 kHz on a 48 kHz rate has a nominal upper -3 dB edge at 32 kHz, above + // Nyquist (24 kHz). RBJ forces the Nyquist gain to 0 dB, which drags the lower edge off its + // nominal 8 kHz / +3 dB location; the oracle keeps the edge and lets Nyquist follow the skirt. + let (fs, f0, gain_db, bw) = (48_000.0, 16_000.0, 6.0, 2.0); + let gain = 10f64.powf(gain_db / 20.0); + let oracle = cook_biquad_voxengo(Kind::Peq, fs, f0, gain, bw); + let rbj = biquad_peaking_coeffs(f0 as f32, fs as f32, q_from_bw(bw) as f32, gain_db as f32); + + let lo = f0 * 2f64.powf(-bw / 2.0); + assert!((lo - 8_000.0).abs() < 1e-9); + + let oracle_lo = db(oracle.magnitude(lo / fs)); + let rbj_lo = rbj_mag_db(rbj, (lo / fs) as f32); + + // The oracle honours the nominal spec: -3 dB relative to the +6 dB peak => +3.01 dB. + assert!( + (oracle_lo - 3.010_3).abs() < 0.1, + "oracle lower edge {oracle_lo:.3} dB" + ); + + // RBJ undershoots that edge by more than a decibel. + assert!( + rbj_lo < 2.0, + "expected RBJ lower edge below +2 dB, got {rbj_lo:.3} dB" + ); + assert!( + oracle_lo - rbj_lo > 1.0, + "oracle {oracle_lo:.3} dB vs RBJ {rbj_lo:.3} dB" + ); + + // Conversely RBJ pins Nyquist to 0 dB while the oracle's skirt lifts it. + let oracle_nyq = db(oracle.magnitude(0.5)); + let rbj_nyq = rbj_mag_db(rbj, 0.5); + assert!(rbj_nyq.abs() < 0.05, "RBJ Nyquist {rbj_nyq:.3} dB"); + assert!(oracle_nyq > 3.5, "oracle Nyquist {oracle_nyq:.3} dB"); +} + +#[test] +fn oracle_is_finite_and_stable_over_extreme_parameters() { + let fs = 48_000.0; + let mut cases = 0; + + for kind in [Kind::Peq, Kind::Bpf] { + let mut f0 = 10.0; + while f0 < fs / 2.0 { + let mut bw = 0.1; + while bw <= 4.0 { + for gain in [0.01, 0.25, 1.0, 4.0, 100.0] { + let f = cook_biquad_voxengo(kind, fs, f0, gain, bw); + let coeffs = [f.b0, f.b1, f.b2, f.a0, f.a1, f.a2]; + assert!( + coeffs.iter().all(|c| c.is_finite()), + "non-finite coefficients at f0={f0} bw={bw} gain={gain}" + ); + let radius = f.max_pole_radius(); + assert!( + radius < 1.0, + "unstable at f0={f0} bw={bw} gain={gain}: pole radius {radius}" + ); + cases += 1; + } + bw += 0.3; + } + f0 *= 1.3; + } + } + + assert!(cases > 1_000, "expected a broad sweep, ran {cases} cases"); +} + +// ─── EQ builder, IHo & WebAudio export ───────────────────────────────────── + +use embedded_dsp::filter_design::{BiquadType, EqError, EqFilter, WebAudioFilter}; +use idsp::iir::coefficients::{Filter as IdspFilter, Type as IdspType}; + +/// Convert an `idsp` cookbook `[b, a]` pair to this crate's normalised Direct Form I +/// `[b0, b1, b2, a1, a2]` convention. +fn idsp_ba_to_df1(ba: [[f32; 3]; 2]) -> [f32; 5] { + let [b, a] = ba; + let inv = 1.0 / a[0]; + [b[0] * inv, b[1] * inv, b[2] * inv, -a[1] * inv, -a[2] * inv] +} + +/// Design `typ` through the `idsp` oracle, with the shelf gain set where it applies. +fn idsp_design(typ: IdspType, f0: f32, fs: f32, q: f32, gain_db: f32) -> [f32; 5] { + let mut filter = IdspFilter::::default(); + filter.frequency(f0, fs).q(q); + if matches!( + typ, + IdspType::Peaking | IdspType::Lowshelf | IdspType::Highshelf | IdspType::IHo + ) { + filter.shelf_db(gain_db); + } + idsp_ba_to_df1(filter.build(typ)) +} + +fn assert_coeffs_close(actual: [f32; 5], oracle: [f32; 5], ctx: &str) { + for i in 0..5 { + let scale = oracle[i].abs().max(1e-3); + assert!( + (actual[i] - oracle[i]).abs() / scale < 5e-5, + "{ctx}: coeff {i}: {} vs idsp oracle {}", + actual[i], + oracle[i] + ); + } +} + +/// The unified builder must reproduce `idsp`'s cookbook coefficients for every response +/// type, including the `IHo` section this crate previously lacked. +#[test] +fn eq_builder_matches_idsp_oracle_for_every_type() { + let fs = 48_000.0f32; + let types = [ + (BiquadType::Lowpass, IdspType::Lowpass), + (BiquadType::Highpass, IdspType::Highpass), + (BiquadType::Bandpass, IdspType::Bandpass), + (BiquadType::Allpass, IdspType::Allpass), + (BiquadType::Notch, IdspType::Notch), + (BiquadType::Peaking, IdspType::Peaking), + (BiquadType::Lowshelf, IdspType::Lowshelf), + (BiquadType::Highshelf, IdspType::Highshelf), + (BiquadType::Iho, IdspType::IHo), + ]; + + let mut cases = 0; + for (typ, idsp_typ) in types { + for f0 in [100.0f32, 1_000.0, 5_000.0, 12_000.0] { + for q in [0.5f32, 0.707, 2.0, 8.0] { + for gain_db in [-12.0f32, 0.0, 6.0] { + let ours = EqFilter::new(f0, fs).q(q).gain_db(gain_db).build(typ); + let oracle = idsp_design(idsp_typ, f0, fs, q, gain_db); + assert_coeffs_close( + ours, + oracle, + &format!("{typ:?} f0={f0} q={q} gain={gain_db}"), + ); + cases += 1; + } + } + } + } + assert!(cases > 400, "expected a broad sweep, ran {cases} cases"); +} + +#[test] +fn eq_builder_validates_and_sanitizes() { + let fs = 48_000.0f32; + + assert_eq!( + EqFilter::new(0.0, fs).validate(), + Err(EqError::OutOfRange("frequency_hz")) + ); + assert_eq!( + EqFilter::new(fs * 0.5, fs).validate(), + Err(EqError::OutOfRange("frequency_hz")) + ); + assert_eq!( + EqFilter::new(1_000.0, 0.0).validate(), + Err(EqError::NonPositive("sample_rate_hz")) + ); + assert_eq!( + EqFilter::new(1_000.0, fs).q(0.0).validate(), + Err(EqError::NonPositive("q")) + ); + assert_eq!( + EqFilter::new(f32::NAN, fs).validate(), + Err(EqError::NonFinite("frequency_hz")) + ); + + // `build` falls back to a passthrough biquad instead of emitting non-finite + // coefficients; `try_build` reports the error instead. + assert_eq!( + EqFilter::new(0.0, fs).build(BiquadType::Lowpass), + [1.0, 0.0, 0.0, 0.0, 0.0] + ); + assert!( + EqFilter::new(1_000.0, fs) + .try_build(BiquadType::Lowpass) + .is_ok() + ); +} + +#[test] +fn eq_builder_resolves_bandwidth_and_slope_shapes() { + let (f0, fs) = (1_000.0f32, 48_000.0f32); + + let bw = 1.0f32; + let by_bw = EqFilter::new(f0, fs).bandwidth_octaves(bw); + let q_bw = biquad_q_from_bw(bw, f0, fs); + assert!((by_bw.q_value() - q_bw).abs() < 1e-6); + assert_eq!( + by_bw.build(BiquadType::Bandpass), + EqFilter::new(f0, fs).q(q_bw).bandpass() + ); + + let (slope, gain_db) = (0.8f32, 6.0f32); + let by_slope = EqFilter::new(f0, fs).shelf_slope(slope).gain_db(gain_db); + let q_slope = biquad_q_from_shelf_slope(slope, gain_db); + assert!((by_slope.q_value() - q_slope).abs() < 1e-9); + assert_eq!( + by_slope.build(BiquadType::Lowshelf), + EqFilter::new(f0, fs).q(q_slope).gain_db(gain_db).lowshelf() + ); +} + +#[test] +fn webaudio_filter_applies_detune_and_names_types() { + // +1200 cents is one octave, exactly as a `BiquadFilterNode` detune. + let wa = WebAudioFilter { + frequency_hz: 1_000.0, + detune_cents: 1_200.0, + ..Default::default() + }; + assert!((wa.effective_frequency_hz() - 2_000.0).abs() < 1e-3); + assert_eq!(wa.type_name(), Some("lowpass")); + + let via_wa = wa.try_build().unwrap(); + let direct = EqFilter::new(2_000.0, 48_000.0) + .q(1.0) + .try_build(BiquadType::Lowpass) + .unwrap(); + assert_eq!(via_wa, direct); + + // `IHo` has no native WebAudio node type. + assert_eq!( + WebAudioFilter { + typ: BiquadType::Iho, + ..Default::default() + } + .type_name(), + None + ); + + // Detune that pushes the effective frequency past Nyquist is rejected. + assert_eq!( + WebAudioFilter { + frequency_hz: 20_000.0, + detune_cents: 1_200.0, + ..Default::default() + } + .validate(), + Err(EqError::OutOfRange("effective_frequency_hz")) + ); +} diff --git a/crates/embedded-dsp/tests/filter_design_eq_params.rs b/crates/embedded-dsp/tests/filter_design_eq_params.rs deleted file mode 100644 index 1222362..0000000 --- a/crates/embedded-dsp/tests/filter_design_eq_params.rs +++ /dev/null @@ -1,291 +0,0 @@ -//! Property tests for the RBJ Audio EQ Cookbook parameter conversions in -//! `embedded_dsp::filter_design`: octave bandwidth (`BW`) and shelf slope (`S`). -//! -//! These lock down the *behavioural* definitions from the cookbook rather than the raw -//! coefficient arithmetic: a bandwidth of `n` octaves must produce a filter whose -3 dB -//! (band-pass / notch) or half-gain (peaking EQ) edges are `n` octaves apart, and `S = 1` must -//! give a monotonic Butterworth-slope shelf whose midpoint at `f0` sits at half its gain. - -use embedded_dsp::filter_analysis::{biquad_frequency_response, response_magnitude_db}; -use embedded_dsp::filter_design::{ - biquad_bandpass_coeffs, biquad_bandpass_skirt_coeffs, biquad_highshelf_coeffs, - biquad_lowshelf_coeffs, biquad_notch_coeffs, biquad_peaking_coeffs, biquad_q_from_bw, - biquad_q_from_shelf_slope, -}; - -/// -3.0103 dB, the half-power point. -const MINUS_3_DB: f32 = -3.010_3; - -fn mag_db(coeffs: [f32; 5], freq: f32, sample_rate: f32) -> f32 { - response_magnitude_db(biquad_frequency_response(&coeffs, freq / sample_rate)) -} - -/// Bisects for the frequency in `[lo, hi]` where the magnitude crosses `target_db`. -/// The caller must ensure the endpoints bracket a single crossing. -fn crossing_freq( - coeffs: [f32; 5], - sample_rate: f32, - mut lo: f32, - mut hi: f32, - target_db: f32, -) -> f32 { - let mut g_lo = mag_db(coeffs, lo, sample_rate) - target_db; - for _ in 0..100 { - let mid = 0.5 * (lo + hi); - let g_mid = mag_db(coeffs, mid, sample_rate) - target_db; - if g_lo * g_mid <= 0.0 { - hi = mid; - } else { - lo = mid; - g_lo = g_mid; - } - } - 0.5 * (lo + hi) -} - -/// Measured octave span of a symmetric band: `log2(f_hi / f_lo)` at the given threshold. -fn measured_octave_span(coeffs: [f32; 5], sample_rate: f32, center: f32, target_db: f32) -> f32 { - let f_lo = crossing_freq( - coeffs, - sample_rate, - sample_rate * 1e-4, - center * 0.999, - target_db, - ); - let f_hi = crossing_freq( - coeffs, - sample_rate, - center * 1.001, - sample_rate * 0.4999, - target_db, - ); - (f_hi / f_lo).log2() -} - -#[test] -fn bw_sets_bandpass_and_notch_octave_bandwidth() { - let fs = 48_000.0; - - for (f0, bw) in [ - (200.0f32, 0.5f32), - (1_000.0, 1.0), - (6_000.0, 1.0), - (12_000.0, 1.0), - (300.0, 3.0), - ] { - let q = biquad_q_from_bw(bw, f0, fs); - assert!(q.is_finite() && q > 0.0, "q={q} for f0={f0} bw={bw}"); - - let cases = [ - ("band-pass", biquad_bandpass_coeffs(f0, fs, q)), - ("notch", biquad_notch_coeffs(f0, fs, q)), - ]; - for (name, coeffs) in cases { - let actual = measured_octave_span(coeffs, fs, f0, MINUS_3_DB); - assert!( - (actual - bw).abs() < 0.05, - "{name} f0={f0} requested {bw} oct, measured {actual:.4} oct" - ); - } - } -} - -#[test] -fn bw_sets_peaking_half_gain_bandwidth() { - let fs = 48_000.0; - - for (f0, bw, gain_db) in [ - (1_000.0f32, 1.0f32, 6.0f32), - (2_000.0, 2.0, 12.0), - (6_000.0, 0.5, -9.0), - (12_000.0, 1.0, 6.0), - ] { - let q = biquad_q_from_bw(bw, f0, fs); - let coeffs = biquad_peaking_coeffs(f0, fs, q, gain_db); - - // Centred exactly on the requested gain; the bandwidth is measured at half of it. - assert!( - (mag_db(coeffs, f0, fs) - gain_db).abs() < 0.05, - "f0={f0}: centre gain {} dB != {gain_db} dB", - mag_db(coeffs, f0, fs) - ); - - let actual = measured_octave_span(coeffs, fs, f0, gain_db * 0.5); - assert!( - (actual - bw).abs() < 0.05, - "peaking f0={f0} gain={gain_db} dB requested {bw} oct, measured {actual:.4} oct" - ); - } -} - -/// The bilinear-transform correction is what keeps the bandwidth honest near Nyquist; the -/// uncorrected analog-prototype shorthand collapses the band as `f0` rises. -#[test] -fn digital_bw_correction_preserves_width_near_nyquist() { - let fs = 48_000.0; - let (f0, bw) = (12_000.0f32, 1.0f32); - - let digital_q = biquad_q_from_bw(bw, f0, fs); - let analog_q = 1.0 / (2.0 * (0.5 * core::f32::consts::LN_2 * bw).sinh()); - - let digital_span = measured_octave_span( - biquad_bandpass_coeffs(f0, fs, digital_q), - fs, - f0, - MINUS_3_DB, - ); - let analog_span = - measured_octave_span(biquad_bandpass_coeffs(f0, fs, analog_q), fs, f0, MINUS_3_DB); - - assert!( - (digital_span - bw).abs() < 0.05, - "digital relation should hold near Nyquist, measured {digital_span:.4} oct" - ); - assert!( - analog_span < bw - 0.2, - "analog shorthand should under-state the width, measured {analog_span:.4} oct" - ); -} - -#[test] -fn shelf_slope_one_is_butterworth_and_centres_at_half_gain() { - let fs = 48_000.0; - let f0 = 1_000.0; - - for gain_db in [6.0f32, 12.0, -6.0, -12.0] { - let q = biquad_q_from_shelf_slope(1.0, gain_db); - assert!( - (q - core::f32::consts::FRAC_1_SQRT_2).abs() < 1e-5, - "S=1 should give Q=1/sqrt(2), got {q} for {gain_db} dB" - ); - - let low = biquad_lowshelf_coeffs(f0, fs, q, gain_db); - let high = biquad_highshelf_coeffs(f0, fs, q, gain_db); - - // The shelf midpoint at f0 is half the shelf gain. - assert!((mag_db(low, f0, fs) - gain_db * 0.5).abs() < 0.05); - assert!((mag_db(high, f0, fs) - gain_db * 0.5).abs() < 0.05); - - // A low shelf acts at DC and is transparent at Nyquist; a high shelf is the reverse. - assert!((mag_db(low, 0.0, fs) - gain_db).abs() < 0.05); - assert!(mag_db(low, fs * 0.5, fs).abs() < 0.05); - assert!(mag_db(high, 0.0, fs).abs() < 0.05); - assert!((mag_db(high, fs * 0.5, fs) - gain_db).abs() < 0.05); - } -} - -#[test] -fn shelf_slope_controls_steepness_and_stays_monotonic() { - let fs = 48_000.0; - let f0 = 1_000.0; - let gain_db = 6.0f32; - - let mut previous_drop = None; - for slope in [0.3f32, 0.6, 1.0] { - let q = biquad_q_from_shelf_slope(slope, gain_db); - let coeffs = biquad_lowshelf_coeffs(f0, fs, q, gain_db); - - // A boost low shelf must fall monotonically from DC to Nyquist for S <= 1. - let mut previous = f32::INFINITY; - let mut f = 20.0f32; - while f < fs * 0.5 { - let m = mag_db(coeffs, f, fs); - assert!( - m <= previous + 1e-3, - "slope={slope}: non-monotonic at {f} Hz ({m} dB > {previous} dB)" - ); - previous = m; - f *= 1.1; - } - - // Steeper slope => a larger gain drop across the octave around f0. - let drop = mag_db(coeffs, f0 * 0.5, fs) - mag_db(coeffs, f0 * 2.0, fs); - if let Some(prev) = previous_drop { - assert!( - drop > prev, - "slope {slope} should drop more than the gentler slope ({drop} vs {prev} dB)" - ); - } - previous_drop = Some(drop); - } -} - -/// The cookbook's peaking definition (`A*Q`) makes +N dB and -N dB at identical `Q`/`f0` an -/// exact unity "wire". -#[test] -fn peaking_boost_then_cut_is_a_unity_wire() { - let fs = 48_000.0; - let q = 1.2f32; - - // The identity is exact mathematically but only approximate in `f32`: at low `f0` the poles - // sit close to z = 1 and rounding in the near-cancelling coefficients costs a little - // accuracy. The bound below covers the sampled grid. - let mut worst_db = 0.0f32; - - for (f0, gain_db) in [(60.0f32, 6.0f32), (1_000.0, 6.0), (10_000.0, 12.0)] { - let boost = biquad_peaking_coeffs(f0, fs, q, gain_db); - let cut = biquad_peaking_coeffs(f0, fs, q, -gain_db); - - let mut f = 5.0f32; - while f < fs * 0.5 { - let combined_db = mag_db(boost, f, fs) + mag_db(cut, f, fs); - worst_db = worst_db.max(combined_db.abs()); - f *= 1.07; - } - } - - assert!( - worst_db < 0.05, - "worst boost/cut cascade deviation was {worst_db} dB, expected unity" - ); -} - -/// Locks the two band-pass variants to the cookbook's distinct gain conventions. -#[test] -fn bandpass_variants_match_their_rbj_conventions() { - let fs = 48_000.0; - let f0 = 1_000.0; - - for q in [0.5f32, 1.0, 2.0, 5.0] { - // Constant 0 dB peak gain. - let peak = biquad_bandpass_coeffs(f0, fs, q); - assert!( - mag_db(peak, f0, fs).abs() < 0.01, - "q={q}: constant-peak band-pass is {} dB at f0", - mag_db(peak, f0, fs) - ); - - // Constant skirt gain, so the peak gain equals Q. - let skirt = biquad_bandpass_skirt_coeffs(f0, fs, q); - let expected = 20.0 * q.log10(); - assert!( - (mag_db(skirt, f0, fs) - expected).abs() < 0.01, - "q={q}: skirt band-pass is {} dB at f0, expected {expected} dB", - mag_db(skirt, f0, fs) - ); - } -} - -#[test] -fn parameter_converters_stay_finite_on_degenerate_inputs() { - for (bw, f0, fs) in [ - (0.0f32, 0.0f32, 48_000.0f32), - (1.0, 24_000.0, 48_000.0), // Nyquist - (-1.0, 1_000.0, 48_000.0), - (1.0, -5.0, 48_000.0), - ] { - let q = biquad_q_from_bw(bw, f0, fs); - assert!( - q.is_finite() && q > 0.0, - "q_from_bw({bw}, {f0}, {fs}) = {q}" - ); - } - - for (slope, gain_db) in [(0.0f32, 6.0f32), (-1.0, 6.0), (1.0, 0.0), (0.5, -60.0)] { - let q = biquad_q_from_shelf_slope(slope, gain_db); - assert!( - q.is_finite() && q > 0.0, - "q_from_shelf_slope({slope}, {gain_db}) = {q}" - ); - } -} diff --git a/crates/embedded-dsp/tests/filtering_coverage.rs b/crates/embedded-dsp/tests/filtering.rs similarity index 74% rename from crates/embedded-dsp/tests/filtering_coverage.rs rename to crates/embedded-dsp/tests/filtering.rs index 5949f56..0e6466c 100644 --- a/crates/embedded-dsp/tests/filtering_coverage.rs +++ b/crates/embedded-dsp/tests/filtering.rs @@ -2,14 +2,10 @@ //! correlation, one-pole filters, and circular buffers. use embedded_dsp::filtering::{ - BiquadCascadeDf2tInstanceF32, BiquadCascadeDf2tInstanceQ15, BiquadCascadeDf2tInstanceQ31, - BiquadCascadeInstanceF32, BiquadCascadeInstanceQ15, BiquadCascadeInstanceQ31, CircularBuffer, - DcBlockerQ15, FirInstanceF32, FirInstanceQ15, FirInstanceQ31, LmsInstanceF32, NlmsInstanceF32, - RecursiveMovingAverage, RecursiveMovingAverageQ15, SinglePoleFilter, biquad_cascade_df1_f32, - biquad_cascade_df1_q15, biquad_cascade_df1_q31, biquad_cascade_df2t_f32, - biquad_cascade_df2t_q15, biquad_cascade_df2t_q31, conv_f32, conv_q7, conv_q15, conv_q31, - correlate_f32, correlate_q15, correlate_q31, fir_f32, fir_q15, fir_q31, lms_f32, lms_leaky_f32, - nlms_f32, + BiquadCascadeDf2tInstance, BiquadCascadeInstance, CircularBuffer, DcBlockerQ15, FirInstance, + LmsInstance, NlmsInstance, RecursiveMovingAverage, SinglePoleFilter, biquad_cascade_df1, + biquad_cascade_df2t, conv_f32, conv_q7, conv_q15, conv_q31, correlate_f32, correlate_q15, + correlate_q31, fir, lms, lms_leaky, nlms, }; use embedded_dsp::types::{q7, q15, q31}; @@ -19,8 +15,8 @@ fn fir_f32_q31_q15_run() { let src = [1.0f32, 2.0, 3.0, 4.0]; let mut state = [0.0f32; 3]; let mut dst = [0.0f32; 4]; - let mut fir = FirInstanceF32::init(3, &coeffs, &mut state); - fir_f32(&mut fir, &src, &mut dst); + let mut fir_inst = FirInstance::::init(3, &coeffs, &mut state); + fir(&mut fir_inst, &src, &mut dst); let qcoeffs = [ q31::from_bits(1 << 30), @@ -35,8 +31,8 @@ fn fir_f32_q31_q15_run() { ]; let mut qstate = [q31::ZERO; 3]; let mut qdst = [q31::ZERO; 4]; - let mut qfir = FirInstanceQ31::init(3, &qcoeffs, &mut qstate); - fir_q31(&mut qfir, &qsrc, &mut qdst); + let mut qfir = FirInstance::::init(3, &qcoeffs, &mut qstate); + fir(&mut qfir, &qsrc, &mut qdst); let q15coeffs = [ q15::from_bits(1 << 14), @@ -51,8 +47,8 @@ fn fir_f32_q31_q15_run() { ]; let mut q15state = [q15::ZERO; 3]; let mut q15dst = [q15::ZERO; 4]; - let mut q15fir = FirInstanceQ15::init(3, &q15coeffs, &mut q15state); - fir_q15(&mut q15fir, &q15src, &mut q15dst); + let mut q15fir = FirInstance::::init(3, &q15coeffs, &mut q15state); + fir(&mut q15fir, &q15src, &mut q15dst); } #[test] @@ -61,13 +57,13 @@ fn biquad_cascade_variants_run() { let src = [1.0f32, 2.0, 3.0, 4.0]; let mut state = [0.0f32; 4]; let mut dst = [0.0f32; 4]; - let mut inst = BiquadCascadeInstanceF32::init(1, &coeffs, &mut state); - biquad_cascade_df1_f32(&mut inst, &src, &mut dst); + let mut inst = BiquadCascadeInstance::::init(1, &coeffs, &mut state); + biquad_cascade_df1(&mut inst, &src, &mut dst); let mut state2 = [0.0f32; 2]; let mut dst2 = [0.0f32; 4]; - let mut inst2 = BiquadCascadeDf2tInstanceF32::init(1, &coeffs, &mut state2); - biquad_cascade_df2t_f32(&mut inst2, &src, &mut dst2); + let mut inst2 = BiquadCascadeDf2tInstance::::init(1, &coeffs, &mut state2); + biquad_cascade_df2t(&mut inst2, &src, &mut dst2); let qcoeffs = [ q31::from_bits(1 << 31), @@ -84,13 +80,14 @@ fn biquad_cascade_variants_run() { ]; let mut qstate = [q31::ZERO; 4]; let mut qdst = [q31::ZERO; 4]; - let mut qinst = BiquadCascadeInstanceQ31::with_post_shift(1, &qcoeffs, &mut qstate, 0); - biquad_cascade_df1_q31(&mut qinst, &qsrc, &mut qdst); + let mut qinst = BiquadCascadeInstance::::with_post_shift(1, &qcoeffs, &mut qstate, 0); + biquad_cascade_df1(&mut qinst, &qsrc, &mut qdst); let mut qstate2 = [q31::ZERO; 2]; let mut qdst2 = [q31::ZERO; 4]; - let mut qinst2 = BiquadCascadeDf2tInstanceQ31::with_post_shift(1, &qcoeffs, &mut qstate2, 0); - biquad_cascade_df2t_q31(&mut qinst2, &qsrc, &mut qdst2); + let mut qinst2 = + BiquadCascadeDf2tInstance::::with_post_shift(1, &qcoeffs, &mut qstate2, 0); + biquad_cascade_df2t(&mut qinst2, &qsrc, &mut qdst2); let q15coeffs = [ q15::from_bits(1 << 15), @@ -107,14 +104,15 @@ fn biquad_cascade_variants_run() { ]; let mut q15state = [q15::ZERO; 4]; let mut q15dst = [q15::ZERO; 4]; - let mut q15inst = BiquadCascadeInstanceQ15::with_post_shift(1, &q15coeffs, &mut q15state, 0); - biquad_cascade_df1_q15(&mut q15inst, &q15src, &mut q15dst); + let mut q15inst = + BiquadCascadeInstance::::with_post_shift(1, &q15coeffs, &mut q15state, 0); + biquad_cascade_df1(&mut q15inst, &q15src, &mut q15dst); let mut q15state2 = [q15::ZERO; 2]; let mut q15dst2 = [q15::ZERO; 4]; let mut q15inst2 = - BiquadCascadeDf2tInstanceQ15::with_post_shift(1, &q15coeffs, &mut q15state2, 0); - biquad_cascade_df2t_q15(&mut q15inst2, &q15src, &mut q15dst2); + BiquadCascadeDf2tInstance::::with_post_shift(1, &q15coeffs, &mut q15state2, 0); + biquad_cascade_df2t(&mut q15inst2, &q15src, &mut q15dst2); } /// The generic biquad cascades must reproduce the hand-written per-width kernels bit for bit, @@ -149,9 +147,10 @@ fn generic_biquad_cascades_match_reference_bit_for_bit() { want_df1[i] = q15::from_bits(out as i16); } let mut st = [q15::ZERO; 4]; - let mut inst = BiquadCascadeInstanceQ15::with_post_shift(1, &coeffs, &mut st, post_shift); + let mut inst = + BiquadCascadeInstance::::with_post_shift(1, &coeffs, &mut st, post_shift); let mut got_df1 = [q15::ZERO; 8]; - biquad_cascade_df1_q15(&mut inst, &src, &mut got_df1); + biquad_cascade_df1(&mut inst, &src, &mut got_df1); assert_eq!(got_df1, want_df1, "DF1 diverged at post_shift {post_shift}"); // Reference transposed Direct Form II. @@ -169,9 +168,9 @@ fn generic_biquad_cascades_match_reference_bit_for_bit() { } let mut st2 = [q15::ZERO; 2]; let mut inst2 = - BiquadCascadeDf2tInstanceQ15::with_post_shift(1, &coeffs, &mut st2, post_shift); + BiquadCascadeDf2tInstance::::with_post_shift(1, &coeffs, &mut st2, post_shift); let mut got_df2t = [q15::ZERO; 8]; - biquad_cascade_df2t_q15(&mut inst2, &src, &mut got_df2t); + biquad_cascade_df2t(&mut inst2, &src, &mut got_df2t); assert_eq!( got_df2t, want_df2t, "DF2T diverged at post_shift {post_shift}" @@ -204,8 +203,8 @@ fn generic_fir_matches_per_term_reference_bit_for_bit() { } let mut st = [q15::ZERO; 3]; let mut got = [q15::ZERO; 8]; - let mut inst = FirInstanceQ15::init(3, &coeffs, &mut st); - fir_q15(&mut inst, &src, &mut got); + let mut inst = FirInstance::::init(3, &coeffs, &mut st); + fir(&mut inst, &src, &mut got); assert_eq!(got, want, "q15 FIR diverged from the per-term reference"); // f32: plain dot product. @@ -225,8 +224,8 @@ fn generic_fir_matches_per_term_reference_bit_for_bit() { } let mut fst = [0.0f32; 3]; let mut fgot = [0.0f32; 8]; - let mut finst = FirInstanceF32::init(3, &fcoeffs, &mut fst); - fir_f32(&mut finst, &fsrc, &mut fgot); + let mut finst = FirInstance::::init(3, &fcoeffs, &mut fst); + fir(&mut finst, &fsrc, &mut fgot); assert_eq!(fgot, fwant, "f32 FIR diverged from the reference"); } @@ -238,18 +237,18 @@ fn lms_and_nlms_adapt() { let mut state = [0.0f32; 2]; let mut out = [0.0f32; 5]; let mut err = [0.0f32; 5]; - let mut lms = LmsInstanceF32::init(2, &mut coeffs, &mut state, 0.01); - lms_f32(&mut lms, &src, &reference, &mut out, &mut err); + let mut lms_inst = LmsInstance::::init(2, &mut coeffs, &mut state, 0.01); + lms(&mut lms_inst, &src, &reference, &mut out, &mut err); let mut coeffs2 = [0.0f32; 2]; let mut state2 = [0.0f32; 2]; - let mut lms2 = LmsInstanceF32::init(2, &mut coeffs2, &mut state2, 0.01); - lms_leaky_f32(&mut lms2, &src, &reference, &mut out, &mut err, 0.001); + let mut lms2 = LmsInstance::::init(2, &mut coeffs2, &mut state2, 0.01); + lms_leaky(&mut lms2, &src, &reference, &mut out, &mut err, 0.001); let mut coeffs3 = [0.0f32; 2]; let mut state3 = [0.0f32; 2]; - let mut nlms = NlmsInstanceF32::init(2, &mut coeffs3, &mut state3, 0.01, 1e-5); - nlms_f32(&mut nlms, &src, &reference, &mut out, &mut err); + let mut nlms_inst = NlmsInstance::::init(2, &mut coeffs3, &mut state3, 0.01, 1e-5); + nlms(&mut nlms_inst, &src, &reference, &mut out, &mut err); } #[test] @@ -307,7 +306,7 @@ fn convolution_correlation_and_misc_filters() { let mut avg = RecursiveMovingAverage::::default(); avg.process(1.0); avg.reset(); - let mut avgq = RecursiveMovingAverageQ15::<4>::default(); + let mut avgq = RecursiveMovingAverage::::default(); avgq.process(q15::from_bits(100)); avgq.reset(); diff --git a/crates/embedded-dsp/tests/filtering_resampling_extras.rs b/crates/embedded-dsp/tests/filtering_resampling_extras.rs deleted file mode 100644 index 530b081..0000000 --- a/crates/embedded-dsp/tests/filtering_resampling_extras.rs +++ /dev/null @@ -1,203 +0,0 @@ -//! Remaining guard/edge coverage for `filtering` and the half-band resamplers. -//! -//! Covers the `median_filter_1d_f32` / `fast_convolve_f32` validation and -//! time-domain fallback, the `Biquad`/`BiquadClamp` `SplitProcess` adapters, -//! the wave-digital-filter adapter architectures, and the half-band -//! interpolator reset / decimation cascade. - -use embedded_dsp::filtering::{ - Biquad, BiquadClamp, DirectForm1, DirectForm2Transposed, Tpa, Wdf, WdfState, fast_convolve_f32, - median_filter_1d_f32, -}; -use embedded_dsp::pipeline::SplitProcess; -use embedded_dsp::resampling::{HbfDecCascade, HbfInt}; -use embedded_dsp::types::Status; - -// ───────────────────────────────────────────────────────────────────────────── -// filtering: wave-digital-filter adapter architectures -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn tpa_decodes_every_nibble_encoding() { - assert_eq!(Tpa::from(0x0u8), Tpa::Z); - assert_eq!(Tpa::from(0xau8), Tpa::A); - assert_eq!(Tpa::from(0xbu8), Tpa::B); - assert_eq!(Tpa::from(0xeu8), Tpa::B1); - assert_eq!(Tpa::from(0x1u8), Tpa::X); - assert_eq!(Tpa::from(0xcu8), Tpa::C); - assert_eq!(Tpa::from(0xfu8), Tpa::C1); - assert_eq!(Tpa::from(0xdu8), Tpa::D); -} - -#[test] -fn wdf_runs_every_adapter_architecture() { - // One nibble per stage, least-significant nibble first: A, B, B1, X, C, C1, D, Z. - const M: u32 = 0x0DFC_1EBA; - // Each `g` is chosen so the architecture's quantized `a` lands inside the - // representable -0.5..=0 range (A needs g in 0.5..1, C needs g in -0.5..0). - let g = [0.5f64, 0.5, 0.5, 0.0, -0.5, -0.5, -0.5, 0.0]; - let mut wdf = Wdf::<8, M>::quantize(&g).expect("coefficients must quantize"); - - let mut state = WdfState::<8>::default(); - assert_eq!(state.z, [0i32; 8]); - - let mut x = 1 << 20; - for _ in 0..32 { - x = SplitProcess::process_with_state(&mut wdf, &mut state, x); - } - assert!( - state.z.iter().any(|&v| v != 0), - "the adapter chain should have driven its delay state" - ); -} - -#[test] -fn wdf_default_starts_with_zero_coefficients() { - let wdf = Wdf::<3, 0xBBB>::default(); - assert_eq!(wdf.a, [0i32; 3]); -} - -// ───────────────────────────────────────────────────────────────────────────── -// filtering: median filter validation -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn median_filter_validates_length_and_window() { - assert_eq!( - median_filter_1d_f32(&[], &mut [], 3, 0.0), - Status::LengthError - ); - - // Even window lengths and zero are rejected. - assert_eq!( - median_filter_1d_f32(&[1.0, 2.0], &mut [0.0, 0.0], 4, 0.0), - Status::ArgumentError - ); - assert_eq!( - median_filter_1d_f32(&[1.0, 2.0], &mut [0.0, 0.0], 0, 0.0), - Status::ArgumentError - ); - - // Odd window within the 63-tap stack limit is accepted. - let mut out = [0.0f32; 5]; - assert_eq!( - median_filter_1d_f32(&[1.0, 2.0, 3.0, 4.0, 5.0], &mut out, 3, 0.0), - Status::Success - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// filtering: FFT convolution and its time-domain fallback -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn fast_convolve_validates_inputs() { - assert_eq!(fast_convolve_f32(&[], &[1.0], &mut []), Status::LengthError); - assert_eq!( - fast_convolve_f32(&[1.0], &[1.0], &mut []), - Status::LengthError - ); -} - -#[test] -fn fast_convolve_falls_back_to_time_domain_when_fft_is_too_large() { - // total_len = 300 + 300 - 1 = 599, so the next power of two is 1024, which - // exceeds the 512-point stack scratch buffer and takes the time-domain path. - let signal = [1.0f32; 300]; - let kernel = [1.0f32; 300]; - let mut dst = [0.0f32; 599]; - - assert_eq!( - fast_convolve_f32(&signal, &kernel, &mut dst), - Status::Success - ); - // Convolving two runs of 300 ones peaks at 300 in the middle. - assert!( - (dst[299] - 300.0).abs() < 1e-3, - "expected peak 300 at the centre, got {}", - dst[299] - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// filtering: SplitProcess adapters for the biquad state machine -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn biquad_split_process_adapters_delegate_to_the_direct_forms() { - let mut biquad32 = Biquad::new(0.5f32, 0.25, 0.125, 0.5, -0.25); - let mut clamp32 = BiquadClamp::new(Biquad::new(2.0f32, 0.0, 0.0, 0.0, 0.0), -1.0, 1.0, 0.0); - let mut biquad64 = Biquad::new(0.5f64, 0.25, 0.125, 0.5, -0.25); - let mut clamp64 = BiquadClamp::new(Biquad::new(2.0f64, 0.0, 0.0, 0.0, 0.0), -1.0, 1.0, 0.0); - - let mut df1_32 = DirectForm1::::new(); - let mut df2t_32 = DirectForm2Transposed::::new(); - let mut df1_64 = DirectForm1::::new(); - let mut df2t_64 = DirectForm2Transposed::::new(); - - // y = b0 * x with zero history. - assert_eq!( - SplitProcess::process_with_state(&mut biquad32, &mut df1_32, 1.0), - 0.5 - ); - assert_eq!( - SplitProcess::process_with_state(&mut biquad32, &mut df2t_32, 1.0), - 0.5 - ); - assert_eq!( - SplitProcess::process_with_state(&mut biquad64, &mut df1_64, 1.0), - 0.5 - ); - assert_eq!( - SplitProcess::process_with_state(&mut biquad64, &mut df2t_64, 1.0), - 0.5 - ); - - // Gain of 2 with +/-1 clamps. - assert_eq!( - SplitProcess::process_with_state(&mut clamp32, &mut df1_32, 0.5), - 1.0 - ); - assert_eq!( - SplitProcess::process_with_state(&mut clamp32, &mut df2t_32, 0.5), - 1.0 - ); - assert_eq!( - SplitProcess::process_with_state(&mut clamp64, &mut df1_64, 0.5), - 1.0 - ); - assert_eq!( - SplitProcess::process_with_state(&mut clamp64, &mut df2t_64, 0.5), - 1.0 - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// resampling: half-band interpolator / decimation cascade -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn hbf_interpolator_reset_and_cascade_default() { - let mut interpolator = HbfInt::<3>::new([0.0f32; 3]); - interpolator.reset(); - - let mut cascade = HbfDecCascade::<2>::default(); - cascade.reset(); -} - -#[test] -fn hbf_dec_cascade_processes_four_and_five_stages() { - // `process` requires src.len() == (1 << STAGES) * dst.len(); the internal - // buffer plumbing differs for the 4-stage and 5-stage specialisations. - let mut four = HbfDecCascade::<4>::new(); - let src4 = [0.0f32; 16]; - let mut dst4 = [0.0f32; 1]; - four.process(&src4, &mut dst4); - assert_eq!(dst4[0], 0.0); - - let mut five = HbfDecCascade::<5>::new(); - let src5 = [0.0f32; 32]; - let mut dst5 = [0.0f32; 1]; - five.process(&src5, &mut dst5); - assert_eq!(dst5[0], 0.0); -} diff --git a/crates/embedded-dsp/tests/filters_and_resampling.rs b/crates/embedded-dsp/tests/filters_and_resampling.rs new file mode 100644 index 0000000..d667fa9 --- /dev/null +++ b/crates/embedded-dsp/tests/filters_and_resampling.rs @@ -0,0 +1,408 @@ +//! Consolidated tests: biquad_and_cic_coverage, filtering_resampling_extras. + +use embedded_dsp::filtering::{ + Biquad, BiquadClamp, DirectForm1, DirectForm2Transposed, Tpa, Wdf, WdfState, fast_convolve_f32, + median_filter_1d_f32, +}; +use embedded_dsp::pipeline::SplitProcess; +use embedded_dsp::resampling::{CicDec3, CicFilter, CicInt3, HbfDecCascade, HbfInt}; +use embedded_dsp::types::Status; + +// ─── from biquad_and_cic_coverage.rs ──────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// Direct form state +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn direct_form_state_defaults_new_and_reset() { + let mut df1 = DirectForm1::::default(); + assert_eq!(df1.xy, [0.0; 4]); + assert_eq!(df1, DirectForm1::new()); + + df1.xy = [1.0, 2.0, 3.0, 4.0]; + df1.reset(); + assert_eq!(df1.xy, [0.0; 4]); + + let mut df2 = DirectForm2Transposed::::default(); + assert_eq!(df2.s, [0.0; 2]); + assert_eq!(df2, DirectForm2Transposed::new()); + + df2.s = [1.0, 2.0]; + df2.reset(); + assert_eq!(df2.s, [0.0; 2]); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Biquad: direct form 1 and direct form 2 transposed +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn biquad_df1_follows_the_documented_recurrence() { + // y0 = 0.5*x0 + 0.5*x1, i.e. a two-point moving average (a1 = a2 = 0). + let bq = Biquad::new(0.5f32, 0.5, 0.0, 0.0, 0.0); + let mut state = DirectForm1::::new(); + + assert_eq!(bq.process_df1(&mut state, 1.0), 0.5); + assert_eq!(bq.process_df1(&mut state, 2.0), 1.5); + assert_eq!(bq.process_df1(&mut state, 4.0), 3.0); + // History is [x0, x1, y0, y1] from the most recent call. + assert_eq!(state.xy, [4.0, 2.0, 3.0, 1.5]); +} + +#[test] +fn biquad_df2t_agrees_with_df1() { + // Direct Form 1 and Direct Form 2 Transposed are equivalent realisations + // of the same transfer function, so they must agree sample for sample. + let ba = Biquad::new(0.5f32, 0.25, 0.125, 0.5, -0.25); + let mut df1 = DirectForm1::::new(); + let mut df2t = DirectForm2Transposed::::new(); + + for n in 0..32 { + let x = (n as f32) * 0.25 - 2.0; + let y1 = ba.process_df1(&mut df1, x); + let y2 = ba.process_df2t(&mut df2t, x); + assert!((y1 - y2).abs() < 1e-5, "step {n}: df1={y1} df2t={y2}"); + } +} + +#[test] +fn biquad_f64_forms_agree() { + let ba = Biquad::new(0.5f64, 0.25, 0.125, 0.5, -0.25); + let mut df1 = DirectForm1::::new(); + let mut df2t = DirectForm2Transposed::::new(); + + for n in 0..32 { + let x = (n as f64) * 0.25 - 2.0; + let y1 = ba.process_df1(&mut df1, x); + let y2 = ba.process_df2t(&mut df2t, x); + assert!((y1 - y2).abs() < 1e-12, "step {n}: df1={y1} df2t={y2}"); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// BiquadClamp: anti-windup clamping +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn biquad_clamp_f32_limits_output() { + // Pure gain of 2, clamped to +/-1. + let clamp = BiquadClamp::new(Biquad::new(2.0f32, 0.0, 0.0, 0.0, 0.0), -1.0, 1.0, 0.0); + + let mut df1 = DirectForm1::::new(); + assert_eq!(clamp.process_df1(&mut df1, 0.25), 0.5); + assert_eq!(clamp.process_df1(&mut df1, 5.0), 1.0); + assert_eq!(clamp.process_df1(&mut df1, -5.0), -1.0); + + let mut df2t = DirectForm2Transposed::::new(); + assert_eq!(clamp.process_df2t(&mut df2t, 0.25), 0.5); + assert_eq!(clamp.process_df2t(&mut df2t, 5.0), 1.0); + assert_eq!(clamp.process_df2t(&mut df2t, -5.0), -1.0); +} + +#[test] +fn biquad_clamp_f32_applies_the_summing_offset() { + // Zero coefficients: the output is just the `u` offset, then clamped. + let clamp = BiquadClamp::new(Biquad::new(0.0f32, 0.0, 0.0, 0.0, 0.0), -0.25, 0.25, 0.5); + + let mut df1 = DirectForm1::::new(); + assert_eq!(clamp.process_df1(&mut df1, 0.0), 0.25); + + let mut df2t = DirectForm2Transposed::::new(); + assert_eq!(clamp.process_df2t(&mut df2t, 0.0), 0.25); +} + +#[test] +fn biquad_clamp_f64_limits_output() { + let clamp = BiquadClamp::new(Biquad::new(2.0f64, 0.0, 0.0, 0.0, 0.0), -1.0, 1.0, 0.0); + + let mut df1 = DirectForm1::::new(); + assert_eq!(clamp.process_df1(&mut df1, 5.0), 1.0); + assert_eq!(clamp.process_df1(&mut df1, -5.0), -1.0); + + let mut df2t = DirectForm2Transposed::::new(); + assert_eq!(clamp.process_df2t(&mut df2t, 5.0), 1.0); + assert_eq!(clamp.process_df2t(&mut df2t, -5.0), -1.0); +} + +// ───────────────────────────────────────────────────────────────────────────── +// CIC filter +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn cic_accessors_report_configuration_and_clear_keeps_rate() { + let mut filter = CicFilter::::new(2); + assert_eq!(filter.order(), 3); + assert_eq!(filter.comb_delay(), 1); + assert_eq!(filter.rate(), 2); + assert!(filter.tick(), "index starts at zero"); + assert_eq!(filter.get_decimate(), 0); + assert_eq!(filter.get_interpolate(), 0); + + filter.set_rate(4); + assert_eq!(filter.rate(), 4); + + // A decimation call advances the phase, so the next tick is not due. + let _ = filter.process_decimate(1); + assert!(!filter.tick()); + + filter.clear(); + assert!(filter.tick(), "clear resets the phase"); + assert_eq!(filter.rate(), 4, "clear preserves the configured rate"); + assert_eq!(filter.get_decimate(), 0); +} + +#[test] +fn cic_decimator_zero_input_stays_zero() { + let mut dec = CicDec3::::new(2); + for _ in 0..30 { + if let Some(y) = dec.process_decimate(0) { + assert_eq!(y, 0); + } + } +} + +#[test] +fn cic_decimator_emits_one_output_per_rate_plus_one_samples() { + let rate = 2u32; + let mut dec = CicFilter::::new(rate); + let mut outputs = 0; + for _ in 0..(3 * (rate as usize + 1)) { + if dec.process_decimate(1).is_some() { + outputs += 1; + } + } + assert_eq!(outputs, 3, "9 inputs at rate 2 decimate to 3 outputs"); +} + +#[test] +fn cic_decimator_is_linear_and_reports_last_output() { + let mut one = CicFilter::::new(2); + let mut three = CicFilter::::new(2); + let mut last = 0; + + for n in 0..24 { + let x = n % 5 - 2; + let y1 = one.process_decimate(x); + let y3 = three.process_decimate(x * 3); + // Linear, zero-state filter: tripling the input triples the output. + assert_eq!(y3, y1.map(|v| v * 3), "step {n}"); + if let Some(y) = y1 { + last = y; + } + } + assert_eq!(one.get_decimate(), last); +} + +#[test] +fn cic_interpolator_emits_rate_plus_one_samples_per_input() { + let rate = 3u32; + let mut interp: CicInt3 = CicInt3::new(rate); + let period = rate as usize + 1; + + let mut inputs = 0; + let mut last_output = 0; + for _ in 0..(period * 4) { + let slow = interp.tick(); + if slow { + inputs += 1; + } + last_output = interp.process_interpolate(if slow { Some(1) } else { None }); + } + + assert_eq!(inputs, 4, "one slow input every {period} fast cycles"); + assert_eq!( + interp.get_interpolate(), + last_output, + "accessor must expose the most recent integrator value" + ); +} + +// ─── from filtering_resampling_extras.rs ──────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// filtering: wave-digital-filter adapter architectures +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn tpa_decodes_every_nibble_encoding() { + assert_eq!(Tpa::from(0x0u8), Tpa::Z); + assert_eq!(Tpa::from(0xau8), Tpa::A); + assert_eq!(Tpa::from(0xbu8), Tpa::B); + assert_eq!(Tpa::from(0xeu8), Tpa::B1); + assert_eq!(Tpa::from(0x1u8), Tpa::X); + assert_eq!(Tpa::from(0xcu8), Tpa::C); + assert_eq!(Tpa::from(0xfu8), Tpa::C1); + assert_eq!(Tpa::from(0xdu8), Tpa::D); +} + +#[test] +fn wdf_runs_every_adapter_architecture() { + // One nibble per stage, least-significant nibble first: A, B, B1, X, C, C1, D, Z. + const M: u32 = 0x0DFC_1EBA; + // Each `g` is chosen so the architecture's quantized `a` lands inside the + // representable -0.5..=0 range (A needs g in 0.5..1, C needs g in -0.5..0). + let g = [0.5f64, 0.5, 0.5, 0.0, -0.5, -0.5, -0.5, 0.0]; + let mut wdf = Wdf::<8, M>::quantize(&g).expect("coefficients must quantize"); + + let mut state = WdfState::<8>::default(); + assert_eq!(state.z, [0i32; 8]); + + let mut x = 1 << 20; + for _ in 0..32 { + x = SplitProcess::process_with_state(&mut wdf, &mut state, x); + } + assert!( + state.z.iter().any(|&v| v != 0), + "the adapter chain should have driven its delay state" + ); +} + +#[test] +fn wdf_default_starts_with_zero_coefficients() { + let wdf = Wdf::<3, 0xBBB>::default(); + assert_eq!(wdf.a, [0i32; 3]); +} + +// ───────────────────────────────────────────────────────────────────────────── +// filtering: median filter validation +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn median_filter_validates_length_and_window() { + assert_eq!( + median_filter_1d_f32(&[], &mut [], 3, 0.0), + Status::LengthError + ); + + // Even window lengths and zero are rejected. + assert_eq!( + median_filter_1d_f32(&[1.0, 2.0], &mut [0.0, 0.0], 4, 0.0), + Status::ArgumentError + ); + assert_eq!( + median_filter_1d_f32(&[1.0, 2.0], &mut [0.0, 0.0], 0, 0.0), + Status::ArgumentError + ); + + // Odd window within the 63-tap stack limit is accepted. + let mut out = [0.0f32; 5]; + assert_eq!( + median_filter_1d_f32(&[1.0, 2.0, 3.0, 4.0, 5.0], &mut out, 3, 0.0), + Status::Success + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// filtering: FFT convolution and its time-domain fallback +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn fast_convolve_validates_inputs() { + assert_eq!(fast_convolve_f32(&[], &[1.0], &mut []), Status::LengthError); + assert_eq!( + fast_convolve_f32(&[1.0], &[1.0], &mut []), + Status::LengthError + ); +} + +#[test] +fn fast_convolve_falls_back_to_time_domain_when_fft_is_too_large() { + // total_len = 300 + 300 - 1 = 599, so the next power of two is 1024, which + // exceeds the 512-point stack scratch buffer and takes the time-domain path. + let signal = [1.0f32; 300]; + let kernel = [1.0f32; 300]; + let mut dst = [0.0f32; 599]; + + assert_eq!( + fast_convolve_f32(&signal, &kernel, &mut dst), + Status::Success + ); + // Convolving two runs of 300 ones peaks at 300 in the middle. + assert!( + (dst[299] - 300.0).abs() < 1e-3, + "expected peak 300 at the centre, got {}", + dst[299] + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// filtering: SplitProcess adapters for the biquad state machine +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn biquad_split_process_adapters_delegate_to_the_direct_forms() { + let mut biquad32 = Biquad::new(0.5f32, 0.25, 0.125, 0.5, -0.25); + let mut clamp32 = BiquadClamp::new(Biquad::new(2.0f32, 0.0, 0.0, 0.0, 0.0), -1.0, 1.0, 0.0); + let mut biquad64 = Biquad::new(0.5f64, 0.25, 0.125, 0.5, -0.25); + let mut clamp64 = BiquadClamp::new(Biquad::new(2.0f64, 0.0, 0.0, 0.0, 0.0), -1.0, 1.0, 0.0); + + let mut df1_32 = DirectForm1::::new(); + let mut df2t_32 = DirectForm2Transposed::::new(); + let mut df1_64 = DirectForm1::::new(); + let mut df2t_64 = DirectForm2Transposed::::new(); + + // y = b0 * x with zero history. + assert_eq!( + SplitProcess::process_with_state(&mut biquad32, &mut df1_32, 1.0), + 0.5 + ); + assert_eq!( + SplitProcess::process_with_state(&mut biquad32, &mut df2t_32, 1.0), + 0.5 + ); + assert_eq!( + SplitProcess::process_with_state(&mut biquad64, &mut df1_64, 1.0), + 0.5 + ); + assert_eq!( + SplitProcess::process_with_state(&mut biquad64, &mut df2t_64, 1.0), + 0.5 + ); + + // Gain of 2 with +/-1 clamps. + assert_eq!( + SplitProcess::process_with_state(&mut clamp32, &mut df1_32, 0.5), + 1.0 + ); + assert_eq!( + SplitProcess::process_with_state(&mut clamp32, &mut df2t_32, 0.5), + 1.0 + ); + assert_eq!( + SplitProcess::process_with_state(&mut clamp64, &mut df1_64, 0.5), + 1.0 + ); + assert_eq!( + SplitProcess::process_with_state(&mut clamp64, &mut df2t_64, 0.5), + 1.0 + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// resampling: half-band interpolator / decimation cascade +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn hbf_interpolator_reset_and_cascade_default() { + let mut interpolator = HbfInt::<3>::new([0.0f32; 3]); + interpolator.reset(); + + let mut cascade = HbfDecCascade::<2>::default(); + cascade.reset(); +} + +#[test] +fn hbf_dec_cascade_processes_four_and_five_stages() { + // `process` requires src.len() == (1 << STAGES) * dst.len(); the internal + // buffer plumbing differs for the 4-stage and 5-stage specialisations. + let mut four = HbfDecCascade::<4>::new(); + let src4 = [0.0f32; 16]; + let mut dst4 = [0.0f32; 1]; + four.process(&src4, &mut dst4); + assert_eq!(dst4[0], 0.0); + + let mut five = HbfDecCascade::<5>::new(); + let src5 = [0.0f32; 32]; + let mut dst5 = [0.0f32; 1]; + five.process(&src5, &mut dst5); + assert_eq!(dst5[0], 0.0); +} diff --git a/crates/embedded-dsp/tests/final_90_plus_coverage_boost.rs b/crates/embedded-dsp/tests/final_90_plus_coverage_boost.rs deleted file mode 100644 index 083b811..0000000 --- a/crates/embedded-dsp/tests/final_90_plus_coverage_boost.rs +++ /dev/null @@ -1,236 +0,0 @@ -use embedded_dsp::*; - -#[test] -fn test_complex_math_q31_q15_all() { - let a_q31 = [q31::from_bits(10000), q31::from_bits(20000)]; - let b_q31 = [q31::from_bits(5000), q31::from_bits(10000)]; - let mut out_q31 = [q31::ZERO; 2]; - - cmplx_add_q31(&a_q31, &b_q31, &mut out_q31); - cmplx_sub_q31(&a_q31, &b_q31, &mut out_q31); - cmplx_mult_cmplx_q31(&a_q31, &b_q31, &mut out_q31); - cmplx_mult_real_q31(&a_q31, &b_q31, &mut out_q31); - cmplx_conj_q31(&a_q31, &mut out_q31); - - let mut mag_q31 = [q31::ZERO; 1]; - cmplx_mag_q31(&a_q31, &mut mag_q31); - cmplx_mag_squared_q31(&a_q31, &mut mag_q31); - let _dot_q31 = cmplx_dot_prod_q31(&a_q31, &b_q31); - - let a_q15 = [q15::from_bits(1000), q15::from_bits(2000)]; - let b_q15 = [q15::from_bits(500), q15::from_bits(1000)]; - let mut out_q15 = [q15::ZERO; 2]; - - cmplx_add_q15(&a_q15, &b_q15, &mut out_q15); - cmplx_sub_q15(&a_q15, &b_q15, &mut out_q15); - cmplx_mult_cmplx_q15(&a_q15, &b_q15, &mut out_q15); - cmplx_mult_real_q15(&a_q15, &b_q15, &mut out_q15); - cmplx_conj_q15(&a_q15, &mut out_q15); - - let mut mag_q15 = [q15::ZERO; 1]; - cmplx_mag_q15(&a_q15, &mut mag_q15); - cmplx_mag_squared_q15(&a_q15, &mut mag_q15); - let _dot_q15 = cmplx_dot_prod_q15(&a_q15, &b_q15); -} - -#[test] -fn test_transforms_q31_q15_and_wavelets() { - let mut q31_buf = [q31::from_bits(1000); 32]; - let mut out_q31 = [q31::ZERO; 32]; - cfft_q31(&mut q31_buf[..32], 16, 0, 1); - cfft_q31(&mut q31_buf[..32], 16, 1, 1); - let _scale_q31 = cfft_bfp_q31(&mut q31_buf[..32], 16, 0, 1); - rfft_q31(&q31_buf[..16], &mut out_q31[..32], 16, 0); - rfft_q31(&q31_buf[..16], &mut out_q31[..32], 16, 1); - irfft_q31(&out_q31[..32], &mut q31_buf[..16], 16); - - let mut q15_buf = [q15::from_bits(100); 32]; - let mut out_q15 = [q15::ZERO; 32]; - cfft_q15(&mut q15_buf[..32], 16, 0, 1); - cfft_q15(&mut q15_buf[..32], 16, 1, 1); - let _scale_q15 = cfft_bfp_q15(&mut q15_buf[..32], 16, 0, 1); - rfft_q15(&q15_buf[..16], &mut out_q15[..32], 16, 0); - rfft_q15(&q15_buf[..16], &mut out_q15[..32], 16, 1); - irfft_q15(&out_q15[..32], &mut q15_buf[..16], 16); - - let f32_buf = [1.0f32; 16]; - let mut out_f32 = [0.0f32; 32]; - rfft_f32(&f32_buf, &mut out_f32, 16, 0); - rfft_f32(&f32_buf, &mut out_f32, 16, 1); - - let mut cep_out = [0.0f32; 16]; - assert_eq!(real_cepstrum_f32(&f32_buf, &mut cep_out), Status::Success); - - // FWHT and Haar - let mut fwht_buf = [1.0f32, 2.0, 3.0, 4.0]; - assert_eq!(fwht_f32(&mut fwht_buf), Status::Success); - assert_eq!(ifwht_f32(&mut fwht_buf), Status::Success); - - let mut fwht_i32_buf = [10i32, 20, 30, 40]; - assert_eq!(fwht_i32(&mut fwht_i32_buf), Status::Success); - - let mut haar_i32_buf = [10i32, 20, 30, 40]; - assert_eq!(haar_transform_i32(&mut haar_i32_buf), Status::Success); - - let mut haar_f32_buf = [1.0f32, 2.0, 3.0, 4.0]; - assert_eq!( - inverse_haar_transform_f32(&mut haar_f32_buf), - Status::Success - ); - - // Wavelets - let daub4 = [0.482_962_9, 0.836_516_3, 0.224_143_86, -0.129_409_52]; - let mut wave_buf = [1.0f32, 2.0, 3.0, 4.0]; - assert_eq!(wavelet_step_f32(&mut wave_buf, 4, &daub4), Status::Success); - assert_eq!( - inverse_wavelet_step_f32(&mut wave_buf, 4, &daub4), - Status::Success - ); - assert_eq!( - wavelet_transform_f32(&mut wave_buf, &daub4), - Status::Success - ); - assert_eq!( - inverse_wavelet_transform_f32(&mut wave_buf, &daub4), - Status::Success - ); -} - -#[test] -fn test_filter_design_all_windowed_sinc() { - let mut hp_taps = [0.0f32; 15]; - assert_eq!( - fir_windowed_sinc_highpass(0.2, &mut hp_taps), - Status::Success - ); - - let mut bp_taps = [0.0f32; 15]; - assert_eq!( - fir_windowed_sinc_bandpass(0.1, 0.3, &mut bp_taps), - Status::Success - ); - - let mut bs_taps = [0.0f32; 15]; - assert_eq!( - fir_windowed_sinc_bandstop(0.1, 0.3, &mut bs_taps), - Status::Success - ); - - // Invalid tap-length/cutoff arguments propagate as ArgumentError, including through - // highpass/bandstop's delegation into lowpass/bandpass. - let mut even_taps = [0.0f32; 4]; - assert_eq!( - fir_windowed_sinc_highpass(0.2, &mut even_taps), - Status::ArgumentError - ); - assert_eq!( - fir_windowed_sinc_bandpass(0.3, 0.1, &mut bp_taps), - Status::ArgumentError - ); - assert_eq!( - fir_windowed_sinc_bandstop(0.3, 0.1, &mut bs_taps), - Status::ArgumentError - ); - assert_eq!( - fir_windowed_sinc_bandstop(0.1, 0.3, &mut even_taps), - Status::ArgumentError - ); - - let mut biquad_q31 = [q31::ZERO; 5]; - assert!( - biquad_quantize_and_scale_q31( - &[1.0, -0.5, 0.25, 0.1, -0.05], - &mut biquad_q31, - ScalingStrategy::Direct - ) - .is_ok() - ); - - let mut biquad_q15 = [q15::ZERO; 5]; - assert!( - biquad_quantize_and_scale_q15( - &[1.0, -0.5, 0.25, 0.1, -0.05], - &mut biquad_q15, - ScalingStrategy::Direct - ) - .is_ok() - ); - - let mut fir_q15_taps = [q15::ZERO; 15]; - assert!(fir_quantize_q15(&hp_taps, &mut fir_q15_taps).is_ok()); - - assert_eq!( - single_pole_decay_from_time_constant(10.0), - (-1.0f32 / 10.0).exp() - ); - assert_eq!( - single_pole_decay_from_cutoff(0.1), - (-2.0f32 * core::f32::consts::PI * 0.1).exp() - ); - - assert!(biquad_lowpass_coeffs(1000.0, 48000.0, 0.707)[0].is_finite()); - assert!(biquad_highpass_coeffs(1000.0, 48000.0, 0.707)[0].is_finite()); - assert!(biquad_bandpass_coeffs(1000.0, 48000.0, 0.707)[0].is_finite()); - assert!(biquad_notch_coeffs(1000.0, 48000.0, 0.707)[0].is_finite()); - assert!(biquad_peaking_coeffs(1000.0, 48000.0, 0.707, 3.0)[0].is_finite()); - assert!(biquad_allpass_coeffs(1000.0, 48000.0, 0.707)[0].is_finite()); - - let mut biquads_bw = [0.0f32; 10]; - butterworth_lowpass_biquads(1000.0, 48000.0, 4, &mut biquads_bw); - - let mut biquads_cheb = [0.0f32; 10]; - chebyshev_lowpass_biquads(0.1, 0.5, 4, &mut biquads_cheb); - chebyshev_highpass_biquads(0.1, 0.5, 4, &mut biquads_cheb); - - let pw = prewarp_cutoff_f32(1000.0, 48000.0); - let biquad_out = bilinear_transform_biquad(pw, 0.0, 0.0, 1.0, 0.0, 0.0, 48000.0); - assert_eq!(biquad_out.len(), 5); -} - -#[test] -fn test_kalman_all_methods() { - let mut kf = KalmanFilter::<2, 1>::from_variances([0.0, 0.0], 1.0, 0.01, 0.1); - let f = [[1.0, 1.0], [0.0, 1.0]]; - kf.predict(&f); - let b = [[0.1], [0.05]]; - let u = [1.0]; - kf.predict_with_control(&f, &b, &u); - assert_eq!(kf.update(&[[1.0, 0.0]], &[1.0]), Status::Success); - - let mut sr_kf = SquareRootKalmanFilter::<2, 1>::new( - [0.0, 0.0], - [[1.0, 0.0], [0.0, 1.0]], - [[1.0, 0.0], [0.0, 1.0]], - [[0.1, 0.0], [0.0, 0.1]], - [[1.0, 0.0]], - [[0.1]], - ); - sr_kf.predict(); - assert_eq!(sr_kf.update(&[1.0]), Status::Success); - assert_eq!(sr_kf.covariance().len(), 2); -} - -#[test] -fn test_math_f64_full_coverage() { - let f: f64 = 0.5; - assert!((FloatMath::abs(f) - 0.5).abs() < 1e-6); - assert!(FloatMath::sin(f) > 0.0); - assert!(FloatMath::cos(f) > 0.0); - assert!(FloatMath::tan(f) > 0.0); - assert!(FloatMath::sqrt(f) > 0.0); - assert!(FloatMath::ln(f) < 0.0); - assert!(FloatMath::log10(f) < 0.0); - assert!(FloatMath::exp(f) > 1.0); - assert!(FloatMath::atan2(f, 1.0) > 0.0); - assert!((FloatMath::powf(f, 2.0) - 0.25).abs() < 1e-6); - assert!(FloatMath::tanh(f) > 0.0); - - assert_eq!(isqrt_u32(0), 0); - assert_eq!(isqrt_u32(1), 1); - assert_eq!(isqrt_u32(16), 4); - assert_eq!(isqrt_u32(100), 10); - - assert_eq!(isqrt_u64(0), 0); - assert_eq!(isqrt_u64(1), 1); - assert_eq!(isqrt_u64(144), 12); -} diff --git a/crates/embedded-dsp/tests/fixed_basic_types_transform.rs b/crates/embedded-dsp/tests/fixed_basic_types.rs similarity index 100% rename from crates/embedded-dsp/tests/fixed_basic_types_transform.rs rename to crates/embedded-dsp/tests/fixed_basic_types.rs diff --git a/crates/embedded-dsp/tests/high_roi_features_test.rs b/crates/embedded-dsp/tests/high_roi.rs similarity index 100% rename from crates/embedded-dsp/tests/high_roi_features_test.rs rename to crates/embedded-dsp/tests/high_roi.rs diff --git a/crates/embedded-dsp/tests/microcontroller_features_tests.rs b/crates/embedded-dsp/tests/microcontroller.rs similarity index 97% rename from crates/embedded-dsp/tests/microcontroller_features_tests.rs rename to crates/embedded-dsp/tests/microcontroller.rs index ac82bec..78441a1 100644 --- a/crates/embedded-dsp/tests/microcontroller_features_tests.rs +++ b/crates/embedded-dsp/tests/microcontroller.rs @@ -1,5 +1,8 @@ +//! Consolidated tests: microcontroller_features_tests. + use embedded_dsp::*; +// ─── from microcontroller_features_tests.rs ──────────────────────────────────────── #[test] fn test_eft_two_sum_and_diff() { // 1. two_sum_f32 @@ -716,13 +719,13 @@ fn test_hilbert_transform_and_analytic_signal() { assert_eq!(designed_15[7 + 2], 0.0); assert_eq!(designed_15[7 - 2], 0.0); - // 3. HilbertTransformF32 construction & errors + // 3. HilbertTransform construction & errors let mut state_35 = [0.0f32; 35]; let mut short_state = [0.0f32; 30]; - assert!(HilbertTransformF32::new(&HILBERT_COEFFS_35, &mut short_state).is_err()); - assert!(HilbertTransformF32::new(&bad_coeffs_even, &mut state_35[..4]).is_err()); + assert!(HilbertTransform::::new(&HILBERT_COEFFS_35, &mut short_state).is_err()); + assert!(HilbertTransform::::new(&bad_coeffs_even, &mut state_35[..4]).is_err()); - let mut hilbert = HilbertTransformF32::new(&HILBERT_COEFFS_35, &mut state_35).unwrap(); + let mut hilbert = HilbertTransform::::new(&HILBERT_COEFFS_35, &mut state_35).unwrap(); assert_eq!(hilbert.group_delay(), 17); // 4. Cosine wave analytic signal: @@ -762,12 +765,13 @@ fn test_hilbert_transform_and_analytic_signal() { Status::Success ); - // 5. HilbertTransformQ15 + // 5. HilbertTransform let mut state_q15 = [q15::ZERO; 35]; let mut short_state_q15 = [q15::ZERO; 10]; - assert!(HilbertTransformQ15::new(&HILBERT_COEFFS_35_Q15, &mut short_state_q15).is_err()); + assert!(HilbertTransform::::new(&HILBERT_COEFFS_35_Q15, &mut short_state_q15).is_err()); - let mut hilbert_q15 = HilbertTransformQ15::new(&HILBERT_COEFFS_35_Q15, &mut state_q15).unwrap(); + let mut hilbert_q15 = + HilbertTransform::::new(&HILBERT_COEFFS_35_Q15, &mut state_q15).unwrap(); assert_eq!(hilbert_q15.group_delay(), 17); hilbert_q15.reset(); @@ -838,7 +842,7 @@ fn hilbert_transform_q15_accumulates_in_i64_and_does_not_overflow() { // Adversarial coefficients: every tap at q15::MAX. Once the delay line fills with full-scale // samples, each of the 5 terms is a full Q30 product (~2^30); summed raw (before the single // narrowing shift `DspSample::from_accum` applies) that's ~2^32.3, overflowing an `i32` - // accumulator (the hand-written `HilbertTransformQ15` this type replaced used one — see + // accumulator (the hand-written `HilbertTransform` this type replaced used one — see // `python3 -c "print(32767*32767*5 > 2**31-1)"` => True). The generic `HilbertTransform` // accumulates via `DspSample::madd`, whose `Accum` is `i64` for q15 specifically so a // recurrence can sum several full-scale terms before that shift — this must saturate to @@ -846,7 +850,7 @@ fn hilbert_transform_q15_accumulates_in_i64_and_does_not_overflow() { const N: usize = 5; let coeffs = [q15::from_bits(i16::MAX); N]; let mut state = [q15::ZERO; N]; - let mut hilbert = HilbertTransformQ15::new(&coeffs, &mut state).unwrap(); + let mut hilbert = HilbertTransform::::new(&coeffs, &mut state).unwrap(); // Prime the delay line: after N pushes of MAX, every state slot holds a full-scale sample. for _ in 0..N { diff --git a/crates/embedded-dsp/tests/misc_modules_coverage.rs b/crates/embedded-dsp/tests/misc_modules_coverage.rs deleted file mode 100644 index bcde425..0000000 --- a/crates/embedded-dsp/tests/misc_modules_coverage.rs +++ /dev/null @@ -1,176 +0,0 @@ -//! Coverage for guard/edge branches in `distance`, `synthesis`, and `spatial`. -//! -//! These are the degenerate-input rejections and small accessors that the -//! existing suites left uncovered, plus the normal path of the Q15/Q31 cosine -//! distance. - -use embedded_dsp::distance::{ - bray_curtis_distance_q15, canberra_distance_q15, cosine_distance_q15, cosine_distance_q31, - euclidean_distance_q15, euclidean_distance_q31, -}; -use embedded_dsp::spatial::{convolve2d_f32, dct2d_f32, histogram_2d_f32, idct2d_f32, mse_2d_f32}; -use embedded_dsp::synthesis::{AccuOsc, Sweep, SweepError}; -use embedded_dsp::types::{Status, q15, q31}; - -// ───────────────────────────────────────────────────────────────────────────── -// distance -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn distance_functions_handle_empty_slices() { - assert_eq!(euclidean_distance_q15(&[], &[]), q15::ZERO); - assert_eq!(euclidean_distance_q31(&[], &[]), q31::ZERO); - assert_eq!(cosine_distance_q15(&[], &[]), q15::ZERO); - assert_eq!(cosine_distance_q31(&[], &[]), q31::ZERO); - assert_eq!(canberra_distance_q15(&[], &[]), q15::ZERO); - assert_eq!(bray_curtis_distance_q15(&[], &[]), q15::ZERO); -} - -#[test] -fn cosine_distance_reports_maximum_for_zero_vectors() { - // Cosine similarity is undefined for a zero-norm vector; the API reports - // the maximum distance (1.0) in both Q15 and Q31. - let zeros15 = [q15::ZERO; 4]; - assert_eq!( - cosine_distance_q15(&zeros15, &zeros15), - q15::from_bits(32_767) - ); - - let zeros31 = [q31::ZERO; 4]; - assert_eq!( - cosine_distance_q31(&zeros31, &zeros31), - q31::from_bits(i32::MAX) - ); -} - -#[test] -fn bray_curtis_distance_is_zero_for_zero_vectors() { - let zeros = [q15::ZERO; 4]; - assert_eq!(bray_curtis_distance_q15(&zeros, &zeros), q15::ZERO); -} - -#[test] -fn cosine_distance_ranks_identical_below_orthogonal_q15() { - let a = [q15::from_bits(16_384), q15::from_bits(8_192)]; - let identical = [q15::from_bits(16_384), q15::from_bits(8_192)]; - // a · c == 0.5*0.25 + 0.25*(-0.5) == 0, i.e. exactly orthogonal. - let orthogonal = [q15::from_bits(8_192), q15::from_bits(-16_384)]; - - let same = cosine_distance_q15(&a, &identical); - let orth = cosine_distance_q15(&a, &orthogonal); - assert!( - same < orth, - "identical vectors ({same:?}) must be closer than orthogonal ones ({orth:?})" - ); -} - -#[test] -fn cosine_distance_ranks_identical_below_orthogonal_q31() { - let a = [q31::from_bits(1 << 30), q31::from_bits(1 << 29)]; - let identical = [q31::from_bits(1 << 30), q31::from_bits(1 << 29)]; - let orthogonal = [q31::from_bits(1 << 29), q31::from_bits(-(1 << 30))]; - - let same = cosine_distance_q31(&a, &identical); - let orth = cosine_distance_q31(&a, &orthogonal); - assert!( - same < orth, - "identical vectors ({same:?}) must be closer than orthogonal ones ({orth:?})" - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// synthesis -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn sweep_derived_quantities_are_consistent() { - // Positive rate: the sweep runs upward in frequency. - let sweep = Sweep::new(1, 1i64 << 40); - assert!(sweep.rate() > 0.0); - assert!( - sweep.delay(2.0) > 0.0, - "upward sweep has a positive harmonic delay" - ); - assert!(sweep.octave() > 0.0 && sweep.decade() > 0.0); - - assert_eq!(sweep.state(), sweep.cycles() * sweep.rate()); - assert_eq!(sweep.continuous(0.0), sweep.cycles()); -} - -#[test] -fn sweep_fit_rejects_out_of_range_parameters() { - // `stop` must lie in 0.0..=0.5 (Nyquist). - assert_eq!(Sweep::fit(0.75, 1_000.0, 1.0), Err(SweepError::Stop)); - assert_eq!(Sweep::fit(-0.1, 1_000.0, 1.0), Err(SweepError::Stop)); - - // A stop frequency this low rounds the rate to zero, leaving a - // non-positive initial state. - assert_eq!(Sweep::fit(0.5, 1.0e12, 1.0), Err(SweepError::Start)); -} - -#[test] -fn sweep_error_display_describes_the_bad_parameter() { - assert_eq!( - format!("{}", SweepError::Start), - "Sweep start parameter out of bounds" - ); - assert_eq!( - format!("{}", SweepError::Stop), - "Sweep stop parameter out of bounds" - ); -} - -#[test] -fn accu_osc_starts_with_a_zero_phase_accumulator() { - let osc = AccuOsc::new(Sweep::new(1, 1i64 << 40)); - assert_eq!(osc.state(), 0); -} - -// ───────────────────────────────────────────────────────────────────────────── -// spatial -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn spatial_transforms_reject_degenerate_shapes() { - // Zero rows/cols. - assert_eq!(dct2d_f32(&[], &mut [], 0, 0), Status::LengthError); - assert_eq!(idct2d_f32(&[], &mut [], 0, 0), Status::LengthError); - - // A zero-sized kernel is an argument error... - assert_eq!( - convolve2d_f32(&[], &mut [], 0, 0, &[], 0, 0, false), - Status::ArgumentError - ); - // ...while a positive shape with undersized buffers is a length error. - assert_eq!( - convolve2d_f32(&[], &mut [], 1, 1, &[], 1, 1, false), - Status::LengthError - ); -} - -#[test] -fn histogram_rejects_empty_input_or_bins() { - let mut bins = [0usize; 4]; - assert_eq!( - histogram_2d_f32(&[], &mut bins, 0.0, 1.0), - Status::ArgumentError - ); - - let mut no_bins: [usize; 0] = []; - assert_eq!( - histogram_2d_f32(&[0.5], &mut no_bins, 0.0, 1.0), - Status::ArgumentError - ); - - // An inverted range is also rejected. - assert_eq!( - histogram_2d_f32(&[0.5], &mut bins, 1.0, 0.0), - Status::ArgumentError - ); -} - -#[test] -fn mse_of_empty_images_is_zero() { - assert_eq!(mse_2d_f32(&[], &[]), 0.0); - assert_eq!(mse_2d_f32(&[1.0, 2.0], &[1.0, 2.0]), 0.0); -} diff --git a/crates/embedded-dsp/tests/module_extras_coverage.rs b/crates/embedded-dsp/tests/module_extras_coverage.rs deleted file mode 100644 index 5e255b4..0000000 --- a/crates/embedded-dsp/tests/module_extras_coverage.rs +++ /dev/null @@ -1,168 +0,0 @@ -//! Coverage for shift/scale branches (`basic_math`), CORDIC edge cases, -//! dynamics-processor branches, and the remaining PSD window types. - -use embedded_dsp::basic_math::{scale_q7, scale_q15, scale_q31, shift_q7, shift_q15, shift_q31}; -use embedded_dsp::cordic::{cordic_cartesian_to_polar_q15, cordic_sqrt_q15}; -use embedded_dsp::dynamics::{DynamicsCompressor, SafetyLimiter}; -use embedded_dsp::pipeline::DspNode; -use embedded_dsp::psd::{WelchWindow, welch_psd_f32}; -use embedded_dsp::types::{Status, q7, q15, q31}; - -// ───────────────────────────────────────────────────────────────────────────── -// basic_math: shifts beyond the storage width / negative shift counts -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn scale_functions_handle_shifts_beyond_the_storage_width() { - // `shift` larger than the type's bit width makes the residual shift - // negative, which takes the left-shift branch. - let mut out31 = [q31::from_bits(0); 2]; - scale_q31( - &[q31::from_bits(1 << 20); 2], - q31::from_bits(1 << 20), - 40, - &mut out31, - ); - - let mut out15 = [q15::from_bits(0); 2]; - scale_q15( - &[q15::from_bits(1_000); 2], - q15::from_bits(1_000), - 20, - &mut out15, - ); - - let mut out7 = [q7::from_bits(0); 2]; - scale_q7(&[q7::from_bits(10); 2], q7::from_bits(10), 10, &mut out7); -} - -#[test] -fn shift_functions_handle_two_complement_counts() { - // Negative counts shift right, positive counts shift left with saturation. - let mut right31 = [q31::from_bits(0); 2]; - shift_q31(&[q31::from_bits(1_024); 2], -2, &mut right31); - assert_eq!(right31[0], q31::from_bits(256)); - - let mut left15 = [q15::from_bits(0); 2]; - shift_q15(&[q15::from_bits(16); 2], 2, &mut left15); - assert_eq!(left15[0], q15::from_bits(64)); - - // Saturation on the left shift for the narrowest type. - let mut sat15 = [q15::from_bits(0); 1]; - shift_q15(&[q15::from_bits(20_000)], 4, &mut sat15); - assert_eq!(sat15[0], q15::from_bits(i16::MAX)); - - let mut right7 = [q7::from_bits(0); 2]; - shift_q7(&[q7::from_bits(16); 2], -2, &mut right7); - assert_eq!(right7[0], q7::from_bits(4)); -} - -// ───────────────────────────────────────────────────────────────────────────── -// cordic -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn cordic_cartesian_to_polar_handles_the_origin() { - assert_eq!( - cordic_cartesian_to_polar_q15(q15::ZERO, q15::ZERO), - (q15::ZERO, q15::ZERO) - ); -} - -#[test] -fn cordic_cartesian_to_polar_handles_negative_angles() { - // A negative y component yields a negative angle, taking the second arm of - // the quadrant-folding branch. - let positive = cordic_cartesian_to_polar_q15(q15::from_bits(16_384), q15::from_bits(16_384)); - let negative = cordic_cartesian_to_polar_q15(q15::from_bits(16_384), q15::from_bits(-16_384)); - - assert!( - positive.1 > q15::ZERO, - "positive y should give a positive angle" - ); - assert!( - negative.1 < q15::ZERO, - "negative y should give a negative angle" - ); - // Magnitude is sign-independent. - assert_eq!(positive.0, negative.0); -} - -#[test] -fn cordic_sqrt_of_zero_is_zero() { - assert_eq!(cordic_sqrt_q15(q15::ZERO), q15::ZERO); -} - -// ───────────────────────────────────────────────────────────────────────────── -// dynamics -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn compressor_soft_knee_and_silence_floor() { - // threshold -20 dB, 4:1, 6 dB knee. - let mut comp = DynamicsCompressor::new(-20.0, 4.0, 6.0, 0.01, 0.1, 0.0, 48_000.0); - - // Just inside the knee: the soft-knee quadratic branch runs. - let knee = comp.process(0.1); - assert!(knee.is_finite()); - - // A vanishingly small sample hits the -120 dB silence floor, where the - // compressor applies no gain reduction and passes the sample through. - let silence = comp.process(1.0e-9); - assert!(silence.is_finite()); - assert!(silence.abs() <= 1.0e-8, "got {silence}"); - - comp.reset(); - comp.process(0.5); -} - -#[test] -fn safety_limiter_release_branch_and_dsp_node() { - let mut limiter = SafetyLimiter::new(1.0, 0.01, 48_000.0); - - // Loud sample attacks the gain down... - let loud = limiter.process(10.0); - assert!(loud.is_finite()); - let after_attack = limiter.current_gain(); - assert!(after_attack < 1.0); - - // ...then a quiet sample relaxes the gain back toward unity. - let quiet = limiter.process(0.001); - assert!(quiet.is_finite()); - assert!(limiter.current_gain() >= after_attack); - - // `DspNode` adapters delegate to the inherent `process`. - let mut via_node = SafetyLimiter::new(1.0, 0.01, 48_000.0); - let mut direct = SafetyLimiter::new(1.0, 0.01, 48_000.0); - assert_eq!( - DspNode::process_sample(&mut via_node, 0.5), - direct.process(0.5) - ); - - limiter.reset(); -} - -// ───────────────────────────────────────────────────────────────────────────── -// psd: remaining window types -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn welch_psd_supports_every_window_variant() { - let src = [0.5f32; 128]; - for window in [ - WelchWindow::Rectangular, - WelchWindow::Hamming, - WelchWindow::Hanning, - WelchWindow::Blackman, - WelchWindow::BlackmanHarris, - WelchWindow::Bartlett, - WelchWindow::Welch, - ] { - let mut psd = [0.0f32; 33]; - assert_eq!( - welch_psd_f32(&src, &mut psd, 64, 32, 1_000.0, window, false), - Status::Success, - "window {window:?} failed" - ); - } -} diff --git a/crates/embedded-dsp/tests/modules_misc.rs b/crates/embedded-dsp/tests/modules_misc.rs new file mode 100644 index 0000000..249dd33 --- /dev/null +++ b/crates/embedded-dsp/tests/modules_misc.rs @@ -0,0 +1,338 @@ +//! Consolidated tests: misc_modules_coverage, module_extras_coverage. + +use embedded_dsp::basic_math::{scale_q7, scale_q15, scale_q31, shift_q7, shift_q15, shift_q31}; +use embedded_dsp::cordic::{cordic_cartesian_to_polar_q15, cordic_sqrt_q15}; +use embedded_dsp::distance::{ + bray_curtis_distance_q15, canberra_distance_q15, cosine_distance_q15, cosine_distance_q31, + euclidean_distance_q15, euclidean_distance_q31, +}; +use embedded_dsp::dynamics::{DynamicsCompressor, SafetyLimiter}; +use embedded_dsp::pipeline::DspNode; +use embedded_dsp::psd::{WelchWindow, welch_psd_f32}; +use embedded_dsp::spatial::{convolve2d_f32, dct2d_f32, histogram_2d_f32, idct2d_f32, mse_2d_f32}; +use embedded_dsp::synthesis::{AccuOsc, Sweep, SweepError}; +use embedded_dsp::types::{Status, q7, q15, q31}; + +// ─── from misc_modules_coverage.rs ──────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// distance +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn distance_functions_handle_empty_slices() { + assert_eq!(euclidean_distance_q15(&[], &[]), q15::ZERO); + assert_eq!(euclidean_distance_q31(&[], &[]), q31::ZERO); + assert_eq!(cosine_distance_q15(&[], &[]), q15::ZERO); + assert_eq!(cosine_distance_q31(&[], &[]), q31::ZERO); + assert_eq!(canberra_distance_q15(&[], &[]), q15::ZERO); + assert_eq!(bray_curtis_distance_q15(&[], &[]), q15::ZERO); +} + +#[test] +fn cosine_distance_reports_maximum_for_zero_vectors() { + // Cosine similarity is undefined for a zero-norm vector; the API reports + // the maximum distance (1.0) in both Q15 and Q31. + let zeros15 = [q15::ZERO; 4]; + assert_eq!( + cosine_distance_q15(&zeros15, &zeros15), + q15::from_bits(32_767) + ); + + let zeros31 = [q31::ZERO; 4]; + assert_eq!( + cosine_distance_q31(&zeros31, &zeros31), + q31::from_bits(i32::MAX) + ); +} + +#[test] +fn bray_curtis_distance_is_zero_for_zero_vectors() { + let zeros = [q15::ZERO; 4]; + assert_eq!(bray_curtis_distance_q15(&zeros, &zeros), q15::ZERO); +} + +#[test] +fn cosine_distance_ranks_identical_below_orthogonal_q15() { + let a = [q15::from_bits(16_384), q15::from_bits(8_192)]; + let identical = [q15::from_bits(16_384), q15::from_bits(8_192)]; + // a · c == 0.5*0.25 + 0.25*(-0.5) == 0, i.e. exactly orthogonal. + let orthogonal = [q15::from_bits(8_192), q15::from_bits(-16_384)]; + + let same = cosine_distance_q15(&a, &identical); + let orth = cosine_distance_q15(&a, &orthogonal); + assert!( + same < orth, + "identical vectors ({same:?}) must be closer than orthogonal ones ({orth:?})" + ); +} + +#[test] +fn cosine_distance_ranks_identical_below_orthogonal_q31() { + let a = [q31::from_bits(1 << 30), q31::from_bits(1 << 29)]; + let identical = [q31::from_bits(1 << 30), q31::from_bits(1 << 29)]; + let orthogonal = [q31::from_bits(1 << 29), q31::from_bits(-(1 << 30))]; + + let same = cosine_distance_q31(&a, &identical); + let orth = cosine_distance_q31(&a, &orthogonal); + assert!( + same < orth, + "identical vectors ({same:?}) must be closer than orthogonal ones ({orth:?})" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// synthesis +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn sweep_derived_quantities_are_consistent() { + // Positive rate: the sweep runs upward in frequency. + let sweep = Sweep::new(1, 1i64 << 40); + assert!(sweep.rate() > 0.0); + assert!( + sweep.delay(2.0) > 0.0, + "upward sweep has a positive harmonic delay" + ); + assert!(sweep.octave() > 0.0 && sweep.decade() > 0.0); + + assert_eq!(sweep.state(), sweep.cycles() * sweep.rate()); + assert_eq!(sweep.continuous(0.0), sweep.cycles()); +} + +#[test] +fn sweep_fit_rejects_out_of_range_parameters() { + // `stop` must lie in 0.0..=0.5 (Nyquist). + assert_eq!(Sweep::fit(0.75, 1_000.0, 1.0), Err(SweepError::Stop)); + assert_eq!(Sweep::fit(-0.1, 1_000.0, 1.0), Err(SweepError::Stop)); + + // A stop frequency this low rounds the rate to zero, leaving a + // non-positive initial state. + assert_eq!(Sweep::fit(0.5, 1.0e12, 1.0), Err(SweepError::Start)); +} + +#[test] +fn sweep_error_display_describes_the_bad_parameter() { + assert_eq!( + format!("{}", SweepError::Start), + "Sweep start parameter out of bounds" + ); + assert_eq!( + format!("{}", SweepError::Stop), + "Sweep stop parameter out of bounds" + ); +} + +#[test] +fn accu_osc_starts_with_a_zero_phase_accumulator() { + let osc = AccuOsc::new(Sweep::new(1, 1i64 << 40)); + assert_eq!(osc.state(), 0); +} + +// ───────────────────────────────────────────────────────────────────────────── +// spatial +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn spatial_transforms_reject_degenerate_shapes() { + // Zero rows/cols. + assert_eq!(dct2d_f32(&[], &mut [], 0, 0), Status::LengthError); + assert_eq!(idct2d_f32(&[], &mut [], 0, 0), Status::LengthError); + + // A zero-sized kernel is an argument error... + assert_eq!( + convolve2d_f32(&[], &mut [], 0, 0, &[], 0, 0, false), + Status::ArgumentError + ); + // ...while a positive shape with undersized buffers is a length error. + assert_eq!( + convolve2d_f32(&[], &mut [], 1, 1, &[], 1, 1, false), + Status::LengthError + ); +} + +#[test] +fn histogram_rejects_empty_input_or_bins() { + let mut bins = [0usize; 4]; + assert_eq!( + histogram_2d_f32(&[], &mut bins, 0.0, 1.0), + Status::ArgumentError + ); + + let mut no_bins: [usize; 0] = []; + assert_eq!( + histogram_2d_f32(&[0.5], &mut no_bins, 0.0, 1.0), + Status::ArgumentError + ); + + // An inverted range is also rejected. + assert_eq!( + histogram_2d_f32(&[0.5], &mut bins, 1.0, 0.0), + Status::ArgumentError + ); +} + +#[test] +fn mse_of_empty_images_is_zero() { + assert_eq!(mse_2d_f32(&[], &[]), 0.0); + assert_eq!(mse_2d_f32(&[1.0, 2.0], &[1.0, 2.0]), 0.0); +} + +// ─── from module_extras_coverage.rs ──────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// basic_math: shifts beyond the storage width / negative shift counts +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn scale_functions_handle_shifts_beyond_the_storage_width() { + // `shift` larger than the type's bit width makes the residual shift + // negative, which takes the left-shift branch. + let mut out31 = [q31::from_bits(0); 2]; + scale_q31( + &[q31::from_bits(1 << 20); 2], + q31::from_bits(1 << 20), + 40, + &mut out31, + ); + + let mut out15 = [q15::from_bits(0); 2]; + scale_q15( + &[q15::from_bits(1_000); 2], + q15::from_bits(1_000), + 20, + &mut out15, + ); + + let mut out7 = [q7::from_bits(0); 2]; + scale_q7(&[q7::from_bits(10); 2], q7::from_bits(10), 10, &mut out7); +} + +#[test] +fn shift_functions_handle_two_complement_counts() { + // Negative counts shift right, positive counts shift left with saturation. + let mut right31 = [q31::from_bits(0); 2]; + shift_q31(&[q31::from_bits(1_024); 2], -2, &mut right31); + assert_eq!(right31[0], q31::from_bits(256)); + + let mut left15 = [q15::from_bits(0); 2]; + shift_q15(&[q15::from_bits(16); 2], 2, &mut left15); + assert_eq!(left15[0], q15::from_bits(64)); + + // Saturation on the left shift for the narrowest type. + let mut sat15 = [q15::from_bits(0); 1]; + shift_q15(&[q15::from_bits(20_000)], 4, &mut sat15); + assert_eq!(sat15[0], q15::from_bits(i16::MAX)); + + let mut right7 = [q7::from_bits(0); 2]; + shift_q7(&[q7::from_bits(16); 2], -2, &mut right7); + assert_eq!(right7[0], q7::from_bits(4)); +} + +// ───────────────────────────────────────────────────────────────────────────── +// cordic +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn cordic_cartesian_to_polar_handles_the_origin() { + assert_eq!( + cordic_cartesian_to_polar_q15(q15::ZERO, q15::ZERO), + (q15::ZERO, q15::ZERO) + ); +} + +#[test] +fn cordic_cartesian_to_polar_handles_negative_angles() { + // A negative y component yields a negative angle, taking the second arm of + // the quadrant-folding branch. + let positive = cordic_cartesian_to_polar_q15(q15::from_bits(16_384), q15::from_bits(16_384)); + let negative = cordic_cartesian_to_polar_q15(q15::from_bits(16_384), q15::from_bits(-16_384)); + + assert!( + positive.1 > q15::ZERO, + "positive y should give a positive angle" + ); + assert!( + negative.1 < q15::ZERO, + "negative y should give a negative angle" + ); + // Magnitude is sign-independent. + assert_eq!(positive.0, negative.0); +} + +#[test] +fn cordic_sqrt_of_zero_is_zero() { + assert_eq!(cordic_sqrt_q15(q15::ZERO), q15::ZERO); +} + +// ───────────────────────────────────────────────────────────────────────────── +// dynamics +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn compressor_soft_knee_and_silence_floor() { + // threshold -20 dB, 4:1, 6 dB knee. + let mut comp = DynamicsCompressor::new(-20.0, 4.0, 6.0, 0.01, 0.1, 0.0, 48_000.0); + + // Just inside the knee: the soft-knee quadratic branch runs. + let knee = comp.process(0.1); + assert!(knee.is_finite()); + + // A vanishingly small sample hits the -120 dB silence floor, where the + // compressor applies no gain reduction and passes the sample through. + let silence = comp.process(1.0e-9); + assert!(silence.is_finite()); + assert!(silence.abs() <= 1.0e-8, "got {silence}"); + + comp.reset(); + comp.process(0.5); +} + +#[test] +fn safety_limiter_release_branch_and_dsp_node() { + let mut limiter = SafetyLimiter::new(1.0, 0.01, 48_000.0); + + // Loud sample attacks the gain down... + let loud = limiter.process(10.0); + assert!(loud.is_finite()); + let after_attack = limiter.current_gain(); + assert!(after_attack < 1.0); + + // ...then a quiet sample relaxes the gain back toward unity. + let quiet = limiter.process(0.001); + assert!(quiet.is_finite()); + assert!(limiter.current_gain() >= after_attack); + + // `DspNode` adapters delegate to the inherent `process`. + let mut via_node = SafetyLimiter::new(1.0, 0.01, 48_000.0); + let mut direct = SafetyLimiter::new(1.0, 0.01, 48_000.0); + assert_eq!( + DspNode::process_sample(&mut via_node, 0.5), + direct.process(0.5) + ); + + limiter.reset(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// psd: remaining window types +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn welch_psd_supports_every_window_variant() { + let src = [0.5f32; 128]; + for window in [ + WelchWindow::Rectangular, + WelchWindow::Hamming, + WelchWindow::Hanning, + WelchWindow::Blackman, + WelchWindow::BlackmanHarris, + WelchWindow::Bartlett, + WelchWindow::Welch, + ] { + let mut psd = [0.0f32; 33]; + assert_eq!( + welch_psd_f32(&src, &mut psd, 64, 32, 1_000.0, window, false), + Status::Success, + "window {window:?} failed" + ); + } +} diff --git a/crates/embedded-dsp/tests/more_coverage_boost.rs b/crates/embedded-dsp/tests/more_coverage_boost.rs deleted file mode 100644 index fec8121..0000000 --- a/crates/embedded-dsp/tests/more_coverage_boost.rs +++ /dev/null @@ -1,97 +0,0 @@ -use embedded_dsp::*; - -#[test] -fn test_dsp_sample_all_primitives() { - let a_f32: f32 = 0.5; - let b_f32: f32 = 0.5; - assert_eq!(a_f32.sat_add(b_f32), 1.0); - assert_eq!(a_f32.sat_sub(b_f32), 0.0); - assert_eq!(a_f32.sat_mul(b_f32), 0.25); - - let a_f64: f64 = 0.5; - let b_f64: f64 = 0.5; - assert_eq!(a_f64.sat_add(b_f64), 1.0); - assert_eq!(a_f64.sat_sub(b_f64), 0.0); - assert_eq!(a_f64.sat_mul(b_f64), 0.25); - - let c1 = Complex::new(1.0f32, 2.0f32); - let c2 = Complex::new(3.0f32, 4.0f32); - assert_eq!(c1.real + c2.real, 4.0); - assert_eq!(c1.imag + c2.imag, 6.0); -} - -#[test] -fn test_const_generics_and_cordic() { - let mut fir = FirFilter::<4>::new([0.25f32, 0.25, 0.25, 0.25]); - let in_buf = [1.0f32, 0.5, 0.2, 0.1]; - let mut out_buf = [0.0f32; 4]; - fir.process(&in_buf, &mut out_buf); - fir.reset(); - - let mut fir_q15 = FirFilterQ15::<4>::new([q15::from_bits(1000); 4]); - let in_q15 = [q15::from_bits(2000); 4]; - let mut out_q15 = [q15::ZERO; 4]; - fir_q15.process(&in_q15, &mut out_q15); - fir_q15.reset(); - - let mut biquad = BiquadCascade::<5, 4>::new([1.0, 0.0, 0.0, 1.0, 0.0]); - biquad.process(&in_buf, &mut out_buf); - biquad.reset(); - - let mut biquad_q15 = BiquadCascadeQ15::<5, 4>::new([q15::from_bits(1000); 5], 0); - biquad_q15.process(&in_q15, &mut out_q15); - biquad_q15.reset(); - - let mat = Matrix::<2, 2, 4>::new([1.0, 2.0, 3.0, 4.0]); - let t = mat.transpose(); - assert_eq!(t.data[1], 3.0); - - // CORDIC engine - let (s, c) = cordic_sin_cos_q31(q31::from_bits(1000000)); - assert!(s.to_bits() != 0); - assert!(c.to_bits() != 0); - - let atan_q15 = cordic_atan2_q15(q15::from_bits(1000), q15::from_bits(1000)); - assert!(atan_q15.to_bits() > 0); - - let sqrt_q15 = cordic_sqrt_q15(q15::from_bits(10000)); - assert!(sqrt_q15.to_bits() > 0); -} - -#[test] -fn test_complex_math_extended() { - let a = [1.0f32, 2.0, 3.0, 4.0]; - let b = [2.0f32, 1.0, 1.0, 2.0]; - let mut out = [0.0f32; 4]; - - cmplx_add_f32(&a, &b, &mut out); - cmplx_sub_f32(&a, &b, &mut out); - cmplx_mult_cmplx_f32(&a, &b, &mut out); - cmplx_mult_real_f32(&a, &b, &mut out); - cmplx_conj_f32(&a, &mut out); - - let mut mag = [0.0f32; 2]; - cmplx_mag_f32(&a, &mut mag); - cmplx_mag_squared_f32(&a, &mut mag); - - let dot = cmplx_dot_prod_f32(&a, &b); - assert!(dot.real.is_finite()); -} - -#[test] -fn test_status_codes() { - let s1 = Status::Success; - let s2 = Status::ArgumentError; - let s3 = Status::LengthError; - assert_ne!(s1, s2); - assert_ne!(s2, s3); -} - -#[test] -fn test_transforms_extended() { - let mut data = [1.0f32, 2.0, 3.0, 4.0]; - let mut out = [0.0f32; 4]; - assert_eq!(haar_transform_f32(&mut data), Status::Success); - hartley_transform_f32(&mut data); - dct4_f32(&data, &mut out, 4); -} diff --git a/crates/embedded-dsp/tests/push_to_95_coverage_a.rs b/crates/embedded-dsp/tests/push_to_95_coverage_a.rs deleted file mode 100644 index e1c7644..0000000 --- a/crates/embedded-dsp/tests/push_to_95_coverage_a.rs +++ /dev/null @@ -1,221 +0,0 @@ -use embedded_dsp::*; - -#[test] -fn test_audio_exhaustive() { - let mut detector = GoertzelDetector::new(1000.0, 16000.0); - let sample_block = [0.1f32; 160]; - for &s in &sample_block { - detector.process_sample(s); - } - assert!(detector.magnitude().is_finite()); - detector.reset(); - - let mut q15_detector = GoertzelDetectorQ15::new(1000.0, 16000.0); - let q15_block = [q15::from_bits(1000); 160]; - for &s in &q15_block { - q15_detector.process_sample(s); - } - assert!(q15_detector.magnitude().to_bits() >= 0); - q15_detector.reset(); - - let mut peak_env = PeakEnvelopeFollower::new(10.0, 100.0); - assert!(peak_env.process(0.5).is_finite()); - peak_env.reset(); - - let mut rms_env = RmsEnvelopeFollower::new(100.0); - assert!(rms_env.process(0.5).is_finite()); - rms_env.reset(); - - let mut peak_env_q15 = PeakEnvelopeFollowerQ15::new(10.0, 100.0); - assert!(peak_env_q15.process(q15::from_bits(10000)).to_bits() >= 0); - peak_env_q15.reset(); - - let mut rms_env_q15 = RmsEnvelopeFollowerQ15::new(100.0); - assert!(rms_env_q15.process(q15::from_bits(10000)).to_bits() >= 0); - rms_env_q15.reset(); - - assert!(hz_to_mel(1000.0).is_finite()); - assert!(mel_to_hz(1000.0).is_finite()); - - let fft_mag = [1.0f32; 64]; - let mut filterbank_energies = [0.0f32; 10]; - let status = mel_filterbank_f32( - &fft_mag, - 64, - 16000.0, - 100.0, - 8000.0, - &mut filterbank_energies, - ); - assert_eq!(status, Status::Success); - - let frame = [0.1f32; 64]; - let mut mel_scratch = [0.0f32; 16]; - let mut mfcc_coeffs = [0.0f32; 10]; - let status = mfcc_f32( - &frame, - 16000.0, - 100.0, - 8000.0, - &mut mel_scratch, - &mut mfcc_coeffs, - ); - assert_eq!(status, Status::Success); - - let left = [0usize, 1, 2]; - let center = [1usize, 2, 3]; - let right = [2usize, 3, 4]; - let mut tri_energies = [0.0f32; 3]; - let status = - generalized_triangular_filterbank(&fft_mag, &left, ¢er, &right, &mut tri_energies); - assert_eq!(status, Status::Success); - - let q15_val = fast_log2_q15(q15::from_bits(4096)); - assert!(q15_val.to_bits() != 0); - - let vad = VadDetectorQ15::new(100, 2); - let q15_frame = [q15::from_bits(1000); 16]; - let _ = vad.is_active(&q15_frame); -} - -#[test] -fn test_beamforming_exhaustive() { - let mut bf = DelayAndSumBeamformer::<4, 64>::new(); - bf.set_delays(&[0.0, 1.0, 2.0, 3.0]); - bf.set_weights(&[0.25, 0.25, 0.25, 0.25]); - let mic_sample = [1.0, 1.0, 1.0, 1.0]; - let out = bf.process_sample(&mic_sample); - assert!(out.is_finite()); - bf.reset(); - - let sig_a = [1.0f32; 64]; - let sig_b = [1.0f32; 64]; - let result = gcc_phat_tdoa_f32(&sig_a, &sig_b, 10); - assert!(result.is_ok()); -} - -#[test] -fn test_controller_exhaustive() { - let mut inst_f32 = PidInstanceF32::new(1.0, 0.1, 0.01); - assert!(inst_f32.process(1.0).is_finite()); - assert!(pid_f32(&mut inst_f32, 1.0).is_finite()); - inst_f32.reset(); - - let mut inst_q31 = PidInstanceQ31::new( - q31::from_bits(1_000_000_000), - q31::from_bits(100_000_000), - q31::from_bits(10_000_000), - ); - let _res_q31_1 = inst_q31.process(q31::from_bits(1_000_000_000)); - let _res_q31_2 = pid_q31(&mut inst_q31, q31::from_bits(1_000_000_000)); - inst_q31.reset(); - - let mut inst_q15 = PidInstanceQ15::new( - q15::from_bits(10000), - q15::from_bits(1000), - q15::from_bits(100), - ); - let _res_q15_1 = inst_q15.process(q15::from_bits(10000)); - let _res_q15_2 = pid_q15(&mut inst_q15, q15::from_bits(10000)); - inst_q15.reset(); - - let (mut alpha, mut beta) = (0.0f32, 0.0f32); - clarke_f32(1.0, 0.0, &mut alpha, &mut beta); - let (mut d, mut q) = (0.0f32, 0.0f32); - park_f32(alpha, beta, 0.5, &mut d, &mut q); - let (mut ia, mut ib) = (0.0f32, 0.0f32); - inv_park_f32(d, q, 0.5, &mut alpha, &mut beta); - inv_clarke_f32(alpha, beta, &mut ia, &mut ib); - assert!(ia.is_finite() && ib.is_finite()); - - let (mut alpha_q, mut beta_q) = (q15::ZERO, q15::ZERO); - clarke_q15(q15::from_bits(1000), q15::ZERO, &mut alpha_q, &mut beta_q); - let (mut d_q, mut q_q) = (q15::ZERO, q15::ZERO); - park_q15( - alpha_q, - beta_q, - q15::from_bits(500), - q15::from_bits(1000), - &mut d_q, - &mut q_q, - ); - inv_park_q15( - d_q, - q_q, - q15::from_bits(500), - q15::from_bits(1000), - &mut alpha_q, - &mut beta_q, - ); - let (mut ia_q, mut ib_q) = (q15::ZERO, q15::ZERO); - inv_clarke_q15(alpha_q, beta_q, &mut ia_q, &mut ib_q); -} - -#[test] -fn test_intrinsics_lut_pll_psd() { - let _d1 = intrinsics::dual_mac_q15(0x00010002, 0x00030004, 0); - let _d2 = intrinsics::dual_mac_q63(0x00010002, 0x00030004, 0); - let _a1 = intrinsics::dual_saturating_add_q15(0x00010002, 0x00030004); - let _s1 = intrinsics::dual_saturating_sub_q15(0x00030004, 0x00010002); - let _sq = intrinsics::saturate_q15(40000); - let _sq31 = intrinsics::saturate_q31(3000000000); - - let src_a = [q15::from_bits(100); 4]; - let src_b = [q15::from_bits(200); 4]; - let mut dst = [q15::ZERO; 4]; - let _dot = intrinsics::simd_dot_prod_q15(&src_a, &src_b); - intrinsics::simd_add_q15(&src_a, &src_b, &mut dst); - intrinsics::simd_sub_q15(&src_a, &src_b, &mut dst); - intrinsics::simd_mult_q15(&src_a, &src_b, &mut dst); - - assert_ne!(lut::fast_sin_i16(1.0), 0); - assert_ne!(lut::fast_cos_i16(1.0), 0); - assert_ne!(lut::sin_q16(10000), 0); - assert_ne!(lut::cos_q16(10000), 0); - - let mut pll = SogiPll::new(50.0, 1000.0, 1.414, 60.0, 1400.0); - assert!(pll.process(1.0).is_finite()); - assert!(pll.frequency_hz().is_finite()); - assert!(pll.phase().is_finite()); - let _ortho = pll.orthogonal_components(); - pll.reset(); - - let mut costas = CostasLoop::new(1000.0, 10000.0, 10.0, 0.707); - let (i_out, q_out) = costas.process_sample(1.0); - assert!(i_out.is_finite() && q_out.is_finite()); - assert!(costas.frequency_hz().is_finite()); - assert!(costas.center_frequency_hz().is_finite()); - - let psd_data = [1.0f32; 128]; - let mut psd_out = [0.0f32; 32]; - assert_eq!( - welch_psd_f32( - &psd_data, - &mut psd_out, - 64, - 32, - 1000.0, - WelchWindow::Hamming, - true - ), - Status::Success - ); - - let mut pgram_out = [0.0f32; 32]; - assert_eq!( - periodogram_f32( - &psd_data[..64], - &mut pgram_out, - 64, - 1000.0, - WelchWindow::Rectangular, - false - ), - Status::Success - ); - - let mut ar_coeffs = [0.0f32; 4]; - if let Ok(noise_var) = ar_burg_f32(&psd_data[..32], 4, &mut ar_coeffs) { - let _ = ar_psd_f32(&ar_coeffs, noise_var, 32, &mut psd_out, false); - } -} diff --git a/crates/embedded-dsp/tests/push_to_95_coverage_b.rs b/crates/embedded-dsp/tests/push_to_95_coverage_b.rs deleted file mode 100644 index 96ec7e2..0000000 --- a/crates/embedded-dsp/tests/push_to_95_coverage_b.rs +++ /dev/null @@ -1,636 +0,0 @@ -use embedded_dsp::pipeline::DspNode; -use embedded_dsp::*; - -#[test] -fn test_math_exhaustive() { - assert_eq!(isqrt_u32(0), 0); - assert_eq!(isqrt_u32(1), 1); - assert_eq!(isqrt_u32(2), 1); - assert_eq!(isqrt_u32(3), 1); - assert_eq!(isqrt_u32(4), 2); - assert_eq!(isqrt_u32(15), 3); - assert_eq!(isqrt_u32(16), 4); - assert_eq!(isqrt_u32(100), 10); - assert_eq!(isqrt_u32(100000), 316); - - assert_eq!(isqrt_u64(0), 0); - assert_eq!(isqrt_u64(1), 1); - assert_eq!(isqrt_u64(2), 1); - assert_eq!(isqrt_u64(3), 1); - assert_eq!(isqrt_u64(4), 2); - assert_eq!(isqrt_u64(15), 3); - assert_eq!(isqrt_u64(16), 4); - assert_eq!(isqrt_u64(100), 10); - assert_eq!(isqrt_u64(1_000_000_000), 31622); - - let x32 = 0.5f32; - assert!(FloatMath::abs(x32).is_finite()); - assert!(FloatMath::sin(x32).is_finite()); - assert!(FloatMath::cos(x32).is_finite()); - assert!(FloatMath::tan(x32).is_finite()); - assert!(FloatMath::sqrt(x32).is_finite()); - assert!(FloatMath::ln(x32).is_finite()); - assert!(FloatMath::log10(x32).is_finite()); - assert!(FloatMath::exp(x32).is_finite()); - assert!(FloatMath::atan2(x32, 1.0f32).is_finite()); - assert!(FloatMath::powf(x32, 2.0f32).is_finite()); - assert!(FloatMath::tanh(x32).is_finite()); - - let x64 = 0.5f64; - assert!(FloatMath::abs(x64).is_finite()); - assert!(FloatMath::sin(x64).is_finite()); - assert!(FloatMath::cos(x64).is_finite()); - assert!(FloatMath::tan(x64).is_finite()); - assert!(FloatMath::sqrt(x64).is_finite()); - assert!(FloatMath::ln(x64).is_finite()); - assert!(FloatMath::log10(x64).is_finite()); - assert!(FloatMath::exp(x64).is_finite()); - assert!(FloatMath::atan2(x64, 1.0f64).is_finite()); - assert!(FloatMath::powf(x64, 2.0f64).is_finite()); - assert!(FloatMath::tanh(x64).is_finite()); -} - -#[test] -fn test_types_and_dspsample_exhaustive() { - assert_eq!( - q15_mult(q15::from_bits(1000), q15::from_bits(2000)).to_bits(), - q15::from_bits(1000) - .saturating_mul(q15::from_bits(2000)) - .to_bits() - ); - assert_eq!( - q31_mult(q31::from_bits(10000), q31::from_bits(20000)).to_bits(), - q31::from_bits(10000) - .saturating_mul(q31::from_bits(20000)) - .to_bits() - ); - assert_eq!( - q7_mult(q7::from_bits(10), q7::from_bits(20)).to_bits(), - q7::from_bits(10) - .saturating_mul(q7::from_bits(20)) - .to_bits() - ); - - // DspSample for f32 - assert_eq!(f32::ZERO, 0.0); - assert_eq!(f32::ONE, 1.0); - assert_eq!(DspSample::sat_add(1.0f32, 2.0f32), 3.0f32); - assert_eq!(DspSample::sat_sub(3.0f32, 1.0f32), 2.0f32); - assert_eq!(DspSample::sat_mul(2.0f32, 3.0f32), 6.0f32); - assert_eq!(DspSample::sat_div(6.0f32, 2.0f32), 3.0f32); - assert_eq!(DspSample::abs_val(-5.0f32), 5.0f32); - assert_eq!(DspSample::abs_val(5.0f32), 5.0f32); - assert_eq!(DspSample::to_f32(4.5f32), 4.5f32); - assert_eq!(::from_f32(4.5f32), 4.5f32); - let _: ::Accum = 0.0f32; - let _: ::Coeff = 0.0f32; - assert_eq!(::madd(0.5, 2.0, 3.0), 6.5); - assert_eq!(::from_accum(6.5), 6.5); - assert_eq!(::coeff_from_f32(0.25), 0.25); - - // DspSample for f64 - assert_eq!(f64::ZERO, 0.0); - assert_eq!(f64::ONE, 1.0); - assert_eq!(DspSample::sat_add(1.0f64, 2.0f64), 3.0f64); - assert_eq!(DspSample::sat_sub(3.0f64, 1.0f64), 2.0f64); - assert_eq!(DspSample::sat_mul(2.0f64, 3.0f64), 6.0f64); - assert_eq!(DspSample::sat_div(6.0f64, 2.0f64), 3.0f64); - assert_eq!(DspSample::abs_val(-5.0f64), 5.0f64); - assert_eq!(DspSample::abs_val(5.0f64), 5.0f64); - assert_eq!(DspSample::to_f32(4.5f64), 4.5f32); - assert_eq!(::from_f32(4.5f32), 4.5f64); - let _: ::Accum = 0.0f64; - let _: ::Coeff = 0.0f64; - assert_eq!(::madd(0.5, 2.0, 3.0), 6.5); - assert_eq!(::from_accum(6.5), 6.5); - assert_eq!(::coeff_from_f32(0.25), 0.25); - - // DspSample for q15 - let a_q15 = q15::from_bits(1000); - let b_q15 = q15::from_bits(500); - assert_eq!( - DspSample::sat_add(a_q15, b_q15), - a_q15.saturating_add(b_q15) - ); - assert_eq!( - DspSample::sat_sub(a_q15, b_q15), - a_q15.saturating_sub(b_q15) - ); - assert_eq!( - DspSample::sat_mul(a_q15, b_q15), - a_q15.saturating_mul(b_q15) - ); - let _div_q15 = DspSample::sat_div(a_q15, b_q15); - let _div_zero15 = DspSample::sat_div(a_q15, q15::ZERO); - let _div_neg_zero15 = DspSample::sat_div(-a_q15, q15::ZERO); - assert_eq!(DspSample::abs_val(-a_q15), a_q15); - let _f_q15 = DspSample::to_f32(a_q15); - let _q15_from_f = ::from_f32(0.5); - - // The Q15 accumulator is wide enough to hold several Q30 products, then narrows once. - let _: ::Accum = 0i64; - let _: ::Coeff = q15::ZERO; - assert_eq!( - ::madd(0, q15::from_bits(1000), q15::from_bits(2000)), - 1000i64 * 2000 - ); - let wide_q15 = ::madd( - ::madd( - ::madd(0, q15::MAX, q15::MAX), - q15::MAX, - q15::MAX, - ), - q15::MAX, - q15::MAX, - ); - assert_eq!(wide_q15, 3 * (32767i64 * 32767)); - assert_eq!(::from_accum(wide_q15), q15::MAX); - assert_eq!(::from_accum(i64::MIN), q15::MIN); - assert_eq!(::from_accum(1 << 15), q15::from_bits(1)); - assert_eq!( - ::coeff_from_f32(0.5), - q15::saturating_from_num(0.5) - ); - assert_eq!(::coeff_from_f32(2.0), q15::MAX); - assert_eq!(::coeff_from_f32(-2.0), q15::MIN); - - // DspSample for q31 - let a_q31 = q31::from_bits(100000); - let b_q31 = q31::from_bits(50000); - assert_eq!( - DspSample::sat_add(a_q31, b_q31), - a_q31.saturating_add(b_q31) - ); - assert_eq!( - DspSample::sat_sub(a_q31, b_q31), - a_q31.saturating_sub(b_q31) - ); - assert_eq!( - DspSample::sat_mul(a_q31, b_q31), - a_q31.saturating_mul(b_q31) - ); - let _div_q31 = DspSample::sat_div(a_q31, b_q31); - let _div_zero31 = DspSample::sat_div(a_q31, q31::ZERO); - let _div_neg_zero31 = DspSample::sat_div(-a_q31, q31::ZERO); - assert_eq!(DspSample::abs_val(-a_q31), a_q31); - let _f_q31 = DspSample::to_f32(a_q31); - let _q31_from_f = ::from_f32(0.5); - - let _: ::Accum = 0i64; - let _: ::Coeff = q31::ZERO; - assert_eq!( - ::madd(0, a_q31, a_q31), - a_q31.to_bits() as i64 * a_q31.to_bits() as i64 - ); - assert_eq!(::from_accum(i64::MAX), q31::MAX); - assert_eq!(::from_accum(i64::MIN), q31::MIN); - assert_eq!(::from_accum(1 << 31), q31::from_bits(1)); - assert_eq!( - ::coeff_from_f32(0.5), - q31::saturating_from_num(0.5) - ); - assert_eq!(::coeff_from_f32(2.0), q31::MAX); - assert_eq!(::coeff_from_f32(-2.0), q31::MIN); - - // Complex operations - let c1 = Complex::new(1.0f32, 2.0f32); - let c2 = Complex::new(3.0f32, 4.0f32); - let c_add = c1 + c2; - let c_sub = c1 - c2; - let c_mul = c1 * c2; - let c_scale = c1 * 2.0f32; - let c_neg = -c1; - assert_eq!(c_add.real, 4.0); - assert_eq!(c_sub.real, -2.0); - assert!(c_mul.real.is_finite()); - assert_eq!(c_scale.real, 2.0); - assert_eq!(c_neg.real, -1.0); -} - -#[test] -fn test_dspsample_stage6_primitives_exhaustive() { - // `sat_neg`: plain negation for floats (preserving signed zero), saturating for fixed widths. - assert_eq!(::sat_neg(1.5), -1.5); - assert!(::sat_neg(0.0).is_sign_negative()); - assert_eq!(::sat_neg(1.5), -1.5); - assert!(::sat_neg(0.0).is_sign_negative()); - assert_eq!( - ::sat_neg(q15::from_bits(i16::MIN)), - q15::from_bits(i16::MAX) - ); - assert_eq!( - ::sat_neg(q15::from_bits(100)), - q15::from_bits(-100) - ); - assert_eq!( - ::sat_neg(q31::from_bits(i32::MIN)), - q31::from_bits(i32::MAX) - ); - assert_eq!( - ::sat_neg(q31::from_bits(100)), - q31::from_bits(-100) - ); - - // `wrapping_madd`: wrap the fixed-point product at native width before widening; identical to - // `madd` for floats since there's nothing to wrap. - assert_eq!(::wrapping_madd(0.5, 2.0, 3.0), 6.5); - assert_eq!(::wrapping_madd(0.5, 2.0, 3.0), 6.5); - assert_eq!( - ::wrapping_madd(0, q15::from_bits(i16::MIN), q15::from_bits(i16::MIN)), - i16::MIN as i64, - "MIN*MIN must wrap, not saturate" - ); - assert_eq!( - ::wrapping_madd(0, q31::from_bits(i32::MIN), q31::from_bits(i32::MIN)), - i32::MIN as i64, - "MIN*MIN must wrap, not saturate" - ); - - // `mul_shifted`: `mul_high` generalized to an explicit shift instead of the fixed `FRAC`. - assert_eq!(::mul_shifted(2.0, 3.0, 5), 6.0); - assert_eq!(::mul_shifted(2.0, 3.0, 5), 6.0); - assert_eq!( - ::mul_shifted(q15::from_bits(1000), q15::from_bits(2000), 17), - (1000i64 * 2000) >> 17 - ); - assert_eq!( - ::mul_shifted(q31::from_bits(1000), q31::from_bits(2000), 33), - (1000i64 * 2000) >> 33 - ); - - // `accum_shift`: shift a value already in the accumulator domain, staying there. - assert_eq!(::accum_shift(6.5, 3), 6.5); - assert_eq!(::accum_shift(6.5, 3), 6.5); - assert_eq!(::accum_shift(1000i64, 3), 1000i64 >> 3); - assert_eq!(::accum_shift(1000i64, 3), 1000i64 >> 3); - - // `coeff_from_q15_bits`: promote a shared Q15-precision twiddle-table entry to this sample's - // native coefficient width. - assert_eq!(::coeff_from_q15_bits(16384), 0.5); - assert_eq!(::coeff_from_q15_bits(16384), 0.5); - assert_eq!( - ::coeff_from_q15_bits(1000), - q15::from_bits(1000) - ); - assert_eq!( - ::coeff_from_q15_bits(1000), - q31::from_bits(1000 << 16) - ); - - // `f64`'s remaining `DspSample` methods (pre-existing since Stage 3, but never directly - // exercised anywhere else in the suite). - assert_eq!(::average_accum(6.0, 3), 2.0); - assert_eq!(::mul_high(2.0, 3.0), 6.0); - assert_eq!(::from_accum_shifted(6.5, 3), 6.5); - assert_eq!(::accum_from_shifted(6.5, 3), 6.5); - assert_eq!(::abs_val(-5.0), 5.0); - assert_eq!(::abs_val(5.0), 5.0); -} - -#[test] -fn test_pid_instance_derived_trait_impls() { - // `PidInstance`'s manual Clone/Copy/Debug/PartialEq/Default (the derive macros can't add - // bounds on associated types like `T::Coeff`, so these are hand-written). - let a = PidInstanceF32::new(1.0, 0.1, 0.01); - let b = a; - #[allow(clippy::clone_on_copy)] - let c = a.clone(); - assert_eq!(a, b); - assert_eq!(a, c); - assert!(format!("{a:?}").contains("PidInstance")); - - let mut d = PidInstanceF32::default(); - assert_ne!(a, d); - d.kp = a.kp; - d.ki = a.ki; - d.kd = a.kd; - d.init(1); - assert_eq!(a, d); - - let e = PidInstanceQ15::new(q15::from_bits(100), q15::from_bits(10), q15::from_bits(1)); - let f = e; - assert_eq!(e, f); - assert!(format!("{e:?}").contains("PidInstance")); - assert_eq!(PidInstanceQ15::default(), PidInstanceQ15::default()); - - let g = PidInstanceQ31::new(q31::from_bits(100), q31::from_bits(10), q31::from_bits(1)); - let h = g; - assert_eq!(g, h); - assert!(format!("{g:?}").contains("PidInstance")); - assert_eq!(PidInstanceQ31::default(), PidInstanceQ31::default()); -} - -#[test] -fn test_resampling_exhaustive() { - let mut cic_dec = CicDecimator::<3>::new(4); - assert!(cic_dec.gain() > 0); - assert!(cic_dec.gain_bits() > 0); - for i in 0..10 { - let _out = cic_dec.process_sample(i * 10); - let _out_s = cic_dec.process_sample_scaled(i * 10); - } - - let mut cic_interp = CicInterpolator::<3>::new(4); - assert!(cic_interp.gain() > 0); - assert!(cic_interp.gain_bits() > 0); - let mut interp_buf = [0i32; 4]; - for i in 0..5 { - cic_interp.process_sample(i * 10, &mut interp_buf); - } - - let coeffs_q15 = [q15::from_bits(4096); 8]; - let src_q15 = [q15::from_bits(1000); 16]; - let mut dst_dec = [q15::ZERO; 8]; - let count_dec = polyphase_decimate_q15(&src_q15, &coeffs_q15, 2, &mut dst_dec); - assert!(count_dec > 0); - - let mut dst_interp = [q15::ZERO; 32]; - let count_interp = polyphase_interpolate_q15(&src_q15, &coeffs_q15, 2, &mut dst_interp); - assert!(count_interp > 0); - - let mut dst_lin_q15 = [q15::ZERO; 32]; - resample_linear_q15(&src_q15, &mut dst_lin_q15, 0x00008000); // 0.5 ratio - - let src_f32 = [1.0f32; 16]; - let mut dst_lin_f32 = [0.0f32; 32]; - resample_linear_f32(&src_f32, &mut dst_lin_f32, 0.5); - - let mut dst_spec = [0.0f32; 32]; - let status_spec = spectral_interpolate_2x_f32(&src_f32, &mut dst_spec); - assert_eq!(status_spec, Status::Success); -} - -#[test] -fn test_spatial_exhaustive() { - let src_img = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]; - let mut dst_img = [0.0f32; 9]; - assert_eq!(dct2d_f32(&src_img, &mut dst_img, 3, 3), Status::Success); - let mut recovered = [0.0f32; 9]; - assert_eq!(idct2d_f32(&dst_img, &mut recovered, 3, 3), Status::Success); - - let kernel = [0.0f32, 1.0, 0.0, 1.0, -4.0, 1.0, 0.0, 1.0, 0.0]; - let mut convolved = [0.0f32; 9]; - assert_eq!( - convolve2d_f32(&src_img, &mut convolved, 3, 3, &kernel, 3, 3, true), - Status::Success - ); - // `normalize = false` skips the kernel-weight-sum normalization branch. - assert_eq!( - convolve2d_f32(&src_img, &mut convolved, 3, 3, &kernel, 3, 3, false), - Status::Success - ); - - let mut nonlin_out = [0.0f32; 9]; - assert_eq!( - nonlin2d_filter_f32(&src_img, &mut nonlin_out, 3, 3, 3, NonlinFilterType::Min), - Status::Success - ); - assert_eq!( - nonlin2d_filter_f32(&src_img, &mut nonlin_out, 3, 3, 3, NonlinFilterType::Max), - Status::Success - ); - assert_eq!( - nonlin2d_filter_f32(&src_img, &mut nonlin_out, 3, 3, 3, NonlinFilterType::Median), - Status::Success - ); - // Descending image reorders where the extrema land relative to the kernel tap-visitation - // order, exercising the "found a new min/max after the first tap" branches. - let src_img_desc = [9.0f32, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0]; - assert_eq!( - nonlin2d_filter_f32( - &src_img_desc, - &mut nonlin_out, - 3, - 3, - 3, - NonlinFilterType::Min - ), - Status::Success - ); - assert_eq!( - nonlin2d_filter_f32( - &src_img_desc, - &mut nonlin_out, - 3, - 3, - 3, - NonlinFilterType::Max - ), - Status::Success - ); - // ArgumentError: even k_size. - assert_eq!( - nonlin2d_filter_f32(&src_img, &mut nonlin_out, 3, 3, 2, NonlinFilterType::Min), - Status::ArgumentError - ); - // LengthError: declared dims exceed what the buffers hold. - assert_eq!( - nonlin2d_filter_f32( - &src_img[..4], - &mut nonlin_out, - 3, - 3, - 3, - NonlinFilterType::Min - ), - Status::LengthError - ); - - let mut edges = [0.0f32; 9]; - assert_eq!( - sobel_edge_detection_f32(&src_img, &mut edges, 3, 3, 2.0), - Status::Success - ); - // LengthError: declared dims exceed what the buffers hold. - assert_eq!( - sobel_edge_detection_f32(&src_img[..4], &mut edges, 3, 3, 2.0), - Status::LengthError - ); - - let mut hist_bins = [0usize; 5]; - assert_eq!( - histogram_2d_f32(&src_img, &mut hist_bins, 0.0, 10.0), - Status::Success - ); - - let mse = mse_2d_f32(&src_img, &recovered); - assert!(mse.is_finite()); - - let psnr = psnr_2d_f32(&src_img, &recovered, 9.0); - assert!(psnr.is_finite()); -} - -#[test] -fn test_fast_math_and_dynamics_exhaustive() { - assert!(fast_exp_f32(1.0).is_finite()); - assert!(fast_tanh_f32(1.0).is_finite()); - assert!(log_f32(2.0).is_finite()); - assert!(exp_f32(1.0).is_finite()); - - let mut s_f32 = 0.0f32; - let mut c_f32 = 0.0f32; - fast_math::sin_cos_f32(45.0, &mut s_f32, &mut c_f32); - assert_ne!(fast_math::sin_f32(1.0), 0.0); - assert_ne!(fast_math::cos_f32(1.0), 0.0); - - let mut s_q31 = q31::ZERO; - let mut c_q31 = q31::ZERO; - fast_math::sin_cos_q31(q31::from_bits(10000), &mut s_q31, &mut c_q31); - assert_ne!(fast_math::sin_q31(q31::from_bits(10000)).to_bits(), 0); - assert_ne!(fast_math::cos_q31(q31::from_bits(10000)).to_bits(), 0); - - // LUT negative inputs - assert_ne!(lut::fast_sin_i16(-1.0), 0); - assert_ne!(lut::fast_cos_i16(-1.0), 0); - assert_ne!(lut::sin_q16(-10000), 0); - assert_ne!(lut::cos_q16(-10000), 0); - - // CORDIC / Atan2 all 4 quadrants - let mut res_f32 = 0.0f32; - assert_eq!(atan2_f32(1.0, 1.0, &mut res_f32), Status::Success); - - let mut res_q31 = q31::ZERO; - assert_eq!( - atan2_q31(q31::from_bits(10000), q31::from_bits(10000), &mut res_q31), - Status::Success - ); - assert_eq!( - atan2_q31(q31::from_bits(10000), q31::from_bits(-10000), &mut res_q31), - Status::Success - ); - assert_eq!( - atan2_q31(q31::from_bits(-10000), q31::from_bits(10000), &mut res_q31), - Status::Success - ); - assert_eq!( - atan2_q31(q31::from_bits(-10000), q31::from_bits(-10000), &mut res_q31), - Status::Success - ); - assert_eq!( - atan2_q31(q31::ZERO, q31::ZERO, &mut res_q31), - Status::Success - ); - assert_eq!( - atan2_q31(q31::from_bits(10000), q31::ZERO, &mut res_q31), - Status::Success - ); - - let mut res_q15 = q15::ZERO; - assert_eq!( - atan2_q15(q15::from_bits(1000), q15::from_bits(1000), &mut res_q15), - Status::Success - ); - assert_eq!( - atan2_q15(q15::from_bits(1000), q15::from_bits(-1000), &mut res_q15), - Status::Success - ); - assert_eq!( - atan2_q15(q15::from_bits(-1000), q15::from_bits(1000), &mut res_q15), - Status::Success - ); - assert_eq!( - atan2_q15(q15::from_bits(-1000), q15::from_bits(-1000), &mut res_q15), - Status::Success - ); - - let mut out_f32 = 0.0f32; - assert_eq!(sqrt_f32(4.0, &mut out_f32), Status::Success); - assert_eq!(sqrt_f32(-1.0, &mut out_f32), Status::ArgumentError); - - let mut out_q31 = q31::ZERO; - assert_eq!( - sqrt_q31(q31::from_bits(1000000), &mut out_q31), - Status::Success - ); - assert_eq!( - sqrt_q31(q31::from_bits(-100), &mut out_q31), - Status::ArgumentError - ); - - let mut out_q15 = q15::ZERO; - assert_eq!( - sqrt_q15(q15::from_bits(10000), &mut out_q15), - Status::Success - ); - assert_eq!( - sqrt_q15(q15::from_bits(-100), &mut out_q15), - Status::ArgumentError - ); - - let src_v = [1.0f32, 4.0, 9.0, 16.0]; - let mut dst_v = [0.0f32; 4]; - vsqrt_f32(&src_v, &mut dst_v); - - let mut quot_q31 = q31::ZERO; - let mut shift_q31 = 0i16; - assert_eq!( - divide_q31( - q31::from_bits(5000), - q31::from_bits(10000), - &mut quot_q31, - &mut shift_q31 - ), - Status::Success - ); - assert_eq!( - divide_q31( - q31::from_bits(5000), - q31::ZERO, - &mut quot_q31, - &mut shift_q31 - ), - Status::ArgumentError - ); - - let mut quot_q15 = q15::ZERO; - let mut shift_q15 = 0i16; - assert_eq!( - divide_q15( - q15::from_bits(500), - q15::from_bits(1000), - &mut quot_q15, - &mut shift_q15 - ), - Status::Success - ); - assert_eq!( - divide_q15( - q15::from_bits(500), - q15::ZERO, - &mut quot_q15, - &mut shift_q15 - ), - Status::ArgumentError - ); - - // SIMD odd lengths (remainder loops) - let src_odd_a = [q15::from_bits(100); 5]; - let src_odd_b = [q15::from_bits(200); 5]; - let mut dst_odd = [q15::ZERO; 5]; - let _dot_odd = intrinsics::simd_dot_prod_q15(&src_odd_a, &src_odd_b); - intrinsics::simd_add_q15(&src_odd_a, &src_odd_b, &mut dst_odd); - intrinsics::simd_sub_q15(&src_odd_a, &src_odd_b, &mut dst_odd); - intrinsics::simd_mult_q15(&src_odd_a, &src_odd_b, &mut dst_odd); - - // SafetyLimiter - let mut limiter = SafetyLimiter::new(0.9, 0.01, 16000.0); - assert!(limiter.process(0.5).is_finite()); - assert!(limiter.process(1.5).is_finite()); - assert!(limiter.process_sample(0.2).is_finite()); - assert!(limiter.current_gain() <= 1.0); - limiter.reset(); - - // DynamicsCompressor - let mut comp = DynamicsCompressor::new(-10.0, 4.0, 6.0, 0.001, 0.05, 3.0, 16000.0); - assert!(comp.process(0.1).is_finite()); - assert!(comp.process(0.8).is_finite()); - assert!(comp.process_sample(0.8).is_finite()); - comp.reset(); - - // NoiseGate - let mut gate = NoiseGate::new(-40.0, -30.0, 0.001, 0.05, 16000.0); - assert!(gate.process(0.001).is_finite()); - assert!(gate.process(0.5).is_finite()); - assert!(gate.process_sample(0.5).is_finite()); - gate.reset(); -} diff --git a/crates/embedded-dsp/tests/push_to_95_coverage_c.rs b/crates/embedded-dsp/tests/push_to_95_coverage_c.rs deleted file mode 100644 index a88f831..0000000 --- a/crates/embedded-dsp/tests/push_to_95_coverage_c.rs +++ /dev/null @@ -1,194 +0,0 @@ -use embedded_dsp::*; - -#[test] -fn test_transforms_inverse_and_flags() { - let mut data_q31 = [q31::from_bits(10000); 16]; - cfft_q31(&mut data_q31, 8, 1, 0); - cfft_q31(&mut data_q31, 8, 1, 1); - cfft_q31(&mut data_q31, 8, 0, 0); - - let mut data_q15 = [q15::from_bits(1000); 16]; - cfft_q15(&mut data_q15, 8, 1, 0); - cfft_q15(&mut data_q15, 8, 1, 1); - cfft_q15(&mut data_q15, 8, 0, 0); - - let mut bfp_q31 = [q31::from_bits(10000); 16]; - let _s_q31_1 = cfft_bfp_q31(&mut bfp_q31, 8, 1, 0); - let _s_q31_2 = cfft_bfp_q31(&mut bfp_q31, 8, 1, 1); - - let mut bfp_q15 = [q15::from_bits(1000); 16]; - let _s_q15_1 = cfft_bfp_q15(&mut bfp_q15, 8, 1, 0); - let _s_q15_2 = cfft_bfp_q15(&mut bfp_q15, 8, 1, 1); - - let src_q31 = [q31::from_bits(5000); 8]; - let mut dst_q31_a = [q31::ZERO; 16]; - let mut dst_q31_b = [q31::ZERO; 8]; - rfft_q31(&src_q31, &mut dst_q31_a, 8, 0); // packed_rfft_q31_forward - irfft_q31(&dst_q31_a, &mut dst_q31_b, 8); // packed_irfft_q31 - rfft_q31(&src_q31, &mut dst_q31_a, 8, 1); // fallback unpack branch - - let src_q15 = [q15::from_bits(500); 8]; - let mut dst_q15_a = [q15::ZERO; 16]; - let mut dst_q15_b = [q15::ZERO; 8]; - rfft_q15(&src_q15, &mut dst_q15_a, 8, 0); // packed_rfft_q15_forward - irfft_q15(&dst_q15_a, &mut dst_q15_b, 8); // packed_irfft_q15 - rfft_q15(&src_q15, &mut dst_q15_a, 8, 1); // fallback unpack branch - - let mut wht_f32 = [1.0f32; 8]; - assert_eq!(ifwht_f32(&mut wht_f32), Status::Success); - - let mut wht_i32 = [10i32; 8]; - assert_eq!(fwht_i32(&mut wht_i32), Status::Success); - - let mut haar_f32 = [1.0f32; 8]; - assert_eq!(haar_transform_f32(&mut haar_f32), Status::Success); - assert_eq!(inverse_haar_transform_f32(&mut haar_f32), Status::Success); - - let mut haar_i32 = [10i32; 8]; - assert_eq!(haar_transform_i32(&mut haar_i32), Status::Success); - - let mut hartley = [1.0f32; 8]; - assert_eq!(hartley_transform_f32(&mut hartley), Status::Success); - - let db4_h = [0.482_962_9, 0.836_516_3, 0.224_143_86, -0.129_409_52]; - let mut wav_data = [1.0f32; 8]; - assert_eq!(wavelet_step_f32(&mut wav_data, 8, &db4_h), Status::Success); - assert_eq!( - inverse_wavelet_step_f32(&mut wav_data, 8, &db4_h), - Status::Success - ); - assert_eq!( - wavelet_transform_f32(&mut wav_data, &db4_h), - Status::Success - ); - assert_eq!( - inverse_wavelet_transform_f32(&mut wav_data, &db4_h), - Status::Success - ); -} - -#[test] -fn test_transforms_error_branches() { - let mut bad_buf = [0.0f32; 3]; - assert_ne!(fwht_f32(&mut bad_buf), Status::Success); - assert_ne!(ifwht_f32(&mut bad_buf), Status::Success); - assert_ne!(haar_transform_f32(&mut bad_buf), Status::Success); - assert_ne!(inverse_haar_transform_f32(&mut bad_buf), Status::Success); - assert_ne!(hartley_transform_f32(&mut bad_buf), Status::Success); - - let mut bad_buf_i32 = [0i32; 3]; - assert_ne!(fwht_i32(&mut bad_buf_i32), Status::Success); - assert_ne!(haar_transform_i32(&mut bad_buf_i32), Status::Success); - - let db4_h = [0.482_962_9, 0.836_516_3, 0.224_143_86, -0.129_409_52]; - assert_ne!(wavelet_step_f32(&mut bad_buf, 3, &db4_h), Status::Success); - assert_ne!( - inverse_wavelet_step_f32(&mut bad_buf, 3, &db4_h), - Status::Success - ); - assert_ne!(wavelet_transform_f32(&mut bad_buf, &db4_h), Status::Success); - assert_ne!( - inverse_wavelet_transform_f32(&mut bad_buf, &db4_h), - Status::Success - ); - - let mut cep_out = [0.0f32; 2]; - assert_ne!(real_cepstrum_f32(&bad_buf, &mut cep_out), Status::Success); - - let src_short = [q31::ZERO; 2]; - let mut dst_short = [q31::ZERO; 2]; - irfft_q31(&src_short, &mut dst_short, 8); - let src_short_q15 = [q15::ZERO; 2]; - let mut dst_short_q15 = [q15::ZERO; 2]; - irfft_q15(&src_short_q15, &mut dst_short_q15, 8); -} - -#[test] -fn test_types_fixed_and_enums() { - let q = q15::from_bits(1000); - assert_eq!(q15::ZERO.to_bits(), 0); - assert_eq!(q15::MIN.to_bits(), i16::MIN); - assert_eq!(q15::MAX.to_bits(), i16::MAX); - assert_eq!(q.to_bits(), 1000); - - let _q_sat_add = q.saturating_add(q15::from_bits(500)); - let _q_sat_sub = q.saturating_sub(q15::from_bits(500)); - let _q_sat_mul = q.saturating_mul(q15::from_bits(500)); - let _q_sat_neg = q.saturating_neg(); - let _q_sat_abs = q.saturating_abs(); - let _q_abs = q.abs(); - let _q_wrap_add = q.wrapping_add(q15::from_bits(500)); - let _q_wrap_sub = q.wrapping_sub(q15::from_bits(500)); - let _q_wrap_neg = q.wrapping_neg(); - let _q_wrap_mul = q.wrapping_mul(q15::from_bits(500)); - let _q_wrap_mul_int = q.wrapping_mul_int(2); - let _q_chk_div = q.checked_div(q15::from_bits(500)); - let _q_chk_div_zero = q.checked_div(q15::ZERO); - - // Status enum variants - assert_eq!(Status::Success as i8, 0); - assert_eq!(Status::ArgumentError as i8, -1); - assert_eq!(Status::LengthError as i8, -2); - assert_eq!(Status::SizeMismatch as i8, -3); - assert_eq!(Status::NanInf as i8, -4); - assert_eq!(Status::Singular as i8, -5); - assert_eq!(Status::TestFailure as i8, -6); - assert_eq!(Status::DecompositionFailure as i8, -7); -} - -#[test] -fn test_windows_and_statistics_extra() { - let mut win_buf = [0.0f32; 16]; - hanning_f32(&mut win_buf); - hamming_f32(&mut win_buf); - blackman_f32(&mut win_buf); - blackman_harris_f32(&mut win_buf); - bartlett_f32(&mut win_buf); - welch_f32(&mut win_buf); - flattop_f32(&mut win_buf); - kaiser_f32(&mut win_buf, 5.0); - apply_window_f32(&mut win_buf, &[1.0f32; 16]); - - let mut q15_win = [q15::ZERO; 16]; - hanning_q15(&mut q15_win); - hamming_q15(&mut q15_win); - blackman_q15(&mut q15_win); - bartlett_q15(&mut q15_win); - apply_window_q15(&mut q15_win, &[q15::from_bits(1000); 16]); - - let src_q7 = [q7::from_bits(10); 8]; - let mut q7_res = q7::ZERO; - let mut idx = 0usize; - assert_eq!(mean_q7(&src_q7, &mut q7_res), Status::Success); - assert_eq!(var_q7(&src_q7, &mut q7_res), Status::Success); - assert_eq!(std_q7(&src_q7, &mut q7_res), Status::Success); - assert_eq!(min_q7(&src_q7, &mut q7_res, &mut idx), Status::Success); - assert_eq!(max_q7(&src_q7, &mut q7_res, &mut idx), Status::Success); - - let data = [0.1f32, 0.2, 0.3, 0.4]; - assert!(entropy_f32(&data).is_finite()); - assert!(kullback_leibler_f32(&data, &data).is_finite()); - assert!(logsumexp_f32(&data).is_finite()); - - let mut f_res = 0.0f32; - assert_eq!(absmax_f32(&data, &mut f_res, &mut idx), Status::Success); - assert_eq!(absmin_f32(&data, &mut f_res, &mut idx), Status::Success); -} - -#[test] -fn test_matrix_extra() { - let data_a = [1.0f32, 2.0, 3.0, 4.0]; - let data_b = [5.0f32, 6.0, 7.0, 8.0]; - let mut data_out = [0.0f32; 4]; - - let mat_a = MatrixInstance::new(2, 2, &data_a); - let mat_b = MatrixInstance::new(2, 2, &data_b); - let mut mat_out = MatrixInstanceMut::new(2, 2, &mut data_out); - - assert_eq!(mat_add_f32(&mat_a, &mat_b, &mut mat_out), Status::Success); - assert_eq!(mat_sub_f32(&mat_a, &mat_b, &mut mat_out), Status::Success); - assert_eq!(mat_scale_f32(&mat_a, 2.0, &mut mat_out), Status::Success); - assert_eq!(mat_mult_f32(&mat_a, &mat_b, &mut mat_out), Status::Success); - assert_eq!(mat_trans_f32(&mat_a, &mut mat_out), Status::Success); - assert_eq!(mat_inverse_f32(&mat_a, &mut mat_out), Status::Success); -} diff --git a/crates/embedded-dsp/tests/push_to_95_coverage_d.rs b/crates/embedded-dsp/tests/push_to_95_coverage_d.rs deleted file mode 100644 index 5c86427..0000000 --- a/crates/embedded-dsp/tests/push_to_95_coverage_d.rs +++ /dev/null @@ -1,136 +0,0 @@ -use embedded_dsp::pipeline::*; -use embedded_dsp::*; - -#[test] -fn test_filter_analysis_exhaustive() { - let coeffs = [0.1f32, 0.2, 0.3, 0.4, 0.5]; - let freq = 0.1f32; - let resp = biquad_frequency_response(&coeffs, freq); - assert!(response_magnitude(resp).is_finite()); - assert!(response_magnitude_db(resp).is_finite()); - assert!(response_phase(resp).is_finite()); - - let fir_taps = [0.1f32, 0.2, 0.3, 0.4]; - let fir_resp = fir_frequency_response(&fir_taps, freq); - assert!(response_magnitude(fir_resp).is_finite()); - - let cascade_coeffs = [0.1f32, 0.2, 0.3, 0.4, 0.5, 0.1, 0.2, 0.3, 0.4, 0.5]; - let cascade_resp = biquad_cascade_frequency_response(&cascade_coeffs, freq); - assert!(response_magnitude(cascade_resp).is_finite()); - - assert!(fir_group_delay(&fir_taps, freq).is_finite()); - assert!(biquad_pole_radius(&coeffs).is_finite()); - assert!(biquad_is_stable(&coeffs)); - assert!(biquad_cascade_is_stable(&cascade_coeffs)); - - assert!(biquad_peak_gain(&coeffs, 32).is_finite()); - assert!(biquad_l2_norm(&coeffs, 32).is_finite()); - - let (headroom, gain) = estimate_biquad_headroom_bits(&coeffs); - assert!(gain.is_finite()); - assert!(headroom <= 32); - - let biquad_q15_resp = biquad_q15_frequency_response(&[q15::from_bits(1000); 5], 0, freq); - assert!(response_magnitude(biquad_q15_resp).is_finite()); - - let snr_biquad = biquad_quantization_snr_db(&coeffs, &[q15::from_bits(1000); 5], 0, 32); - assert!(snr_biquad.is_finite()); - - let fir_taps_q15 = [q15::from_bits(1000); 4]; - let snr_fir = fir_quantization_snr_db(&fir_taps, &fir_taps_q15, 32); - assert!(snr_fir.is_finite()); -} - -#[test] -fn test_const_generics_exhaustive() { - let fir_taps = [0.1f32, 0.2, 0.3, 0.4]; - let mut fir = FirFilter::<4>::new(fir_taps); - let src = [1.0f32; 8]; - let mut dst = [0.0f32; 8]; - fir.process(&src, &mut dst); - fir.reset(); - - let biquad_coeffs = [0.1f32, 0.2, 0.3, 0.4, 0.5]; - let mut biquad = BiquadCascade::<5, 4>::new(biquad_coeffs); - biquad.process(&src, &mut dst); - biquad.reset(); - - let fir_taps_q15 = [q15::from_bits(1000); 4]; - let mut fir_q15 = FirFilterQ15::<4>::new(fir_taps_q15); - let src_q15 = [q15::from_bits(1000); 8]; - let mut dst_q15 = [q15::ZERO; 8]; - fir_q15.process(&src_q15, &mut dst_q15); - fir_q15.reset(); - - let biquad_coeffs_q15 = [q15::from_bits(1000); 5]; - let mut biquad_q15 = BiquadCascadeQ15::<5, 4>::new(biquad_coeffs_q15, 0); - biquad_q15.process(&src_q15, &mut dst_q15); - biquad_q15.reset(); - - let m1 = Matrix::<2, 2, 4>::new([1.0, 2.0, 3.0, 4.0]); - let m2 = Matrix::<2, 2, 4>::new([5.0, 6.0, 7.0, 8.0]); - let _m_add = m1.add(&m2); - let _m_sub = m1.sub(&m2); - let _m_scale = m1.scale(2.0); - let _m_trans = m1.transpose(); - let _m_mul = m1.mul_mat::<2, 4, 4>(&m2); -} - -#[test] -fn test_quaternion_exhaustive() { - let mut q = [1.0f32, 2.0, 3.0, 4.0]; - assert!(quaternion_norm_f32(&q) > 0.0); - assert_eq!(quaternion_normalize_f32(&mut q), Status::Success); - - let q1 = [1.0f32, 0.0, 0.0, 0.0]; - let q2 = [0.0f32, 1.0, 0.0, 0.0]; - let mut out = [0.0f32; 4]; - quaternion_product_f32(&q1, &q2, &mut out); - quaternion_conjugate_f32(&q1, &mut out); - assert_eq!(quaternion_inverse_f32(&q1, &mut out), Status::Success); - - let mut rot_mat = [0.0f32; 9]; - quaternion_to_rotmat_f32(&q, &mut rot_mat); - - // Error branches - let mut q_zero = [0.0f32; 4]; - assert_eq!(quaternion_normalize_f32(&mut q_zero), Status::ArgumentError); - assert_eq!( - quaternion_inverse_f32(&q_zero, &mut out), - Status::ArgumentError - ); -} - -#[test] -fn test_pipeline_nodes_exhaustive() { - let mut gain_i16 = Gain::::new(16384); - assert_eq!(gain_i16.process_sample(1000i16), 500i16); - - let mut gain_i32 = Gain::::new(1073741824); - let _g_i32 = gain_i32.process_sample(1000i32); - - let mut limiter = Limiter::new(-1.0f32, 1.0f32); - let mut block_in = [0.5f32, 1.5, -2.0]; - let mut block_out = [0.0f32; 3]; - limiter.process_block(&block_in, &mut block_out); - limiter.process_in_place(&mut block_in); - - let mut pid_f32 = PidInstanceF32::new(1.0, 0.1, 0.01); - assert!(pid_f32.process_sample(1.0).is_finite()); - - let mut pid_q15 = PidInstanceQ15::new( - q15::from_bits(1000), - q15::from_bits(100), - q15::from_bits(10), - ); - let _p_q15 = pid_q15.process_sample(q15::from_bits(500)); - - let mut filter_f32 = SinglePoleFilter::::lowpass(0.1); - assert!(filter_f32.process_sample(1.0).is_finite()); - - let mut filter_q15 = SinglePoleFilter::::lowpass(q15::from_bits(3000)); - let _f_q15 = filter_q15.process_sample(q15::from_bits(1000)); - - let mut dc_blocker = DcBlockerQ15::new(q15::from_bits(32000)); - let _dc_out = dc_blocker.process_sample(q15::from_bits(1000)); -} diff --git a/crates/embedded-dsp/tests/push_to_95_coverage_e.rs b/crates/embedded-dsp/tests/push_to_95_coverage_e.rs deleted file mode 100644 index f964c0d..0000000 --- a/crates/embedded-dsp/tests/push_to_95_coverage_e.rs +++ /dev/null @@ -1,146 +0,0 @@ -use embedded_dsp::*; - -#[test] -fn test_square_root_kalman_filter_exhaustive() { - let x0 = [0.0f32, 0.0]; - let s0 = [[1.0f32, 0.0], [0.0, 1.0]]; - let f = [[1.0f32, 1.0], [0.0, 1.0]]; - let s_q = [[0.1f32, 0.0], [0.0, 0.1]]; - let h = [[1.0f32, 0.0]]; - let s_r = [[0.5f32]]; - - let mut sr_kf = SquareRootKalmanFilter::<2, 1>::new(x0, s0, f, s_q, h, s_r); - sr_kf.predict(); - let status = sr_kf.update(&[1.0f32]); - assert_eq!(status, Status::Success); - - let cov = sr_kf.covariance(); - assert!(cov[0][0] > 0.0); -} - -#[test] -fn test_psd_error_branches_and_db() { - let src = [1.0f32; 128]; - let mut dst = [0.0f32; 32]; - - // Invalid arguments for welch_psd_f32 - assert_eq!( - welch_psd_f32( - &src, - &mut dst, - 3, - 0, - 1000.0, - WelchWindow::Rectangular, - false - ), - Status::ArgumentError - ); - assert_eq!( - welch_psd_f32( - &src, - &mut dst, - 64, - 64, - 1000.0, - WelchWindow::Rectangular, - false - ), - Status::ArgumentError - ); - assert_eq!( - welch_psd_f32( - &src, - &mut dst, - 64, - 16, - -100.0, - WelchWindow::Rectangular, - false - ), - Status::ArgumentError - ); - assert_eq!( - welch_psd_f32( - &src[..10], - &mut dst, - 64, - 16, - 1000.0, - WelchWindow::Rectangular, - false - ), - Status::LengthError - ); - - // ar_burg_f32 invalid arguments - let mut ar_coeffs = [0.0f32; 4]; - assert_eq!( - ar_burg_f32(&src[..4], 4, &mut ar_coeffs), - Err(Status::ArgumentError) - ); - assert_eq!( - ar_burg_f32(&src, 0, &mut ar_coeffs), - Err(Status::ArgumentError) - ); - - // ar_psd_f32 db and linear - if let Ok(noise_var) = ar_burg_f32(&src[..32], 4, &mut ar_coeffs) { - assert_eq!( - ar_psd_f32(&ar_coeffs, noise_var, 32, &mut dst, true), - Status::Success - ); - assert_eq!( - ar_psd_f32(&ar_coeffs, noise_var, 32, &mut dst, false), - Status::Success - ); - } -} - -#[test] -fn test_quantization_and_scaling_strategies() { - let sos_f32 = [0.1f32, 0.2, 0.3, 0.4, 0.5]; - let mut q15_out = [q15::ZERO; 5]; - let mut q31_out = [q31::ZERO; 5]; - - assert!( - biquad_quantize_and_scale_q15(&sos_f32, &mut q15_out, ScalingStrategy::LInfNorm).is_ok() - ); - assert!(biquad_quantize_and_scale_q15(&sos_f32, &mut q15_out, ScalingStrategy::L2Norm).is_ok()); - assert!(biquad_quantize_and_scale_q15(&sos_f32, &mut q15_out, ScalingStrategy::Direct).is_ok()); - - assert!( - biquad_quantize_and_scale_q31(&sos_f32, &mut q31_out, ScalingStrategy::LInfNorm).is_ok() - ); - assert!(biquad_quantize_and_scale_q31(&sos_f32, &mut q31_out, ScalingStrategy::L2Norm).is_ok()); - assert!(biquad_quantize_and_scale_q31(&sos_f32, &mut q31_out, ScalingStrategy::Direct).is_ok()); - - let taps_f32 = [0.1f32, 0.2, 0.3, 0.4]; - let mut taps_q15 = [q15::ZERO; 4]; - assert!(fir_quantize_q15(&taps_f32, &mut taps_q15).is_ok()); - - // Error length tests - let mut bad_q15 = [q15::ZERO; 4]; - assert_eq!( - biquad_quantize_and_scale_q15(&sos_f32, &mut bad_q15, ScalingStrategy::Direct), - Err(Status::LengthError) - ); - assert_eq!( - fir_quantize_q15(&taps_f32, &mut bad_q15[..2]), - Err(Status::LengthError) - ); - - // More than 26 biquad stages (130 elements) overflows the internal 128-element scratch - // buffer, an ArgumentError distinct from the length-mismatch case above. - let big_sos = [0.1f32; 130]; - let mut big_q15 = [q15::ZERO; 130]; - let mut big_q31 = [q31::ZERO; 130]; - assert_eq!( - biquad_quantize_and_scale_q15(&big_sos, &mut big_q15, ScalingStrategy::Direct), - Err(Status::ArgumentError) - ); - assert_eq!( - biquad_quantize_and_scale_q31(&big_sos, &mut big_q31, ScalingStrategy::Direct), - Err(Status::ArgumentError) - ); -} diff --git a/crates/embedded-dsp/tests/snapshot_tests.rs b/crates/embedded-dsp/tests/snapshot.rs similarity index 83% rename from crates/embedded-dsp/tests/snapshot_tests.rs rename to crates/embedded-dsp/tests/snapshot.rs index 7e64f22..4fb3e21 100644 --- a/crates/embedded-dsp/tests/snapshot_tests.rs +++ b/crates/embedded-dsp/tests/snapshot.rs @@ -1,5 +1,8 @@ +//! Consolidated tests: snapshot_tests. + use embedded_dsp::*; +// ─── from snapshot_tests.rs ──────────────────────────────────────── #[test] fn test_snapshot_buffer_push_and_reset() { let mut snap = SnapshotBuffer::<4>::new(); diff --git a/crates/embedded-dsp/tests/split_process_biquad_quickcheck.rs b/crates/embedded-dsp/tests/split_process_quickcheck.rs similarity index 95% rename from crates/embedded-dsp/tests/split_process_biquad_quickcheck.rs rename to crates/embedded-dsp/tests/split_process_quickcheck.rs index 871f376..56e0b2a 100644 --- a/crates/embedded-dsp/tests/split_process_biquad_quickcheck.rs +++ b/crates/embedded-dsp/tests/split_process_quickcheck.rs @@ -1,9 +1,12 @@ use embedded_dsp::filtering::{ - Biquad, BiquadClamp, BiquadFixed, DirectForm1, DirectForm1NoiseShaped, Dsm, XorShift32, + Biquad, BiquadClamp, BiquadFixed, DirectForm1, DirectForm1NoiseShaped, }; use embedded_dsp::pipeline::{Lanes, Pair, Process, Split, SplitProcess}; +// `Dsm`/`XorShift32` live in their dedicated modules and are re-exported at the +// crate root; `filtering` no longer carries a duplicate copy of either. #[cfg(feature = "bytemuck")] use embedded_dsp::types::Complex; +use embedded_dsp::{Dsm, XorShift32}; use quickcheck_macros::quickcheck; #[test] @@ -77,13 +80,13 @@ fn clamp_node() -> BiquadClamp { #[test] fn test_dsm_delta_sigma_mean() { - let mut dsm = Dsm::<3>::new(); + let mut dsm = Dsm::<3>::default(); // 0x4000_0000 is 1/4 of full scale (2^32) let x: u32 = 0x4000_0000; let n = 1 << 16; let mut sum: i64 = 0; for _ in 0..n { - sum += dsm.process_sample(x) as i64; + sum += dsm.process(x) as i64; } let mean = sum as f64 / n as f64; // Expected average is 0.25, with high precision over 65536 samples diff --git a/crates/embedded-dsp/tests/stats_window_matrix_coverage.rs b/crates/embedded-dsp/tests/statistics_and_windows.rs similarity index 97% rename from crates/embedded-dsp/tests/stats_window_matrix_coverage.rs rename to crates/embedded-dsp/tests/statistics_and_windows.rs index f16b97d..0be811d 100644 --- a/crates/embedded-dsp/tests/stats_window_matrix_coverage.rs +++ b/crates/embedded-dsp/tests/statistics_and_windows.rs @@ -1,10 +1,4 @@ -//! Coverage for the guard/error branches of the `statistics`, `window`, and -//! `matrix` modules. -//! -//! These modules validate their inputs (`Status::LengthError`, -//! `Status::SizeMismatch`, ...) and short-circuit degenerate lengths. The -//! happy paths were already exercised; these tests pin down the rejection -//! paths and the min/abs-min index tracking branches. +//! Consolidated tests: stats_window_matrix_coverage. use embedded_dsp::matrix::{ MatrixInstance, MatrixInstanceMut, mat_add_f32, mat_add_q15, mat_add_q31, mat_inverse_f32, @@ -24,6 +18,7 @@ use embedded_dsp::window::{ welch_f32, }; +// ─── from stats_window_matrix_coverage.rs ──────────────────────────────────────── // ───────────────────────────────────────────────────────────────────────────── // statistics: empty-input rejection // ───────────────────────────────────────────────────────────────────────────── diff --git a/crates/embedded-dsp/tests/stats_interp_distance_complex_coverage.rs b/crates/embedded-dsp/tests/stats_interp_distance_complex.rs similarity index 100% rename from crates/embedded-dsp/tests/stats_interp_distance_complex_coverage.rs rename to crates/embedded-dsp/tests/stats_interp_distance_complex.rs diff --git a/crates/embedded-dsp/tests/swept_sine_inverse_filter.rs b/crates/embedded-dsp/tests/swept_sine_inverse.rs similarity index 100% rename from crates/embedded-dsp/tests/swept_sine_inverse_filter.rs rename to crates/embedded-dsp/tests/swept_sine_inverse.rs diff --git a/crates/embedded-dsp/tests/synthesis_tests.rs b/crates/embedded-dsp/tests/synthesis.rs similarity index 95% rename from crates/embedded-dsp/tests/synthesis_tests.rs rename to crates/embedded-dsp/tests/synthesis.rs index 8cbe961..c1b23cc 100644 --- a/crates/embedded-dsp/tests/synthesis_tests.rs +++ b/crates/embedded-dsp/tests/synthesis.rs @@ -1,5 +1,8 @@ +//! Consolidated tests: synthesis_tests. + use embedded_dsp::*; +// ─── from synthesis_tests.rs ──────────────────────────────────────── #[test] fn test_polyblep_oscillator_waveforms() { let waveforms = [ diff --git a/crates/embedded-dsp/tests/types_controller_coverage.rs b/crates/embedded-dsp/tests/types_controller_coverage.rs deleted file mode 100644 index d4d39a7..0000000 --- a/crates/embedded-dsp/tests/types_controller_coverage.rs +++ /dev/null @@ -1,229 +0,0 @@ -//! Coverage for the fixed-point `DspSample` plumbing and the PID builder. -//! -//! The `fallback` fixed-point types in `types.rs` (used when the `fixed` -//! feature is off) expose arithmetic helpers and integer comparisons that no -//! test touched, and the `PidBuilder` accessors/validation paths were -//! unexercised. - -use embedded_dsp::controller::{PidAction, PidBuilder, PidError}; -use embedded_dsp::types::{DspSample, q15, q31}; - -// ───────────────────────────────────────────────────────────────────────────── -// DspSample impls (present in both `fixed` configurations) -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn dsp_sample_saturating_ops_for_float_types() { - assert_eq!(::sat_div(1.0, 4.0), 0.25); - assert_eq!(::to_f32(2.5), 2.5); -} - -#[test] -fn dsp_sample_saturating_ops_for_q15_and_q31() { - let one = q15::from_bits(32_767); - let half = q15::from_bits(16_384); - let _ = ::sat_mul(half, one); - let _ = ::sat_div(half, one); - assert!((::to_f32(half) - 0.5).abs() < 1e-3); - - let one31 = q31::from_bits(i32::MAX); - let half31 = q31::from_bits(1 << 30); - let _ = ::sat_mul(half31, one31); - let _ = ::sat_div(half31, one31); - assert!((::to_f32(half31) - 0.5).abs() < 1e-6); -} - -// ───────────────────────────────────────────────────────────────────────────── -// PID builder -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn pid_builder_accessors_chain_into_a_valid_configuration() { - let builder = PidBuilder::default() - .gain(PidAction::P, 1.0) - .limit(PidAction::I, 0.5) - .kd2(0.25) - .limit_d2(0.125) - .offset(0.75) - .output_limits(-1.0, 1.0); - - assert!(builder.validate(0.001).is_ok()); -} - -#[test] -fn pid_builder_validate_rejects_non_finite_period() { - assert!(matches!( - PidBuilder::default().validate(f32::NAN), - Err(PidError::NonFinite("period")) - )); -} - -#[test] -fn pid_builder_validate_rejects_non_finite_limit() { - assert!(matches!( - PidBuilder::default() - .limit(PidAction::I, f32::NAN) - .validate(0.001), - Err(PidError::NonFinite("limit")) - )); -} - -#[test] -fn pid_builder_validate_rejects_zero_limit() { - assert!(matches!( - PidBuilder::default() - .limit(PidAction::D, 0.0) - .validate(0.001), - Err(PidError::NonPositive("limit")) - )); -} - -#[test] -fn pid_builder_validate_rejects_inverted_output_limits() { - assert!(matches!( - PidBuilder::default() - .output_limits(1.0, -1.0) - .validate(0.001), - Err(PidError::InvertedRange("output_limits")) - )); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Fallback fixed-point types (`fixed` feature off) -// ───────────────────────────────────────────────────────────────────────────── - -#[cfg(not(feature = "fixed"))] -mod fallback_fixed_point { - use embedded_dsp::types::{FixedNum, I16F16, q15}; - - #[test] - fn fixed_num_f64_conversion_edge_cases() { - // NaN maps to zero. - assert_eq!( - ::to_raw_fixed(f64::NAN, 8, -1000, 1000, false), - 0 - ); - - // Saturating conversion clamps to the low rail. - assert_eq!( - ::to_raw_fixed(-1.0e9, 8, -100, 100, true), - -100 - ); - assert_eq!( - ::to_raw_fixed(1.0e9, 8, -100, 100, true), - 100 - ); - - // Exact .5 tie with an odd integer part rounds away from zero. - // 1.5 / 256 scaled by 256 gives abs_int = 1 (odd) -> rounds to 2. - let tie = 1.5f64 / 256.0; - assert_eq!( - ::to_raw_fixed(tie, 8, -1000, 1000, true), - 2 - ); - - assert_eq!(::from_raw_fixed(256, 8), 1.0); - } - - #[test] - fn fixed_num_integer_conversion_saturates() { - assert_eq!( - ::to_raw_fixed(1000, 8, -100, 100, true), - 100 - ); - assert_eq!( - ::to_raw_fixed(-1000, 8, -100, 100, true), - -100 - ); - assert_eq!(::from_raw_fixed(256, 8), 1); - } - - #[test] - fn q15_wrapping_div_and_recip_edge_cases() { - let half = q15::from_bits(16_384); - let zero = q15::from_bits(0); - - // Division by zero short-circuits to zero instead of dividing. - assert_eq!(half.wrapping_div(zero), zero); - assert_eq!(half.wrapping_div_int(0), zero); - - // Reciprocal of zero saturates to MAX. - assert_eq!(zero.recip(), q15::MAX); - } - - #[test] - fn q15_checked_div_reports_zero_and_overflow() { - let zero = q15::from_bits(0); - assert_eq!(q15::from_bits(16_384).checked_div(zero), None); - - // 1.0 / tiny overflows the i16 backing store. - assert_eq!(q15::from_bits(32_767).checked_div(q15::from_bits(1)), None); - - // 0.25 / 0.5 = 0.5, which fits. - assert_eq!( - q15::from_bits(8_192).checked_div(q15::from_bits(16_384)), - Some(q15::from_bits(16_384)) - ); - } - - #[test] - fn fallback_fixed_num_scaling_saturates() { - // q15 has 15 fractional bits; converting into a narrow window clamps. - assert_eq!( - q15::from_bits(32_767).to_raw_fixed(15, -100, 100, true), - 100 - ); - assert_eq!( - q15::from_bits(-32_768).to_raw_fixed(15, -100, 100, true), - -100 - ); - - // Raw values at or below the type's own precision shift left... - assert_eq!(::from_raw_fixed(1, 15), q15::from_bits(1)); - assert_eq!( - ::from_raw_fixed(128, 8), - q15::from_bits(16_384) - ); - // ...and coarser inputs shift right. - assert_eq!(::from_raw_fixed(32, 20), q15::from_bits(1)); - } - - #[test] - fn fallback_arithmetic_operators() { - let a = q15::from_bits(1_000); - let b = q15::from_bits(200); - - assert_eq!(a + b, q15::from_bits(1_200)); - assert_eq!(a - b, q15::from_bits(800)); - - // Q15 multiply: (1000 * 200) >> 15 == 6. - assert_eq!(a * b, q15::from_bits(6)); - - let mut sub = a; - sub -= b; - assert_eq!(sub, q15::from_bits(800)); - - let mut mul = a; - mul *= b; - assert_eq!(mul, q15::from_bits(6)); - } - - #[test] - fn fallback_display_shows_raw_bits() { - assert_eq!(format!("{}", q15::from_bits(1_000)), "1000"); - assert_eq!(format!("{}", q15::from_bits(-1)), "-1"); - } - - #[test] - fn fallback_integer_comparisons() { - // I16F16 is backed by i32 with 16 fractional bits, so 65536 raw == 1. - let one = I16F16::from_bits(1 << 16); - - assert!(one == 1i32); - assert!(1i32 == one); - assert_eq!(1i32.partial_cmp(&one), Some(core::cmp::Ordering::Equal)); - - let two = I16F16::from_bits(2 << 16); - assert!(1i32 < two); - } -} diff --git a/crates/embedded-dsp/tests/utility_api_coverage.rs b/crates/embedded-dsp/tests/utility_api.rs similarity index 100% rename from crates/embedded-dsp/tests/utility_api_coverage.rs rename to crates/embedded-dsp/tests/utility_api.rs diff --git a/crates/embedded-dsp/tests/validation_tests.rs b/crates/embedded-dsp/tests/validation.rs similarity index 83% rename from crates/embedded-dsp/tests/validation_tests.rs rename to crates/embedded-dsp/tests/validation.rs index 1083c00..ce84743 100644 --- a/crates/embedded-dsp/tests/validation_tests.rs +++ b/crates/embedded-dsp/tests/validation.rs @@ -1,5 +1,8 @@ +//! Consolidated tests: validation_tests. + use embedded_dsp::*; +// ─── from validation_tests.rs ──────────────────────────────────────── #[test] fn test_differential_evaluation_exact_match() { let reference = [1.0f32, -0.5, 0.25, -0.125, 0.0]; diff --git a/crates/embedded-dsp/tests/voxengo_oracle.rs b/crates/embedded-dsp/tests/voxengo_oracle.rs deleted file mode 100644 index 4060085..0000000 --- a/crates/embedded-dsp/tests/voxengo_oracle.rs +++ /dev/null @@ -1,365 +0,0 @@ -//! Independent-oracle tests for bell / notch / band-pass biquad design. -//! -//! `biquad_peaking_coeffs` follows the RBJ Audio EQ Cookbook, which anchors the response to -//! 0 dB at both DC and Nyquist. That anchor is an approximation of an analog bell: for *wide* -//! bands whose upper edge approaches or passes Nyquist, forcing the Nyquist gain to 0 dB drags -//! the lower -3 dB edge away from its nominal `f0 * 2^(-BW/2)` location. -//! -//! These tests cross-check the RBJ design against an independent reference that targets the -//! `f0 * 2^(-BW/2)` octave-band edges directly instead of pinning Nyquist. The reference is a -//! test-only `f64` port of Aleksey Vaneev's -//! `cookBiquadVoxengo` ("perfect biquad", `biquad_voxengo.h` v1.2). It is deliberately *not* -//! part of the public API — it exists to validate and characterize the crate's own filters. -//! -//! Reference: -//! -//! ```text -//! Copyright (c) 2026 Aleksey Vaneev -//! -//! Permission is hereby granted, free of charge, to any person obtaining a copy of this -//! software and associated documentation files (the "Software"), to deal in the Software -//! without restriction, including without limitation the rights to use, copy, modify, merge, -//! publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons -//! to whom the Software is furnished to do so, subject to the following conditions: -//! -//! The above copyright notice and this permission notice shall be included in all copies or -//! substantial portions of the Software. -//! -//! THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, -//! INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR -//! PURPOSE AND NONINFRINGEMENT. -//! ``` - -use embedded_dsp::filter_analysis::{biquad_frequency_response, response_magnitude_db}; -use embedded_dsp::filter_design::biquad_peaking_coeffs; - -/// Filter family understood by [`cook_biquad_voxengo`]. -#[derive(Clone, Copy, Debug)] -enum Kind { - /// Bell (`gain > 1`) or notch (`gain < 1`); the reference's `BT_PEQ`. - Peq, - /// Constant-peak-gain band-pass; the reference's `BT_BPF`. - Bpf, -} - -/// A biquad with an explicit, unnormalised `a0`, as the reference emits it. -#[derive(Clone, Copy, Debug)] -struct Biquad6 { - b0: f64, - b1: f64, - b2: f64, - a0: f64, - a1: f64, - a2: f64, -} - -impl Biquad6 { - /// Magnitude `|H(e^{jω})|` at normalised frequency `freq_norm` (cycles/sample). - fn magnitude(&self, freq_norm: f64) -> f64 { - let omega = 2.0 * core::f64::consts::PI * freq_norm; - let (s1, c1) = omega.sin_cos(); - let (s2, c2) = (2.0 * omega).sin_cos(); - - let num_re = self.b0 + self.b1 * c1 + self.b2 * c2; - let num_im = -(self.b1 * s1 + self.b2 * s2); - let den_re = self.a0 + self.a1 * c1 + self.a2 * c2; - let den_im = -(self.a1 * s1 + self.a2 * s2); - - ((num_re * num_re + num_im * num_im) / (den_re * den_re + den_im * den_im)).sqrt() - } - - /// Normalises to this crate's Direct Form I `[b0, b1, b2, a1, a2]` convention, i.e. - /// `H(z) = (b0 + b1 z^-1 + b2 z^-2) / (1 - a1 z^-1 - a2 z^-2)`. - fn to_df1_f32(self) -> [f32; 5] { - let inv = 1.0 / self.a0; - [ - (self.b0 * inv) as f32, - (self.b1 * inv) as f32, - (self.b2 * inv) as f32, - (-self.a1 * inv) as f32, - (-self.a2 * inv) as f32, - ] - } - - /// Largest pole modulus of `a0 + a1 z^-1 + a2 z^-2 = 0`. - fn max_pole_radius(&self) -> f64 { - let disc = self.a1 * self.a1 - 4.0 * self.a0 * self.a2; - if disc >= 0.0 { - let root = disc.sqrt(); - let p1 = (-self.a1 + root) / (2.0 * self.a0); - let p2 = (-self.a1 - root) / (2.0 * self.a0); - p1.abs().max(p2.abs()) - } else { - (self.a2 / self.a0).sqrt() - } - } -} - -/// Faithful `f64` transcription of `cookBiquadVoxengo` (see module docs). -/// -/// `gain` is linear (`2.0` is +6 dB) and `bw` is the -3 dB bandwidth in octaves. -fn cook_biquad_voxengo(kind: Kind, sample_rate: f64, freq: f64, gain: f64, bw: f64) -> Biquad6 { - // Normalised centre frequency, clamped away from 0 and Nyquist (`tan` blows up otherwise). - let fp = (freq / sample_rate).clamp(1e-9, 0.499_999_9); - let fb = fp * 2f64.powf(-bw * 0.5); - - // `rs` is a shift parameter with 2.0 yielding the intended design (the reference notes a - // value of 1.7 tracks the analog prototype more closely). - const RS: f64 = 2.0; - let r = (fp * fp - fb * fb) / (RS * fb * (0.25 - fp * fp)); - let y = r * r; - - // Family anchors: `gn` (Nyquist), `g0` (DC), `gb` (band edge), `gp` (peak), and the skew `v2`. - let (gn, g0, gb, gp, v2): (f64, f64, f64, f64, f64) = match kind { - Kind::Peq => { - if (gain - 1.0).abs() < 1e-9 { - return Biquad6 { - b0: 1.0, - b1: 0.0, - b2: 0.0, - a0: 1.0, - a1: 0.0, - a2: 0.0, - }; - } - ( - (1.0 + gain * y) / (1.0 + y / gain), - 1.0, - gain, - gain * gain, - gain / (gain + y), - ) - } - Kind::Bpf => (y / (1.0 + y), 0.0, 0.5, 1.0, 1.0 / (1.0 + y)), - }; - - // Warped frequency axis. - let xp = (core::f64::consts::PI * fp).tan().powi(2); - let xb = (core::f64::consts::PI * fb).tan().powi(2); - - let w = xp * v2.sqrt(); - let gn_sqrt = gn.sqrt(); - let g0w = g0.sqrt() * w; - - // 2x2 linear solve for the numerator/denominator quadratic terms. - let t = w - xp; - let u = g0w - gn_sqrt * xp; - let r1 = (gp * t * t - u * u) / xp; - - let t = w - xb; - let u = g0w - gn_sqrt * xb; - let r2 = (gb * t * t - u * u) / xb; - - let den = gb - gp; - let a_sq = (r1 - r2) / den; - let b_sq = (gb * r1 - gp * r2) / den; - let a = a_sq.sqrt(); - let b = b_sq.sqrt(); - - Biquad6 { - b0: gn_sqrt + g0w + b, - b1: 2.0 * (g0w - gn_sqrt), - b2: gn_sqrt + g0w - b, - a0: 1.0 + w + a, - a1: 2.0 * (w - 1.0), - a2: 1.0 + w - a, - } -} - -/// `Q` that yields a -3 dB octave bandwidth `bw`, per the reference's own relation -/// `BW = 2/ln(2) * asinh(1/(2Q))`. -fn q_from_bw(bw: f64) -> f64 { - 1.0 / (2.0 * (bw * core::f64::consts::LN_2 / 2.0).sinh()) -} - -/// Linear magnitude -> decibels. -fn db(mag: f64) -> f64 { - 20.0 * mag.log10() -} - -/// Magnitude in dB of a crate-format Direct Form I section, via the crate's own analyzer. -fn rbj_mag_db(coeffs: [f32; 5], freq_norm: f32) -> f64 { - response_magnitude_db(biquad_frequency_response(&coeffs, freq_norm)) as f64 -} - -#[test] -fn oracle_bell_hits_center_gain_exactly_and_targets_octave_edges() { - // (sample_rate, centre_hz, linear_gain, bandwidth_octaves) - let cases = [ - (48_000.0, 1_000.0, 2.0, 1.0), - (48_000.0, 1_000.0, 3.981_071_7, 3.0), - (48_000.0, 250.0, 10.0, 0.5), - (44_100.0, 5_000.0, 0.5, 1.5), - ]; - - for (fs, f0, gain, bw) in cases { - let f = cook_biquad_voxengo(Kind::Peq, fs, f0, gain, bw); - - let center = f.magnitude(f0 / fs); - assert!( - (center - gain).abs() / gain < 1e-9, - "centre gain {center} != {gain}" - ); - - // Band edges target -3 dB below the peak, i.e. sqrt(gain) in linear terms. The - // reference is approximate here: residual edge error is small (well under 0.2 dB for - // these mid-band cases) but grows for very high-frequency, narrow, deep bands. - let edge_target_db = db(gain.sqrt()); - let lo = f0 * 2f64.powf(-bw / 2.0); - let hi = f0 * 2f64.powf(bw / 2.0); - for freq in [lo, hi] { - let m_db = db(f.magnitude(freq / fs)); - assert!( - (m_db - edge_target_db).abs() < 0.25, - "f0={f0} bw={bw} gain={gain}: edge {freq} Hz at {m_db:.3} dB, \ - expected {edge_target_db:.3} dB" - ); - } - - // A bell is transparent at DC. - assert!((f.magnitude(0.0) - 1.0).abs() < 1e-9); - } -} - -#[test] -fn oracle_bandpass_is_unity_peak_with_minus_3db_edges() { - let (fs, f0, bw) = (48_000.0, 1_000.0, 1.0); - let f = cook_biquad_voxengo(Kind::Bpf, fs, f0, 1.0, bw); - - assert!((f.magnitude(f0 / fs) - 1.0).abs() < 1e-9); - - let lo = f0 * 2f64.powf(-bw / 2.0); - let hi = f0 * 2f64.powf(bw / 2.0); - for freq in [lo, hi] { - let m_db = db(f.magnitude(freq / fs)); - assert!( - (m_db + 3.010_3).abs() < 0.1, - "edge {freq} Hz at {m_db:.3} dB, expected -3.01 dB" - ); - } - - // A band-pass rejects DC. - assert!(f.magnitude(0.0) < 1e-6); -} - -#[test] -fn oracle_df1_conversion_agrees_with_crate_frequency_response() { - let f = cook_biquad_voxengo(Kind::Peq, 48_000.0, 2_000.0, 2.0, 1.5); - let df1 = f.to_df1_f32(); - - for k in 0..=100 { - let freq_norm = 0.5 * f64::from(k) / 100.0; - let oracle_db = db(f.magnitude(freq_norm)); - let crate_db = rbj_mag_db(df1, freq_norm as f32); - assert!( - (oracle_db - crate_db).abs() < 0.01, - "at {freq_norm} cyc/sample: oracle {oracle_db:.4} dB vs crate {crate_db:.4} dB" - ); - } -} - -#[test] -fn rbj_peaking_tracks_oracle_for_midband_bands() { - let fs = 48_000.0; - - for f0 in [200.0, 1_000.0, 2_000.0] { - for bw in [0.5, 1.0, 2.0] { - for gain_db in [-12.0, -6.0, 6.0, 12.0] { - let gain = 10f64.powf(gain_db / 20.0); - let oracle = cook_biquad_voxengo(Kind::Peq, fs, f0, gain, bw); - let rbj = biquad_peaking_coeffs( - f0 as f32, - fs as f32, - q_from_bw(bw) as f32, - gain_db as f32, - ); - - let lo = f0 * 2f64.powf(-bw / 2.0); - let hi = f0 * 2f64.powf(bw / 2.0); - for freq in [lo, f0, hi] { - let oracle_db = db(oracle.magnitude(freq / fs)); - let rbj_db = rbj_mag_db(rbj, (freq / fs) as f32); - assert!( - (oracle_db - rbj_db).abs() < 0.2, - "f0={f0} bw={bw} gain={gain_db} dB at {freq} Hz: \ - oracle {oracle_db:.3} dB vs RBJ {rbj_db:.3} dB" - ); - } - } - } - } -} - -/// Characterization test: documents *where* RBJ and the oracle diverge, so a future change in -/// either design is caught rather than silently absorbed. -#[test] -fn rbj_nyquist_anchor_pulls_wide_high_band_edges_off_spec() { - // A 2-octave bell at 16 kHz on a 48 kHz rate has a nominal upper -3 dB edge at 32 kHz, above - // Nyquist (24 kHz). RBJ forces the Nyquist gain to 0 dB, which drags the lower edge off its - // nominal 8 kHz / +3 dB location; the oracle keeps the edge and lets Nyquist follow the skirt. - let (fs, f0, gain_db, bw) = (48_000.0, 16_000.0, 6.0, 2.0); - let gain = 10f64.powf(gain_db / 20.0); - let oracle = cook_biquad_voxengo(Kind::Peq, fs, f0, gain, bw); - let rbj = biquad_peaking_coeffs(f0 as f32, fs as f32, q_from_bw(bw) as f32, gain_db as f32); - - let lo = f0 * 2f64.powf(-bw / 2.0); - assert!((lo - 8_000.0).abs() < 1e-9); - - let oracle_lo = db(oracle.magnitude(lo / fs)); - let rbj_lo = rbj_mag_db(rbj, (lo / fs) as f32); - - // The oracle honours the nominal spec: -3 dB relative to the +6 dB peak => +3.01 dB. - assert!( - (oracle_lo - 3.010_3).abs() < 0.1, - "oracle lower edge {oracle_lo:.3} dB" - ); - - // RBJ undershoots that edge by more than a decibel. - assert!( - rbj_lo < 2.0, - "expected RBJ lower edge below +2 dB, got {rbj_lo:.3} dB" - ); - assert!( - oracle_lo - rbj_lo > 1.0, - "oracle {oracle_lo:.3} dB vs RBJ {rbj_lo:.3} dB" - ); - - // Conversely RBJ pins Nyquist to 0 dB while the oracle's skirt lifts it. - let oracle_nyq = db(oracle.magnitude(0.5)); - let rbj_nyq = rbj_mag_db(rbj, 0.5); - assert!(rbj_nyq.abs() < 0.05, "RBJ Nyquist {rbj_nyq:.3} dB"); - assert!(oracle_nyq > 3.5, "oracle Nyquist {oracle_nyq:.3} dB"); -} - -#[test] -fn oracle_is_finite_and_stable_over_extreme_parameters() { - let fs = 48_000.0; - let mut cases = 0; - - for kind in [Kind::Peq, Kind::Bpf] { - let mut f0 = 10.0; - while f0 < fs / 2.0 { - let mut bw = 0.1; - while bw <= 4.0 { - for gain in [0.01, 0.25, 1.0, 4.0, 100.0] { - let f = cook_biquad_voxengo(kind, fs, f0, gain, bw); - let coeffs = [f.b0, f.b1, f.b2, f.a0, f.a1, f.a2]; - assert!( - coeffs.iter().all(|c| c.is_finite()), - "non-finite coefficients at f0={f0} bw={bw} gain={gain}" - ); - let radius = f.max_pole_radius(); - assert!( - radius < 1.0, - "unstable at f0={f0} bw={bw} gain={gain}: pole radius {radius}" - ); - cases += 1; - } - bw += 0.3; - } - f0 *= 1.3; - } - } - - assert!(cases > 1_000, "expected a broad sweep, ran {cases} cases"); -} diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..fe68c97 --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,4 @@ +target/ +corpus/ +artifacts/ +coverage/ diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 0000000..ded87f9 --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,206 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "az" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "embedded-dsp" +version = "0.5.1" +dependencies = [ + "fixed", + "libm", +] + +[[package]] +name = "embedded-dsp-fuzz" +version = "0.0.0" +dependencies = [ + "embedded-dsp", + "libfuzzer-sys", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "fixed" +version = "1.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9af2cbf772fa6d1c11358f92ef554cb6b386201210bcf0e91fb7fba8a907fb40" +dependencies = [ + "az", + "bytemuck", + "half", + "typenum", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..ba9b9d7 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "embedded-dsp-fuzz" +version = "0.0.0" +edition = "2024" +publish = false + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +embedded-dsp = { path = "../crates/embedded-dsp", features = ["std", "full", "fixed"] } + +[[bin]] +name = "fir_differential" +path = "fuzz_targets/fir_differential.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "biquad_differential" +path = "fuzz_targets/biquad_differential.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/biquad_differential.rs b/fuzz/fuzz_targets/biquad_differential.rs new file mode 100644 index 0000000..847f845 --- /dev/null +++ b/fuzz/fuzz_targets/biquad_differential.rs @@ -0,0 +1,103 @@ +#![no_main] +//! Differential fuzz: audio-EQ-designed biquad cascades (`f32`) against an `f64` +//! Direct Form I reference, one to three stages. + +use libfuzzer_sys::fuzz_target; + +use embedded_dsp::filter_design::{BiquadType, EqFilter}; +use embedded_dsp::filtering::{BiquadCascadeInstance, biquad_cascade_df1}; + +fn typ(d: u8) -> BiquadType { + match d % 9 { + 0 => BiquadType::Lowpass, + 1 => BiquadType::Highpass, + 2 => BiquadType::Bandpass, + 3 => BiquadType::Allpass, + 4 => BiquadType::Notch, + 5 => BiquadType::Peaking, + 6 => BiquadType::Lowshelf, + 7 => BiquadType::Highshelf, + _ => BiquadType::Iho, + } +} + +fn u16_at(b: &[u8], i: usize) -> u16 { + u16::from_le_bytes([b[i], b[i + 1]]) +} + +fn i32_at(b: &[u8], i: usize) -> i32 { + i32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) +} + +/// A well-scaled value in `[-1, 1)`. +fn decode(b: &[u8], i: usize) -> f32 { + i32_at(b, i) as f32 / 2_147_483_648.0 +} + +fuzz_target!(|data: &[u8]| { + const LEN: usize = 64; + const FS: f32 = 48_000.0; + if data.len() < 1 + 3 * 8 + LEN * 4 { + return; + } + + let stages = 1 + (data[0] as usize % 3); + let mut coeffs = vec![0.0f32; stages * 5]; + for s in 0..stages { + let base = 1 + s * 8; + let f0 = 20.0 + (u16_at(data, base) as f32 / 65_535.0) * (FS * 0.45 - 20.0); + let q = 0.1 + (data[base + 2] as f32 / 255.0) * 9.9; + let gain_db = decode(data, base + 4) * 24.0; + let Ok(section) = EqFilter::new(f0, FS) + .q(q) + .gain_db(gain_db) + .try_build(typ(data[base + 3])) + else { + return; + }; + coeffs[s * 5..s * 5 + 5].copy_from_slice(§ion); + } + + let src: Vec = (0..LEN).map(|i| decode(data, 25 + i * 4)).collect(); + let mut state = vec![0.0f32; stages * 4]; + let mut dst = vec![0.0f32; LEN]; + let mut instance = BiquadCascadeInstance:: { + num_stages: stages as u8, + post_shift: 0, + coeffs: &coeffs, + state: &mut state, + }; + biquad_cascade_df1(&mut instance, &src, &mut dst); + assert!( + dst.iter().all(|v| v.is_finite()), + "non-finite biquad output" + ); + + // f64 reference: one stage at a time over the whole block. + let mut signal: Vec = src.iter().map(|&x| x as f64).collect(); + for s in 0..stages { + let c = &coeffs[s * 5..s * 5 + 5]; + let (b0, b1, b2) = (c[0] as f64, c[1] as f64, c[2] as f64); + let (a1, a2) = (c[3] as f64, c[4] as f64); + let (mut x1, mut x2, mut y1, mut y2) = (0.0f64, 0.0, 0.0, 0.0); + for x in signal.iter_mut() { + let y = b0 * *x + b1 * x1 + b2 * x2 + a1 * y1 + a2 * y2; + x2 = x1; + x1 = *x; + y2 = y1; + y1 = y; + *x = y; + } + } + + for i in 0..LEN { + let reference = signal[i]; + let diff = (dst[i] as f64 - reference).abs(); + assert!( + diff <= 2e-3 + 1e-3 * reference.abs(), + "biquad f32 {} vs f64 {} (stages={stages}, i={i})", + dst[i], + reference + ); + } +}); diff --git a/fuzz/fuzz_targets/fir_differential.rs b/fuzz/fuzz_targets/fir_differential.rs new file mode 100644 index 0000000..2379860 --- /dev/null +++ b/fuzz/fuzz_targets/fir_differential.rs @@ -0,0 +1,60 @@ +#![no_main] +//! Differential fuzz: the generic `fir::` kernel against an `f64` causal +//! convolution reference, on well-scaled inputs. + +use libfuzzer_sys::fuzz_target; + +/// Maps four bytes to a value in `[-1, 1)` so the `f32`/`f64` gap stays a pure +/// rounding difference. +fn decode(b: &[u8]) -> f32 { + i32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f32 / 2_147_483_648.0 +} + +fuzz_target!(|data: &[u8]| { + const SRC_LEN: usize = 64; + if data.is_empty() { + return; + } + let taps = (data[0] as usize % 32) + 1; + if data.len() < 1 + (taps + SRC_LEN) * 4 { + return; + } + + let mut coeffs = [0.0f32; 32]; + let mut src = [0.0f32; SRC_LEN]; + let mut p = 1; + for c in coeffs.iter_mut().take(taps) { + *c = decode(&data[p..p + 4]); + p += 4; + } + for s in src.iter_mut() { + *s = decode(&data[p..p + 4]); + p += 4; + } + + let mut state = [0.0f32; 32]; + let mut dst = [0.0f32; SRC_LEN]; + let mut instance = embedded_dsp::filtering::FirInstance:: { + num_taps: taps as u16, + coeffs: &coeffs[..taps], + state: &mut state[..taps], + }; + embedded_dsp::filtering::fir(&mut instance, &src, &mut dst); + assert!(dst.iter().all(|v| v.is_finite()), "non-finite FIR output"); + + for i in 0..SRC_LEN { + let mut reference = 0.0f64; + for k in 0..taps { + if i >= k { + reference += coeffs[k] as f64 * src[i - k] as f64; + } + } + let diff = (dst[i] as f64 - reference).abs(); + assert!( + diff <= 1e-4 + 1e-5 * reference.abs(), + "FIR f32 {} vs f64 {} (taps={taps}, i={i})", + dst[i], + reference + ); + } +});