From 238b9ae8233d96774f75580a336b4cf37dd0d4cc Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Fri, 21 Aug 2026 15:24:13 -0700 Subject: [PATCH 01/32] feat(ten-vad): add mutable context input-buffer driver and context-forward cross test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ten_vad` had only the network half: `TenVad::forward` starts at an already-widened `[1, 3, 41]` feature stack, and nothing turned audio into those 41 bins. This adds the other half, matching the split `silero_vad` already has. New `ten_vad::context` module, porting the reference front end (`ALGO_TRACE.md` §3.3 - §3.7): * `coeff` - reference constants and the `coeff.h` mean/std tables * `pre_emphasis` - `y[n] = x[n] - 0.97*x[n-1]`, with cross-hop carry * `mel` - the 40-band filterbank: HTK mel, truncating bin cast, integer slopes, no area normalization * `pitch` - `TenVadPitchSource` seam, with a `ZeroPitch` default * `features` - the 41-dim extractor and its streaming state * `driver` - `TenVadContext` plus `TenVad::context_forward` and `context_forward_sequence` The STFT half reuses `ops::signal::SlidingStft` as-is, and the Hann-768 window is generated by `StftWindowConfig::Hann { periodic: true }` rather than transcribed -- verified equal to the reference `coeff.h` table to within f32 rounding. Two orderings from the reference are load-bearing and preserved: pitch reads the raw, un-pre-emphasized hop and the un-normalized bin power, and the `1/32768^2` division precedes the filterbank matmul. Adds `test_reference_model_context_forward_sequence_cross_test`, which drives real 16 kHz audio through the driver and pins three arms against each other: the batched sequence path, the iterative single-step path, and the ONNX reference graph stepped by hand over the driver's own feature stacks. Since all three share those features, it also asserts the output actually tracks the audio rather than sitting flat. Feature-extraction fidelity is pinned separately, against a from-scratch host implementation of the pipeline. `TenVad::forward` gains rustdoc recording that its leading `a` axis is the graph's sequence axis rather than a stream batch, and that `frame_features`' reshape diverges from the reference for `a > 1`. Left as-is deliberately: batch stays 1 until the driver machinery is fully mapped. Known deviations, documented in `context::mod`: the pitch feature is stubbed, there is no periodic LSTM reset, and there is no numeric golden yet. Also promotes the duplicated `load_audio_mono_sr` test helper into `support::testing`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DFXHzoUTEy67NZqa1wTarA --- crates/bunsen/src/kits/speech/mod.rs | 3 + .../src/kits/speech/silero_vad/cross_test.rs | 60 +- .../src/kits/speech/ten_vad/blocks/module.rs | 83 +- .../src/kits/speech/ten_vad/context/coeff.rs | 178 +++ .../src/kits/speech/ten_vad/context/driver.rs | 782 ++++++++++++ .../kits/speech/ten_vad/context/features.rs | 1105 +++++++++++++++++ .../src/kits/speech/ten_vad/context/mel.rs | 512 ++++++++ .../src/kits/speech/ten_vad/context/mod.rs | 100 ++ .../src/kits/speech/ten_vad/context/pitch.rs | 185 +++ .../speech/ten_vad/context/pre_emphasis.rs | 387 ++++++ .../src/kits/speech/ten_vad/cross_test.rs | 166 ++- crates/bunsen/src/kits/speech/ten_vad/mod.rs | 3 + crates/bunsen/src/support/testing/mod.rs | 72 ++ 13 files changed, 3566 insertions(+), 70 deletions(-) create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/coeff.rs create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/driver.rs create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/features.rs create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/mel.rs create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/mod.rs create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/pitch.rs create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/pre_emphasis.rs diff --git a/crates/bunsen/src/kits/speech/mod.rs b/crates/bunsen/src/kits/speech/mod.rs index 960cca6b..72a6961f 100644 --- a/crates/bunsen/src/kits/speech/mod.rs +++ b/crates/bunsen/src/kits/speech/mod.rs @@ -3,6 +3,9 @@ /// A fully functional Silero VAD model. pub mod silero_vad; +/// A ten-vad model, with a full audio pre-processing driver. +/// +/// The pitch feature is stubbed; see [`ten_vad::context`]. pub mod ten_vad; /// A structural whisper model. diff --git a/crates/bunsen/src/kits/speech/silero_vad/cross_test.rs b/crates/bunsen/src/kits/speech/silero_vad/cross_test.rs index 3bd22627..c075b71e 100644 --- a/crates/bunsen/src/kits/speech/silero_vad/cross_test.rs +++ b/crates/bunsen/src/kits/speech/silero_vad/cross_test.rs @@ -1,7 +1,5 @@ #[cfg(test)] mod tests { - use std::path::Path; - use burn::{ Tensor, prelude::TensorData, @@ -11,11 +9,6 @@ mod tests { backend::BackendTypes, }, }; - use hound::{ - SampleFormat, - WavReader, - WavSpec, - }; use crate::{ errors::*, @@ -27,7 +20,10 @@ mod tests { reference::ReferenceModel, }, prelude::*, - support::testing::PerformanceBackend, + support::testing::{ + PerformanceBackend, + audio::load_audio_mono_sr, + }, }; #[test] @@ -132,52 +128,4 @@ mod tests { Ok(()) } - - /// Loads a mono audio file. - /// - /// # Arguments - /// * `filename` - path to an audio file. - /// * `sample_rate` - sample rate of the audio file. - pub fn load_audio_mono_sr>( - filename: P, - sample_rate: usize, - ) -> BunsenResult<(WavSpec, Vec)> { - let filename = filename.as_ref(); - - let mut reader = WavReader::open(filename).map_err(BunsenError::external)?; - let spec = reader.spec(); - - if spec.channels != 1 { - return Err(BunsenError::Invalid( - "The audio must be single-channel".to_string(), - )); - } - if spec.sample_rate as usize != sample_rate { - return Err(BunsenError::Invalid(format!( - "Expected sample_rate = {}, but found {}", - sample_rate, spec.sample_rate - ))); - } - - let spec = reader.spec(); - let samples: Vec = match (spec.sample_format, spec.bits_per_sample) { - (SampleFormat::Float, 32) => reader - .samples::() - .map(|s| s.unwrap()) - .collect::>(), - (SampleFormat::Int, bits) => { - let scale = (1i64 << (bits - 1)) as f32; - reader - .samples::() - .collect::, _>>() - .map_err(BunsenError::external)? - .into_iter() - .map(|s| s as f32 / scale) - .collect() - } - _ => unreachable!("hound rejects other formats at open"), - }; - - Ok((spec, samples)) - } } diff --git a/crates/bunsen/src/kits/speech/ten_vad/blocks/module.rs b/crates/bunsen/src/kits/speech/ten_vad/blocks/module.rs index 467b8cb5..71632512 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/blocks/module.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/blocks/module.rs @@ -1,3 +1,31 @@ +//! # ten-vad model. +//! +//! [ten-vad][t] is a small, streaming voice-activity-detection model: given a +//! short stack of consecutive feature frames and the previous recurrent +//! states, it emits a per-frame speech probability and the next states. +//! +//! [t]: https://github.com/TEN-framework/ten-vad +//! +//! The pipeline is: +//! +//! 1. a `[1, 3, 41]` feature stack through a 2D conv stem ([`ConvSeq2d`] + +//! [`MaxPool2d`] + a depthwise/pointwise [`ConvSeq2d`]), producing an +//! `[f_ctx, d_features]` embedding, +//! 2. two stacked single-step [`Lstm`] blocks, whose outputs are concatenated, +//! 3. a two-layer `ReLU` / sigmoid [`Linear`] head producing the speech +//! probability. +//! +//! [`TenVad::forward`] is the stateless call through the network. It starts at +//! an already-widened feature stack; everything that turns audio into those +//! 41 bins — pre-emphasis, the sliding STFT, the mel filterbank, the +//! normalization, and the rolling frame history — lives in +//! [`context`](crate::kits::speech::ten_vad::context), and is driven through +//! [`TenVadContext`] by +//! [`context_forward`](TenVad::context_forward) / +//! [`context_forward_sequence`](TenVad::context_forward_sequence). +//! +//! [`TenVadContext`]: crate::kits::speech::ten_vad::TenVadContext + use burn::{ nn::{ Linear, @@ -224,19 +252,43 @@ impl TenVad { } impl TenVad { - /// Forward pass. + /// Stateless forward pass over one feature stack. + /// + /// This is the model half only: `input` must already be the widened, + /// normalized context stack. See + /// [`context_forward`](Self::context_forward) to drive raw audio. + /// + /// # The `a` axis + /// + /// `a` is **not** a stream-batch axis, and this implementation pins it to + /// `1`. In the reference ONNX graph the leading dimension of the feature + /// input lands on the LSTM's *sequence* axis, with the LSTM batch fixed at + /// 1 by a graph constant (`new_shape__177 = [-1, 1, 80]`); the reference + /// `ALGO_TRACE.md` §8 documents this and verifies it empirically. + /// + /// Two consequences, both deliberate and both currently out of scope: /// - /// There are messy questions about `a` wrt batching and sequences; - /// this seems to currently be bound to 1 per the reference model, - /// this needs some R&D. + /// * **Sequence batching is available in the graph but not here.** Feeding + /// `T` stacked context frames as one call is bit-identical to `T` + /// sequential calls (§8.2), and far faster. Reaching it from bunsen needs + /// [`frame_features`](Self::frame_features) to reshape `[-1, 1, d_ctx, + /// n_freq]`, as the reference graph does, rather than the `[1, -1, d_ctx, + /// n_freq]` it currently uses — the two agree only at `a == 1`, which is + /// why the shape contract pins it there. + /// * **Multi-stream batching is structurally impossible** against the stock + /// graph: batched states fail shape validation outright. It requires + /// patching two reshape constants (§8.3), i.e. a different model file. /// - /// # Argument - /// * `input`: `[a, d_ctx, n_freq]` - /// * `state1`: `[a, d_hidden]` LSTM state. - /// * `state2`: `[a, d_hidden]` LSTM state. + /// # Arguments + /// * `input`: `[a, d_ctx, n_freq]` the widened feature stack, `a == 1`. + /// * `state1`: `[a, d_hidden]` first-LSTM state, or `None` to start zeroed. + /// * `state2`: `[a, d_hidden]` second-LSTM state, or `None` to start + /// zeroed. /// /// # Returns - /// `[(a,1)?, (a,1)?]` probs. + /// `(probabilities, state1, state2)`, with: + /// * `probabilities`: `[a, 1]` speech probabilities in `[0, 1]` + /// * `state1` / `state2`: `[a, d_hidden]` next LSTM states pub fn forward( &self, input: Tensor, @@ -278,7 +330,18 @@ impl TenVad { (x, state1, state2) } - fn frame_features( + /// Runs the conv stem over a feature stack. + /// + /// # Arguments + /// * `x`: `[a, d_ctx, n_freq]` the widened feature stack. + /// + /// # Returns + /// `[a, f_ctx, d_features]` embeddings. + /// + /// Note the `[1, -1, d_ctx, n_freq]` reshape below: the reference graph + /// uses `[-1, 1, d_ctx, n_freq]`. The two agree at `a == 1`, which is all + /// this model is contracted for; see [`forward`](Self::forward). + pub fn frame_features( &self, x: Tensor, ) -> Tensor { diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/coeff.rs b/crates/bunsen/src/kits/speech/ten_vad/context/coeff.rs new file mode 100644 index 00000000..cff362f0 --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/coeff.rs @@ -0,0 +1,178 @@ +//! # ten-vad front-end coefficients. +//! +//! The fixed scalar constants and normalization tables the ten-vad +//! pre-processing driver is built from. +//! +//! [`FEATURE_MEANS`] and [`FEATURE_STDS`] are transcribed verbatim from the +//! reference implementation's `src/coeff.h`; the remaining constants come +//! from the reference feature path (see `ALGO_TRACE.md` §3.1, §3.3, §3.6). +//! +//! These are reference data, not tunables: changing any of them decouples the +//! driver from the pretrained weights. + +/// The number of mel filterbank bands. +/// +/// Features `0..N_MELS` are the log-mel energies; feature `N_MELS` is the +/// pitch. See [`crate::kits::speech::ten_vad::TenVadMeta::n_freq`]. +pub const N_MELS: usize = 40; + +/// The ten-vad feature width: [`N_MELS`] log-mel bands plus one pitch bin. +pub const N_FREQ: usize = N_MELS + 1; + +/// The frame context depth; the model consumes `[f_{t-2}, f_{t-1}, f_t]`. +/// +/// See `ALGO_TRACE.md` §3.7. +pub const D_CTX: usize = 3; + +/// The hop size, in samples, of one ten-vad frame (16 ms at 16 kHz). +/// +/// The reference C API accepts other hop sizes but drains its FIFO in +/// 256-sample steps and reports only the last internal frame, so 256 is the +/// only size that yields one score per model call (`ALGO_TRACE.md` §7). +pub const HOP_SIZE: usize = 256; + +/// The sample rate, in Hz, the ten-vad front end is defined for. +pub const SAMPLE_RATE: usize = 16000; + +/// The epsilon used both as the log floor and as the normalization guard. +/// +/// The reference applies it twice: `log(melPower + EPS)` and +/// `(v - MEAN) / (STD + EPS)` (`ALGO_TRACE.md` §3.6). +pub const FEATURE_EPS: f32 = 1e-20; + +/// The pre-emphasis coefficient: `y[n] = x[n] - PRE_EMPHASIS_COEFF * x[n-1]`. +/// +/// See `ALGO_TRACE.md` §3.3. +pub const PRE_EMPHASIS_COEFF: f32 = 0.97; + +/// The bin-power normalizer, `32768^2`. +/// +/// The reference pipeline runs at int16 scale, and divides the bin powers by +/// this before the mel filterbank (`ALGO_TRACE.md` §3.6). +pub const POWER_NORMAL: f32 = 32768.0 * 32768.0; + +/// The scale from unit-range audio to the reference's int16 scale. +/// +/// The reference casts `i16` samples to `f32` without rescaling. bunsen's +/// driver takes `[-1, 1]` audio (matching the rest of the crate) and +/// multiplies by this on entry, so both paths see the same values. +pub const INPUT_SCALE: f32 = 32768.0; + +/// Per-feature means, from the reference `src/coeff.h`. +/// +/// Index `40` is the pitch mean, in Hz. +/// +/// Transcribed at the reference's full written precision rather than rounded +/// to what `f32` can hold, so the table stays diffable against `coeff.h`. +#[allow(clippy::excessive_precision)] +#[rustfmt::skip] +pub const FEATURE_MEANS: [f32; N_FREQ] = [ + -8.198236465454e+00, -6.265716552734e+00, -5.483818531036e+00, + -4.758691310883e+00, -4.417088985443e+00, -4.142892837524e+00, + -3.912850379944e+00, -3.845927953720e+00, -3.657090425491e+00, + -3.723418712616e+00, -3.876134157181e+00, -3.843890905380e+00, + -3.690405130386e+00, -3.756065845490e+00, -3.698696136475e+00, + -3.650463104248e+00, -3.700468778610e+00, -3.567321300507e+00, + -3.498900175095e+00, -3.477807044983e+00, -3.458816051483e+00, + -3.444923877716e+00, -3.401328563690e+00, -3.306261301041e+00, + -3.278556823730e+00, -3.233250856400e+00, -3.198616027832e+00, + -3.204526424408e+00, -3.208798646927e+00, -3.257838010788e+00, + -3.381376743317e+00, -3.534021377563e+00, -3.640867948532e+00, + -3.726858854294e+00, -3.773730993271e+00, -3.804667234421e+00, + -3.832901000977e+00, -3.871120452881e+00, -3.990592956543e+00, + -4.480289459229e+00, 9.235690307617e+01,]; + +/// Per-feature standard deviations, from the reference `src/coeff.h`. +/// +/// Index `40` is the pitch standard deviation, in Hz. +/// +/// Transcribed at the reference's full written precision rather than rounded +/// to what `f32` can hold, so the table stays diffable against `coeff.h`. +#[allow(clippy::excessive_precision)] +#[rustfmt::skip] +pub const FEATURE_STDS: [f32; N_FREQ] = [ + 5.166063785553e+00, 4.977209568024e+00, 4.698895931244e+00, + 4.630621433258e+00, 4.634347915649e+00, 4.641156196594e+00, + 4.640676498413e+00, 4.666367053986e+00, 4.650534629822e+00, + 4.640020847321e+00, 4.637400150299e+00, 4.620099067688e+00, + 4.596316337585e+00, 4.562654972076e+00, 4.554360389709e+00, + 4.566910743713e+00, 4.562489986420e+00, 4.562412738800e+00, + 4.585299491882e+00, 4.600179672241e+00, 4.592845916748e+00, + 4.585922718048e+00, 4.583496570587e+00, 4.626092910767e+00, + 4.626957893372e+00, 4.626289367676e+00, 4.637005805969e+00, + 4.683015823364e+00, 4.726813793182e+00, 4.734289646149e+00, + 4.753227233887e+00, 4.849722862244e+00, 4.869434833527e+00, + 4.884482860565e+00, 4.921327114105e+00, 4.959212303162e+00, + 4.996619224548e+00, 5.044823646545e+00, 5.072216987610e+00, + 5.096439361572e+00, 1.152136917114e+02,]; + +#[cfg(test)] +// The anchors below are quoted from the reference `coeff.h` at its full +// written precision, so they stay greppable against the source table. +#[allow(clippy::excessive_precision)] +mod tests { + use super::*; + + #[test] + fn test_shape_constants() { + assert_eq!(N_MELS, 40); + assert_eq!(N_FREQ, 41); + assert_eq!(D_CTX, 3); + assert_eq!(HOP_SIZE, 256); + assert_eq!(SAMPLE_RATE, 16000); + + // The tables are indexed by feature, so they must be N_FREQ wide. + assert_eq!(FEATURE_MEANS.len(), N_FREQ); + assert_eq!(FEATURE_STDS.len(), N_FREQ); + } + + #[test] + fn test_scalar_constants() { + assert_eq!(FEATURE_EPS, 1e-20); + assert_eq!(PRE_EMPHASIS_COEFF, 0.97); + assert_eq!(INPUT_SCALE, 32768.0); + assert_eq!(POWER_NORMAL, 32768.0 * 32768.0); + assert_eq!(POWER_NORMAL, 1073741824.0); + } + + #[test] + fn test_table_anchors() { + // Anchors against the reference `src/coeff.h` table, at the two ends + // and at the pitch entry. + assert_eq!(FEATURE_MEANS[0], -8.198236465454e+00); + assert_eq!(FEATURE_MEANS[N_MELS - 1], -4.480289459229e+00); + assert_eq!(FEATURE_MEANS[N_MELS], 9.235690307617e+01); + + assert_eq!(FEATURE_STDS[0], 5.166063785553e+00); + assert_eq!(FEATURE_STDS[N_MELS - 1], 5.096439361572e+00); + assert_eq!(FEATURE_STDS[N_MELS], 1.152136917114e+02); + } + + #[test] + fn test_stds_are_usable_divisors() { + // Every std is used as `1 / (std + EPS)`; a non-positive entry would + // make the normalization explode or flip sign. + for (i, &std) in FEATURE_STDS.iter().enumerate() { + assert!(std > 0.0, "FEATURE_STDS[{i}] = {std} is not positive"); + assert!(std.is_finite(), "FEATURE_STDS[{i}] = {std} is not finite"); + } + for (i, &mean) in FEATURE_MEANS.iter().enumerate() { + assert!( + mean.is_finite(), + "FEATURE_MEANS[{i}] = {mean} is not finite" + ); + } + } + + #[test] + fn test_log_mel_means_are_negative() { + // The mel bands are `log(power / 32768^2 + eps)` of speech-scale + // audio, so their means sit well below zero; the pitch entry (in Hz) + // is the only positive one. + for (i, &mean) in FEATURE_MEANS[..N_MELS].iter().enumerate() { + assert!(mean < 0.0, "FEATURE_MEANS[{i}] = {mean} should be negative"); + } + // The pitch mean is in Hz, so a compile-time check is available. + const { assert!(FEATURE_MEANS[N_MELS] > 0.0) }; + } +} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs b/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs new file mode 100644 index 00000000..f35af6a3 --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs @@ -0,0 +1,782 @@ +//! # ten-vad streaming driver. +//! +//! The mutable context that carries a ten-vad stream across calls, and the +//! `context_forward` family that drives raw audio through it. +//! +//! [`TenVad::forward`] is the stateless call through the network: it consumes +//! an already-widened `[batch, d_ctx, n_freq]` feature stack. Everything +//! needed to *build* that stack from audio — the pre-emphasis carry, the +//! sliding STFT queue, the pitch recurrence, and the rolling frame history — +//! lives in [`TenVadContext`], and +//! [`context_forward`](TenVad::context_forward) is what ties the two +//! together. This mirrors the split in +//! [`SileroVad`](crate::kits::speech::silero_vad::SileroVad). +//! +//! ## Mutable, not moved +//! +//! [`SileroVadContext`] is a burn `Module` moved in and out by value. This +//! context cannot be: it owns a [`SlidingStftContext`] and one +//! [`TenVadPitchSource`] per stream, neither of which is a tensor. It is a +//! plain struct driven through `&mut`, matching [`SlidingStftContext`]'s own +//! style. +//! +//! ## Batch size +//! +//! The stock ten-vad ONNX graph pins its LSTM batch to 1, so this driver is +//! built and tested at `batch_size = 1`. See [`TenVad::forward`] for what the +//! leading axis actually means and why multi-stream batching needs a patched +//! graph. +//! +//! [`SileroVadContext`]: crate::kits::speech::silero_vad::SileroVadContext + +use burn::{ + config::Config, + prelude::*, +}; + +use crate::{ + blocks::rnn::lstm::ExtLstmState, + errors::{ + BunsenError, + BunsenResult, + }, + kits::speech::ten_vad::{ + TenVad, + TenVadMeta, + context::{ + coeff::{ + D_CTX, + SAMPLE_RATE, + }, + features::{ + TenVadFeatureConfig, + TenVadFeatureContext, + TenVadFeatureMeta, + }, + pitch::{ + TenVadPitchSource, + ZeroPitch, + }, + }, + }, + ops::signal::SlidingStftContext, + prelude::TensorOpExt, +}; + +/// Common meta for [`TenVadContextConfig`] and [`TenVadContext`]. +pub trait TenVadContextMeta { + /// The sample rate, in Hz, this context expects. + fn sample_rate(&self) -> usize; + + /// The batch size; each batch row is an independent stream. + fn batch_size(&self) -> usize; + + /// The hop size, in samples; one hop yields one probability. + fn hop_size(&self) -> usize; + + /// The frame context depth carried into the model. + fn d_ctx(&self) -> usize; + + /// The feature width of one frame. + fn n_freq(&self) -> usize; +} + +/// Config for [`TenVadContext`]. +/// +/// Defaults match the ten-vad reference driver: one 16 kHz stream, hop 256, +/// a 3-frame context stack. +/// +/// Builds [`TenVadContext`] via [`TenVad::init_context`]. Implements +/// [`TenVadContextMeta`]. +#[derive(Config, Debug)] +pub struct TenVadContextConfig { + /// The sample rate, in Hz. + #[config(default = "SAMPLE_RATE")] + pub sample_rate: usize, + + /// The number of independent streams. + /// + /// The stock ONNX graph pins this to 1; see the module docs. + #[config(default = "1")] + pub batch_size: usize, + + /// The frame context depth. + #[config(default = "D_CTX")] + pub d_ctx: usize, + + /// The feature front-end geometry. + #[config(default = "TenVadFeatureConfig::new()")] + pub features: TenVadFeatureConfig, +} + +impl TenVadContextMeta for TenVadContextConfig { + fn sample_rate(&self) -> usize { + self.sample_rate + } + + fn batch_size(&self) -> usize { + self.batch_size + } + + fn hop_size(&self) -> usize { + self.features.hop_size() + } + + fn d_ctx(&self) -> usize { + self.d_ctx + } + + fn n_freq(&self) -> usize { + self.features.n_freq() + } +} + +impl TenVadContextConfig { + /// Validates the context geometry. + /// + /// # Errors + /// + /// [`BunsenError::Invalid`] if the batch size or context depth is zero, if + /// the sample rate disagrees with the front end, or if the front-end + /// geometry is itself invalid. + pub fn validate(&self) -> BunsenResult<()> { + self.features.validate()?; + + if self.batch_size == 0 { + return Err(BunsenError::Invalid( + "TenVadContext batch_size must be non-zero".to_string(), + )); + } + if self.d_ctx == 0 { + return Err(BunsenError::Invalid( + "TenVadContext d_ctx must be non-zero".to_string(), + )); + } + if self.sample_rate != self.features.sample_rate() { + return Err(BunsenError::Invalid(format!( + "TenVadContext sample_rate ({}) != feature sample_rate ({})", + self.sample_rate, + self.features.sample_rate(), + ))); + } + Ok(()) + } +} + +/// The mutable driving context for a ten-vad stream. +/// +/// Carries everything the model cannot: the audio front-end state +/// ([`TenVadFeatureContext`]), the rolling `[batch, d_ctx, n_freq]` feature +/// stack, and the two LSTM states. +/// +/// Built by [`TenVad::init_context`]. Implements [`TenVadContextMeta`]. +#[derive(Debug, Clone)] +pub struct TenVadContext { + /// The audio front-end streaming state. + pub features: TenVadFeatureContext, + + /// The `[batch, d_ctx, n_freq]` rolling feature stack. + /// + /// Row `d_ctx - 1` is the most recent frame; row `0` the oldest. This is + /// exactly what [`TenVad::forward`] consumes. + pub stack: Tensor, + + /// The `[batch, d_hidden]` first-LSTM state. + pub state1: ExtLstmState, + + /// The `[batch, d_hidden]` second-LSTM state. + pub state2: ExtLstmState, +} + +impl TenVadContextMeta for TenVadContext { + fn sample_rate(&self) -> usize { + self.features.sample_rate() + } + + fn batch_size(&self) -> usize { + self.stack.dims()[0] + } + + fn hop_size(&self) -> usize { + self.features.hop_size() + } + + fn d_ctx(&self) -> usize { + self.stack.dims()[1] + } + + fn n_freq(&self) -> usize { + self.stack.dims()[2] + } +} + +impl TenVadContext { + /// The recurrent hidden width. + pub fn d_hidden(&self) -> usize { + self.state1.hidden.dims()[1] + } + + /// The sliding STFT queue, for inspection. + pub fn stft(&self) -> &SlidingStftContext { + &self.features.stft + } + + /// Resets the whole context to the start-of-stream condition. + /// + /// Zeroes the feature stack, both LSTM states, the STFT queue, the + /// pre-emphasis carry, and every pitch source. + pub fn reset(&mut self) { + self.features.reset(); + self.stack = Tensor::zeros_like(&self.stack); + self.state1 = ExtLstmState::initial(self.state1.hidden.dims(), &self.stack.device()); + self.state2 = ExtLstmState::initial(self.state2.hidden.dims(), &self.stack.device()); + } + + /// Rolls one feature frame into the context stack. + /// + /// Drops the oldest frame and appends `feats`, matching the reference's + /// shift-left-and-write (`ALGO_TRACE.md` §3.7). + /// + /// This is the widened-context construction itself: the result is what + /// [`TenVad::forward`] expects, so callers driving the model by hand (or + /// cross-checking it against the ONNX graph) can use it directly. + /// + /// # Arguments + /// * `feats`: `[batch, n_freq]` the newest feature frame. + /// + /// # Returns + /// The updated `[batch, d_ctx, n_freq]` stack. + pub fn push_features( + &mut self, + feats: Tensor, + ) -> Tensor { + #[cfg(any(test, debug_assertions))] + crate::contracts::assert_shape_contract!( + ["batch", "n_freq"], + &feats, + &[("batch", self.batch_size()), ("n_freq", self.n_freq())], + ); + + // [batch, 1, n_freq] + let newest: Tensor = feats.unsqueeze_dim(1); + + // Inplace, so the old stack's storage can be reused. + let older = self.stack.extract().slice_dim(1, 1..); + self.stack = Tensor::cat(vec![older, newest], 1); + + self.stack.clone() + } +} + +impl TenVad { + /// Builds a zeroed driving context with the default [`ZeroPitch`] source. + /// + /// # Errors + /// + /// [`BunsenError::Invalid`] if the config is invalid, or if its context + /// depth or feature width disagrees with this model. + pub fn init_context( + &self, + cfg: &TenVadContextConfig, + device: &B::Device, + ) -> BunsenResult> { + self.init_context_with(cfg, ZeroPitch, device) + } + + /// Builds a zeroed driving context over a specific pitch source. + /// + /// # Arguments + /// * `cfg`: the context geometry. + /// * `pitch`: the prototype pitch source, cloned once per stream. + /// + /// # Errors + /// + /// [`BunsenError::Invalid`] if the config is invalid, or if its context + /// depth or feature width disagrees with this model. + pub fn init_context_with( + &self, + cfg: &TenVadContextConfig, + pitch: P, + device: &B::Device, + ) -> BunsenResult> { + cfg.validate()?; + + if cfg.d_ctx() != self.d_ctx() { + return Err(BunsenError::Invalid(format!( + "TenVadContext d_ctx ({}) != model d_ctx ({})", + cfg.d_ctx(), + self.d_ctx(), + ))); + } + if cfg.n_freq() != self.n_freq() { + return Err(BunsenError::Invalid(format!( + "TenVadContext n_freq ({}) != model n_freq ({})", + cfg.n_freq(), + self.n_freq(), + ))); + } + + let batch = cfg.batch_size(); + let state_shape = [batch, self.d_hidden()]; + + Ok(TenVadContext { + features: cfg.features.try_init_context(batch, pitch, device)?, + stack: Tensor::zeros([batch, cfg.d_ctx(), cfg.n_freq()], device), + state1: ExtLstmState::initial(state_shape, device), + state2: ExtLstmState::initial(state_shape, device), + }) + } + + /// Drives one hop of audio through the front end and the model. + /// + /// Extracts the frame's features, rolls them into the context stack, and + /// runs one [`forward`](Self::forward) with the carried LSTM states. The + /// context is advanced in place. + /// + /// # Arguments + /// * `hop`: `[batch, hop_size]` mono audio in `[-1, 1]`, at this model's + /// sample rate. + /// * `ctx`: the driving context, advanced in place. + /// + /// # Returns + /// `[batch]` speech probabilities in `[0, 1]`. + pub fn context_forward( + &self, + hop: Tensor, + ctx: &mut TenVadContext, + ) -> Tensor { + #[cfg(any(test, debug_assertions))] + crate::contracts::assert_shape_contract!( + ["batch", "hop_size"], + &hop, + &[("batch", ctx.batch_size()), ("hop_size", ctx.hop_size()),], + ); + + // [batch, n_freq] + let feats = ctx.features.forward(hop); + + // [batch, d_ctx, n_freq] + let stack = ctx.push_features(feats); + + let (probs, state1, state2) = + self.forward(stack, Some(ctx.state1.clone()), Some(ctx.state2.clone())); + ctx.state1 = state1; + ctx.state2 = state2; + + // [batch, 1] -> [batch] + probs.squeeze_dim(1) + } + + /// Drives `steps` consecutive hops through the front end and the model. + /// + /// Equivalent to `steps` calls of + /// [`context_forward`](Self::context_forward), but the entire front end + /// runs batched across the sequence: one pre-emphasis pass, one `stft` + /// call, one filterbank matmul, and every step's context stack + /// materialized in a single concatenation. Only the recurrence itself is + /// stepped. + /// + /// # Arguments + /// * `hop_seq`: `[steps, batch, hop_size]` consecutive mono audio hops in + /// `[-1, 1]`, with `steps` non-zero. + /// * `ctx`: the driving context, advanced in place. + /// + /// # Returns + /// `[steps, batch]` speech probabilities in `[0, 1]`. + pub fn context_forward_sequence( + &self, + hop_seq: Tensor, + ctx: &mut TenVadContext, + ) -> Tensor { + #[cfg(any(test, debug_assertions))] + let [steps] = crate::contracts::unpack_shape_contract!( + ["steps", "batch", "hop_size"], + &hop_seq, + &["steps"], + &[("batch", ctx.batch_size()), ("hop_size", ctx.hop_size()),], + ); + #[cfg(not(any(test, debug_assertions)))] + let steps = hop_seq.dims()[0]; + + assert_ne!(steps, 0, "TenVad hop_seq must be non-empty"); + + let d_ctx = ctx.d_ctx(); + + // [steps, batch, n_freq] + let feats = ctx.features.forward_sequence(hop_seq); + + // The carried stack followed by every new frame, so that step `s`'s + // widened context is a slice of one tensor. + // [batch, d_ctx + steps, n_freq] + let history = Tensor::cat(vec![ctx.stack.extract(), feats.swap_dims(0, 1)], 1); + + // The carry-out stack is the final `d_ctx` frames. + ctx.stack = history.clone().slice_dim(1, steps as isize..); + + let mut probs = Vec::with_capacity(steps); + for step in 0..steps { + // Step `s` sees frames `s + 1 ..= s + d_ctx` of the history. + // [batch, d_ctx, n_freq] + let stack = history + .clone() + .slice_dim(1, (step + 1) as isize..(step + 1 + d_ctx) as isize); + + let (prob, state1, state2) = + self.forward(stack, Some(ctx.state1.clone()), Some(ctx.state2.clone())); + ctx.state1 = state1; + ctx.state2 = state2; + + probs.push(prob); + } + + // [steps, batch, 1] -> [steps, batch] + Tensor::stack::<3>(probs, 0).squeeze_dim(2) + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::{ + Distribution, + Tolerance, + backend::BackendTypes, + }; + + use super::*; + use crate::{ + burner::module::ModuleInit, + kits::speech::ten_vad::{ + TenVadStructureConfig, + context::coeff::N_FREQ, + }, + prelude::*, + support::testing::CpuBackend, + }; + + type B = CpuBackend; + type F = ::FloatElem; + + fn model() -> (TenVad, burn::backend::flex::FlexDevice) { + let device = Default::default(); + let vad: TenVad = TenVadStructureConfig::default().init(&device); + (vad, device) + } + + #[test] + fn test_config_meta() { + let cfg = TenVadContextConfig::new(); + + assert_eq!(cfg.sample_rate(), 16000); + assert_eq!(cfg.batch_size(), 1); + assert_eq!(cfg.hop_size(), 256); + assert_eq!(cfg.d_ctx(), 3); + assert_eq!(cfg.n_freq(), N_FREQ); + + cfg.validate().unwrap(); + } + + #[test] + fn test_validate_rejects_bad_geometry() { + for bad in [ + TenVadContextConfig::new().with_batch_size(0), + TenVadContextConfig::new().with_d_ctx(0), + // The declared rate must agree with the front end's. + TenVadContextConfig::new().with_sample_rate(8000), + ] { + assert!( + matches!(bad.validate(), Err(BunsenError::Invalid(_))), + "expected Invalid: {bad:?}", + ); + } + } + + #[test] + fn test_init_context_shapes_and_zeroing() { + let (vad, device) = model(); + let cfg = TenVadContextConfig::new(); + let ctx = vad.init_context(&cfg, &device).unwrap(); + + assert_eq!(ctx.sample_rate(), 16000); + assert_eq!(ctx.batch_size(), 1); + assert_eq!(ctx.hop_size(), 256); + assert_eq!(ctx.d_ctx(), vad.d_ctx()); + assert_eq!(ctx.n_freq(), vad.n_freq()); + assert_eq!(ctx.d_hidden(), vad.d_hidden()); + + assert_eq!(ctx.stack.dims(), [1, 3, 41]); + assert_eq!(ctx.state1.hidden.dims(), [1, 64]); + assert_eq!(ctx.state2.cell.dims(), [1, 64]); + + // Everything starts at zero, as in the reference driver. + let zeros = Tensor::::zeros([1, 3, 41], &device); + ctx.stack.to_data().assert_eq(&zeros.to_data(), true); + + let zeros2 = Tensor::::zeros([1, 64], &device); + ctx.state1 + .hidden + .to_data() + .assert_eq(&zeros2.to_data(), true); + ctx.state1.cell.to_data().assert_eq(&zeros2.to_data(), true); + ctx.state2 + .hidden + .to_data() + .assert_eq(&zeros2.to_data(), true); + ctx.state2.cell.to_data().assert_eq(&zeros2.to_data(), true); + + // The STFT queue starts zeroed too. + assert_eq!(ctx.stft().queue.dims(), [1, 768]); + } + + #[test] + fn test_init_context_rejects_model_mismatch() { + let (vad, device) = model(); + + // A context depth the model does not consume. + let bad = TenVadContextConfig::new().with_d_ctx(5); + assert!(matches!( + vad.init_context(&bad, &device), + Err(BunsenError::Invalid(_)), + )); + + // A feature width the model does not consume. The front end accepts + // it (it is under the table bound); the driver must not. + let bad = TenVadContextConfig::new().with_features( + TenVadFeatureConfig::new().with_mel( + crate::kits::speech::ten_vad::context::mel::TenVadMelConfig::new() + .with_n_mels(6) + .with_fft_size(1024), + ), + ); + bad.validate().unwrap(); + assert!(matches!( + vad.init_context(&bad, &device), + Err(BunsenError::Invalid(_)), + )); + } + + #[test] + fn test_push_features_rolls_the_stack() { + let (vad, device) = model(); + let cfg = TenVadContextConfig::new(); + let mut ctx = vad.init_context(&cfg, &device).unwrap(); + + let d_ctx = ctx.d_ctx(); + let n_freq = ctx.n_freq(); + + // Push distinguishable frames: frame `f` is all-`f`. + let mut pushed: Vec = Vec::new(); + for f in 1..=(d_ctx + 2) { + let value = f as f32; + pushed.push(value); + + let feats = Tensor::::full([1, n_freq], value, &device); + let stack = ctx.push_features(feats); + assert_eq!(stack.dims(), [1, d_ctx, n_freq]); + + let host: Vec = stack.to_data_as::().to_vec_as::().unwrap(); + + // The stack holds the last `d_ctx` frames, oldest first; slots + // not yet filled are still zero. + for row in 0..d_ctx { + let age = d_ctx - 1 - row; + let expected = if age < pushed.len() { + pushed[pushed.len() - 1 - age] + } else { + 0.0 + }; + for col in 0..n_freq { + assert_eq!( + host[row * n_freq + col], + expected, + "after {f} pushes, row {row} col {col}", + ); + } + } + } + } + + #[test] + fn test_context_forward_shapes_and_range() { + let (vad, device) = model(); + let cfg = TenVadContextConfig::new(); + let mut ctx = vad.init_context(&cfg, &device).unwrap(); + + let hop = Tensor::::random([1, cfg.hop_size()], Distribution::Default, &device); + let probs = vad.context_forward(hop, &mut ctx); + + assert_eq!(probs.dims(), [1]); + + let host: Vec = probs.to_data_as::().to_vec_as::().unwrap(); + assert!(host.iter().all(|&p| (0.0..=1.0).contains(&p)), "{host:?}"); + } + + #[test] + fn test_context_forward_sequence_shapes_and_range() { + let (vad, device) = model(); + let cfg = TenVadContextConfig::new(); + let mut ctx = vad.init_context(&cfg, &device).unwrap(); + + let steps = 7; + let hops = + Tensor::::random([steps, 1, cfg.hop_size()], Distribution::Default, &device); + let probs = vad.context_forward_sequence(hops, &mut ctx); + + assert_eq!(probs.dims(), [steps, 1]); + + let host: Vec = probs.to_data_as::().to_vec_as::().unwrap(); + assert!(host.iter().all(|&p| (0.0..=1.0).contains(&p)), "{host:?}"); + } + + #[test] + fn test_sequence_matches_stepwise() { + // The whole point of the sequence form: same answer, fewer passes. + let (vad, device) = model(); + let cfg = TenVadContextConfig::new(); + + let steps = 9; + let hops = + Tensor::::random([steps, 1, cfg.hop_size()], Distribution::Default, &device); + + let mut seq_ctx = vad.init_context(&cfg, &device).unwrap(); + let seq_probs = vad.context_forward_sequence(hops.clone(), &mut seq_ctx); + + let mut step_ctx = vad.init_context(&cfg, &device).unwrap(); + let mut step_probs = Vec::with_capacity(steps); + for step in 0..steps { + let hop = hops.clone().select_dim::<2>(0, step); + step_probs.push(vad.context_forward(hop, &mut step_ctx)); + } + let step_probs: Tensor = Tensor::stack(step_probs, 0); + + let tol = Tolerance::::permissive(); + seq_probs + .to_data_as::() + .assert_approx_eq::(&step_probs.to_data_as::(), tol); + + // Every carried field must agree, or the next call diverges. + seq_ctx + .stack + .to_data_as::() + .assert_approx_eq::(&step_ctx.stack.to_data_as::(), tol); + seq_ctx + .state1 + .hidden + .to_data_as::() + .assert_approx_eq::(&step_ctx.state1.hidden.to_data_as::(), tol); + seq_ctx + .state1 + .cell + .to_data_as::() + .assert_approx_eq::(&step_ctx.state1.cell.to_data_as::(), tol); + seq_ctx + .state2 + .hidden + .to_data_as::() + .assert_approx_eq::(&step_ctx.state2.hidden.to_data_as::(), tol); + seq_ctx + .state2 + .cell + .to_data_as::() + .assert_approx_eq::(&step_ctx.state2.cell.to_data_as::(), tol); + seq_ctx + .features + .stft + .queue + .to_data_as::() + .assert_approx_eq::(&step_ctx.features.stft.queue.to_data_as::(), tol); + } + + #[test] + fn test_single_step_sequence_matches_context_forward() { + // The `steps == 1` boundary: the sequence path's history slicing must + // degenerate to a plain roll-and-run. + let (vad, device) = model(); + let cfg = TenVadContextConfig::new(); + + let hop = Tensor::::random([1, cfg.hop_size()], Distribution::Default, &device); + + let mut seq_ctx = vad.init_context(&cfg, &device).unwrap(); + let seq_probs = + vad.context_forward_sequence(hop.clone().unsqueeze_dim::<3>(0), &mut seq_ctx); + + let mut step_ctx = vad.init_context(&cfg, &device).unwrap(); + let step_probs = vad.context_forward(hop, &mut step_ctx); + + let tol = Tolerance::::permissive(); + seq_probs + .squeeze_dim::<1>(0) + .to_data_as::() + .assert_approx_eq::(&step_probs.to_data_as::(), tol); + seq_ctx + .stack + .to_data_as::() + .assert_approx_eq::(&step_ctx.stack.to_data_as::(), tol); + } + + #[test] + fn test_sequence_resumes_across_chunks() { + // Splitting a stream into two sequence calls must match one call over + // the whole thing -- that is what makes the context a *continuation*. + let (vad, device) = model(); + let cfg = TenVadContextConfig::new(); + + let steps = 8; + let hops = + Tensor::::random([steps, 1, cfg.hop_size()], Distribution::Default, &device); + + let mut whole_ctx = vad.init_context(&cfg, &device).unwrap(); + let whole = vad.context_forward_sequence(hops.clone(), &mut whole_ctx); + + let mut split_ctx = vad.init_context(&cfg, &device).unwrap(); + let head = hops.clone().slice_dim(0, ..3); + let tail = hops.slice_dim(0, 3..); + let a = vad.context_forward_sequence(head, &mut split_ctx); + let b = vad.context_forward_sequence(tail, &mut split_ctx); + let split = Tensor::cat(vec![a, b], 0); + + let tol = Tolerance::::permissive(); + whole + .to_data_as::() + .assert_approx_eq::(&split.to_data_as::(), tol); + whole_ctx + .stack + .to_data_as::() + .assert_approx_eq::(&split_ctx.stack.to_data_as::(), tol); + whole_ctx + .state2 + .hidden + .to_data_as::() + .assert_approx_eq::(&split_ctx.state2.hidden.to_data_as::(), tol); + } + + #[test] + fn test_reset_rewinds_the_stream() { + let (vad, device) = model(); + let cfg = TenVadContextConfig::new(); + let mut ctx = vad.init_context(&cfg, &device).unwrap(); + + let hop = Tensor::::random([1, cfg.hop_size()], Distribution::Default, &device); + + let first = vad.context_forward(hop.clone(), &mut ctx); + + vad.context_forward(hop.clone(), &mut ctx); + vad.context_forward(hop.clone(), &mut ctx); + ctx.reset(); + + let again = vad.context_forward(hop, &mut ctx); + again + .to_data_as::() + .assert_approx_eq::(&first.to_data_as::(), Tolerance::permissive()); + } + + #[test] + #[should_panic(expected = "hop_seq must be non-empty")] + fn test_sequence_rejects_empty_input() { + let (vad, device) = model(); + let cfg = TenVadContextConfig::new(); + let mut ctx = vad.init_context(&cfg, &device).unwrap(); + + let empty = Tensor::::zeros([0, 1, cfg.hop_size()], &device); + vad.context_forward_sequence(empty, &mut ctx); + } +} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/features.rs b/crates/bunsen/src/kits/speech/ten_vad/context/features.rs new file mode 100644 index 00000000..918f9a26 --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/features.rs @@ -0,0 +1,1105 @@ +//! # ten-vad feature extraction. +//! +//! Turns `[-1, 1]` mono audio hops into the 41-dimensional feature vectors +//! [`TenVad`] consumes, reproducing the reference front end +//! (`ALGO_TRACE.md` §3.3 - §3.6) stage for stage: +//! +//! 1. scale to the reference's int16 range ([`INPUT_SCALE`]), +//! 2. [`PreEmphasisContext`] on the STFT branch only — the pitch branch keeps +//! the raw samples, +//! 3. [`SlidingStftContext`] over a 768-sample queue, zero-padded to a +//! 1024-point FFT, +//! 4. bin power `re^2 + im^2`, +//! 5. pitch, from the [`TenVadPitchSource`], reading the raw hop and the +//! **un-normalized** bin power, +//! 6. `1 / 32768^2` normalization, then the [`TenVadMelBank`], then `ln(x + +//! 1e-20)`, +//! 7. per-feature standardization against the reference mean / std tables. +//! +//! Two orderings here are load-bearing and easy to get backwards: +//! +//! * **Pitch runs before the power normalization**, and reads the raw, +//! un-pre-emphasized hop. +//! * **The `1 / 32768^2` division happens before the filterbank matmul**, not +//! after the log. The two commute algebraically but not in `f32`. +//! +//! The pieces are: +//! * [`TenVadFeatureConfig`] — the geometry. +//! * [`TenVadFeatures`] — the fixed analysis coefficients; stateless. +//! * [`TenVadFeatureContext`] — a streaming state bound to a batch; built by +//! [`TenVadFeatureConfig::try_init_context`]. +//! +//! [`TenVad`]: crate::kits::speech::ten_vad::TenVad + +use burn::{ + config::Config, + prelude::*, +}; + +use crate::{ + errors::{ + BunsenError, + BunsenResult, + WithOkOrPanic, + }, + kits::speech::ten_vad::context::{ + coeff::{ + FEATURE_EPS, + FEATURE_MEANS, + FEATURE_STDS, + INPUT_SCALE, + N_FREQ, + POWER_NORMAL, + SAMPLE_RATE, + }, + mel::{ + TenVadMelBank, + TenVadMelConfig, + TenVadMelMeta, + }, + pitch::{ + TenVadPitchSource, + ZeroPitch, + }, + pre_emphasis::{ + PreEmphasisConfig, + PreEmphasisContext, + }, + }, + ops::signal::{ + SlidingStft, + SlidingStftConfig, + SlidingStftContext, + SlidingStftMeta, + }, + prelude::{ + TensorDataToVecAsExt, + TensorElemOpExt, + }, +}; + +/// Common meta for [`TenVadFeatureConfig`], [`TenVadFeatures`], and +/// [`TenVadFeatureContext`]. +pub trait TenVadFeatureMeta { + /// The sample rate, in Hz, this front end expects. + fn sample_rate(&self) -> usize; + + /// The hop size, in samples; one hop yields one feature frame. + fn hop_size(&self) -> usize; + + /// The STFT analysis window length, in samples. + fn win_len(&self) -> usize; + + /// The FFT size the analysis window is zero-padded to. + fn fft_size(&self) -> usize; + + /// The number of frequency bins: `fft_size / 2 + 1`. + fn n_bins(&self) -> usize { + self.fft_size() / 2 + 1 + } + + /// The number of mel bands. + fn n_mels(&self) -> usize; + + /// The feature width: [`n_mels`](Self::n_mels) log-mel bands plus one + /// pitch bin. + fn n_freq(&self) -> usize { + self.n_mels() + 1 + } +} + +/// Config for [`TenVadFeatures`] and [`TenVadFeatureContext`]. +/// +/// Defaults match the ten-vad reference front end: 16 kHz, hop 256, a +/// 768-sample periodic Hann window over a 1024-point FFT, and 40 mel bands. +/// +/// Builds [`TenVadFeatures`] and [`TenVadFeatureContext`]. Implements +/// [`TenVadFeatureMeta`]. +#[derive(Config, Debug)] +pub struct TenVadFeatureConfig { + /// The sample rate, in Hz. + #[config(default = "SAMPLE_RATE")] + pub sample_rate: usize, + + /// The sliding STFT analyzer geometry. + /// + /// Its defaults are already the ten-vad analyzer + /// (`win_len = 768`, `hop_size = 256`, `fft_size = 1024`, periodic Hann). + #[config(default = "SlidingStftConfig::new()")] + pub stft: SlidingStftConfig, + + /// The mel filterbank geometry. + #[config(default = "TenVadMelConfig::new()")] + pub mel: TenVadMelConfig, + + /// The pre-emphasis filter applied to the STFT branch. + #[config(default = "PreEmphasisConfig::new()")] + pub pre_emphasis: PreEmphasisConfig, +} + +impl TenVadFeatureMeta for TenVadFeatureConfig { + fn sample_rate(&self) -> usize { + self.sample_rate + } + + fn hop_size(&self) -> usize { + self.stft.hop_size() + } + + fn win_len(&self) -> usize { + self.stft.win_len() + } + + fn fft_size(&self) -> usize { + self.stft.fft_size() + } + + fn n_mels(&self) -> usize { + self.mel.n_mels() + } +} + +impl TenVadFeatureConfig { + /// Validates the front-end geometry. + /// + /// # Errors + /// + /// [`BunsenError::Invalid`] if the STFT or mel geometry is itself invalid, + /// if the two disagree on the FFT size or bin count, if the mel config + /// disagrees with the sample rate, or if the feature width exceeds the + /// [`N_FREQ`] reference normalization entries. + /// + /// This deliberately does *not* pin the feature width to [`N_FREQ`]: the + /// front end is geometry-generic, and only the driver has to match the + /// pretrained model. Smaller geometries are useful for testing. + pub fn validate(&self) -> BunsenResult<()> { + self.stft.validate()?; + self.mel.validate()?; + + if self.stft.fft_size() != self.mel.fft_size { + return Err(BunsenError::Invalid(format!( + "TenVadFeature stft fft_size ({}) != mel fft_size ({})", + self.stft.fft_size(), + self.mel.fft_size, + ))); + } + if self.stft.n_bins() != self.mel.n_bins() { + return Err(BunsenError::Invalid(format!( + "TenVadFeature stft n_bins ({}) != mel n_bins ({})", + self.stft.n_bins(), + self.mel.n_bins(), + ))); + } + if self.sample_rate != self.mel.sample_rate { + return Err(BunsenError::Invalid(format!( + "TenVadFeature sample_rate ({}) != mel sample_rate ({})", + self.sample_rate, self.mel.sample_rate, + ))); + } + // The normalization tables are the reference's, so they bound the + // feature width from above. The exact match against the model is + // enforced where it belongs, in + // [`TenVad::init_context_with`](crate::kits::speech::ten_vad::TenVad::init_context_with). + if self.n_freq() > N_FREQ { + return Err(BunsenError::Invalid(format!( + "TenVadFeature n_freq ({}) exceeds the {N_FREQ} reference \ + normalization entries", + self.n_freq(), + ))); + } + Ok(()) + } + + /// Initializes the fixed analysis coefficients on `device`. + /// + /// # Errors + /// + /// See [`validate`](Self::validate). + pub fn try_init( + &self, + device: &B::Device, + ) -> BunsenResult> { + self.validate()?; + + let n_freq = self.n_freq(); + + // Precompute the reciprocal so the per-frame path is a multiply. + let stds_recip: Vec = FEATURE_STDS[..n_freq] + .iter() + .map(|&s| 1.0 / (s + FEATURE_EPS)) + .collect(); + + Ok(TenVadFeatures { + sample_rate: self.sample_rate, + stft: self.stft.try_init(device)?, + mel: self.mel.try_init(device)?, + pre_emphasis: self.pre_emphasis, + means: Tensor::from_data(TensorData::from(&FEATURE_MEANS[..n_freq]), device), + stds_recip: Tensor::from_data(TensorData::new(stds_recip, [n_freq]), device), + }) + } + + /// Initializes the fixed analysis coefficients, panicking on error. + pub fn init( + &self, + device: &B::Device, + ) -> TenVadFeatures { + self.try_init(device).ok_or_panic() + } + + /// Initializes a streaming [`TenVadFeatureContext`] over `batch_size` + /// independent streams. + /// + /// # Arguments + /// * `batch_size`: the number of independent streams; must be non-zero. + /// * `pitch`: the prototype pitch source, cloned once per stream. + /// + /// # Errors + /// + /// See [`validate`](Self::validate). + pub fn try_init_context( + &self, + batch_size: usize, + pitch: P, + device: &B::Device, + ) -> BunsenResult> { + Ok(self.try_init(device)?.init_state(batch_size, pitch)) + } +} + +/// The fixed ten-vad feature-extraction coefficients. +/// +/// Holds the STFT analysis window, the mel filterbank, and the normalization +/// tables. Stateless, so one instance can be shared by (or cheaply cloned +/// into) any number of streams. This is deliberately **not** a burn `Module`: +/// nothing here is a learnable parameter. +/// +/// Built by [`TenVadFeatureConfig`]. Implements [`TenVadFeatureMeta`]. +/// Streaming states are built by [`init_state`](Self::init_state). +#[derive(Debug, Clone)] +pub struct TenVadFeatures { + sample_rate: usize, + pre_emphasis: PreEmphasisConfig, + + /// The sliding STFT analysis coefficients. + pub stft: SlidingStft, + + /// The mel filterbank. + pub mel: TenVadMelBank, + + /// The `[n_freq]` per-feature means. + pub means: Tensor, + + /// The `[n_freq]` per-feature reciprocal standard deviations, + /// `1 / (std + eps)`. + pub stds_recip: Tensor, +} + +impl TenVadFeatureMeta for TenVadFeatures { + fn sample_rate(&self) -> usize { + self.sample_rate + } + + fn hop_size(&self) -> usize { + self.stft.hop_size() + } + + fn win_len(&self) -> usize { + self.stft.win_len() + } + + fn fft_size(&self) -> usize { + self.stft.fft_size() + } + + fn n_mels(&self) -> usize { + self.mel.n_mels() + } +} + +impl TenVadFeatures { + /// Builds a [`TenVadFeatureContext`] streaming state over these + /// coefficients. + /// + /// # Arguments + /// * `batch_size`: the number of independent streams; must be non-zero. + /// * `pitch`: the prototype pitch source, cloned once per stream. + pub fn init_state( + &self, + batch_size: usize, + pitch: P, + ) -> TenVadFeatureContext { + assert_ne!(batch_size, 0, "TenVadFeatures batch_size must be non-zero"); + let device = self.means.device(); + TenVadFeatureContext { + stft: self.stft.init_state(batch_size), + pre_emphasis: self.pre_emphasis.init(batch_size, &device), + pitch: vec![pitch; batch_size], + coef: self.clone(), + } + } +} + +/// Streaming ten-vad feature-extraction state. +/// +/// Binds the pre-emphasis carry, the sliding STFT queue, and one +/// [`TenVadPitchSource`] per stream to a [`TenVadFeatures`]. +/// +/// At stream start every buffer is zero, so — as in the reference — the first +/// couple of frames see partially zero-padded analysis windows. +/// +/// Built by [`TenVadFeatures::init_state`]. Implements [`TenVadFeatureMeta`]. +#[derive(Debug, Clone)] +pub struct TenVadFeatureContext { + /// The fixed analysis coefficients. + pub coef: TenVadFeatures, + + /// The sliding STFT queue, over the pre-emphasized signal. + pub stft: SlidingStftContext, + + /// The pre-emphasis carry. + pub pre_emphasis: PreEmphasisContext, + + /// The per-stream pitch sources; one entry per batch row. + pub pitch: Vec

, +} + +impl TenVadFeatureMeta for TenVadFeatureContext { + fn sample_rate(&self) -> usize { + self.coef.sample_rate() + } + + fn hop_size(&self) -> usize { + self.coef.hop_size() + } + + fn win_len(&self) -> usize { + self.coef.win_len() + } + + fn fft_size(&self) -> usize { + self.coef.fft_size() + } + + fn n_mels(&self) -> usize { + self.coef.n_mels() + } +} + +impl TenVadFeatureContext { + /// The batch size; each batch row is an independent stream. + pub fn batch_size(&self) -> usize { + self.pitch.len() + } + + /// Resets every streaming buffer to the start-of-stream condition. + pub fn reset(&mut self) { + self.stft.reset(); + self.pre_emphasis.reset(); + for pitch in &mut self.pitch { + pitch.reset(); + } + } + + /// Extracts the feature frame for one hop. + /// + /// # Arguments + /// * `hop`: `[batch, hop_size]` mono audio in `[-1, 1]`. + /// + /// # Returns + /// `[batch, n_freq]` normalized features. + pub fn forward( + &mut self, + hop: Tensor, + ) -> Tensor { + #[cfg(any(test, debug_assertions))] + crate::contracts::assert_shape_contract!( + ["batch", "hop_size"], + &hop, + &[("batch", self.batch_size()), ("hop_size", self.hop_size()),], + ); + + // The reference runs at int16 scale; bunsen takes unit-range audio. + let raw = hop.mul_scalar(INPUT_SCALE); + + // Pre-emphasis feeds the STFT branch only; pitch sees `raw`. + let emph = self.pre_emphasis.forward(raw.clone()); + + // [batch, n_bins] + let bin_power = self.stft.forward(emph).square().sum_dim(2).squeeze_dim(2); + + // Pitch reads the raw hop and the *un-normalized* bin power. + // [batch, 1] + let pitch = self.pitch_column(&raw, &bin_power); + + self.finish_frame(bin_power, pitch) + } + + /// Extracts `steps` consecutive feature frames at once. + /// + /// Equivalent to `steps` calls of [`forward`](Self::forward), but the + /// whole hop stream is pre-emphasized and analyzed in one pass — a single + /// `stft` call for the sequence, and one matmul for the filterbank. + /// + /// # Arguments + /// * `hops`: `[steps, batch, hop_size]` consecutive mono audio hops in + /// `[-1, 1]`. + /// + /// # Returns + /// `[steps, batch, n_freq]` normalized features. + pub fn forward_sequence( + &mut self, + hops: Tensor, + ) -> Tensor { + #[cfg(any(test, debug_assertions))] + let [steps] = crate::contracts::unpack_shape_contract!( + ["steps", "batch", "hop_size"], + &hops, + &["steps"], + &[("batch", self.batch_size()), ("hop_size", self.hop_size()),], + ); + #[cfg(not(any(test, debug_assertions)))] + let steps = hops.dims()[0]; + + let batch = self.batch_size(); + let n_freq = self.n_freq(); + + let raw = hops.mul_scalar(INPUT_SCALE); + let emph = self.pre_emphasis.forward_sequence(raw.clone()); + + // [steps, batch, n_bins] + let bin_power = self + .stft + .forward_sequence(emph) + .square() + .sum_dim(3) + .squeeze_dim(3); + + // [steps, batch, 1] + let pitch = self.pitch_column_sequence(&raw, &bin_power); + + // Fold the step axis into the row axis for the batched stages. + let feat = self.finish_frame( + bin_power.reshape([steps * batch, self.n_bins()]), + pitch.reshape([steps * batch, 1]), + ); + + feat.reshape([steps, batch, n_freq]) + } + + /// The shared tail of the per-frame pipeline: power normalization, mel + /// filterbank, log, pitch concatenation, and standardization. + /// + /// # Arguments + /// * `bin_power`: `[rows, n_bins]` un-normalized bin powers. + /// * `pitch`: `[rows, 1]` pitch estimates, in Hz. + /// + /// # Returns + /// `[rows, n_freq]` normalized features. + fn finish_frame( + &self, + bin_power: Tensor, + pitch: Tensor, + ) -> Tensor { + // The division precedes the filterbank matmul, as in the reference; + // the two commute algebraically but not in f32. + let mel_power = self.coef.mel.forward(bin_power.div_scalar(POWER_NORMAL)); + + // [rows, n_mels] + let log_mel = mel_power.add_scalar(FEATURE_EPS).log(); + + // [rows, n_freq] + let feat = Tensor::cat(vec![log_mel, pitch], 1); + + (feat - self.coef.means.clone().unsqueeze::<2>()) + * self.coef.stds_recip.clone().unsqueeze::<2>() + } + + /// The `[batch, 1]` pitch column for one frame. + fn pitch_column( + &mut self, + raw: &Tensor, + bin_power: &Tensor, + ) -> Tensor { + let batch = self.batch_size(); + let device = raw.device(); + + if !self.inspects_input() { + let value = self.pitch[0].frame_pitch(&[], &[]); + return Tensor::full([batch, 1], value, &device); + } + + let hop_size = self.hop_size(); + let n_bins = self.n_bins(); + let raw_host: Vec = raw.to_data_as::().to_vec_as::().ok_or_panic(); + let power_host: Vec = bin_power + .to_data_as::() + .to_vec_as::() + .ok_or_panic(); + + let values: Vec = (0..batch) + .map(|b| { + self.pitch[b].frame_pitch( + &raw_host[b * hop_size..(b + 1) * hop_size], + &power_host[b * n_bins..(b + 1) * n_bins], + ) + }) + .collect(); + + Tensor::from_data(TensorData::new(values, [batch, 1]), &device) + } + + /// The `[steps, batch, 1]` pitch column for a sequence. + /// + /// Pitch is a per-stream recurrence, so when the source inspects its input + /// the frames are walked in order, one host-side call per stream per step. + fn pitch_column_sequence( + &mut self, + raw: &Tensor, + bin_power: &Tensor, + ) -> Tensor { + let steps = raw.dims()[0]; + let batch = self.batch_size(); + let device = raw.device(); + + if !self.inspects_input() { + let value = self.pitch[0].frame_pitch(&[], &[]); + return Tensor::full([steps, batch, 1], value, &device); + } + + let hop_size = self.hop_size(); + let n_bins = self.n_bins(); + let raw_host: Vec = raw.to_data_as::().to_vec_as::().ok_or_panic(); + let power_host: Vec = bin_power + .to_data_as::() + .to_vec_as::() + .ok_or_panic(); + + let mut values = Vec::with_capacity(steps * batch); + for step in 0..steps { + for b in 0..batch { + let raw_at = (step * batch + b) * hop_size; + let pow_at = (step * batch + b) * n_bins; + values.push(self.pitch[b].frame_pitch( + &raw_host[raw_at..raw_at + hop_size], + &power_host[pow_at..pow_at + n_bins], + )); + } + } + + Tensor::from_data(TensorData::new(values, [steps, batch, 1]), &device) + } + + /// Whether any stream's pitch source inspects its input. + /// + /// When no source does, the driver skips the device-to-host readback the + /// pitch branch would otherwise force. + fn inspects_input(&self) -> bool { + self.pitch.iter().any(|p| p.inspects_input()) + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::{ + Distribution, + Tolerance, + backend::BackendTypes, + }; + + use super::*; + use crate::{ + kits::speech::ten_vad::context::coeff::N_MELS, + ops::signal::{ + SamplingWindowBuilder, + StftWindowConfig, + }, + prelude::*, + support::testing::CpuBackend, + }; + + type B = CpuBackend; + type F = ::FloatElem; + + /// A small geometry whose naive-DFT reference is cheap to evaluate. + /// + /// The feature width (7) is below [`N_FREQ`], so it exercises the same + /// code path with hand-checkable numbers. + fn tiny_config() -> TenVadFeatureConfig { + TenVadFeatureConfig::new() + .with_stft( + SlidingStftConfig::new() + .with_win_len(48) + .with_hop_size(16) + .with_fft_size(64), + ) + .with_mel(TenVadMelConfig::new().with_n_mels(6).with_fft_size(64)) + } + + #[test] + fn test_config_meta() { + let cfg = TenVadFeatureConfig::new(); + + assert_eq!(cfg.sample_rate(), 16000); + assert_eq!(cfg.hop_size(), 256); + assert_eq!(cfg.win_len(), 768); + assert_eq!(cfg.fft_size(), 1024); + assert_eq!(cfg.n_bins(), 513); + assert_eq!(cfg.n_mels(), 40); + assert_eq!(cfg.n_freq(), 41); + assert_eq!(cfg.n_freq(), N_FREQ); + + cfg.validate().unwrap(); + tiny_config().validate().unwrap(); + } + + #[test] + fn test_stft_window_matches_the_reference_table() { + // The reference ships a 768-entry Hann table in `coeff.h`. We generate + // it instead, from `StftWindowConfig::Hann { periodic: true }`; this + // pins that the generated window really is that table. + let window = StftWindowConfig::Hann { periodic: true }.to_vec_window(768); + assert_eq!(window.len(), 768); + + // Anchors read straight out of the reference table. + for (n, expected) in [ + (0usize, 0.0000000e+00f64), + (1, 1.6733041e-05), + (2, 6.6931045e-05), + (96, 1.4644661e-01), + (192, 5.0000000e-01), + (384, 1.0000000e+00), + (576, 5.0000000e-01), + (700, 7.5398909e-02), + (766, 6.6931045e-05), + (767, 1.6733041e-05), + ] { + assert!( + (window[n] - expected).abs() < 1e-7, + "window[{n}]: {} vs {expected}", + window[n], + ); + } + + // A periodic window is symmetric about its midpoint, unlike the + // symmetric variant; getting this wrong shifts every spectrum. + for n in 1..768 { + assert!((window[n] - window[768 - n]).abs() < 1e-9); + } + } + + #[test] + fn test_validate_rejects_bad_geometry() { + for bad in [ + // STFT and mel disagree on the FFT size. + TenVadFeatureConfig::new().with_mel(TenVadMelConfig::new().with_fft_size(512)), + // The mel config disagrees with the declared sample rate. + TenVadFeatureConfig::new().with_sample_rate(8000), + // Too many bands for the reference normalization tables. + TenVadFeatureConfig::new().with_mel(TenVadMelConfig::new().with_n_mels(64)), + // A structurally invalid STFT is rejected by delegation. + TenVadFeatureConfig::new().with_stft(SlidingStftConfig::new().with_hop_size(0)), + ] { + assert!( + matches!(bad.validate(), Err(BunsenError::Invalid(_))), + "expected Invalid: {bad:?}", + ); + } + } + + #[test] + fn test_init_meta_matches_config() { + let device = Default::default(); + let cfg = TenVadFeatureConfig::new(); + + let coef: TenVadFeatures = cfg.init(&device); + assert_eq!(coef.sample_rate(), cfg.sample_rate()); + assert_eq!(coef.hop_size(), cfg.hop_size()); + assert_eq!(coef.win_len(), cfg.win_len()); + assert_eq!(coef.fft_size(), cfg.fft_size()); + assert_eq!(coef.n_bins(), cfg.n_bins()); + assert_eq!(coef.n_mels(), cfg.n_mels()); + assert_eq!(coef.n_freq(), cfg.n_freq()); + + assert_eq!(coef.means.dims(), [41]); + assert_eq!(coef.stds_recip.dims(), [41]); + + let ctx = coef.init_state(2, ZeroPitch); + assert_eq!(ctx.batch_size(), 2); + assert_eq!(ctx.n_freq(), 41); + assert_eq!(ctx.stft.batch_size(), 2); + assert_eq!(ctx.pre_emphasis.batch_size(), 2); + assert_eq!(ctx.pitch.len(), 2); + } + + #[test] + fn test_stds_recip_is_the_normalization_divisor() { + let device = Default::default(); + let coef: TenVadFeatures = TenVadFeatureConfig::new().init(&device); + + let host: Vec = coef + .stds_recip + .to_data_as::() + .to_vec_as::() + .unwrap(); + for (i, &r) in host.iter().enumerate() { + let expected = 1.0 / (FEATURE_STDS[i] + FEATURE_EPS); + assert!((r - expected).abs() < 1e-6, "stds_recip[{i}]"); + } + } + + #[test] + #[should_panic(expected = "batch_size must be non-zero")] + fn test_init_state_rejects_zero_batch() { + let device = Default::default(); + let coef: TenVadFeatures = TenVadFeatureConfig::new().init(&device); + let _ = coef.init_state(0, ZeroPitch); + } + + #[test] + fn test_silence_hits_the_log_floor() { + // Digital silence drives every mel band to zero, so the log floor + // `ln(0 + FEATURE_EPS)` is what reaches the normalization. This pins + // the epsilon, the standardization, and the pitch column's position + // all at once. + let device = Default::default(); + let cfg = TenVadFeatureConfig::new(); + let mut ctx: TenVadFeatureContext = + cfg.try_init_context(1, ZeroPitch, &device).unwrap(); + + let hop = Tensor::::zeros([1, cfg.hop_size()], &device); + let feats = ctx.forward(hop); + assert_eq!(feats.dims(), [1, 41]); + + let host: Vec = feats.to_data_as::().to_vec_as::().unwrap(); + + let floor = FEATURE_EPS.ln(); + for i in 0..N_MELS { + let expected = (floor - FEATURE_MEANS[i]) / (FEATURE_STDS[i] + FEATURE_EPS); + assert!( + (host[i] - expected).abs() < 1e-4, + "silence feature {i}: {} vs {expected}", + host[i], + ); + } + + // Feature 40 is the pitch bin; under ZeroPitch it is the constant. + assert!( + (host[N_MELS] - ZeroPitch::normalized_feature()).abs() < 1e-5, + "pitch feature: {} vs {}", + host[N_MELS], + ZeroPitch::normalized_feature(), + ); + } + + #[test] + fn test_input_gain_shifts_log_mel_by_two_log_k() { + // The mel path is `ln(|k * X|^2 / c) = ln(|X|^2 / c) + 2 ln k`, so + // scaling the input shifts every normalized mel feature by a known + // amount. This pins that the log wraps the *power* and that the + // standardization is a plain affine map applied afterwards. + let device = Default::default(); + let cfg = TenVadFeatureConfig::new(); + let k = 4.0f32; + + // Loud enough that the epsilon floor is irrelevant. + let hop = Tensor::::random( + [1, cfg.hop_size()], + Distribution::Uniform(-0.5, 0.5), + &device, + ); + + let mut base: TenVadFeatureContext = + cfg.try_init_context(1, ZeroPitch, &device).unwrap(); + let mut scaled: TenVadFeatureContext = + cfg.try_init_context(1, ZeroPitch, &device).unwrap(); + + let a: Vec = base + .forward(hop.clone()) + .to_data_as::() + .to_vec_as::() + .unwrap(); + let b: Vec = scaled + .forward(hop.mul_scalar(k)) + .to_data_as::() + .to_vec_as::() + .unwrap(); + + let shift = 2.0 * k.ln(); + for i in 0..N_MELS { + let expected = shift / (FEATURE_STDS[i] + FEATURE_EPS); + assert!( + ((b[i] - a[i]) - expected).abs() < 1e-3, + "band {i}: delta {} vs {expected}", + b[i] - a[i], + ); + } + + // The pitch bin is scale-invariant under ZeroPitch. + assert!((b[N_MELS] - a[N_MELS]).abs() < 1e-6); + } + + /// A fully independent host reference for the whole front end. + /// + /// Deliberately written from the reference ordering rather than from the + /// tensor implementation: scale, pre-emphasize, slide the queue, window, + /// zero-pad, naive DFT, power, normalize, filterbank, log, standardize. + struct HostFeatures { + cfg: TenVadFeatureConfig, + window: Vec, + weights: Vec, + queue: Vec, + prev: f32, + } + + impl HostFeatures { + fn new(cfg: &TenVadFeatureConfig) -> Self { + Self { + window: cfg.stft.window.to_vec_window(cfg.win_len()), + weights: cfg.mel.to_vec_weights(), + queue: vec![0.0; cfg.win_len()], + prev: 0.0, + cfg: cfg.clone(), + } + } + + fn push( + &mut self, + hop: &[f32], + ) -> Vec { + let n_bins = self.cfg.n_bins(); + let n_mels = self.cfg.n_mels(); + let fft_size = self.cfg.fft_size(); + let win_len = self.cfg.win_len(); + + // 1. Scale to the reference's int16 range. + let raw: Vec = hop.iter().map(|&x| x * INPUT_SCALE).collect(); + + // 2. Pre-emphasis, on the STFT branch only. + let mut emph = Vec::with_capacity(raw.len()); + for (i, &x) in raw.iter().enumerate() { + let back = if i == 0 { self.prev } else { raw[i - 1] }; + emph.push(x - self.cfg.pre_emphasis.coeff * back); + } + self.prev = *raw.last().unwrap(); + + // 3. Slide the analysis queue. + self.queue.drain(..hop.len()); + self.queue.extend_from_slice(&emph); + + // 4. Window, zero-pad to fft_size, naive real DFT. + let mut bin_power = Vec::with_capacity(n_bins); + for k in 0..n_bins { + let (mut re, mut im) = (0.0f64, 0.0f64); + for n in 0..win_len { + let x = self.queue[n] as f64 * self.window[n]; + let theta = + core::f64::consts::TAU * ((n * k) % fft_size) as f64 / fft_size as f64; + re += x * theta.cos(); + im -= x * theta.sin(); + } + bin_power.push((re * re + im * im) as f32); + } + + // 5. Normalize the power, fold through the filterbank, log. + let mut feats = Vec::with_capacity(n_mels + 1); + for m in 0..n_mels { + let mut acc = 0.0f32; + for (j, &p) in bin_power.iter().enumerate() { + acc += (p / POWER_NORMAL) * self.weights[m * n_bins + j]; + } + feats.push((acc + FEATURE_EPS).ln()); + } + + // 6. The pitch bin, then standardization. + feats.push(0.0); + for (i, f) in feats.iter_mut().enumerate() { + *f = (*f - FEATURE_MEANS[i]) / (FEATURE_STDS[i] + FEATURE_EPS); + } + feats + } + } + + #[test] + fn test_forward_matches_independent_host_pipeline() { + let device = Default::default(); + let cfg = tiny_config(); + let hop_size = cfg.hop_size(); + let n_freq = cfg.n_freq(); + + let mut ctx: TenVadFeatureContext = + cfg.try_init_context(1, ZeroPitch, &device).unwrap(); + let mut host = HostFeatures::new(&cfg); + + // Several hops, so the warm-up frames and the steady state are both + // covered, and so the pre-emphasis carry is exercised. + for step in 0..5 { + let row: Vec = (0..hop_size) + .map(|i| { + let t = (step * hop_size + i) as f32; + 0.4 * (t * 0.11).sin() + 0.2 * (t * 0.37).cos() + }) + .collect(); + + let input = + Tensor::::from_data(TensorData::new(row.clone(), [1, hop_size]), &device); + let out = ctx.forward(input); + assert_eq!(out.dims(), [1, n_freq]); + + let expected = host.push(&row); + out.to_data_as::().assert_approx_eq::( + &TensorData::new(expected, [1, n_freq]).convert::(), + Tolerance::permissive(), + ); + } + } + + #[test] + fn test_forward_sequence_matches_stepwise() { + let device = Default::default(); + + for cfg in [tiny_config(), TenVadFeatureConfig::new()] { + let steps = 6; + let batch = 2; + let hop_size = cfg.hop_size(); + + let hops = + Tensor::::random([steps, batch, hop_size], Distribution::Default, &device); + + let mut seq_ctx: TenVadFeatureContext = + cfg.try_init_context(batch, ZeroPitch, &device).unwrap(); + let mut step_ctx = seq_ctx.clone(); + + let seq_out = seq_ctx.forward_sequence(hops.clone()); + assert_eq!(seq_out.dims(), [steps, batch, cfg.n_freq()]); + + let mut step_outs = Vec::with_capacity(steps); + for step in 0..steps { + step_outs.push(step_ctx.forward(hops.clone().select_dim::<2>(0, step))); + } + let step_out: Tensor = Tensor::stack(step_outs, 0); + + let tol = Tolerance::::permissive(); + seq_out + .to_data_as::() + .assert_approx_eq::(&step_out.to_data_as::(), tol); + + // Residual state must agree, or the next call diverges. + seq_ctx + .stft + .queue + .to_data_as::() + .assert_approx_eq::(&step_ctx.stft.queue.to_data_as::(), tol); + seq_ctx + .pre_emphasis + .prev + .to_data_as::() + .assert_approx_eq::(&step_ctx.pre_emphasis.prev.to_data_as::(), tol); + } + } + + #[test] + fn test_batch_rows_are_independent() { + let device = Default::default(); + let cfg = tiny_config(); + let hop_size = cfg.hop_size(); + let batch = 3; + + let hops = Tensor::::random([4, batch, hop_size], Distribution::Default, &device); + + let mut batched: TenVadFeatureContext = + cfg.try_init_context(batch, ZeroPitch, &device).unwrap(); + let batched_out = batched.forward_sequence(hops.clone()); + + for b in 0..batch { + let mut solo: TenVadFeatureContext = + cfg.try_init_context(1, ZeroPitch, &device).unwrap(); + // [steps, 1, hop_size] + let row = hops.clone().slice_dim(1, b as isize..(b + 1) as isize); + let solo_out = solo.forward_sequence(row); + + let expected = batched_out + .clone() + .slice_dim(1, b as isize..(b + 1) as isize); + solo_out + .to_data_as::() + .assert_approx_eq::(&expected.to_data_as::(), Tolerance::permissive()); + } + } + + #[test] + fn test_reset() { + let device = Default::default(); + let cfg = tiny_config(); + let hop_size = cfg.hop_size(); + + let mut ctx: TenVadFeatureContext = + cfg.try_init_context(1, ZeroPitch, &device).unwrap(); + + let hop = Tensor::::random([1, hop_size], Distribution::Default, &device); + + let first = ctx.forward(hop.clone()); + + // Advance the state, then wind it back. + ctx.forward(hop.clone()); + ctx.forward(hop.clone()); + ctx.reset(); + + let again = ctx.forward(hop); + again + .to_data_as::() + .assert_approx_eq::(&first.to_data_as::(), Tolerance::permissive()); + } + + #[test] + fn test_custom_pitch_source_reaches_feature_40() { + // A source that reports a fixed pitch must land, normalized, in the + // last feature slot -- and must be driven once per stream per frame. + #[derive(Clone, Default)] + struct FixedPitch { + hz: f32, + calls: usize, + } + + impl TenVadPitchSource for FixedPitch { + fn frame_pitch( + &mut self, + _raw_hop: &[f32], + _bin_power: &[f32], + ) -> f32 { + self.calls += 1; + self.hz + } + + fn reset(&mut self) { + self.calls = 0; + } + } + + let device = Default::default(); + let cfg = TenVadFeatureConfig::new(); + let hz = 220.0f32; + + let mut ctx: TenVadFeatureContext = cfg + .try_init_context(1, FixedPitch { hz, calls: 0 }, &device) + .unwrap(); + + let steps = 3; + let hops = Tensor::::zeros([steps, 1, cfg.hop_size()], &device); + let out = ctx.forward_sequence(hops); + + let host: Vec = out.to_data_as::().to_vec_as::().unwrap(); + let expected = (hz - FEATURE_MEANS[N_MELS]) / (FEATURE_STDS[N_MELS] + FEATURE_EPS); + + for step in 0..steps { + let got = host[step * cfg.n_freq() + N_MELS]; + assert!( + (got - expected).abs() < 1e-4, + "step {step} pitch feature: {got} vs {expected}", + ); + } + + // One call per frame, in order. + assert_eq!(ctx.pitch[0].calls, steps); + } +} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/mel.rs b/crates/bunsen/src/kits/speech/ten_vad/context/mel.rs new file mode 100644 index 00000000..88d3a837 --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/mel.rs @@ -0,0 +1,512 @@ +//! # ten-vad mel filterbank. +//! +//! The 40-band triangular filterbank the ten-vad front end folds its 513 bin +//! powers through (`ALGO_TRACE.md` §3.6). +//! +//! This is **not** a standard mel filterbank, and a librosa / Slaney-style +//! builder will not reproduce it. Three details are load-bearing. +//! +//! **Edge mapping.** Band edges are equally spaced on the HTK mel scale, then +//! mapped to FFT bins by a truncating integer cast: +//! +//! ```text +//! mel = 2595 * log10(1 + hz / 700) +//! bin = (usize) ((fft_size + 1) * hz / sample_rate) +//! ``` +//! +//! Note both the `fft_size + 1` and that the cast truncates, not rounds. +//! +//! **Integer slopes.** The triangles are built from integer bin differences, +//! so their slopes follow where the truncation landed rather than the exact +//! edge frequencies. +//! +//! **No area normalization.** Each filter rises from zero to one and falls +//! back to zero, peaking at exactly `1.0`. +//! +//! The edge arithmetic is deliberately done in `f32`, matching the reference; +//! computing it in `f64` can round an edge across an integer boundary and +//! silently produce a different filterbank. +//! +//! The pieces are: +//! * [`TenVadMelConfig`] — the geometry. +//! * [`TenVadMelBank`] — the materialized `[n_mels, n_bins]` filter matrix; +//! built by [`TenVadMelConfig::try_init`]. + +use burn::{ + config::Config, + prelude::*, +}; + +use crate::{ + errors::{ + BunsenError, + BunsenResult, + WithOkOrPanic, + }, + kits::speech::ten_vad::context::coeff::{ + N_MELS, + SAMPLE_RATE, + }, +}; + +/// Common meta for [`TenVadMelConfig`] and [`TenVadMelBank`]. +pub trait TenVadMelMeta { + /// The number of mel bands. + fn n_mels(&self) -> usize; + + /// The number of frequency bins consumed: `fft_size / 2 + 1`. + fn n_bins(&self) -> usize; +} + +/// Config for [`TenVadMelBank`]. +/// +/// Defaults match the ten-vad reference: 40 bands spanning 0 Hz to 8 kHz over +/// a 1024-point FFT at 16 kHz. +/// +/// Builds [`TenVadMelBank`]. Implements [`TenVadMelMeta`]. +#[derive(Config, Debug)] +pub struct TenVadMelConfig { + /// The number of mel bands. + #[config(default = "N_MELS")] + pub n_mels: usize, + + /// The FFT size the bin powers came from. + #[config(default = "1024")] + pub fft_size: usize, + + /// The sample rate, in Hz. + #[config(default = "SAMPLE_RATE")] + pub sample_rate: usize, + + /// The low edge of the filterbank, in Hz. + #[config(default = "0.0")] + pub f_min: f32, + + /// The high edge of the filterbank, in Hz. + #[config(default = "8000.0")] + pub f_max: f32, +} + +impl TenVadMelMeta for TenVadMelConfig { + fn n_mels(&self) -> usize { + self.n_mels + } + + fn n_bins(&self) -> usize { + self.fft_size / 2 + 1 + } +} + +/// Converts a frequency, in Hz, to the HTK mel scale. +/// +/// `mel = 2595 * log10(1 + hz / 700)`, evaluated in `f32` to match the +/// reference. +pub fn hz_to_mel(hz: f32) -> f32 { + 2595.0f32 * (1.0f32 + hz / 700.0f32).log10() +} + +/// Converts an HTK mel value back to a frequency, in Hz. +/// +/// The inverse of [`hz_to_mel`], evaluated in `f32` to match the reference. +pub fn mel_to_hz(mel: f32) -> f32 { + 700.0f32 * (10.0f32.powf(mel / 2595.0f32) - 1.0f32) +} + +impl TenVadMelConfig { + /// The `n_mels + 2` triangular band edges, as FFT bin indices. + /// + /// Edges are equally spaced on the mel scale between + /// [`f_min`](Self::f_min) and [`f_max`](Self::f_max), then mapped to bins + /// by the reference's truncating `(fft_size + 1) * hz / sample_rate` cast. + /// + /// Band `i` rises from `edges[i]`, peaks at `edges[i + 1]`, and falls to + /// `edges[i + 2]`. + pub fn bin_edges(&self) -> Vec { + let low_mel = hz_to_mel(self.f_min); + let high_mel = hz_to_mel(self.f_max); + + let steps = self.n_mels + 1; + (0..=steps) + .map(|i| { + let mel = low_mel + (high_mel - low_mel) * i as f32 / steps as f32; + let hz = mel_to_hz(mel); + // Truncating, as in the reference; note the `fft_size + 1`. + ((self.fft_size + 1) as f32 * hz / self.sample_rate as f32) as usize + }) + .collect() + } + + /// Validates the filterbank geometry. + /// + /// # Errors + /// + /// [`BunsenError::Invalid`] if `n_mels` or `fft_size` is zero, if the + /// frequency range is not increasing, or if two adjacent band edges land + /// on the same FFT bin (which would make a triangle infinitely steep). + pub fn validate(&self) -> BunsenResult<()> { + if self.n_mels == 0 { + return Err(BunsenError::Invalid( + "TenVadMel n_mels must be non-zero".to_string(), + )); + } + if self.fft_size == 0 { + return Err(BunsenError::Invalid( + "TenVadMel fft_size must be non-zero".to_string(), + )); + } + if self.sample_rate == 0 { + return Err(BunsenError::Invalid( + "TenVadMel sample_rate must be non-zero".to_string(), + )); + } + // Written through `partial_cmp` so a NaN bound is rejected, rather + // than silently passing a negated comparison. + if !matches!( + self.f_max.partial_cmp(&self.f_min), + Some(core::cmp::Ordering::Greater), + ) { + return Err(BunsenError::Invalid(format!( + "TenVadMel f_max ({}) must be > f_min ({})", + self.f_max, self.f_min, + ))); + } + + let edges = self.bin_edges(); + for i in 1..edges.len() { + if edges[i] == edges[i - 1] { + return Err(BunsenError::Invalid(format!( + "TenVadMel band edges {} and {} both land on bin {}; \ + the geometry is too coarse for {} bands", + i - 1, + i, + edges[i], + self.n_mels, + ))); + } + } + Ok(()) + } + + /// The `[n_mels, n_bins]` filter matrix, as a host-side row-major vector. + /// + /// Exposed so callers (and tests) can inspect the coefficients without a + /// device round trip. + pub fn to_vec_weights(&self) -> Vec { + let n_bins = self.n_bins(); + let edges = self.bin_edges(); + let mut weights = vec![0.0f32; self.n_mels * n_bins]; + + for i in 0..self.n_mels { + let (lo, mid, hi) = (edges[i], edges[i + 1], edges[i + 2]); + let row = i * n_bins; + + // Rising slope: 0 -> 1 across [lo, mid). + for j in lo..mid { + if j < n_bins { + weights[row + j] = (j - lo) as f32 / (mid - lo) as f32; + } + } + // Falling slope: 1 -> 0 across [mid, hi). The peak of exactly 1.0 + // lands at `mid`, from this loop's first iteration. + for j in mid..hi { + if j < n_bins { + weights[row + j] = (hi - j) as f32 / (hi - mid) as f32; + } + } + } + weights + } + + /// Initializes a [`TenVadMelBank`] on `device`. + /// + /// # Errors + /// + /// See [`validate`](Self::validate). + pub fn try_init( + &self, + device: &B::Device, + ) -> BunsenResult> { + self.validate()?; + + let weights = Tensor::from_data( + TensorData::new(self.to_vec_weights(), [self.n_mels, self.n_bins()]), + device, + ); + + Ok(TenVadMelBank { weights }) + } + + /// Initializes a [`TenVadMelBank`] on `device`, panicking on error. + pub fn init( + &self, + device: &B::Device, + ) -> TenVadMelBank { + self.try_init(device).ok_or_panic() + } +} + +/// The materialized ten-vad mel filterbank. +/// +/// Holds the dense `[n_mels, n_bins]` filter matrix. Stateless, so one +/// instance can be shared by (or cheaply cloned into) any number of streams. +/// This is deliberately **not** a burn `Module`: nothing here is a learnable +/// parameter, and the coefficients are fixed by the pretrained weights. +/// +/// Built by [`TenVadMelConfig`]. Implements [`TenVadMelMeta`]. +#[derive(Debug, Clone)] +pub struct TenVadMelBank { + /// The `[n_mels, n_bins]` triangular filter matrix. + pub weights: Tensor, +} + +impl TenVadMelMeta for TenVadMelBank { + fn n_mels(&self) -> usize { + self.weights.dims()[0] + } + + fn n_bins(&self) -> usize { + self.weights.dims()[1] + } +} + +impl TenVadMelBank { + /// Folds bin powers into mel band energies. + /// + /// # Arguments + /// * `bin_power`: `[rows, n_bins]` non-negative bin powers. The driver + /// passes a `[steps * batch, n_bins]` flatten, so `rows` is whatever + /// leading extent the caller has collapsed. + /// + /// # Returns + /// `[rows, n_mels]` band energies. + pub fn forward( + &self, + bin_power: Tensor, + ) -> Tensor { + #[cfg(any(test, debug_assertions))] + crate::contracts::assert_shape_contract!( + ["rows", "n_bins"], + &bin_power, + &[("n_bins", self.n_bins())], + ); + + bin_power.matmul(self.weights.clone().transpose()) + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::{ + Distribution, + Tolerance, + backend::BackendTypes, + }; + + use super::*; + use crate::{ + prelude::*, + support::testing::CpuBackend, + }; + + type B = CpuBackend; + type F = ::FloatElem; + + /// The reference band edges, in FFT bins, for the stock ten-vad geometry. + /// + /// Independently recomputed from the reference formula in `f32`; these are + /// the numbers the pretrained weights were trained against. + const REFERENCE_EDGES: [usize; 42] = [ + 0, 2, 5, 9, 12, 16, 19, 24, 28, 33, 38, 43, 48, 54, 61, 67, 75, 82, 90, 99, 108, 118, 128, + 139, 151, 163, 176, 190, 205, 221, 238, 256, 275, 296, 317, 340, 365, 391, 418, 448, 479, + 512, + ]; + + #[test] + fn test_config_meta() { + let cfg = TenVadMelConfig::new(); + assert_eq!(cfg.n_mels, 40); + assert_eq!(cfg.fft_size, 1024); + assert_eq!(cfg.sample_rate, 16000); + assert_eq!(cfg.f_min, 0.0); + assert_eq!(cfg.f_max, 8000.0); + + assert_eq!(cfg.n_mels(), 40); + assert_eq!(cfg.n_bins(), 513); + + cfg.validate().unwrap(); + } + + #[test] + fn test_mel_scale_round_trips() { + // The HTK mel scale, anchored at its defining points. + assert_eq!(hz_to_mel(0.0), 0.0); + assert_eq!(mel_to_hz(0.0), 0.0); + + // 700 Hz is one doubling of the `1 + hz/700` term: 2595 * log10(2). + assert!((hz_to_mel(700.0) - 2595.0 * 2.0f32.log10()).abs() < 1e-2); + + for hz in [100.0f32, 700.0, 1000.0, 4000.0, 8000.0] { + let back = mel_to_hz(hz_to_mel(hz)); + assert!((back - hz).abs() < 1e-2, "{hz} -> {back}"); + } + } + + #[test] + fn test_bin_edges_match_the_reference() { + let edges = TenVadMelConfig::new().bin_edges(); + assert_eq!(edges.len(), 42, "n_mels + 2 edges"); + assert_eq!(edges.as_slice(), REFERENCE_EDGES.as_slice()); + + // The bank spans the whole spectrum, exactly: bin 0 through bin 512. + assert_eq!(edges[0], 0); + assert_eq!(*edges.last().unwrap(), 512); + assert_eq!(*edges.last().unwrap(), TenVadMelConfig::new().n_bins() - 1); + + // Strictly increasing, so no triangle is degenerate. + for i in 1..edges.len() { + assert!(edges[i] > edges[i - 1], "edge {i} did not advance"); + } + } + + #[test] + fn test_weights_shape_and_range() { + let cfg = TenVadMelConfig::new(); + let weights = cfg.to_vec_weights(); + assert_eq!(weights.len(), 40 * 513); + + for (i, &w) in weights.iter().enumerate() { + assert!((0.0..=1.0).contains(&w), "weight[{i}] = {w} outside [0, 1]",); + } + + // 949 non-zero coefficients across the bank; a change here means the + // triangle geometry moved. + assert_eq!(weights.iter().filter(|&&w| w > 0.0).count(), 949); + } + + #[test] + fn test_each_filter_peaks_at_exactly_one() { + // The filters are *not* area-normalized: each rises 0 -> 1 and falls + // 1 -> 0, peaking at its centre edge. + let cfg = TenVadMelConfig::new(); + let edges = cfg.bin_edges(); + let weights = cfg.to_vec_weights(); + let n_bins = cfg.n_bins(); + + for i in 0..cfg.n_mels { + let row = &weights[i * n_bins..(i + 1) * n_bins]; + let peak = row.iter().copied().fold(0.0f32, f32::max); + assert_eq!(peak, 1.0, "filter {i} peak"); + assert_eq!(row[edges[i + 1]], 1.0, "filter {i} peak position"); + } + } + + #[test] + fn test_first_filter_coefficients() { + // Band 0 spans edges (0, 2, 5): it rises across bins 0..2 and falls + // across bins 2..5, so the exact triangle is checkable by hand. + let cfg = TenVadMelConfig::new(); + let weights = cfg.to_vec_weights(); + + let expected = [0.0, 0.5, 1.0, 2.0 / 3.0, 1.0 / 3.0, 0.0]; + for (j, &e) in expected.iter().enumerate() { + assert!( + (weights[j] - e).abs() < 1e-6, + "band 0 bin {j}: {} vs {e}", + weights[j], + ); + } + } + + #[test] + fn test_validate_rejects_bad_geometry() { + for bad in [ + TenVadMelConfig::new().with_n_mels(0), + TenVadMelConfig::new().with_fft_size(0), + TenVadMelConfig::new().with_sample_rate(0), + // f_max must exceed f_min. + TenVadMelConfig::new().with_f_max(0.0), + TenVadMelConfig::new().with_f_min(9000.0), + // Far too few bins to separate 40 bands: edges collide. + TenVadMelConfig::new().with_fft_size(64), + ] { + assert!( + matches!(bad.validate(), Err(BunsenError::Invalid(_))), + "expected Invalid: {bad:?}", + ); + } + } + + #[test] + fn test_init_meta_matches_config() { + let device = Default::default(); + let cfg = TenVadMelConfig::new(); + let bank: TenVadMelBank = cfg.init(&device); + + assert_eq!(bank.n_mels(), cfg.n_mels()); + assert_eq!(bank.n_bins(), cfg.n_bins()); + assert_eq!(bank.weights.dims(), [40, 513]); + + // The device matrix matches the host construction elementwise. + bank.weights.to_data_as::().assert_approx_eq::( + &TensorData::new(cfg.to_vec_weights(), [40, 513]).convert::(), + Tolerance::default(), + ); + } + + #[test] + fn test_forward_matches_naive_host_dot() { + let device = Default::default(); + let cfg = TenVadMelConfig::new(); + let bank: TenVadMelBank = cfg.init(&device); + + let rows = 3; + let n_bins = cfg.n_bins(); + + // Bin powers are non-negative by construction. + let bin_power = + Tensor::::random([rows, n_bins], Distribution::Uniform(0.0, 4.0), &device); + + let out = bank.forward(bin_power.clone()); + assert_eq!(out.dims(), [rows, cfg.n_mels()]); + + let host_power: Vec = bin_power.to_data_as::().to_vec_as::().unwrap(); + let host_weights = cfg.to_vec_weights(); + + let mut expected = Vec::with_capacity(rows * cfg.n_mels()); + for r in 0..rows { + for m in 0..cfg.n_mels() { + let mut acc = 0.0f32; + for j in 0..n_bins { + acc += host_power[r * n_bins + j] * host_weights[m * n_bins + j]; + } + expected.push(acc); + } + } + + out.to_data_as::().assert_approx_eq::( + &TensorData::new(expected, [rows, cfg.n_mels()]).convert::(), + Tolerance::permissive(), + ); + } + + #[test] + fn test_forward_is_non_negative_and_linear() { + // The bank is a non-negative linear map, so scaling the input scales + // the output and the result never goes negative. + let device = Default::default(); + let bank: TenVadMelBank = TenVadMelConfig::new().init(&device); + + let bin_power = Tensor::::random([2, 513], Distribution::Uniform(0.0, 1.0), &device); + + let once = bank.forward(bin_power.clone()); + let twice = bank.forward(bin_power.mul_scalar(2.0)); + + let host: Vec = once.to_data_as::().to_vec_as::().unwrap(); + assert!(host.iter().all(|&v| v >= 0.0)); + + twice.to_data_as::().assert_approx_eq::( + &once.mul_scalar(2.0).to_data_as::(), + Tolerance::permissive(), + ); + } +} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs new file mode 100644 index 00000000..d5333842 --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs @@ -0,0 +1,100 @@ +//! # ten-vad pre-processing driver. +//! +//! Everything between raw audio and the `[batch, d_ctx, n_freq]` feature +//! stack [`TenVad::forward`] consumes, plus the mutable context that carries +//! it across calls. +//! +//! [`TenVad::forward`] is the stateless call through the network; +//! [`TenVad::context_forward`] is what turns audio into the widened, +//! normalized input that call expects. The `_sequence` forms are equivalent +//! to iterating their single-step counterparts, but run the whole front end +//! batched across the sequence. +//! +//! ## The pipeline +//! +//! Per 256-sample hop, reproducing the reference C driver +//! (`ALGO_TRACE.md` §3.3 - §3.7, §5): +//! +//! ```text +//! raw = hop * 32768 # [-1, 1] -> int16 scale +//! emph = raw[n] - 0.97 * raw[n-1] # carry = previous raw sample +//! binpow = |rfft(hann768 * queue768, n=1024)|^2 # 513 bins, queue = last 3 hops +//! pitch = pitch_estimate(raw, binpow) # Hz, 0 = unvoiced +//! mel = ln(melbank40(binpow / 32768^2) + 1e-20) # 40 triangular filters +//! feat = (concat(mel, [pitch]) - MEANS) / (STDS + 1e-20) +//! stack = concat(stack[:, 1:], feat) # [1, 3, 41] +//! ``` +//! +//! Two orderings are load-bearing and easy to get backwards: +//! +//! * **Pitch runs before the power normalization**, and reads the raw, +//! un-pre-emphasized hop. The reference keeps two parallel FIFOs for exactly +//! this reason. +//! * **The `1 / 32768^2` division happens before the filterbank matmul.** It +//! commutes algebraically with the matmul but not in `f32`. +//! +//! ## The pieces +//! +//! * [`coeff`](self) — the reference constants and normalization tables. +//! * [`PreEmphasisContext`] — the first-order high-pass, with carry. +//! * [`TenVadMelBank`] — the 40-band triangular filterbank. +//! * [`TenVadPitchSource`] — the pitch seam; [`ZeroPitch`] by default. +//! * [`TenVadFeatureContext`] — the 41-dim feature extractor and its state. +//! * [`TenVadContext`] — the driving context: features, frame stack, and both +//! LSTM states. +//! +//! The sliding STFT itself is [`SlidingStftContext`], which already ports the +//! reference analyzer. +//! +//! ## Known deviations from the reference driver +//! +//! * **The pitch feature is stubbed.** [`ZeroPitch`] pins feature `40` to a +//! constant. The other 40 features are exact. Porting the real estimator is a +//! drop-in behind [`TenVadPitchSource`]. +//! * **No periodic state reset.** The C driver zeroes both LSTM states every +//! `resetFrameNum = 1875` model calls — 30 s of audio — while leaving the +//! feature stack intact (`ALGO_TRACE.md` §5). This driver does not, so +//! `context_forward_sequence` stays exactly "iterating `context_forward`". +//! Byte-parity with the C driver on clips longer than 30 s needs it. +//! * **Batch size 1.** The stock ONNX graph pins its LSTM batch to 1; see +//! [`TenVad::forward`] for what the leading axis actually means. +//! +//! ## Establishing a numeric golden +//! +//! There is no checked-in feature golden yet. The recipe, once the pitch +//! estimator lands so a golden can cover all 41 bins: +//! +//! 1. Dump per-hop 41-dim feature vectors from a reference implementation over +//! a short 16 kHz mono clip. +//! 2. Check the vectors into `crates/bunsen/testdata/ten/`. +//! 3. Assert [`TenVadFeatureContext::forward_sequence`] reproduces them. +//! +//! Until then, [`TenVadFeatureContext`]'s own tests pin the pipeline against +//! an independent host implementation written from the reference ordering, +//! and the kit's cross test pins the driver against the ONNX graph over real +//! audio. +//! +//! [`TenVad::forward`]: crate::kits::speech::ten_vad::TenVad::forward +//! [`TenVad::context_forward`]: crate::kits::speech::ten_vad::TenVad::context_forward +//! [`SlidingStftContext`]: crate::ops::signal::SlidingStftContext + +pub mod coeff; + +mod driver; +mod features; +mod mel; +mod pitch; +mod pre_emphasis; + +#[doc(inline)] +pub use coeff::*; +#[doc(inline)] +pub use driver::*; +#[doc(inline)] +pub use features::*; +#[doc(inline)] +pub use mel::*; +#[doc(inline)] +pub use pitch::*; +#[doc(inline)] +pub use pre_emphasis::*; diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch.rs new file mode 100644 index 00000000..81bb5cbd --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch.rs @@ -0,0 +1,185 @@ +//! # ten-vad pitch feature source. +//! +//! Feature `40` of the ten-vad feature vector is a pitch estimate, in Hz, +//! with `0.0` meaning "unvoiced" (`ALGO_TRACE.md` §3.5). +//! +//! The reference estimator is a large, deeply serial RNNoise-derived tracker: +//! band energy -> DCT -> celt LPC -> LPC pre-filter -> a five-section IIR +//! cascade -> 4 kHz decimation -> normalized moving cross-correlation -> +//! Viterbi DP over candidate periods -> weighted linear regression. None of +//! it vectorizes, and all of it carries state across frames. +//! +//! Rather than block the rest of the front end on that port, the driver takes +//! its pitch through the [`TenVadPitchSource`] seam, and ships [`ZeroPitch`] +//! as the default. Everything upstream of feature `40` — pre-emphasis, the +//! sliding STFT, the mel filterbank, the log, the normalization, and the +//! frame context stack — is complete and exercised regardless. +//! +//! An implementation of the real estimator drops in behind this trait without +//! touching the driver. + +use crate::kits::speech::ten_vad::context::coeff::{ + FEATURE_EPS, + FEATURE_MEANS, + FEATURE_STDS, + N_MELS, +}; + +/// A source for the ten-vad pitch feature. +/// +/// Implemented by [`ZeroPitch`]. +/// +/// The interface is host-side by necessity: the reference algorithm is a +/// serial recurrence over scalars, not a tensor op. Implementations are +/// per-stream — the driver runs one instance per batch row. +pub trait TenVadPitchSource { + /// Estimates the pitch of one hop. + /// + /// # Arguments + /// * `raw_hop` - the hop's samples at the reference's int16 scale. These + /// are the **raw** samples: pre-emphasis is applied only to the STFT + /// branch, never to the pitch branch (`ALGO_TRACE.md` §3.3). + /// * `bin_power` - the `[n_bins]` bin powers, `re^2 + im^2`, **before** the + /// `1 / 32768^2` normalization the mel branch applies. + /// + /// # Returns + /// The pitch in Hz, or `0.0` when nothing voiced was detected. + fn frame_pitch( + &mut self, + raw_hop: &[f32], + bin_power: &[f32], + ) -> f32; + + /// Whether [`frame_pitch`](Self::frame_pitch) inspects its arguments. + /// + /// The driver reads `raw_hop` and `bin_power` back from the device to + /// call [`frame_pitch`](Self::frame_pitch). Returning `false` lets it skip + /// that synchronization entirely and keep the sequence path on-device. + /// + /// An implementation returning `false` must produce the same value for + /// every input, and must not depend on being called once per frame. + fn inspects_input(&self) -> bool { + true + } + + /// Resets any carried state to the start-of-stream condition. + fn reset(&mut self); +} + +/// A [`TenVadPitchSource`] that always reports unvoiced. +/// +/// Feature `40` is then pinned to the constant +/// `(0.0 - FEATURE_MEANS[40]) / (FEATURE_STDS[40] + FEATURE_EPS)`, which +/// [`ZeroPitch::normalized_feature`] reports. +/// +/// This is the driver's default until the reference estimator is ported. The +/// other 40 features are unaffected: nothing upstream of the pitch branch +/// reads its output. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ZeroPitch; + +impl ZeroPitch { + /// The normalized value feature `40` takes under [`ZeroPitch`]. + pub fn normalized_feature() -> f32 { + (0.0 - FEATURE_MEANS[N_MELS]) / (FEATURE_STDS[N_MELS] + FEATURE_EPS) + } +} + +impl TenVadPitchSource for ZeroPitch { + fn frame_pitch( + &mut self, + _raw_hop: &[f32], + _bin_power: &[f32], + ) -> f32 { + 0.0 + } + + fn inspects_input(&self) -> bool { + false + } + + fn reset(&mut self) {} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_zero_pitch_is_always_unvoiced() { + let mut pitch = ZeroPitch; + + assert_eq!(pitch.frame_pitch(&[], &[]), 0.0); + assert_eq!(pitch.frame_pitch(&[1.0, -2.0, 3.0], &[9.0; 513]), 0.0); + assert_eq!(pitch.frame_pitch(&[f32::MAX], &[f32::MIN]), 0.0); + } + + #[test] + fn test_zero_pitch_skips_readback() { + // The driver keys its device-to-host sync off this. + assert!(!ZeroPitch.inspects_input()); + } + + #[test] + fn test_zero_pitch_reset_is_stateless() { + let mut pitch = ZeroPitch; + let before = pitch.frame_pitch(&[1.0], &[1.0]); + pitch.reset(); + let after = pitch.frame_pitch(&[1.0], &[1.0]); + assert_eq!(before, after); + assert_eq!(pitch, ZeroPitch); + } + + #[test] + fn test_normalized_feature_matches_the_normalization_formula() { + let expected = (0.0 - FEATURE_MEANS[N_MELS]) / (FEATURE_STDS[N_MELS] + FEATURE_EPS); + assert_eq!(ZeroPitch::normalized_feature(), expected); + + // A pitch mean of ~92.36 Hz over a std of ~115.21 puts silence a bit + // over half a standard deviation below the mean. + assert!(ZeroPitch::normalized_feature() < 0.0); + assert!((ZeroPitch::normalized_feature() - (-0.80161)).abs() < 1e-4); + } + + #[test] + fn test_usable_as_a_trait_object() { + let mut pitch: Box = Box::new(ZeroPitch); + assert_eq!(pitch.frame_pitch(&[0.5], &[0.5]), 0.0); + assert!(!pitch.inspects_input()); + pitch.reset(); + } + + /// A minimal non-trivial implementation, to prove the seam is usable and + /// that `inspects_input` defaults to `true`. + #[derive(Default)] + struct CountingPitch { + calls: usize, + } + + impl TenVadPitchSource for CountingPitch { + fn frame_pitch( + &mut self, + raw_hop: &[f32], + _bin_power: &[f32], + ) -> f32 { + self.calls += 1; + raw_hop.len() as f32 + } + + fn reset(&mut self) { + self.calls = 0; + } + } + + #[test] + fn test_custom_source_defaults_to_inspecting_input() { + let mut pitch = CountingPitch::default(); + assert!(pitch.inspects_input()); + + assert_eq!(pitch.frame_pitch(&[0.0; 4], &[]), 4.0); + assert_eq!(pitch.calls, 1); + + pitch.reset(); + assert_eq!(pitch.calls, 0); + } +} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pre_emphasis.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pre_emphasis.rs new file mode 100644 index 00000000..e278d0f6 --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pre_emphasis.rs @@ -0,0 +1,387 @@ +//! # Streaming pre-emphasis filter. +//! +//! The first-order high-pass the ten-vad front end applies to the audio +//! before the STFT: +//! +//! ```text +//! y[n] = x[n] - coeff * x[n-1] +//! ``` +//! +//! The reference keeps two parallel FIFOs (`ALGO_TRACE.md` §3.3): raw samples +//! feed the pitch estimator, pre-emphasized samples feed the STFT. Only the +//! STFT branch is filtered, and the filter is continuous across hop +//! boundaries — the previous hop's last **raw** sample is carried forward, so +//! a stream chopped into hops yields exactly the same output as the whole +//! stream filtered at once. +//! +//! The pieces are: +//! * [`PreEmphasisConfig`] — the coefficient. +//! * [`PreEmphasisContext`] — a streaming state (the carried sample) bound to a +//! batch; built by [`PreEmphasisConfig::init`]. +//! +//! Note: this is the most generalizable block in the ten-vad front end — a +//! first-order FIR with carry, with nothing ten-vad-specific but its default +//! coefficient. It lives here rather than in [`crate::ops::signal`] until a +//! second caller justifies the general form. + +use burn::{ + config::Config, + prelude::*, +}; + +use crate::{ + contracts::unpack_shape_contract, + kits::speech::ten_vad::context::coeff::PRE_EMPHASIS_COEFF, + prelude::TensorOpExt, +}; + +/// Config for [`PreEmphasisContext`]. +/// +/// Builds [`PreEmphasisContext`] via [`init`](Self::init). +#[derive(Config, Debug, Copy)] +pub struct PreEmphasisConfig { + /// The pre-emphasis coefficient. + /// + /// Defaults to the ten-vad reference value, + /// [`PRE_EMPHASIS_COEFF`]. + #[config(default = "PRE_EMPHASIS_COEFF")] + pub coeff: f32, +} + +impl PreEmphasisConfig { + /// Initializes a zeroed [`PreEmphasisContext`] for `batch_size` streams. + /// + /// The carried sample starts at zero, so the first sample of the first + /// hop passes through unfiltered. + /// + /// # Arguments + /// * `batch_size`: the number of independent streams; must be non-zero. + pub fn init( + &self, + batch_size: usize, + device: &B::Device, + ) -> PreEmphasisContext { + assert_ne!(batch_size, 0, "PreEmphasis batch_size must be non-zero"); + PreEmphasisContext { + coeff: self.coeff, + prev: Tensor::zeros([batch_size], device), + } + } +} + +/// Streaming pre-emphasis state. +/// +/// Holds the coefficient and the `[batch]` last raw sample of each stream. +/// +/// Built by [`PreEmphasisConfig::init`]. +#[derive(Debug, Clone)] +pub struct PreEmphasisContext { + coeff: f32, + + /// The `[batch]` last raw input sample of each stream. + /// + /// Zero before the stream starts. + pub prev: Tensor, +} + +impl PreEmphasisContext { + /// The pre-emphasis coefficient. + pub fn coeff(&self) -> f32 { + self.coeff + } + + /// The batch size; each batch row is an independent stream. + pub fn batch_size(&self) -> usize { + self.prev.dims()[0] + } + + /// Resets the carried sample to zero. + pub fn reset(&mut self) { + self.prev = Tensor::zeros_like(&self.prev); + } + + /// Filters one hop, carrying the boundary sample forward. + /// + /// # Arguments + /// * `hop`: `[batch, samples]` new raw samples, with `samples` non-zero. + /// + /// # Returns + /// `[batch, samples]` pre-emphasized samples. + pub fn forward( + &mut self, + hop: Tensor, + ) -> Tensor { + #[cfg(any(test, debug_assertions))] + let [samples] = unpack_shape_contract!( + ["batch", "samples"], + &hop, + &["samples"], + &[("batch", self.batch_size())], + ); + #[cfg(not(any(test, debug_assertions)))] + let samples = hop.dims()[1]; + + assert_ne!(samples, 0, "PreEmphasis hop must be non-empty"); + + // `[batch, samples]`: the input delayed by one, with the carried + // sample filling the first slot. + // [batch, 1] + let carry: Tensor = self.prev.extract().unsqueeze_dim(1); + let delayed = if samples == 1 { + carry + } else { + let head = hop.clone().slice_dim(1, ..(samples - 1) as isize); + Tensor::cat(vec![carry, head], 1) + }; + + // Carry the last *raw* sample, not the filtered one. + self.prev = hop.clone().select_dim::<1>(1, samples - 1); + + hop - delayed.mul_scalar(self.coeff) + } + + /// Filters `steps` consecutive hops at once. + /// + /// Equivalent to `steps` calls of [`forward`](Self::forward): the hops are + /// concatenated into one per-stream signal, filtered in a single pass, and + /// split back apart. The filter is causal with a one-sample memory, so + /// splitting the pass at hop boundaries changes nothing. + /// + /// # Arguments + /// * `hops`: `[steps, batch, samples]` consecutive raw hops. + /// + /// # Returns + /// `[steps, batch, samples]` pre-emphasized hops. + pub fn forward_sequence( + &mut self, + hops: Tensor, + ) -> Tensor { + #[cfg(any(test, debug_assertions))] + let [steps, samples] = unpack_shape_contract!( + ["steps", "batch", "samples"], + &hops, + &["steps", "samples"], + &[("batch", self.batch_size())], + ); + #[cfg(not(any(test, debug_assertions)))] + let [steps, _, samples] = hops.dims(); + + assert_ne!(steps, 0, "PreEmphasis hops must be non-empty"); + + let batch = self.batch_size(); + + // [batch, steps * samples] + let stream = hops.swap_dims(0, 1).flatten::<2>(1, 2); + + // [batch, steps * samples] + let out = self.forward(stream); + + // [steps, batch, samples] + out.reshape([batch, steps, samples]).swap_dims(0, 1) + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::{ + Distribution, + Tolerance, + backend::BackendTypes, + }; + + use super::*; + use crate::{ + prelude::*, + support::testing::CpuBackend, + }; + + type B = CpuBackend; + type F = ::FloatElem; + + /// Host reference: the scalar filter, over one stream, with carry. + fn host_pre_emphasis( + signal: &[f32], + coeff: f32, + prev: &mut f32, + ) -> Vec { + let mut out = Vec::with_capacity(signal.len()); + for (i, &x) in signal.iter().enumerate() { + let back = if i == 0 { *prev } else { signal[i - 1] }; + out.push(x - coeff * back); + } + if let Some(&last) = signal.last() { + *prev = last; + } + out + } + + #[test] + fn test_config_default() { + let cfg = PreEmphasisConfig::new(); + assert_eq!(cfg.coeff, PRE_EMPHASIS_COEFF); + assert_eq!(cfg.coeff, 0.97); + + let cfg = PreEmphasisConfig::new().with_coeff(0.5); + assert_eq!(cfg.coeff, 0.5); + } + + #[test] + fn test_init_state() { + let device = Default::default(); + let ctx: PreEmphasisContext = PreEmphasisConfig::new().init(3, &device); + + assert_eq!(ctx.batch_size(), 3); + assert_eq!(ctx.coeff(), PRE_EMPHASIS_COEFF); + assert_eq!(ctx.prev.dims(), [3]); + + // The carried sample starts at zero. + ctx.prev + .to_data() + .assert_eq(&Tensor::::zeros([3], &device).to_data(), true); + } + + #[test] + #[should_panic(expected = "batch_size must be non-zero")] + fn test_init_rejects_zero_batch() { + let device = Default::default(); + let _: PreEmphasisContext = PreEmphasisConfig::new().init(0, &device); + } + + #[test] + fn test_forward_matches_host_reference() { + let device = Default::default(); + let coeff = PRE_EMPHASIS_COEFF; + let mut ctx: PreEmphasisContext = PreEmphasisConfig::new().init(1, &device); + + // Two consecutive hops: the second must see the first's last sample. + let hops = [vec![1.0f32, 2.0, 3.0, 4.0], vec![5.0f32, -6.0, 7.0, -8.0]]; + + let mut host_prev = 0.0f32; + for hop in &hops { + let expected = host_pre_emphasis(hop, coeff, &mut host_prev); + + let input = + Tensor::::from_data(TensorData::new(hop.clone(), [1, hop.len()]), &device); + let out = ctx.forward(input); + + out.to_data_as::().assert_approx_eq::( + &TensorData::new(expected, [1, hop.len()]).convert::(), + Tolerance::default(), + ); + } + + // The carried sample is the last *raw* input, not the filtered one. + ctx.prev.to_data_as::().assert_approx_eq::( + &TensorData::new(vec![-8.0f32], [1]).convert::(), + Tolerance::default(), + ); + } + + #[test] + fn test_forward_single_sample_hop() { + // A one-sample hop is entirely carry-driven; it exercises the branch + // where there is no in-hop history at all. + let device = Default::default(); + let mut ctx: PreEmphasisContext = + PreEmphasisConfig::new().with_coeff(0.5).init(1, &device); + + let first = Tensor::::from_data(TensorData::new(vec![4.0f32], [1, 1]), &device); + let out = ctx.forward(first); + // 4.0 - 0.5 * 0.0 + out.to_data_as::().assert_approx_eq::( + &TensorData::new(vec![4.0f32], [1, 1]).convert::(), + Tolerance::default(), + ); + + let second = Tensor::::from_data(TensorData::new(vec![10.0f32], [1, 1]), &device); + let out = ctx.forward(second); + // 10.0 - 0.5 * 4.0 + out.to_data_as::().assert_approx_eq::( + &TensorData::new(vec![8.0f32], [1, 1]).convert::(), + Tolerance::default(), + ); + } + + #[test] + fn test_batch_rows_are_independent() { + let device = Default::default(); + let coeff = PRE_EMPHASIS_COEFF; + let batch = 3; + let samples = 5; + + let rows: Vec> = (0..batch) + .map(|b| (0..samples).map(|i| (b * 10 + i) as f32).collect()) + .collect(); + + let mut ctx: PreEmphasisContext = PreEmphasisConfig::new().init(batch, &device); + let input = + Tensor::::from_data(TensorData::new(rows.concat(), [batch, samples]), &device); + let out = ctx.forward(input); + + let expected: Vec = rows + .iter() + .flat_map(|row| { + let mut prev = 0.0f32; + host_pre_emphasis(row, coeff, &mut prev) + }) + .collect(); + + out.to_data_as::().assert_approx_eq::( + &TensorData::new(expected, [batch, samples]).convert::(), + Tolerance::default(), + ); + } + + #[test] + fn test_forward_sequence_matches_stepwise() { + let device = Default::default(); + let steps = 4; + let batch = 2; + let samples = 6; + + let hops = Tensor::::random([steps, batch, samples], Distribution::Default, &device); + + let mut seq_ctx: PreEmphasisContext = PreEmphasisConfig::new().init(batch, &device); + let mut step_ctx = seq_ctx.clone(); + + let seq_out = seq_ctx.forward_sequence(hops.clone()); + assert_eq!(seq_out.dims(), [steps, batch, samples]); + + let mut step_outs = Vec::with_capacity(steps); + for step in 0..steps { + step_outs.push(step_ctx.forward(hops.clone().select_dim::<2>(0, step))); + } + let step_out: Tensor = Tensor::stack(step_outs, 0); + + let tol = Tolerance::::default(); + seq_out + .to_data_as::() + .assert_approx_eq::(&step_out.to_data_as::(), tol); + + // The residual carry must agree too, or the next call diverges. + seq_ctx + .prev + .to_data_as::() + .assert_approx_eq::(&step_ctx.prev.to_data_as::(), tol); + } + + #[test] + fn test_reset() { + let device = Default::default(); + let mut ctx: PreEmphasisContext = PreEmphasisConfig::new().init(1, &device); + + let hop = Tensor::::from_data(TensorData::new(vec![3.0f32, 9.0], [1, 2]), &device); + let first = ctx.forward(hop.clone()); + + ctx.reset(); + ctx.prev + .to_data() + .assert_eq(&Tensor::::zeros([1], &device).to_data(), true); + + // After a reset the same hop reproduces the very first output. + let again = ctx.forward(hop); + again + .to_data_as::() + .assert_approx_eq::(&first.to_data_as::(), Tolerance::default()); + } +} diff --git a/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs b/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs index 42c4a2b9..90cba047 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs @@ -1,5 +1,5 @@ #[cfg(test)] -mod test { +mod tests { use burn::tensor::{ Distribution, Tensor, @@ -11,15 +11,20 @@ mod test { blocks::rnn::lstm::ExtLstmState, kits::speech::ten_vad::{ TenVad, + TenVadContextConfig, + TenVadContextMeta, + TenVadMeta, reference::ReferenceModel, }, prelude::*, - support::testing::PerformanceBackend, + support::testing::{ + PerformanceBackend, + audio::load_audio_mono_sr, + }, }; #[test] #[serial_test::serial] - #[allow(unused)] fn test_reference_model_forward_cross_test() { type B = PerformanceBackend; type F = ::FloatElem; @@ -30,7 +35,8 @@ mod test { let vad: TenVad = TenVad::load_pretrained(&device).unwrap(); - // TODO: batch support appears to be broken? + // The leading axis is the graph's sequence axis, not a stream batch; + // see `TenVad::forward`. Both are 1 here. let shape = [1, 64]; let input = Tensor::random([1, 3, 41], Distribution::Default, &device); @@ -74,4 +80,156 @@ mod test { .to_data_as::() .assert_approx_eq::(&ref_lstm2_cell.to_data_as::(), Tolerance::permissive()); } + + /// Drives real audio end-to-end through the context driver, and pins + /// three independent arms against each other: + /// + /// 1. [`TenVad::context_forward_sequence`] — the batched front end, + /// 2. [`TenVad::context_forward`] — the same work, one hop at a time, + /// 3. the ONNX reference graph, stepped by hand over the *driver's own* + /// feature stacks with the recurrence threaded externally. + /// + /// Arms 1 and 2 pin that the sequence form really is "iterating the + /// non-sequence form"; arm 3 pins that the widened stack the driver builds + /// is what the reference model expects, and that bunsen's port of the + /// network agrees with the graph over a whole stream rather than a single + /// random frame. + #[test] + #[serial_test::serial] + fn test_reference_model_context_forward_sequence_cross_test() + -> Result<(), Box> { + type B = PerformanceBackend; + type F = ::FloatElem; + + let device = Default::default(); + + let vad: TenVad = TenVad::load_pretrained(&device)?; + let ref_vad: ReferenceModel = ReferenceModel::load_pretrained(&device); + + let cfg = TenVadContextConfig::new(); + let sample_rate = cfg.sample_rate(); + let hop_size = cfg.hop_size(); + + // A bounded slice of the shared 16 kHz fixture: the reference arm runs + // one ONNX call per hop, and the full file is 3750 of them. + const STEPS: usize = 400; + + let wav_path = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata/silero/test.wav"); + let (_, wav_vec) = load_audio_mono_sr(wav_path, sample_rate)?; + assert!( + wav_vec.len() >= STEPS * hop_size, + "fixture is too short: {} samples", + wav_vec.len(), + ); + + // [steps, batch=1, hop_size] + let hop_seq: Tensor = + Tensor::::from_floats(&wav_vec[..STEPS * hop_size], &device) + .reshape([STEPS, 1, hop_size]); + + // Arm 1: the batched sequence path. + let mut seq_ctx = vad.init_context(&cfg, &device)?; + let seq_probs = vad.context_forward_sequence(hop_seq.clone(), &mut seq_ctx); + assert_eq!(seq_probs.dims(), [STEPS, 1]); + + // Arm 2: the same stream, one hop at a time. + let mut step_ctx = vad.init_context(&cfg, &device)?; + let mut step_probs = Vec::with_capacity(STEPS); + for step in 0..STEPS { + let hop = hop_seq.clone().select_dim::<2>(0, step); + step_probs.push(vad.context_forward(hop, &mut step_ctx)); + } + let step_probs: Tensor = Tensor::stack(step_probs, 0); + + // Arm 3: the reference graph, fed the driver's own feature stacks. + let mut ref_ctx = vad.init_context(&cfg, &device)?; + let mut ref_state1 = ExtLstmState::initial([1, vad.d_hidden()], &device); + let mut ref_state2 = ExtLstmState::initial([1, vad.d_hidden()], &device); + let mut ref_probs = Vec::with_capacity(STEPS); + + for step in 0..STEPS { + let hop = hop_seq.clone().select_dim::<2>(0, step); + + // [batch, n_freq] -> [batch, d_ctx, n_freq] + let feats = ref_ctx.features.forward(hop); + let stack = ref_ctx.push_features(feats); + + // The graph takes `(features, h1, c1, h2, c2)` positionally. + let (prob, h1, c1, h2, c2) = ref_vad.forward( + stack, + ref_state1.hidden.clone(), + ref_state1.cell.clone(), + ref_state2.hidden.clone(), + ref_state2.cell.clone(), + ); + ref_state1 = ExtLstmState::new(c1, h1); + ref_state2 = ExtLstmState::new(c2, h2); + + // [1, 1, 1] -> [1] + ref_probs.push(prob.reshape([1])); + } + let ref_probs: Tensor = Tensor::stack(ref_probs, 0); + + // The sequence and stepwise arms run identical arithmetic in a + // different order. + let probs = seq_probs.clone(); + seq_probs + .clone() + .to_data_as::() + .assert_approx_eq::(&step_probs.to_data_as::(), Tolerance::permissive()); + + // The reference arm crosses an independently generated graph. + seq_probs + .to_data_as::() + .assert_approx_eq::(&ref_probs.to_data_as::(), Tolerance::permissive()); + + // The carried recurrence must land in the same place, or a resumed + // stream would diverge from the reference after the first chunk. + for (ours, theirs) in [ + (&seq_ctx.state1, &ref_state1), + (&seq_ctx.state2, &ref_state2), + ] { + ours.hidden + .to_data_as::() + .assert_approx_eq::(&theirs.hidden.to_data_as::(), Tolerance::permissive()); + ours.cell + .to_data_as::() + .assert_approx_eq::(&theirs.cell.to_data_as::(), Tolerance::permissive()); + } + + // And the driver's feature history must match what the reference arm + // was actually fed. + seq_ctx + .stack + .to_data_as::() + .assert_approx_eq::(&ref_ctx.stack.to_data_as::(), Tolerance::permissive()); + + // All three arms share the driver's features, so agreement alone + // would also hold for a driver emitting constants. Check the output + // is actually tracking the audio: the fixture is continuous speech + // entered from a zeroed context, so the probabilities must be valid, + // must span a real range, and must sit mostly high. + let host: Vec = probs.to_data_as::().to_vec_as::().unwrap(); + assert!( + host.iter().all(|&p| (0.0..=1.0).contains(&p)), + "probabilities outside [0, 1]", + ); + + let min = host.iter().copied().fold(f32::INFINITY, f32::min); + let max = host.iter().copied().fold(f32::NEG_INFINITY, f32::max); + assert!( + max - min > 0.2, + "probabilities are nearly constant ({min} ..= {max}); \ + the front end is not tracking the audio", + ); + + let voiced = host.iter().filter(|&&p| p > 0.5).count(); + assert!( + voiced * 2 > host.len(), + "only {voiced}/{} frames read as speech on a speech fixture", + host.len(), + ); + + Ok(()) + } } diff --git a/crates/bunsen/src/kits/speech/ten_vad/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/mod.rs index 6ab807f0..1985c733 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/mod.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/mod.rs @@ -15,6 +15,9 @@ mod cross_test; pub mod pretrained; pub mod blocks; +pub mod context; #[doc(inline)] pub use blocks::*; +#[doc(inline)] +pub use context::*; diff --git a/crates/bunsen/src/support/testing/mod.rs b/crates/bunsen/src/support/testing/mod.rs index 72e2478e..45702315 100644 --- a/crates/bunsen/src/support/testing/mod.rs +++ b/crates/bunsen/src/support/testing/mod.rs @@ -79,3 +79,75 @@ mod tests { assert_close_to_vec(&actual, &expected, 0.01); } } + +/// Audio fixture loading, for tests that drive models from real waveforms. +#[cfg(test)] +pub mod audio { + use std::path::Path; + + use hound::{ + SampleFormat, + WavReader, + WavSpec, + }; + + use crate::errors::{ + BunsenError, + BunsenResult, + }; + + /// Loads a mono audio file as `[-1, 1]` samples. + /// + /// Integer formats are scaled by `1 << (bits - 1)`, so a 16-bit fixture + /// round-trips exactly through a `* 32768` rescale. + /// + /// # Arguments + /// * `filename` - path to an audio file. + /// * `sample_rate` - the sample rate the file is required to be at. + /// + /// # Errors + /// + /// [`BunsenError::Invalid`] if the file is not single-channel or is not at + /// `sample_rate`; [`BunsenError::External`] if it cannot be read. + pub fn load_audio_mono_sr>( + filename: P, + sample_rate: usize, + ) -> BunsenResult<(WavSpec, Vec)> { + let filename = filename.as_ref(); + + let mut reader = WavReader::open(filename).map_err(BunsenError::external)?; + let spec = reader.spec(); + + if spec.channels != 1 { + return Err(BunsenError::Invalid( + "The audio must be single-channel".to_string(), + )); + } + if spec.sample_rate as usize != sample_rate { + return Err(BunsenError::Invalid(format!( + "Expected sample_rate = {}, but found {}", + sample_rate, spec.sample_rate + ))); + } + + let samples: Vec = match (spec.sample_format, spec.bits_per_sample) { + (SampleFormat::Float, 32) => reader + .samples::() + .map(|s| s.unwrap()) + .collect::>(), + (SampleFormat::Int, bits) => { + let scale = (1i64 << (bits - 1)) as f32; + reader + .samples::() + .collect::, _>>() + .map_err(BunsenError::external)? + .into_iter() + .map(|s| s as f32 / scale) + .collect() + } + _ => unreachable!("hound rejects other formats at open"), + }; + + Ok((spec, samples)) + } +} From 113cdda9ffcb508db071917b3f63d2adc7292d46 Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Fri, 21 Aug 2026 19:25:48 -0700 Subject: [PATCH 02/32] feat(ten-vad): port the reference pitch estimator and pin it to a C golden Feature `40` of the ten-vad feature vector was stubbed: `ZeroPitch` pinned it to a constant while the other 40 features were exact. This ports the reference estimator behind the existing `TenVadPitchSource` seam. Ported from the **C reference** (`/home/crutcher/git/ten-vad/src/pitch_est.cc`), not from the `ten-vad-rs` Rust port, which was found to carry two divergences: * its `lpc_from_bands` scales the inverse FFT by `1/n`, where the C's `AUP_FFTW_RescaleIFFTOut` multiplies by `0.5` -- leaving the autocorrelation 512x too small, which matters because `ac[0]` then gets an *absolute* noise floor added; and * it clears `pitch_max_path_reg` every frame, where the C carries that Viterbi accumulator across hops and renormalizes it by the running maximum. New `ten_vad::context::pitch` module: * `coeff` - the reference constants, including `AUP_PE_PI`'s truncated `3.1415926` (one ULP below `f32::consts::PI`, and load-bearing only in that it seeds the DCT table) * `biquad` - the 5-section Direct-Form-II anti-alias cascade * `lpc` - band folding, the DCT/IDCT cepstrum, the autocorrelation, and the Levinson-Durbin solve * `estimator` - the four-stage tracker: pre-filter design, excitation, correlation, and the Viterbi period search The reference reaches its autocorrelation through an FFTW half-complex inverse transform. Since the input is real and even that collapses to a cosine sum, and only 17 of 1024 outputs are ever read, so `Autocorrelator` evaluates it directly against a cosine table -- verified equal to the reference transform to within f32 rounding, and free of any FFT dependency. Adds `test_pitch_estimator_reference_golden`, against `testdata/ten/pitch.json`: one reference pitch per hop over the whole 60 s fixture, produced by driving the C `AUP_PE_proc` from the C `AUP_Analyzer` STFT -- the same wiring `AUP_Aed_runOneFrm` uses. The two arms reach their bin powers through different FFTs, so the test asserts the voicing decision on every frame and a relative bound where both call a frame voiced. Measured: **100% voicing agreement across all 3750 frames, mean relative error 2.5e-7**. `testdata/ten/README.md` documents the fixture's provenance and carries `dump_pitch.cc`, the harness that generates it; the documented recipe was verified to reproduce the checked-in file byte for byte. `TenVad::init_context` now defaults to the real estimator rather than `ZeroPitch`; `init_context_with` still selects the stub, which keeps the sequence path entirely on-device at the cost of feature `40`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../src/kits/speech/ten_vad/context/driver.rs | 19 +- .../src/kits/speech/ten_vad/context/mod.rs | 46 +- .../speech/ten_vad/context/pitch/biquad.rs | 257 +++++ .../speech/ten_vad/context/pitch/coeff.rs | 174 ++++ .../speech/ten_vad/context/pitch/estimator.rs | 943 ++++++++++++++++++ .../kits/speech/ten_vad/context/pitch/lpc.rs | 656 ++++++++++++ .../kits/speech/ten_vad/context/pitch/mod.rs | 50 + .../context/{pitch.rs => pitch/source.rs} | 31 +- .../src/kits/speech/ten_vad/cross_test.rs | 104 ++ crates/bunsen/testdata/ten/README.md | 39 + crates/bunsen/testdata/ten/dump_pitch.cc | 116 +++ crates/bunsen/testdata/ten/pitch.json | 1 + 12 files changed, 2395 insertions(+), 41 deletions(-) create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/pitch/biquad.rs create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/pitch/coeff.rs create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/pitch/estimator.rs create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/pitch/lpc.rs create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/pitch/mod.rs rename crates/bunsen/src/kits/speech/ten_vad/context/{pitch.rs => pitch/source.rs} (82%) create mode 100644 crates/bunsen/testdata/ten/README.md create mode 100644 crates/bunsen/testdata/ten/dump_pitch.cc create mode 100644 crates/bunsen/testdata/ten/pitch.json diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs b/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs index f35af6a3..98570229 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs @@ -54,8 +54,8 @@ use crate::{ TenVadFeatureMeta, }, pitch::{ + TenVadPitchEstimator, TenVadPitchSource, - ZeroPitch, }, }, }, @@ -171,7 +171,7 @@ impl TenVadContextConfig { /// /// Built by [`TenVad::init_context`]. Implements [`TenVadContextMeta`]. #[derive(Debug, Clone)] -pub struct TenVadContext { +pub struct TenVadContext { /// The audio front-end streaming state. pub features: TenVadFeatureContext, @@ -269,7 +269,16 @@ impl TenVadContext { } impl TenVad { - /// Builds a zeroed driving context with the default [`ZeroPitch`] source. + /// Builds a zeroed driving context with the reference pitch estimator. + /// + /// This is the faithful front end: all 41 features match the reference + /// implementation. Feature `40` is a host-side recurrence, so every frame + /// synchronizes the raw hop and the bin powers back from the device. + /// + /// To trade that fidelity for an entirely on-device sequence path, pass + /// [`ZeroPitch`](super::ZeroPitch) to + /// [`init_context_with`](Self::init_context_with); it pins feature `40` to + /// a constant and leaves the other 40 exact. /// /// # Errors /// @@ -279,8 +288,8 @@ impl TenVad { &self, cfg: &TenVadContextConfig, device: &B::Device, - ) -> BunsenResult> { - self.init_context_with(cfg, ZeroPitch, device) + ) -> BunsenResult> { + self.init_context_with(cfg, TenVadPitchEstimator::new(), device) } /// Builds a zeroed driving context over a specific pitch source. diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs index d5333842..5fade27f 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs @@ -38,7 +38,8 @@ //! * [`coeff`](self) — the reference constants and normalization tables. //! * [`PreEmphasisContext`] — the first-order high-pass, with carry. //! * [`TenVadMelBank`] — the 40-band triangular filterbank. -//! * [`TenVadPitchSource`] — the pitch seam; [`ZeroPitch`] by default. +//! * [`TenVadPitchSource`] — the pitch seam; [`TenVadPitchEstimator`] is the +//! reference estimator, [`ZeroPitch`] the constant stub. //! * [`TenVadFeatureContext`] — the 41-dim feature extractor and its state. //! * [`TenVadContext`] — the driving context: features, frame stack, and both //! LSTM states. @@ -46,11 +47,23 @@ //! The sliding STFT itself is [`SlidingStftContext`], which already ports the //! reference analyzer. //! +//! ## Choosing a pitch source +//! +//! Feature `40` is a serial host-side recurrence, so the driver reaches it +//! through the [`TenVadPitchSource`] seam: +//! +//! * [`TenVadPitchEstimator`] — the reference estimator, and what +//! [`TenVad::init_context`](crate::kits::speech::ten_vad::TenVad::init_context) +//! builds. Every frame synchronizes the raw hop and the bin powers back from +//! the device to step it. +//! * [`ZeroPitch`] — pins feature `40` to a constant and reports +//! [`inspects_input`](TenVadPitchSource::inspects_input) as `false`, which +//! lets the sequence path stay entirely on-device. The other 40 features are +//! exact either way. Select it via +//! [`TenVad::init_context_with`](crate::kits::speech::ten_vad::TenVad::init_context_with). +//! //! ## Known deviations from the reference driver //! -//! * **The pitch feature is stubbed.** [`ZeroPitch`] pins feature `40` to a -//! constant. The other 40 features are exact. Porting the real estimator is a -//! drop-in behind [`TenVadPitchSource`]. //! * **No periodic state reset.** The C driver zeroes both LSTM states every //! `resetFrameNum = 1875` model calls — 30 s of audio — while leaving the //! feature stack intact (`ALGO_TRACE.md` §5). This driver does not, so @@ -59,20 +72,17 @@ //! * **Batch size 1.** The stock ONNX graph pins its LSTM batch to 1; see //! [`TenVad::forward`] for what the leading axis actually means. //! -//! ## Establishing a numeric golden -//! -//! There is no checked-in feature golden yet. The recipe, once the pitch -//! estimator lands so a golden can cover all 41 bins: -//! -//! 1. Dump per-hop 41-dim feature vectors from a reference implementation over -//! a short 16 kHz mono clip. -//! 2. Check the vectors into `crates/bunsen/testdata/ten/`. -//! 3. Assert [`TenVadFeatureContext::forward_sequence`] reproduces them. -//! -//! Until then, [`TenVadFeatureContext`]'s own tests pin the pipeline against -//! an independent host implementation written from the reference ordering, -//! and the kit's cross test pins the driver against the ONNX graph over real -//! audio. +//! ## What is pinned numerically +//! +//! * **Feature `40`** — `testdata/ten/pitch.json` holds one reference pitch per +//! hop over the 60 s fixture, dumped from the C `AUP_PE_proc` driven by the C +//! STFT. The kit's cross test asserts the voicing decision matches on every +//! frame and the voiced estimates agree to within f32 rounding. +//! * **Features `0..40`** — [`TenVadFeatureContext`]'s own tests pin the mel +//! path against an independent host implementation written from the reference +//! ordering. +//! * **The driver as a whole** — the kit's cross test pins it against the ONNX +//! graph over real audio. //! //! [`TenVad::forward`]: crate::kits::speech::ten_vad::TenVad::forward //! [`TenVad::context_forward`]: crate::kits::speech::ten_vad::TenVad::context_forward diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/biquad.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/biquad.rs new file mode 100644 index 00000000..97a2efd0 --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/biquad.rs @@ -0,0 +1,257 @@ +//! # Cascaded biquad IIR filter. +//! +//! The anti-alias filter the reference pitch estimator runs before decimating +//! 16 kHz down to 4 kHz (`src/biquad.cc`). +//! +//! Each section is Direct Form II: +//! +//! ```text +//! w0 = x - a1*w1 - a2*w2 +//! y = g * (b0*w0 + b1*w1 + b2*w2) +//! w2 = w1; w1 = w0 +//! ``` +//! +//! and sections are applied in series, each carrying its own two-sample +//! state across calls. Sections are run one at a time over the whole block, +//! matching the reference; because each section's state depends only on its +//! own input sequence, that is equivalent to running every section per +//! sample, and both are equivalent to filtering the unsegmented stream. +//! +//! Like the rest of the pitch branch this is host-side scalar code. It is a +//! plain DSP primitive with nothing ten-vad-specific in it but the +//! coefficients in [`super::coeff`], so it would move to +//! [`crate::ops::signal`] unchanged if a tensor-side caller ever wanted it. + +/// One second-order section of a [`BiquadCascade`]. +/// +/// `a[0]` is assumed to be `1`, as it is in every tabulated ten-vad section; +/// the difference equation ignores it. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BiquadSection { + /// Numerator coefficients `[b0, b1, b2]`. + pub b: [f32; 3], + + /// Denominator coefficients `[_, a1, a2]`; `a[0]` is unused. + pub a: [f32; 3], + + /// The section's output gain. + pub g: f32, +} + +/// A series of [`BiquadSection`]s with per-section state. +/// +/// Built by [`BiquadCascade::new`]; reset by [`BiquadCascade::reset`]. +#[derive(Debug, Clone, PartialEq)] +pub struct BiquadCascade { + sections: [BiquadSection; NSECT], + + /// Per-section `[w1, w2]` delay state. + state: [[f32; 2]; NSECT], +} + +impl BiquadCascade { + /// Builds a cascade with zeroed state. + pub fn new(sections: [BiquadSection; NSECT]) -> Self { + Self { + sections, + state: [[0.0; 2]; NSECT], + } + } + + /// Builds a cascade from the reference's parallel coefficient tables. + /// + /// # Arguments + /// * `b`: per-section numerator coefficients. + /// * `a`: per-section denominator coefficients. + /// * `g`: per-section gains. + pub fn from_tables( + b: &[[f32; 3]; NSECT], + a: &[[f32; 3]; NSECT], + g: &[f32; NSECT], + ) -> Self { + Self::new(core::array::from_fn(|i| BiquadSection { + b: b[i], + a: a[i], + g: g[i], + })) + } + + /// The number of sections. + pub fn len(&self) -> usize { + NSECT + } + + /// Whether the cascade has no sections, in which case it is a pass-through. + pub fn is_empty(&self) -> bool { + NSECT == 0 + } + + /// Zeroes every section's delay state. + pub fn reset(&mut self) { + self.state = [[0.0; 2]; NSECT]; + } + + /// Filters `buf` in place, carrying state across calls. + pub fn process_in_place( + &mut self, + buf: &mut [f32], + ) { + for (section, state) in self.sections.iter().zip(self.state.iter_mut()) { + let [b0, b1, b2] = section.b; + let a1 = section.a[1]; + let a2 = section.a[2]; + let g = section.g; + + let [mut w1, mut w2] = *state; + for y in buf.iter_mut() { + let w0 = *y - a1 * w1 - a2 * w2; + *y = g * (b0 * w0 + b1 * w1 + b2 * w2); + w2 = w1; + w1 = w0; + } + *state = [w1, w2]; + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + super::coeff::{ + ANTI_ALIAS_A_4KHZ, + ANTI_ALIAS_B_4KHZ, + ANTI_ALIAS_G_4KHZ, + ANTI_ALIAS_SECTIONS, + }, + *, + }; + + fn anti_alias() -> BiquadCascade { + BiquadCascade::from_tables(&ANTI_ALIAS_B_4KHZ, &ANTI_ALIAS_A_4KHZ, &ANTI_ALIAS_G_4KHZ) + } + + /// A single section reproducing the difference equation by hand. + fn reference_section( + section: &BiquadSection, + input: &[f32], + ) -> Vec { + let (mut w1, mut w2) = (0.0f32, 0.0f32); + input + .iter() + .map(|&x| { + let w0 = x - section.a[1] * w1 - section.a[2] * w2; + let y = section.g * (section.b[0] * w0 + section.b[1] * w1 + section.b[2] * w2); + w2 = w1; + w1 = w0; + y + }) + .collect() + } + + #[test] + fn test_single_section_matches_the_difference_equation() { + let section = BiquadSection { + b: ANTI_ALIAS_B_4KHZ[0], + a: ANTI_ALIAS_A_4KHZ[0], + g: ANTI_ALIAS_G_4KHZ[0], + }; + let input: Vec = (0..64).map(|i| (i as f32 * 0.3).sin()).collect(); + + let mut cascade = BiquadCascade::new([section]); + let mut got = input.clone(); + cascade.process_in_place(&mut got); + + assert_eq!(got, reference_section(§ion, &input)); + } + + #[test] + fn test_block_split_matches_whole_stream() { + // The state carry is the whole point: a stream chopped into hops must + // filter identically to the same stream filtered at once. + let input: Vec = (0..512).map(|i| (i as f32 * 0.11).sin() * 1000.0).collect(); + + let mut whole = input.clone(); + anti_alias().process_in_place(&mut whole); + + let mut split = input.clone(); + let mut cascade = anti_alias(); + for chunk in split.chunks_mut(64) { + cascade.process_in_place(chunk); + } + + assert_eq!(whole, split); + } + + #[test] + fn test_reset_restores_start_of_stream() { + let input: Vec = (0..128).map(|i| (i as f32 * 0.7).cos()).collect(); + let mut cascade = anti_alias(); + + let mut first = input.clone(); + cascade.process_in_place(&mut first); + + // Without a reset the tail state colors the next block. + let mut carried = input.clone(); + cascade.process_in_place(&mut carried); + assert_ne!(first, carried); + + cascade.reset(); + let mut after_reset = input.clone(); + cascade.process_in_place(&mut after_reset); + assert_eq!(first, after_reset); + } + + #[test] + fn test_sections_apply_in_series() { + let input: Vec = (0..96).map(|i| (i as f32 * 0.23).sin()).collect(); + + let mut got = input.clone(); + anti_alias().process_in_place(&mut got); + + let mut expected = input; + for i in 0..ANTI_ALIAS_SECTIONS { + expected = reference_section( + &BiquadSection { + b: ANTI_ALIAS_B_4KHZ[i], + a: ANTI_ALIAS_A_4KHZ[i], + g: ANTI_ALIAS_G_4KHZ[i], + }, + &expected, + ); + } + + assert_eq!(got, expected); + } + + #[test] + fn test_anti_alias_attenuates_above_2khz() { + // Decimating 16 kHz by 4 puts the new Nyquist at 2 kHz; the cascade + // exists to keep everything above it out of the correlation branch. + let response = |freq_hz: f32| { + let input: Vec = (0..4096) + .map(|i| (core::f32::consts::TAU * freq_hz * i as f32 / 16000.0).sin()) + .collect(); + let mut out = input; + anti_alias().process_in_place(&mut out); + // Peak amplitude over the settled tail. + out[2048..].iter().fold(0.0f32, |m, v| m.max(v.abs())) + }; + + let passband = response(300.0); + let stopband = response(4000.0); + + assert!(passband > 0.5, "300 Hz should pass: {passband}"); + assert!(stopband < 0.02, "4 kHz should be rejected: {stopband}"); + } + + #[test] + fn test_empty_cascade_is_pass_through() { + let mut cascade: BiquadCascade<0> = BiquadCascade::new([]); + assert!(cascade.is_empty()); + assert_eq!(cascade.len(), 0); + + let mut buf = [1.0, -2.0, 3.0]; + cascade.process_in_place(&mut buf); + assert_eq!(buf, [1.0, -2.0, 3.0]); + } +} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/coeff.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/coeff.rs new file mode 100644 index 00000000..3158c082 --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/coeff.rs @@ -0,0 +1,174 @@ +//! # Pitch-estimator coefficients. +//! +//! The fixed constants of the reference pitch estimator, transcribed from +//! `src/pitch_est_st.h` and `src/pitch_est.cc` in the ten-vad reference. +//! +//! Like [`super::super::coeff`], these are reference data rather than +//! tunables: the estimator's output feeds feature `40`, whose mean and +//! standard deviation were fitted against exactly these values. + +/// The number of bands the pitch estimator's LPC front end works in. +/// +/// Unrelated to the 40 mel bands of the feature path: these 18 bands exist +/// only to shape the LPC pre-filter. +pub const NB_BANDS: usize = 18; + +/// The order of the LPC pre-filter. +pub const LPC_ORDER: usize = 16; + +/// The shortest pitch period considered, in samples at 16 kHz (≈500 Hz). +pub const MIN_PERIOD_16KHZ: usize = 32; + +/// The longest pitch period considered, in samples at 16 kHz (≈62 Hz). +pub const MAX_PERIOD_16KHZ: usize = 256; + +const _: () = assert!( + MAX_PERIOD_16KHZ > MIN_PERIOD_16KHZ, + "the period search range must be non-empty", +); + +/// How far the correlation branch lags the current hop, in samples at 16 kHz. +/// +/// The reference reads its LPC-filter input from this many samples behind the +/// newest sample, so the excitation buffer is aligned with the correlation +/// window. +pub const XCORR_TRAINING_OFFSET: usize = 80; + +/// The correlation history the tracker spans, in milliseconds. +pub const FEAT_TIME_WINDOW_MS: usize = 40; + +/// The cap on the number of history frames, whatever the hop size implies. +pub const FEAT_MAX_NFRM: usize = 12; + +/// The internal processing rate of the correlation branch, in Hz. +/// +/// The reference decimates to this rate before correlating, which is what +/// makes the period search 64 lags wide instead of 256. +pub const PROC_FS: usize = 4000; + +/// The frame-correlation above which a frame is called voiced. +/// +/// The reference's `pitchEstVoicedThr` for a 4 kHz processing rate. +pub const VOICED_THRESHOLD: f32 = 0.4; + +/// The per-step penalty weight of the Viterbi period tracker. +/// +/// A candidate `jdx` steps from the current period is discounted by +/// `PITCH_MAX_PATH_W * jdx²`. +pub const PITCH_MAX_PATH_W: f32 = 0.02; + +/// The FFT size the band layout in [`BAND_START_INDEX`] was tabulated for. +/// +/// The reference rescales those indices by `fft_size / ASSUMED_FFT_FOR_BANDS` +/// rather than retabulating them, so this is a divisor, not a real FFT size. +pub const ASSUMED_FFT_FOR_BANDS: f32 = 80.0; + +/// π, as the reference spells it. +/// +/// The reference's `AUP_PE_PI` is the literal `3.1415926f`, which is one ULP +/// below [`core::f32::consts::PI`]. It is preserved here because it seeds the +/// DCT table; the difference is far below any threshold in the estimator, but +/// matching it costs nothing. +/// +/// `clippy::approx_constant` is exactly right that this is a worse π. That is +/// the point: it is the reference's π, and using a better one would be the bug. +#[allow(clippy::approx_constant)] +pub const PITCH_PI: f32 = 3.1415926; + +/// The band edges, as bin indices under an [`ASSUMED_FFT_FOR_BANDS`]-point FFT. +/// +/// Approximately: 0, 200, 400, 600, 800, 1k, 1.2k, 1.4k, 1.6k, 2k, 2.4k, +/// 2.8k, 3.2k, 4k, 4.8k, 5.6k, 6.8k, 8k Hz. +pub const BAND_START_INDEX: [usize; NB_BANDS] = [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 12, 14, 16, 20, 24, 28, 34, 40, +]; + +/// Per-band compensation applied to the band gains before the LPC solve. +/// +/// Roughly the reciprocal of each band's width in [`BAND_START_INDEX`] units, +/// undoing the widening of the upper bands. +pub const BAND_LPC_COMP: [f32; NB_BANDS] = [ + 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.666667, 0.5, 0.5, 0.5, 0.333333, 0.25, 0.25, 0.2, + 0.166667, 0.173913, +]; + +/// The number of second-order sections in the anti-alias filter. +pub const ANTI_ALIAS_SECTIONS: usize = 5; + +/// Numerator coefficients of the 4 kHz anti-alias cascade. +/// +/// Applied before decimating 16 kHz to [`PROC_FS`]. +pub const ANTI_ALIAS_B_4KHZ: [[f32; 3]; ANTI_ALIAS_SECTIONS] = [ + [1.0, 1.198825, 1.0], + [1.0, -0.5674614, 1.0], + [1.0, -1.099061, 1.0], + [1.0, -1.265846, 1.0], + [1.0, -1.318849, 1.0], +]; + +/// Denominator coefficients of the 4 kHz anti-alias cascade. +pub const ANTI_ALIAS_A_4KHZ: [[f32; 3]; ANTI_ALIAS_SECTIONS] = [ + [1.0, -1.445267, 0.5463974], + [1.0, -1.42672, 0.6820138], + [1.0, -1.408255, 0.8286664], + [1.0, -1.400909, 0.924032], + [1.0, -1.408242, 0.9789776], +]; + +/// Per-section gains of the 4 kHz anti-alias cascade. +pub const ANTI_ALIAS_G_4KHZ: [f32; ANTI_ALIAS_SECTIONS] = + [0.2692541, 0.2692541, 0.2692541, 0.2692541, 0.2692541]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_band_start_index_is_monotonic() { + for pair in BAND_START_INDEX.windows(2) { + assert!(pair[1] > pair[0], "band edges must strictly increase"); + } + assert_eq!(BAND_START_INDEX[0], 0); + assert_eq!( + BAND_START_INDEX[NB_BANDS - 1] as f32, + ASSUMED_FFT_FOR_BANDS / 2.0, + "the top edge is the Nyquist bin of the assumed FFT", + ); + } + + #[test] + fn test_pitch_pi_is_one_ulp_below_std_pi() { + // The reference's truncated literal, kept deliberately. + assert_ne!(PITCH_PI, core::f32::consts::PI); + assert_eq!( + f32::from_bits(PITCH_PI.to_bits() + 1), + core::f32::consts::PI + ); + } + + #[test] + fn test_period_bounds_divide_the_resample_rate() { + let rate = super::super::estimator::PROC_RESAMPLE_RATE; + assert_eq!(MIN_PERIOD_16KHZ % rate, 0); + assert_eq!(MAX_PERIOD_16KHZ % rate, 0); + } + + #[test] + fn test_anti_alias_sections_are_normalized() { + // Every section is stated with a leading denominator coefficient of 1, + // so the difference equation needs no division. + for a in ANTI_ALIAS_A_4KHZ { + assert_eq!(a[0], 1.0); + } + for b in ANTI_ALIAS_B_4KHZ { + assert_eq!(b[0], 1.0); + } + // A stable all-pole section needs |a2| < 1. + for a in ANTI_ALIAS_A_4KHZ { + assert!( + a[2].abs() < 1.0, + "section pole radius must be inside the unit circle" + ); + } + } +} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/estimator.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/estimator.rs new file mode 100644 index 00000000..8da374eb --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/estimator.rs @@ -0,0 +1,943 @@ +//! # The ten-vad pitch estimator. +//! +//! A port of the reference `AUP_PE_proc` (`src/pitch_est.cc`), producing +//! feature `40` of the ten-vad feature vector. +//! +//! The estimator is an RNNoise-derived tracker. Per 256-sample hop it runs +//! four stages, each depending on state the previous hops left behind: +//! +//! 1. **Pre-filter design.** The bin powers are folded into 18 bands, log +//! compressed with a decay clamp, taken through a cepstrum, and fitted with +//! a 16th-order all-pole model ([`super::lpc`]). +//! 2. **Excitation.** The raw hop — delayed by +//! [`XCORR_TRAINING_OFFSET`](super::coeff::XCORR_TRAINING_OFFSET) samples so +//! it lines up with the correlation window — is whitened by that filter, +//! smoothed by a one-pole leak, anti-alias filtered, and decimated 16 kHz → +//! 4 kHz. Whitening is what leaves a clean impulse train at the pitch +//! period. +//! 3. **Correlation.** Two half-hop windows per hop are correlated against 64 +//! candidate lags, each normalized by the energy under the lagged window, +//! then sharpened against their own half-lag to suppress octave doubling. +//! 4. **Tracking.** A Viterbi pass over the last `2 * n_feat` half-hops picks a +//! period path, penalizing `PITCH_MAX_PATH_W * step²` for jumping. The +//! backtrace yields both a voicing score and a weighted linear regression +//! over the recovered periods, extrapolated half a frame forward. +//! +//! ## Ordering that is load-bearing +//! +//! * The estimator reads the **raw** hop, never the pre-emphasized one, and the +//! **un-normalized** bin powers. The reference keeps two parallel FIFOs for +//! exactly this (`ALGO_TRACE.md` §3.3). +//! * The Viterbi accumulator carries **across hops**. It is renormalized by +//! subtracting the running maximum each step rather than being cleared, so +//! clearing it per hop would discard the tracker's whole memory. +//! * The correlation buffer is a circular buffer of `2 * n_feat` half-hops +//! indexed off [`xcorr_offset`](TenVadPitchEstimator), and the DP walks it in +//! stream order, not slot order. +//! +//! ## Divergences from the reference +//! +//! * The reference's inverse FFT in the LPC fit is replaced by the equivalent +//! closed-form cosine sum; see [`super::lpc`]. +//! * The reference copies its correlation buffer to a scratch buffer before the +//! DP, commenting that the DP may modify it. Nothing does, so the copy is +//! dropped and the DP reads the buffer directly. +//! * Where the reference divides by an unguarded `sw`, this guards against a +//! `0/0`. That case is reachable only on digital silence, where the frame is +//! already unvoiced and the quotient is discarded, so no output changes. + +use super::{ + biquad::BiquadCascade, + coeff::{ + ANTI_ALIAS_A_4KHZ, + ANTI_ALIAS_B_4KHZ, + ANTI_ALIAS_G_4KHZ, + ANTI_ALIAS_SECTIONS, + FEAT_MAX_NFRM, + FEAT_TIME_WINDOW_MS, + LPC_ORDER, + MAX_PERIOD_16KHZ, + MIN_PERIOD_16KHZ, + PITCH_MAX_PATH_W, + PROC_FS, + VOICED_THRESHOLD, + XCORR_TRAINING_OFFSET, + }, + lpc::{ + Autocorrelator, + DctTable, + band_energy, + lpc_from_cepstrum, + }, + source::TenVadPitchSource, +}; +use crate::kits::speech::ten_vad::context::coeff::{ + HOP_SIZE, + SAMPLE_RATE, +}; + +/// The decimation factor from 16 kHz to the correlation branch's rate. +pub const PROC_RESAMPLE_RATE: usize = SAMPLE_RATE / PROC_FS; + +/// The ten-vad STFT size, which sets the bin count the estimator expects. +const FFT_SIZE: usize = 1024; + +/// The ten-vad STFT analysis window, which sets the LPC noise floor. +const WINDOW_SIZE: usize = 768; + +/// The reference pitch estimator. +/// +/// Implements [`TenVadPitchSource`], so it drops into +/// [`TenVadFeatureContext`](crate::kits::speech::ten_vad::context::TenVadFeatureContext) +/// in place of [`ZeroPitch`](super::ZeroPitch): +/// +/// ```rust,ignore +/// let ctx = config.init_context(batch_size, TenVadPitchEstimator::new(), &device)?; +/// ``` +/// +/// One instance per stream; the driver clones the prototype per batch row. +/// Built by [`new`](Self::new), rewound by [`reset`](Self::reset). +#[derive(Debug, Clone)] +pub struct TenVadPitchEstimator { + // --- Fixed geometry, all derived from the ten-vad configuration. --- + /// The shortest candidate period, in samples at [`PROC_FS`]. + min_period: usize, + + /// The longest candidate period, in samples at [`PROC_FS`]; also the width + /// of the correlation search. + max_period: usize, + + /// `max_period - min_period`: the width of the tracker's state space. + dif_period: usize, + + /// The number of whole hops of correlation history; each contributes two + /// half-hop slots. + n_feat: usize, + + /// Half a hop, in samples at [`PROC_FS`]; the correlation window length. + corr_half_hop: usize, + + // --- Tables. --- + dct: DctTable, + autocorrelator: Autocorrelator, + + // --- Stage 1: pre-filter design. --- + /// The current whitening filter. + lpc: [f32; LPC_ORDER], + + /// `[fft_size / 2 + 1]` scratch for the band-gain interpolation. + bin_scratch: Vec, + + // --- Stage 2: excitation. --- + /// The raw-sample FIFO the delayed hop is read out of. + input_q: Vec, + + /// The delayed hop: `input_q` read `XCORR_TRAINING_OFFSET` behind the tail. + aligned_in: Vec, + + /// The whitening filter's sample memory. + pitch_mem: [f32; LPC_ORDER], + + /// The one-pole leak carried across samples and hops. + pitch_filt: f32, + + /// The whitened hop, before anti-aliasing. + lpc_filter_out: Vec, + + /// The anti-alias cascade run before decimation. + biquad: BiquadCascade, + + /// The decimated excitation history the correlation reads. + exc_buf: Vec, + + /// `exc_buf`, squared. + exc_buf_sq: Vec, + + // --- Stage 3: correlation. --- + /// The oldest slot in the circular correlation buffer, in whole hops. + xcorr_offset: usize, + + /// The un-normalized correlations of the half-hop being processed. + xcorr_inst: Vec, + + /// `[2 * n_feat][max_period]` normalized correlations, circular. + xcorr: Vec>, + + /// `[2 * n_feat]` per-half-hop energies, in stream order. + frm_weight: Vec, + + /// `frm_weight`, scaled to average 1. + frm_weight_norm: Vec, + + // --- Stage 4: tracking. --- + /// The Viterbi accumulator: `[previous, current]`, each `[dif_period]`. + path_score: [Vec; 2], + + /// `[2 * n_feat][dif_period]` backpointers, in stream order. + path_prev: Vec>, + + /// The best path score reached so far, carried across hops. + path_best_all: f32, + + /// The best period index reached so far, carried across hops. + best_period: usize, + + /// The recovered period per half-hop, filled by the backtrace. + period_local: Vec, + + /// Whether the last hop was voiced. + voiced: bool, + + /// The last hop's pitch, in Hz. + pitch_hz: f32, +} + +impl Default for TenVadPitchEstimator { + fn default() -> Self { + Self::new() + } +} + +impl TenVadPitchEstimator { + /// Builds an estimator for the ten-vad front end, in start-of-stream state. + /// + /// The geometry is fixed: 16 kHz in, a 256-sample hop, a 1024-point STFT + /// with a 768-sample window, and a 4 kHz correlation branch. + pub fn new() -> Self { + let hop_size = HOP_SIZE; + let min_period = MIN_PERIOD_16KHZ / PROC_RESAMPLE_RATE; + let max_period = MAX_PERIOD_16KHZ / PROC_RESAMPLE_RATE; + let dif_period = max_period - min_period; + let corr_half_hop = hop_size / (PROC_RESAMPLE_RATE * 2); + + let n_feat = FEAT_MAX_NFRM.min( + ((FEAT_TIME_WINDOW_MS * SAMPLE_RATE) as f32 / (hop_size * 1000) as f32).ceil() as usize, + ); + let slots = n_feat * 2; + + // The FIFO must hold the hop plus the correlation delay ahead of it. + let input_q_len = XCORR_TRAINING_OFFSET.max(hop_size) + hop_size; + // One decimated hop of headroom past the search width, plus the slot + // the reference over-allocates. + let exc_shift = hop_size.div_ceil(PROC_RESAMPLE_RATE); + let exc_buf_len = max_period + exc_shift + 1; + + Self { + min_period, + max_period, + dif_period, + n_feat, + corr_half_hop, + + dct: DctTable::new(), + autocorrelator: Autocorrelator::new(FFT_SIZE), + + lpc: [0.0; LPC_ORDER], + bin_scratch: vec![0.0; FFT_SIZE / 2 + 1], + + input_q: vec![0.0; input_q_len], + aligned_in: vec![0.0; hop_size], + pitch_mem: [0.0; LPC_ORDER], + pitch_filt: 0.0, + lpc_filter_out: vec![0.0; hop_size], + biquad: BiquadCascade::from_tables( + &ANTI_ALIAS_B_4KHZ, + &ANTI_ALIAS_A_4KHZ, + &ANTI_ALIAS_G_4KHZ, + ), + exc_buf: vec![0.0; exc_buf_len], + exc_buf_sq: vec![0.0; exc_buf_len], + + xcorr_offset: 0, + xcorr_inst: vec![0.0; max_period], + xcorr: vec![vec![0.0; max_period]; slots], + frm_weight: vec![0.0; slots], + frm_weight_norm: vec![0.0; slots], + + path_score: [vec![0.0; dif_period], vec![0.0; dif_period]], + path_prev: vec![vec![0; dif_period]; slots], + path_best_all: 0.0, + best_period: 0, + period_local: vec![0; slots], + voiced: false, + pitch_hz: 0.0, + } + } + + /// The hop size, in samples at 16 kHz. + pub fn hop_size(&self) -> usize { + self.aligned_in.len() + } + + /// The number of frequency bins the estimator expects. + pub fn n_bins(&self) -> usize { + self.bin_scratch.len() + } + + /// The number of half-hop slots the tracker spans. + pub fn slots(&self) -> usize { + self.n_feat * 2 + } + + /// Whether the most recent hop was judged voiced. + /// + /// [`pitch_hz`](Self::pitch_hz) is zero whenever this is `false`. + pub fn voiced(&self) -> bool { + self.voiced + } + + /// The most recent hop's pitch estimate, in Hz; `0.0` when unvoiced. + pub fn pitch_hz(&self) -> f32 { + self.pitch_hz + } + + /// The current whitening filter coefficients. + pub fn lpc(&self) -> &[f32; LPC_ORDER] { + &self.lpc + } + + /// Stage 1: fits the whitening filter to this hop's spectrum. + fn design_prefilter( + &mut self, + bin_power: &[f32], + ) { + let mut band = band_energy(bin_power, FFT_SIZE); + + // Log compression with a two-sided floor: nothing may sit more than + // 8 decades below the running peak, nor fall faster than 2.5 decades + // per band. Without it, near-empty bands would dominate the cepstrum. + let mut log_max = -2.0f32; + let mut follow = -2.0f32; + for b in band.iter_mut() { + let ly = (1e-2 + *b).log10(); + let ly = (log_max - 8.0).max((follow - 2.5).max(ly)); + log_max = log_max.max(ly); + follow = (follow - 2.5).max(ly); + *b = ly; + } + + let cepstrum = self.dct.dct(&band); + self.lpc = lpc_from_cepstrum( + &cepstrum, + &self.dct, + WINDOW_SIZE, + &self.autocorrelator, + &mut self.bin_scratch, + ); + } + + /// Stage 2: whitens, anti-aliases, and decimates the delayed hop. + fn extract_excitation( + &mut self, + raw_hop: &[f32], + ) { + let hop = self.hop_size(); + + // Slide the raw FIFO and append this hop. + self.input_q.copy_within(hop.., 0); + let tail = self.input_q.len() - hop; + self.input_q[tail..].copy_from_slice(raw_hop); + + // Read out the hop that sits `XCORR_TRAINING_OFFSET` behind the tail. + let offset = self + .input_q + .len() + .saturating_sub(hop) + .saturating_sub(XCORR_TRAINING_OFFSET); + self.aligned_in + .copy_from_slice(&self.input_q[offset..offset + hop]); + + // FIR whitening, plus a one-pole leak that keeps the excitation from + // going fully impulsive. + for i in 0..hop { + let sample = self.aligned_in[i]; + let mut whitened = sample; + for j in 0..LPC_ORDER { + whitened += self.lpc[j] * self.pitch_mem[j]; + } + + self.pitch_mem.copy_within(0..LPC_ORDER - 1, 1); + self.pitch_mem[0] = sample; + + self.lpc_filter_out[i] = whitened + 0.7 * self.pitch_filt; + self.pitch_filt = whitened; + } + + self.biquad.process_in_place(&mut self.lpc_filter_out); + + // Decimate and push into the excitation history. + let shift = hop.div_ceil(PROC_RESAMPLE_RATE); + self.exc_buf.copy_within(shift.., 0); + let tail = self.exc_buf.len() - shift; + for n in 0..shift { + self.exc_buf[tail + n] = self.lpc_filter_out[n * PROC_RESAMPLE_RATE]; + } + } + + /// Stage 3: correlates both half-hops against every candidate lag. + fn correlate(&mut self) { + for (dst, &x) in self.exc_buf_sq.iter_mut().zip(self.exc_buf.iter()) { + *dst = x * x; + } + + // Slide the energy history left by one hop's worth of slots. + for idx in 0..(self.n_feat - 1) { + self.frm_weight[2 * idx] = self.frm_weight[2 * (idx + 1)]; + self.frm_weight[2 * idx + 1] = self.frm_weight[2 * (idx + 1) + 1]; + } + + let half = self.corr_half_hop; + for sub in 0..2 { + let slot = 2 * self.xcorr_offset + sub; + let base = sub * half; + + // Correlate the newest half-hop against every lag behind it. + for (lag, dst) in self.xcorr_inst.iter_mut().enumerate() { + let mut sum = 0.0f32; + for j in 0..half { + sum += self.exc_buf[self.max_period + base + j] * self.exc_buf[base + lag + j]; + } + *dst = sum; + } + + // The reference window's energy, which is also this slot's weight + // in the tracker. + let mut reference_energy = 0.0f32; + for j in 0..half { + reference_energy += self.exc_buf_sq[self.max_period + base + j]; + } + self.frm_weight[2 * (self.n_feat - 1) + sub] = reference_energy; + + // Normalize each lag by the energy under the lagged window, kept + // as a sliding sum. The `1 +` keeps silence from amplifying noise. + let mut lagged_energy = 0.0f32; + for j in 0..half { + lagged_energy += self.exc_buf_sq[base + j]; + } + + let mut denom = (lagged_energy + (1.0 + reference_energy)).max(1e-12); + self.xcorr[slot][0] = 2.0 * self.xcorr_inst[0] / denom; + for lag in 1..self.max_period { + lagged_energy = (lagged_energy - self.exc_buf_sq[base + lag - 1]).max(0.0); + lagged_energy += self.exc_buf_sq[base + lag + half - 1]; + denom = (lagged_energy + (1.0 + reference_energy)).max(1e-12); + self.xcorr[slot][lag] = 2.0 * self.xcorr_inst[lag] / denom; + } + + // Octave suppression: a lag that does not clearly beat its own + // half-lag neighborhood is a likely period double, so discount it. + for lag in 0..(self.max_period - 2 * self.min_period) { + let mut rival = self.xcorr[slot][(self.max_period + lag) / 2]; + rival = rival.max(self.xcorr[slot][(self.max_period + lag + 2) / 2]); + rival = rival.max(self.xcorr[slot][(self.max_period + lag - 1) / 2]); + if self.xcorr[slot][lag] < rival * 1.1 { + self.xcorr[slot][lag] *= 0.8; + } + } + } + + self.xcorr_offset = (self.xcorr_offset + 1) % self.n_feat; + } + + /// The circular correlation slot holding stream position `sub`. + fn slot_of( + &self, + sub: usize, + ) -> usize { + (sub + self.xcorr_offset * 2) % self.slots() + } + + /// Stage 4: tracks a period path and regresses it to a pitch. + fn track(&mut self) -> f32 { + let slots = self.slots(); + + // Scale the energies to average 1, so the tracker's correlation term + // and its jump penalty stay commensurable regardless of level. + let mut total = 1e-15f32; + for sub in 0..slots { + total += self.frm_weight[sub]; + } + for sub in 0..slots { + self.frm_weight_norm[sub] = self.frm_weight[sub] * (slots as f32 / total); + } + + // Slide the backpointer history left by this hop's two slots. + for sub in 0..(slots - 2) { + let (head, tail) = self.path_prev.split_at_mut(sub + 2); + head[sub].copy_from_slice(&tail[0]); + } + + // Forward Viterbi over the two new slots. `path_score[0]` carries over + // from the previous hop; it is renormalized, never cleared. + for sub in (slots - 2)..slots { + let slot = self.slot_of(sub); + + for idx in 0..self.dif_period { + let mut best_score = self.path_best_all - 1e10; + let mut best_prev = self.best_period; + + // Candidates reachable in one step. The window is asymmetric + // near the short-period end, exactly as the reference has it. + let first = 0.min(4 - idx as i32); + for jdx in first..=4 { + let cand = idx as i32 + jdx; + debug_assert!(cand >= 0, "candidate period index went negative"); + let cand = cand as usize; + if cand >= self.dif_period { + break; + } + let penalty = PITCH_MAX_PATH_W * (jdx.abs() as f32) * (jdx.abs() as f32); + let score = self.path_score[0][cand] - penalty; + if score > best_score { + best_score = score; + best_prev = cand; + } + } + + self.path_prev[sub][idx] = best_prev; + self.path_score[1][idx] = + best_score + self.frm_weight_norm[sub] * self.xcorr[slot][idx]; + } + + let mut max_score = -1e15f32; + let mut arg_max = 0usize; + for idx in 0..self.dif_period { + if self.path_score[1][idx] > max_score { + max_score = self.path_score[1][idx]; + arg_max = idx; + } + } + self.path_best_all = max_score; + self.best_period = arg_max; + + // Roll current into previous, renormalized so the peak sits at 0 + // and the accumulator cannot drift out of range. + let (previous, current) = self.path_score.split_at_mut(1); + previous[0].copy_from_slice(¤t[0]); + for score in self.path_score[0].iter_mut() { + *score -= max_score; + } + } + + // Backtrace, collecting both the path and its correlation score. + let mut cursor = self.best_period; + let mut frame_corr = 0.0f32; + for sub in (0..slots).rev() { + self.period_local[sub] = self.max_period - cursor; + let slot = self.slot_of(sub); + frame_corr += self.frm_weight_norm[sub] * self.xcorr[slot][cursor]; + cursor = self.path_prev[sub][cursor]; + } + frame_corr = (frame_corr / slots as f32).max(0.0); + self.voiced = frame_corr >= VOICED_THRESHOLD; + + // Weighted least squares of period against slot index. + let (mut sw, mut sx, mut sxx, mut sxy, mut sy) = (0.0f32, 0.0f32, 0.0f32, 0.0f32, 0.0f32); + for sub in 0..slots { + let w = self.frm_weight_norm[sub]; + let x = sub as f32; + let y = self.period_local[sub] as f32; + sw += w; + sx += w * x; + sxx += w * x * x; + sxy += w * x * y; + sy += w * y; + } + + let denom = sw * sxx - sx * sx; + let mut slope = if denom == 0.0 { + (sw * sxy - sx * sy) / 1e-15 + } else { + (sw * sxy - sx * sy) / denom + }; + + if self.voiced { + // Cap the contour slope so one bad slot cannot swing the estimate. + let limit = (sy / sw) / (4.0 * 2.0 * self.n_feat as f32); + slope = slope.max(-limit).min(limit); + } else { + slope = 0.0; + } + + // Extrapolate half a hop past the last slot, where this hop's output + // nominally sits. + let intercept = (sy - slope * sx) / sw; + let period = intercept + 5.5 * slope; + + self.pitch_hz = if self.voiced { + PROC_FS as f32 / period.max(1.0) + } else { + 0.0 + }; + self.pitch_hz + } +} + +impl TenVadPitchSource for TenVadPitchEstimator { + /// Estimates the pitch of one hop. + /// + /// # Panics + /// If `raw_hop` is not [`hop_size`](Self::hop_size) long, or `bin_power` + /// is not [`n_bins`](Self::n_bins) long. + fn frame_pitch( + &mut self, + raw_hop: &[f32], + bin_power: &[f32], + ) -> f32 { + assert_eq!( + raw_hop.len(), + self.hop_size(), + "TenVadPitchEstimator expects a {}-sample hop", + self.hop_size(), + ); + assert_eq!( + bin_power.len(), + self.n_bins(), + "TenVadPitchEstimator expects {} bins", + self.n_bins(), + ); + + self.design_prefilter(bin_power); + self.extract_excitation(raw_hop); + self.correlate(); + self.track() + } + + fn reset(&mut self) { + self.lpc = [0.0; LPC_ORDER]; + self.bin_scratch.fill(0.0); + + self.input_q.fill(0.0); + self.aligned_in.fill(0.0); + self.pitch_mem = [0.0; LPC_ORDER]; + self.pitch_filt = 0.0; + self.lpc_filter_out.fill(0.0); + self.biquad.reset(); + self.exc_buf.fill(0.0); + self.exc_buf_sq.fill(0.0); + + self.xcorr_offset = 0; + self.xcorr_inst.fill(0.0); + for row in &mut self.xcorr { + row.fill(0.0); + } + self.frm_weight.fill(0.0); + self.frm_weight_norm.fill(0.0); + + for reg in &mut self.path_score { + reg.fill(0.0); + } + for row in &mut self.path_prev { + row.fill(0); + } + self.path_best_all = 0.0; + self.best_period = 0; + self.period_local.fill(0); + self.voiced = false; + self.pitch_hz = 0.0; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const N_BINS: usize = FFT_SIZE / 2 + 1; + + /// A 16 kHz tone at `freq_hz`, at the reference's int16 scale. + fn tone( + freq_hz: f32, + samples: usize, + phase_at: usize, + ) -> Vec { + (0..samples) + .map(|i| { + let t = (phase_at + i) as f32 / SAMPLE_RATE as f32; + 8000.0 * (core::f32::consts::TAU * freq_hz * t).sin() + }) + .collect() + } + + /// A glottal-like pulse train at `f0`, which is what the estimator is + /// actually built to find. + fn pulse_train( + f0: f32, + samples: usize, + phase_at: usize, + ) -> Vec { + let period = SAMPLE_RATE as f32 / f0; + (0..samples) + .map(|i| { + let pos = (phase_at + i) as f32 % period; + // A short, decaying burst once per period. + 8000.0 * (-pos / (period * 0.08)).exp() + }) + .collect() + } + + /// The bin powers of one hop, via the same path the driver uses: + /// pre-emphasis, Hann-768 window over the last three hops, 1024-point + /// real FFT, `re² + im²`. + struct HostStft { + queue: Vec, + window: Vec, + prev_raw: f32, + } + + impl HostStft { + fn new() -> Self { + let window = (0..WINDOW_SIZE) + .map(|n| 0.5 - 0.5 * (core::f32::consts::TAU * n as f32 / WINDOW_SIZE as f32).cos()) + .collect(); + Self { + queue: vec![0.0; WINDOW_SIZE], + window, + prev_raw: 0.0, + } + } + + fn push( + &mut self, + raw_hop: &[f32], + ) -> Vec { + let hop = raw_hop.len(); + self.queue.copy_within(hop.., 0); + let tail = self.queue.len() - hop; + for (i, &x) in raw_hop.iter().enumerate() { + self.queue[tail + i] = x - 0.97 * self.prev_raw; + self.prev_raw = x; + } + + let windowed: Vec = self + .queue + .iter() + .zip(self.window.iter()) + .map(|(&x, &w)| x * w) + .collect(); + + (0..N_BINS) + .map(|k| { + let (mut re, mut im) = (0.0f64, 0.0f64); + for (n, &x) in windowed.iter().enumerate() { + let ang = -core::f64::consts::TAU * k as f64 * n as f64 / FFT_SIZE as f64; + re += x as f64 * ang.cos(); + im += x as f64 * ang.sin(); + } + (re * re + im * im) as f32 + }) + .collect() + } + } + + /// The signal's whole hops, in order; any trailing partial hop is dropped. + fn hops(signal: &[f32]) -> impl Iterator { + signal + .as_chunks::() + .0 + .iter() + .map(|hop| hop.as_slice()) + } + + /// Runs `signal` through the estimator hop by hop, returning per-hop pitch. + fn run(signal: &[f32]) -> Vec { + let mut est = TenVadPitchEstimator::new(); + let mut stft = HostStft::new(); + hops(signal) + .map(|hop| { + let power = stft.push(hop); + est.frame_pitch(hop, &power) + }) + .collect() + } + + #[test] + fn test_geometry_matches_the_ten_vad_configuration() { + let est = TenVadPitchEstimator::new(); + assert_eq!(PROC_RESAMPLE_RATE, 4); + assert_eq!(est.hop_size(), 256); + assert_eq!(est.n_bins(), 513); + assert_eq!(est.min_period, 8); + assert_eq!(est.max_period, 64); + assert_eq!(est.dif_period, 56); + assert_eq!(est.corr_half_hop, 32); + // ceil(40 ms * 16 kHz / 256 samples) = ceil(2.5) = 3 hops of history. + assert_eq!(est.n_feat, 3); + assert_eq!(est.slots(), 6); + } + + #[test] + fn test_silence_is_unvoiced() { + let pitches = run(&vec![0.0f32; HOP_SIZE * 20]); + assert_eq!(pitches.len(), 20); + for (i, p) in pitches.iter().enumerate() { + assert_eq!(*p, 0.0, "hop {i} of silence reported {p} Hz"); + } + } + + #[test] + fn test_silence_leaves_no_nan_behind() { + // The reference divides by an unguarded weight sum here; make sure the + // guarded form still reports a clean zero rather than a NaN. + let mut est = TenVadPitchEstimator::new(); + for _ in 0..8 { + let p = est.frame_pitch(&[0.0; HOP_SIZE], &[0.0; N_BINS]); + assert!(p.is_finite(), "pitch went non-finite on silence"); + assert_eq!(p, 0.0); + } + assert!(!est.voiced()); + } + + #[test] + fn test_pulse_train_recovers_its_fundamental() { + // The tracker needs a few hops of history before its regression is + // meaningful, so judge the settled tail. + for f0 in [110.0f32, 160.0, 220.0] { + let signal = pulse_train(f0, HOP_SIZE * 40, 0); + let pitches = run(&signal); + let tail = &pitches[20..]; + + let voiced: Vec = tail.iter().copied().filter(|p| *p > 0.0).collect(); + assert!( + voiced.len() * 2 > tail.len(), + "f0 {f0}: only {} of {} tail hops voiced", + voiced.len(), + tail.len(), + ); + + let mean = voiced.iter().sum::() / voiced.len() as f32; + let rel = (mean - f0).abs() / f0; + assert!(rel < 0.12, "f0 {f0}: estimated {mean} Hz (rel err {rel})"); + } + } + + #[test] + fn test_pitch_is_bounded_by_the_search_range() { + // The regression can extrapolate, but never far outside the lags the + // correlation actually searched. + let signal = pulse_train(150.0, HOP_SIZE * 30, 0); + for p in run(&signal) { + if p > 0.0 { + assert!( + (40.0..=600.0).contains(&p), + "{p} Hz is outside the plausible search range", + ); + } + } + } + + #[test] + fn test_reset_rewinds_to_start_of_stream() { + let signal = pulse_train(140.0, HOP_SIZE * 12, 0); + + let mut est = TenVadPitchEstimator::new(); + let mut stft = HostStft::new(); + let first: Vec = hops(&signal) + .map(|hop| est.frame_pitch(hop, &stft.push(hop))) + .collect(); + + // Drive it somewhere else entirely, then rewind. + let other = tone(300.0, HOP_SIZE * 8, 0); + let mut other_stft = HostStft::new(); + for hop in hops(&other) { + est.frame_pitch(hop, &other_stft.push(hop)); + } + + est.reset(); + let mut stft = HostStft::new(); + let second: Vec = hops(&signal) + .map(|hop| est.frame_pitch(hop, &stft.push(hop))) + .collect(); + + assert_eq!(first, second); + } + + #[test] + fn test_state_carries_across_hops() { + // Feeding the same hop twice must not give the same answer, or the + // tracker's history is not doing anything. + let signal = pulse_train(150.0, HOP_SIZE * 16, 0); + let pitches = run(&signal); + + let first = pitches[0]; + assert!( + pitches.iter().any(|p| *p != first), + "every hop produced the same estimate", + ); + } + + #[test] + fn test_clone_is_an_independent_stream() { + let signal = pulse_train(130.0, HOP_SIZE * 16, 0); + let mut a = TenVadPitchEstimator::new(); + let mut stft = HostStft::new(); + + let mut powers = Vec::new(); + for hop in hops(&signal).take(8) { + let power = stft.push(hop); + a.frame_pitch(hop, &power); + powers.push(power); + } + + // A clone mid-stream continues identically to the original. + let mut b = a.clone(); + let tail: Vec<&[f32]> = hops(&signal).skip(8).take(4).collect(); + let mut next_powers = Vec::new(); + for hop in &tail { + next_powers.push(stft.push(hop)); + } + + let from_a: Vec = tail + .iter() + .zip(next_powers.iter()) + .map(|(hop, power)| a.frame_pitch(hop, power)) + .collect(); + let from_b: Vec = tail + .iter() + .zip(next_powers.iter()) + .map(|(hop, power)| b.frame_pitch(hop, power)) + .collect(); + + assert_eq!(from_a, from_b); + } + + #[test] + fn test_voiced_flag_agrees_with_the_reported_pitch() { + let signal = pulse_train(180.0, HOP_SIZE * 24, 0); + let mut est = TenVadPitchEstimator::new(); + let mut stft = HostStft::new(); + + for hop in hops(&signal) { + let p = est.frame_pitch(hop, &stft.push(hop)); + assert_eq!(p > 0.0, est.voiced(), "voiced flag disagrees with {p} Hz"); + assert_eq!(p, est.pitch_hz()); + } + } + + #[test] + fn test_inspects_input_is_true() { + // The driver keys its device-to-host readback off this; a real + // estimator must opt in. + assert!(TenVadPitchEstimator::new().inspects_input()); + } + + #[test] + #[should_panic(expected = "expects a 256-sample hop")] + fn test_wrong_hop_length_panics() { + TenVadPitchEstimator::new().frame_pitch(&[0.0; 128], &[0.0; N_BINS]); + } + + #[test] + #[should_panic(expected = "expects 513 bins")] + fn test_wrong_bin_count_panics() { + TenVadPitchEstimator::new().frame_pitch(&[0.0; HOP_SIZE], &[0.0; 257]); + } + + #[test] + fn test_usable_as_a_trait_object() { + let mut source: Box = Box::new(TenVadPitchEstimator::new()); + assert!(source.inspects_input()); + let p = source.frame_pitch(&[0.0; HOP_SIZE], &[0.0; N_BINS]); + assert_eq!(p, 0.0); + source.reset(); + } +} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/lpc.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/lpc.rs new file mode 100644 index 00000000..bc86741d --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/lpc.rs @@ -0,0 +1,656 @@ +//! # The pitch estimator's LPC pre-filter design. +//! +//! Everything between the bin powers and the 16 LPC coefficients that +//! whiten the signal before the correlation search +//! (`src/pitch_est.cc`, `AUP_PE_computeBandEnergy` through +//! `AUP_PE_lpcCompute`). +//! +//! The chain, once per hop: +//! +//! ```text +//! band = 18 triangular bands over the bin powers [`band_energy`] +//! ly = clamped log10(band + 1e-2) (caller) +//! cep = dct(ly) [`DctTable::dct`] +//! gain = 10^idct(cep) * BAND_LPC_COMP [`DctTable::idct`] +//! spec = gain interpolated back over the bins [`interp_band_gain`] +//! ac = autocorrelation of spec, lags 0..=16 [`Autocorrelator`] +//! lpc = Levinson-Durbin(ac) [`celt_lpc`] +//! ``` +//! +//! The round trip through the cepstrum is not an identity: the band gains +//! come back smoothed, which is what makes the resulting all-pole fit a +//! spectral envelope rather than the spectrum itself. +//! +//! ## The autocorrelation step +//! +//! The reference reaches the autocorrelation by packing the (real, +//! Nyquist-zeroed) envelope into FFTW half-complex layout and running a +//! 1024-point inverse transform, then keeping lags `0..=16` and scaling by +//! `0.5` — note `0.5`, *not* `1/N`, so the result sits `N/2` times above a +//! conventionally normalized autocorrelation. +//! +//! Since the input is real and even, that transform collapses to a cosine +//! sum, and only 17 of its 1024 outputs are ever read: +//! +//! ```text +//! ac[i] = 0.5*S[0] + Σ_{k=1}^{N/2-1} S[k]·cos(2πik/N) +//! ``` +//! +//! [`Autocorrelator`] evaluates that directly against a cosine table, which +//! is both cheaper than a full inverse FFT and free of any FFT dependency. +//! It agrees with the reference transform to within f32 rounding. +//! +//! The scale is load-bearing rather than cosmetic. [`celt_lpc`] is itself +//! scale-invariant, but [`lpc_from_bands`] adds an *absolute* noise floor to +//! lag 0 before solving, so shrinking `ac` would silently raise that floor's +//! relative weight and flatten the fit on quiet frames. + +use super::coeff::{ + ASSUMED_FFT_FOR_BANDS, + BAND_LPC_COMP, + BAND_START_INDEX, + LPC_ORDER, + NB_BANDS, + PITCH_PI, +}; + +/// The noise floor added to lag 0 before the LPC solve. +/// +/// The reference computes `windowSz / 12 / 38.0f` with an **integer** first +/// division (`src/pitch_est.cc`, `DC0_BIAS`). +fn dc0_bias(window_size: usize) -> f32 { + (window_size / 12) as f32 / 38.0 +} + +/// The `NB_BANDS`-point DCT basis the pitch estimator's cepstrum uses. +/// +/// Not one of the standard DCT normalizations: the reference builds +/// `cos((i + 0.5)·j·π/NB_BANDS)`, scales column `0` by `sqrt(0.5)`, and +/// applies `sqrt(2/NB_BANDS)` to both directions. [`dct`](Self::dct) and +/// [`idct`](Self::idct) therefore differ only in which index of the table +/// they walk. +#[derive(Debug, Clone, PartialEq)] +pub struct DctTable { + table: [f32; NB_BANDS * NB_BANDS], +} + +impl Default for DctTable { + fn default() -> Self { + Self::new() + } +} + +impl DctTable { + /// Builds the table. + pub fn new() -> Self { + let mut table = [0.0f32; NB_BANDS * NB_BANDS]; + for idx in 0..NB_BANDS { + for jdx in 0..NB_BANDS { + let mut v = ((idx as f32 + 0.5) * jdx as f32 * PITCH_PI / NB_BANDS as f32).cos(); + if jdx == 0 { + v *= 0.5f32.sqrt(); + } + table[idx * NB_BANDS + jdx] = v; + } + } + Self { table } + } + + /// The shared `sqrt(2/NB_BANDS)` scale of both directions. + fn ratio() -> f32 { + (2.0 / NB_BANDS as f32).sqrt() + } + + /// Band log-energies to cepstrum. + pub fn dct( + &self, + input: &[f32; NB_BANDS], + ) -> [f32; NB_BANDS] { + let ratio = Self::ratio(); + core::array::from_fn(|idx| { + let mut sum = 0.0f32; + for (j, &x) in input.iter().enumerate() { + sum += x * self.table[j * NB_BANDS + idx]; + } + sum * ratio + }) + } + + /// Cepstrum back to band log-energies. + pub fn idct( + &self, + input: &[f32; NB_BANDS], + ) -> [f32; NB_BANDS] { + let ratio = Self::ratio(); + core::array::from_fn(|idx| { + let mut sum = 0.0f32; + for (j, &x) in input.iter().enumerate() { + sum += x * self.table[idx * NB_BANDS + j]; + } + sum * ratio + }) + } +} + +/// The bin span of band `idx`, as `(offset, width)`. +/// +/// [`BAND_START_INDEX`] is tabulated against an [`ASSUMED_FFT_FOR_BANDS`]-point +/// FFT, so both ends are rescaled to the real FFT size and rounded. Rounding +/// each end independently — rather than differencing rounded offsets — is the +/// reference's behavior, and lets adjacent spans disagree by a bin. At the +/// ten-vad geometry they overlap at three boundaries and never gap, so every +/// bin below Nyquist stays covered. +fn band_span( + idx: usize, + fft_size: usize, +) -> (usize, usize) { + let rate = fft_size as f32 / ASSUMED_FFT_FOR_BANDS; + let width = (((BAND_START_INDEX[idx + 1] - BAND_START_INDEX[idx]) as f32) * rate).round(); + let offset = ((BAND_START_INDEX[idx] as f32) * rate).round(); + (offset as usize, width as usize) +} + +/// Folds `bin_power` into [`NB_BANDS`] triangular bands. +/// +/// Each band pair shares a linear ramp: bin `j` of span `i` contributes +/// `1 - j/width` to band `i` and `j/width` to band `i + 1`. The two edge +/// bands are doubled, since each only ever receives one side of a ramp. +/// +/// # Arguments +/// * `bin_power`: `[fft_size / 2 + 1]` bin powers. +/// * `fft_size`: the FFT size `bin_power` came from. +/// +/// # Panics +/// If `bin_power` is not `fft_size / 2 + 1` long. +pub fn band_energy( + bin_power: &[f32], + fft_size: usize, +) -> [f32; NB_BANDS] { + let n_bins = fft_size / 2 + 1; + assert_eq!( + bin_power.len(), + n_bins, + "band_energy expects {n_bins} bins for a {fft_size}-point FFT", + ); + + let mut band = [0.0f32; NB_BANDS]; + for i in 0..(NB_BANDS - 1) { + let (offset, width) = band_span(i, fft_size); + for j in 0..width { + let frac = j as f32 / width as f32; + let power = bin_power[(offset + j).min(n_bins - 1)]; + band[i] += (1.0 - frac) * power; + band[i + 1] += frac * power; + } + } + band[0] *= 2.0; + band[NB_BANDS - 1] *= 2.0; + band +} + +/// Spreads per-band gains back across the bins, inverting [`band_energy`]. +/// +/// Unlike [`band_energy`] this *assigns* rather than accumulates, so where +/// rounding makes two spans overlap the later band wins, and where it leaves +/// a gap the bin keeps its zero. +/// +/// # Arguments +/// * `band_gain`: the per-band gains. +/// * `gain_per_bin`: `[fft_size / 2 + 1]` output, overwritten in full. +pub fn interp_band_gain( + band_gain: &[f32; NB_BANDS], + gain_per_bin: &mut [f32], +) { + let n_bins = gain_per_bin.len(); + let fft_size = (n_bins - 1) * 2; + + gain_per_bin.fill(0.0); + for idx in 0..(NB_BANDS - 1) { + let (offset, width) = band_span(idx, fft_size); + for j in 0..width { + let frac = j as f32 / width as f32; + gain_per_bin[(offset + j).min(n_bins - 1)] = + (1.0 - frac) * band_gain[idx] + frac * band_gain[idx + 1]; + } + } +} + +/// Autocorrelation lags from a real, even power spectrum. +/// +/// Holds the cosine table the sum is evaluated against; see the module docs +/// for why this replaces the reference's inverse FFT and why the result is +/// scaled by `0.5` rather than `1/fft_size`. +#[derive(Debug, Clone, PartialEq)] +pub struct Autocorrelator { + fft_size: usize, + + /// `cos(2πt/fft_size)` for `t` in `0..fft_size`. + cos_table: Vec, +} + +impl Autocorrelator { + /// Builds an autocorrelator for a given FFT size. + /// + /// # Panics + /// If `fft_size` is not even. + pub fn new(fft_size: usize) -> Self { + assert_eq!(fft_size % 2, 0, "fft_size must be even"); + let cos_table = (0..fft_size) + .map(|t| (core::f64::consts::TAU * t as f64 / fft_size as f64).cos()) + .collect(); + Self { + fft_size, + cos_table, + } + } + + /// The FFT size this autocorrelator was built for. + pub fn fft_size(&self) -> usize { + self.fft_size + } + + /// Lags `0..n_lags` of the spectrum in `spectrum`. + /// + /// The Nyquist bin is ignored; the reference zeroes it before + /// transforming, and this reproduces that by construction. + /// + /// The sum is accumulated in `f64`. The reference accumulates through FFT + /// butterflies, whose error grows with `log(fft_size)` rather than + /// `fft_size`; a flat `f32` sum over 511 terms would be materially worse + /// than the thing being reproduced, while `f64` is at least as good. + /// + /// # Arguments + /// * `spectrum`: `[fft_size / 2 + 1]` non-negative bin values. + /// * `out`: the lags to fill; `out.len()` must not exceed `fft_size`. + /// + /// # Panics + /// If `spectrum` is not `fft_size / 2 + 1` long. + pub fn autocorrelate( + &self, + spectrum: &[f32], + out: &mut [f32], + ) { + let n_bins = self.fft_size / 2 + 1; + assert_eq!( + spectrum.len(), + n_bins, + "autocorrelate expects {n_bins} bins for a {}-point FFT", + self.fft_size, + ); + + for (lag, slot) in out.iter_mut().enumerate() { + let mut acc = 0.0f64; + for (k, &s) in spectrum.iter().enumerate().take(self.fft_size / 2).skip(1) { + acc += s as f64 * self.cos_table[(lag * k) % self.fft_size]; + } + *slot = (0.5 * spectrum[0] as f64 + acc) as f32; + } + } +} + +/// Solves for LPC coefficients by Levinson-Durbin recursion. +/// +/// The reference's `AUP_PE_celt_lpc`, itself CELT's. Returns the coefficients +/// of the whitening filter, and bails out early once the residual error has +/// dropped 30 dB below lag 0 — leaving the remaining coefficients at whatever +/// the recursion had reached. +/// +/// Scale-invariant: multiplying `ac` through by a constant leaves the result +/// unchanged, including the bail-out point. +/// +/// # Arguments +/// * `ac`: autocorrelation lags `0..=LPC_ORDER`. +/// +/// # Returns +/// The `LPC_ORDER` coefficients, all zero if `ac[0]` is zero. +pub fn celt_lpc(ac: &[f32; LPC_ORDER + 1]) -> [f32; LPC_ORDER] { + let mut lpc = [0.0f32; LPC_ORDER]; + if ac[0] == 0.0 { + return lpc; + } + + let mut error = ac[0]; + for i in 0..LPC_ORDER { + // The reference sums the products first and folds in `ac[i + 1]` + // last; the order is preserved because it is observable in f32. + let mut rr = 0.0f32; + for j in 0..i { + rr += lpc[j] * ac[i - j]; + } + rr += ac[i + 1]; + + let r = -rr / error; + lpc[i] = r; + for j in 0..((i + 1) >> 1) { + let head = lpc[j]; + let tail = lpc[i - 1 - j]; + lpc[j] = head + r * tail; + lpc[i - 1 - j] = tail + r * head; + } + + error -= r * r * error; + if error < 0.001 * ac[0] { + break; + } + } + + lpc +} + +/// Fits an all-pole filter to a set of band gains. +/// +/// Interpolates the gains across the bins, autocorrelates, applies the noise +/// floor and lag window, and solves. +/// +/// # Arguments +/// * `band_gain`: the per-band spectral envelope. +/// * `window_size`: the STFT analysis window length, which sets the noise +/// floor. +/// * `autocorrelator`: sized to the FFT the bands are spread over. +/// * `bin_scratch`: `[fft_size / 2 + 1]` scratch, overwritten. +pub fn lpc_from_bands( + band_gain: &[f32; NB_BANDS], + window_size: usize, + autocorrelator: &Autocorrelator, + bin_scratch: &mut [f32], +) -> [f32; LPC_ORDER] { + interp_band_gain(band_gain, bin_scratch); + // Drop the Nyquist bin, as the reference does before transforming. + let last = bin_scratch.len() - 1; + bin_scratch[last] = 0.0; + + let mut ac = [0.0f32; LPC_ORDER + 1]; + autocorrelator.autocorrelate(bin_scratch, &mut ac); + + // -40 dB noise floor, then a lag window tapering the higher lags. + ac[0] += ac[0] * 1e-4 + dc0_bias(window_size); + for (i, slot) in ac.iter_mut().enumerate().skip(1) { + *slot *= 1.0 - 6e-5 * i as f32 * i as f32; + } + + celt_lpc(&ac) +} + +/// Fits an all-pole filter to a cepstrum. +/// +/// Undoes the DCT, exponentiates back to linear gains, applies +/// [`BAND_LPC_COMP`], and hands off to [`lpc_from_bands`]. +/// +/// # Arguments +/// * `cepstrum`: the DCT of the clamped band log-energies. +/// * `dct`: the table `cepstrum` was produced with. +/// * `window_size`: the STFT analysis window length. +/// * `autocorrelator`: sized to the FFT the bands are spread over. +/// * `bin_scratch`: `[fft_size / 2 + 1]` scratch, overwritten. +pub fn lpc_from_cepstrum( + cepstrum: &[f32; NB_BANDS], + dct: &DctTable, + window_size: usize, + autocorrelator: &Autocorrelator, + bin_scratch: &mut [f32], +) -> [f32; LPC_ORDER] { + let mut band_gain = dct.idct(cepstrum); + for (gain, comp) in band_gain.iter_mut().zip(BAND_LPC_COMP) { + *gain = 10.0f32.powf(*gain) * comp; + } + lpc_from_bands(&band_gain, window_size, autocorrelator, bin_scratch) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FFT: usize = 1024; + const NBINS: usize = FFT / 2 + 1; + + /// A smooth, non-negative, decaying spectrum — the shape a real band + /// envelope has. + fn envelope() -> Vec { + (0..NBINS) + .map(|k| 1e3 * (-(k as f32) / 90.0).exp() * (1.0 + 0.4 * (k as f32 * 0.07).sin())) + .collect() + } + + #[test] + fn test_dct_round_trips_through_idct() { + let dct = DctTable::new(); + let input: [f32; NB_BANDS] = core::array::from_fn(|i| (i as f32 * 0.4).sin()); + + let back = dct.idct(&dct.dct(&input)); + for (got, want) in back.iter().zip(input.iter()) { + assert!((got - want).abs() < 1e-5, "{got} vs {want}"); + } + } + + #[test] + fn test_dct_is_orthonormal_enough_to_preserve_energy() { + let dct = DctTable::new(); + let input: [f32; NB_BANDS] = core::array::from_fn(|i| (i as f32 * 0.9).cos()); + + let energy = |v: &[f32; NB_BANDS]| v.iter().map(|x| x * x).sum::(); + let ratio = energy(&dct.dct(&input)) / energy(&input); + assert!((ratio - 1.0).abs() < 1e-4, "energy ratio {ratio}"); + } + + #[test] + fn test_band_energy_conserves_total_power() { + // Every ramp pair sums to 1, and the doubled edges compensate for the + // half-ramps the first and last bands see, so a flat spectrum lands + // as roughly `2 * total` spread over the bands. + let flat = vec![1.0f32; NBINS]; + let bands = band_energy(&flat, FFT); + + for (i, b) in bands.iter().enumerate() { + assert!(*b > 0.0, "band {i} is empty"); + } + // Interior bands see one full ramp in and one out. + let interior: f32 = bands[1..NB_BANDS - 1].iter().sum(); + assert!(interior > 0.0); + } + + #[test] + fn test_band_energy_tracks_where_the_power_is() { + let mut spectrum = vec![0.0f32; NBINS]; + // Band 0 spans bins 0..13 at this FFT size. + spectrum[4] = 100.0; + let low = band_energy(&spectrum, FFT); + assert!(low[0] > 0.0); + assert!(low[NB_BANDS - 1] == 0.0); + + let mut spectrum = vec![0.0f32; NBINS]; + spectrum[500] = 100.0; + let high = band_energy(&spectrum, FFT); + assert!(high[0] == 0.0); + assert!(high[NB_BANDS - 1] > 0.0); + } + + #[test] + fn test_band_spans_cover_the_spectrum_without_gaps() { + // Both ends of each span are rounded independently, so a span may + // start one bin *before* the previous one ended -- but never after, + // which is what keeps `interp_band_gain` from leaving a bin at zero. + let mut next = 0usize; + let mut overlaps = 0; + for i in 0..(NB_BANDS - 1) { + let (offset, width) = band_span(i, FFT); + assert!( + offset <= next, + "band {i} starts at {offset}, leaving a gap after {next}", + ); + overlaps += next - offset; + next = offset + width; + } + assert_eq!(next, NBINS - 1, "spans should stop just below Nyquist"); + // At this geometry the rounding overlaps at exactly three boundaries. + assert_eq!(overlaps, 3); + } + + #[test] + fn test_overlapping_spans_double_count_in_band_energy() { + // Where spans overlap, the shared bin lands in both bands: once as the + // tail of one ramp and once as the head of the next. The reference does + // this, and the doubled bin is why the bands are not a partition. + let mut spectrum = vec![0.0f32; NBINS]; + // Band 3 starts at bin 38; band 2 runs through bin 38 as well. + spectrum[38] = 1.0; + let bands = band_energy(&spectrum, FFT); + + assert!(bands[2] > 0.0, "the overlapped bin should reach band 2"); + assert!(bands[3] > 0.0, "the overlapped bin should reach band 3"); + } + + #[test] + fn test_interp_band_gain_reproduces_a_flat_envelope() { + let flat = [3.0f32; NB_BANDS]; + let mut bins = vec![0.0f32; NBINS]; + interp_band_gain(&flat, &mut bins); + + // Every bin below Nyquist is covered by exactly one span, and a flat + // set of gains interpolates to itself. + for (k, &g) in bins.iter().enumerate().take(NBINS - 1) { + assert!((g - 3.0).abs() < 1e-5, "bin {k} = {g}"); + } + } + + #[test] + fn test_autocorrelate_matches_a_direct_dft() { + let spectrum = envelope(); + let ac = Autocorrelator::new(FFT); + let mut got = [0.0f32; LPC_ORDER + 1]; + ac.autocorrelate(&spectrum, &mut got); + + for (lag, &g) in got.iter().enumerate() { + // The closed form, evaluated independently in f64. + let mut want = 0.5 * spectrum[0] as f64; + for (k, &s) in spectrum.iter().enumerate().take(FFT / 2).skip(1) { + want += + s as f64 * (core::f64::consts::TAU * lag as f64 * k as f64 / FFT as f64).cos(); + } + let rel = (g as f64 - want).abs() / want.abs().max(1.0); + assert!(rel < 1e-6, "lag {lag}: {g} vs {want}"); + } + } + + #[test] + fn test_autocorrelate_ignores_the_nyquist_bin() { + let ac = Autocorrelator::new(FFT); + let mut spectrum = envelope(); + let mut with_nyquist = [0.0f32; LPC_ORDER + 1]; + let mut without = [0.0f32; LPC_ORDER + 1]; + + spectrum[NBINS - 1] = 0.0; + ac.autocorrelate(&spectrum, &mut without); + spectrum[NBINS - 1] = 1e6; + ac.autocorrelate(&spectrum, &mut with_nyquist); + + assert_eq!(with_nyquist, without); + } + + #[test] + fn test_autocorrelate_lag_zero_is_the_mean_power() { + // With the reference's 0.5 scale, lag 0 is half the DC bin plus the + // sum of every other bin. + let spectrum = envelope(); + let ac = Autocorrelator::new(FFT); + let mut got = [0.0f32; 1]; + ac.autocorrelate(&spectrum, &mut got); + + let want = + 0.5 * spectrum[0] as f64 + spectrum[1..FFT / 2].iter().map(|&s| s as f64).sum::(); + assert!((got[0] as f64 - want).abs() / want < 1e-6); + } + + #[test] + fn test_celt_lpc_is_scale_invariant() { + let mut ac = [0.0f32; LPC_ORDER + 1]; + for (i, slot) in ac.iter_mut().enumerate() { + *slot = 1000.0 * (-(i as f32) / 4.0).exp(); + } + let base = celt_lpc(&ac); + + for scale in [1e-3f32, 7.0, 512.0] { + let scaled: [f32; LPC_ORDER + 1] = core::array::from_fn(|i| ac[i] * scale); + let got = celt_lpc(&scaled); + for (g, b) in got.iter().zip(base.iter()) { + assert!((g - b).abs() < 1e-4, "scale {scale}: {g} vs {b}"); + } + } + } + + #[test] + fn test_celt_lpc_of_a_zero_signal_is_zero() { + assert_eq!(celt_lpc(&[0.0; LPC_ORDER + 1]), [0.0; LPC_ORDER]); + } + + #[test] + fn test_celt_lpc_whitens_a_known_ar_process() { + // An AR(1) process x[n] = a*x[n-1] + e has autocorrelation a^|k|, and + // the whitening filter should recover -a in the first coefficient. + let a = 0.8f32; + let ac: [f32; LPC_ORDER + 1] = core::array::from_fn(|i| a.powi(i as i32)); + let lpc = celt_lpc(&ac); + + assert!((lpc[0] + a).abs() < 1e-3, "lpc[0] = {}", lpc[0]); + for (i, c) in lpc.iter().enumerate().skip(1) { + assert!(c.abs() < 1e-3, "lpc[{i}] = {c} should be negligible"); + } + } + + #[test] + fn test_dc0_bias_uses_integer_division() { + // 768/12 is exact, so this matches either reading; the smaller windows + // are where the reference's integer division would show. + assert_eq!(dc0_bias(768), 64.0 / 38.0); + assert_eq!(dc0_bias(770), 64.0 / 38.0); + assert_ne!(dc0_bias(770), (770.0 / 12.0) / 38.0); + } + + #[test] + fn test_lpc_from_bands_is_stable_for_a_flat_envelope() { + let flat = [1.0f32; NB_BANDS]; + let mut scratch = vec![0.0f32; NBINS]; + let ac = Autocorrelator::new(FFT); + let lpc = lpc_from_bands(&flat, 768, &ac, &mut scratch); + + // A flat envelope is already white: nothing to predict. + for (i, c) in lpc.iter().enumerate() { + assert!(c.abs() < 0.2, "lpc[{i}] = {c} for a flat spectrum"); + } + } + + #[test] + fn test_lpc_from_bands_responds_to_a_tilted_envelope() { + let mut scratch = vec![0.0f32; NBINS]; + let ac = Autocorrelator::new(FFT); + + let tilted: [f32; NB_BANDS] = core::array::from_fn(|i| 100.0 * (-(i as f32) / 3.0).exp()); + let lpc = lpc_from_bands(&tilted, 768, &ac, &mut scratch); + + let magnitude: f32 = lpc.iter().map(|c| c.abs()).sum(); + assert!( + magnitude > 0.2, + "a strongly tilted spectrum should fit: {magnitude}" + ); + } + + #[test] + fn test_lpc_from_cepstrum_round_trips_through_lpc_from_bands() { + let dct = DctTable::new(); + let ac = Autocorrelator::new(FFT); + let mut scratch = vec![0.0f32; NBINS]; + + let log_bands: [f32; NB_BANDS] = core::array::from_fn(|i| 1.5 - i as f32 * 0.1); + let cepstrum = dct.dct(&log_bands); + + let from_cep = lpc_from_cepstrum(&cepstrum, &dct, 768, &ac, &mut scratch); + + // The same path, spelled out. + let mut gains = dct.idct(&cepstrum); + for (g, comp) in gains.iter_mut().zip(BAND_LPC_COMP) { + *g = 10.0f32.powf(*g) * comp; + } + let from_bands = lpc_from_bands(&gains, 768, &ac, &mut scratch); + + assert_eq!(from_cep, from_bands); + } +} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/mod.rs new file mode 100644 index 00000000..a2c93c3b --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/mod.rs @@ -0,0 +1,50 @@ +//! # ten-vad pitch estimation. +//! +//! Feature `40` of the ten-vad feature vector: a pitch estimate in Hz, `0.0` +//! when unvoiced (`ALGO_TRACE.md` §3.5). +//! +//! Unlike the rest of the front end this branch does not vectorize. The +//! reference estimator is a deeply serial recurrence over scalars — an LPC +//! fit, an IIR cascade, a lag search, and a Viterbi tracker, each carrying +//! state across hops — so it runs host-side, per stream, behind the +//! [`TenVadPitchSource`] seam. +//! +//! ## The pieces +//! +//! * [`TenVadPitchSource`] — the seam the driver calls through. +//! * [`TenVadPitchEstimator`] — the port of the reference estimator. +//! * [`ZeroPitch`] — a constant stub that skips the branch entirely. +//! * [`BiquadCascade`] — the anti-alias filter before decimation. +//! * [`coeff`](self) — the reference constants. +//! +//! [`lpc`] holds the pre-filter design: band folding, the cepstrum, the +//! autocorrelation, and the Levinson-Durbin solve. +//! +//! ## Choosing a source +//! +//! [`TenVadPitchEstimator`] is the faithful choice and what +//! [`TenVadContext`](super::TenVadContext) should carry for reference +//! parity. It costs a device-to-host readback of the raw hop and the bin +//! powers on every frame, which pins the sequence path to a host-side walk. +//! +//! [`ZeroPitch`] pins feature `40` to a constant and reports +//! [`inspects_input`](TenVadPitchSource::inspects_input) as `false`, letting +//! the driver keep the whole sequence path on-device. The other 40 features +//! are exact either way. + +mod biquad; +mod coeff; +mod estimator; +mod lpc; +mod source; + +#[doc(inline)] +pub use biquad::*; +#[doc(inline)] +pub use coeff::*; +#[doc(inline)] +pub use estimator::*; +#[doc(inline)] +pub use lpc::*; +#[doc(inline)] +pub use source::*; diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/source.rs similarity index 82% rename from crates/bunsen/src/kits/speech/ten_vad/context/pitch.rs rename to crates/bunsen/src/kits/speech/ten_vad/context/pitch/source.rs index 81bb5cbd..c812347e 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/source.rs @@ -1,22 +1,15 @@ -//! # ten-vad pitch feature source. +//! # The ten-vad pitch feature seam. //! //! Feature `40` of the ten-vad feature vector is a pitch estimate, in Hz, //! with `0.0` meaning "unvoiced" (`ALGO_TRACE.md` §3.5). //! -//! The reference estimator is a large, deeply serial RNNoise-derived tracker: -//! band energy -> DCT -> celt LPC -> LPC pre-filter -> a five-section IIR -//! cascade -> 4 kHz decimation -> normalized moving cross-correlation -> -//! Viterbi DP over candidate periods -> weighted linear regression. None of -//! it vectorizes, and all of it carries state across frames. +//! The driver reaches it through [`TenVadPitchSource`], which has two +//! implementations: //! -//! Rather than block the rest of the front end on that port, the driver takes -//! its pitch through the [`TenVadPitchSource`] seam, and ships [`ZeroPitch`] -//! as the default. Everything upstream of feature `40` — pre-emphasis, the -//! sliding STFT, the mel filterbank, the log, the normalization, and the -//! frame context stack — is complete and exercised regardless. -//! -//! An implementation of the real estimator drops in behind this trait without -//! touching the driver. +//! * [`TenVadPitchEstimator`](super::TenVadPitchEstimator) — the port of the +//! reference estimator, and what a faithful front end wants. +//! * [`ZeroPitch`] — a constant stub, for callers that want the 40 mel features +//! without paying for the pitch branch's host-side recurrence. use crate::kits::speech::ten_vad::context::coeff::{ FEATURE_EPS, @@ -27,7 +20,8 @@ use crate::kits::speech::ten_vad::context::coeff::{ /// A source for the ten-vad pitch feature. /// -/// Implemented by [`ZeroPitch`]. +/// Implemented by [`TenVadPitchEstimator`](super::TenVadPitchEstimator) and +/// [`ZeroPitch`]. /// /// The interface is host-side by necessity: the reference algorithm is a /// serial recurrence over scalars, not a tensor op. Implementations are @@ -72,9 +66,10 @@ pub trait TenVadPitchSource { /// `(0.0 - FEATURE_MEANS[40]) / (FEATURE_STDS[40] + FEATURE_EPS)`, which /// [`ZeroPitch::normalized_feature`] reports. /// -/// This is the driver's default until the reference estimator is ported. The -/// other 40 features are unaffected: nothing upstream of the pitch branch -/// reads its output. +/// The other 40 features are unaffected: nothing upstream of the pitch branch +/// reads its output. This is a deliberate approximation, not a placeholder — +/// [`TenVadPitchEstimator`](super::TenVadPitchEstimator) is the faithful +/// source, and costs a device-to-host readback per hop that this avoids. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct ZeroPitch; diff --git a/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs b/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs index 90cba047..29368c09 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs @@ -14,6 +14,15 @@ mod tests { TenVadContextConfig, TenVadContextMeta, TenVadMeta, + context::{ + FEATURE_EPS, + FEATURE_MEANS, + FEATURE_STDS, + N_MELS, + TenVadFeatureConfig, + TenVadFeatureMeta, + TenVadPitchEstimator, + }, reference::ReferenceModel, }, prelude::*, @@ -232,4 +241,99 @@ mod tests { Ok(()) } + + /// Pins the ported pitch estimator against the ten-vad C reference. + /// + /// `testdata/ten/pitch.json` holds one pitch estimate per 256-sample hop + /// over the whole 60 s fixture, produced by driving the reference + /// `AUP_PE_proc` (`src/pitch_est.cc`) from the reference `AUP_Analyzer` + /// STFT — the same wiring `AUP_Aed_runOneFrm` uses, so the estimator sees + /// the raw hop and the un-normalized bin powers exactly as it does in the + /// C driver. + /// + /// The two arms reach their bin powers through different FFTs, so they are + /// not bit-identical. Two things are asserted instead: + /// + /// * the **voicing decision** agrees on every frame — the discrete output, + /// and the one a porting error would flip; and + /// * where both call a frame voiced, the estimates agree to well inside f32 + /// rounding. + /// + /// This covers feature `40`. The other 40 features are pinned by + /// [`TenVadFeatureContext`]'s own tests, against an independent host + /// implementation of the mel path. + /// + /// [`TenVadFeatureContext`]: crate::kits::speech::ten_vad::context::TenVadFeatureContext + #[test] + #[serial_test::serial] + fn test_pitch_estimator_reference_golden() -> Result<(), Box> { + type B = PerformanceBackend; + type F = ::FloatElem; + + let device = Default::default(); + let cfg = TenVadFeatureConfig::new(); + let hop_size = cfg.hop_size(); + let sample_rate = cfg.sample_rate(); + + let wav_path = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata/silero/test.wav"); + let golden_path = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata/ten/pitch.json"); + + let (_, wav_vec) = load_audio_mono_sr(wav_path, sample_rate)?; + let expected: Vec = serde_json::from_reader( + std::fs::File::open(golden_path).map_err(BunsenError::external)?, + ) + .map_err(BunsenError::external)?; + + let steps = expected.len(); + assert!( + wav_vec.len() >= steps * hop_size, + "fixture is too short for {steps} hops: {} samples", + wav_vec.len(), + ); + + // [steps, batch=1, hop_size] + let hop_seq: Tensor = + Tensor::::from_floats(&wav_vec[..steps * hop_size], &device) + .reshape([steps, 1, hop_size]); + + let mut ctx = cfg.try_init_context::(1, TenVadPitchEstimator::new(), &device)?; + + // [steps, batch=1, n_freq] + let feats = ctx.forward_sequence(hop_seq); + let flat: Vec = feats.to_data_as::().to_vec_as::().ok_or_panic(); + + // Undo the standardization to recover Hz, which is what the reference + // reports and what stays legible in a failure message. + let n_freq = N_MELS + 1; + let scale = FEATURE_STDS[N_MELS] + FEATURE_EPS; + let got: Vec = (0..steps) + .map(|t| flat[t * n_freq + N_MELS] * scale + FEATURE_MEANS[N_MELS]) + .collect(); + + let mut voiced_frames = 0usize; + for (t, (&g, &e)) in got.iter().zip(expected.iter()).enumerate() { + assert_eq!( + g > 0.0, + e > 0.0, + "frame {t}: voicing disagrees, got {g} Hz vs reference {e} Hz", + ); + if e > 0.0 { + voiced_frames += 1; + let rel = (g - e).abs() / e; + assert!( + rel < 1e-4, + "frame {t}: {g} Hz vs reference {e} Hz (rel err {rel})", + ); + } + } + + // Guard the guard: a golden of all-unvoiced frames would pass the loop + // above without exercising the estimator at all. + assert!( + voiced_frames * 2 > steps, + "fixture should be mostly voiced, got {voiced_frames} of {steps}", + ); + + Ok(()) + } } diff --git a/crates/bunsen/testdata/ten/README.md b/crates/bunsen/testdata/ten/README.md new file mode 100644 index 00000000..9d11d07f --- /dev/null +++ b/crates/bunsen/testdata/ten/README.md @@ -0,0 +1,39 @@ +# ten-vad reference fixtures + +## `pitch.json` + +One pitch estimate in Hz per 256-sample hop — `0.0` meaning unvoiced — over +`../silero/test.wav` (16 kHz mono, 60 s, 3750 hops). Produced by the ten-vad +**C reference**, not by a port. + +Consumed by +`kits::speech::ten_vad::cross_test::tests::test_pitch_estimator_reference_golden`, +which pins `TenVadPitchEstimator` against it. + +### Regenerating + +`dump_pitch.cc` reproduces the reference's pitch branch: it drives +`AUP_PE_proc` (`src/pitch_est.cc`) from the reference `AUP_Analyzer` STFT, +feeding the estimator the raw hop and the un-normalized bin powers — the same +wiring `AUP_Aed_runOneFrm` uses. It needs a checkout of the ten-vad reference +for its sources; only the front end is linked, so no ONNX runtime is involved. + +`coeff.h` cannot be included directly (it pulls in `aed_st.h`, which needs +`onnxruntime_c_api.h`), so the Hann-768 analysis window is extracted from it +and given external linkage: + +```sh +TENVAD=/path/to/ten-vad # https://github.com/TEN-framework/ten-vad +awk '/^const float AUP_AED_STFTWindow_Hann768/,/};/' "$TENVAD/src/coeff.h" \ + | sed '1s/^const float/extern const float/' > window.cc + +g++ -O2 -w -I"$TENVAD/src" -o dump_pitch dump_pitch.cc window.cc \ + "$TENVAD/src/stft.cc" "$TENVAD/src/pitch_est.cc" \ + "$TENVAD/src/biquad.cc" "$TENVAD/src/fftw.c" + +./dump_pitch ../silero/test.wav \ + | awk '{printf "%s%s", (NR>1 ? ", " : "["), $2} END {print "]"}' > pitch.json +``` + +The dump prints `frameIndex pitchHz voiced` per line; only the pitch column is +checked in, since the voicing flag is recoverable as `pitch > 0`. diff --git a/crates/bunsen/testdata/ten/dump_pitch.cc b/crates/bunsen/testdata/ten/dump_pitch.cc new file mode 100644 index 00000000..36879d43 --- /dev/null +++ b/crates/bunsen/testdata/ten/dump_pitch.cc @@ -0,0 +1,116 @@ +// Reference dump: drives the ten-vad C front end (STFT + pitch estimator) +// over a 16 kHz mono 16-bit WAV and prints one line per hop: +// frameIdx pitchFreq voiced +// +// Reproduces AUP_Aed_procAudio's pitch branch exactly: +// * pre-emphasis feeds the STFT branch only, +// * the pitch estimator reads the raw hop and the un-normalized bin power. +#include +#include +#include +#include +#include + +#include "stft.h" +#include "pitch_est.h" + +extern const float AUP_AED_STFTWindow_Hann768[768]; + +static bool read_wav_i16(const char* path, std::vector& out, int& sr, int& ch) { + FILE* f = fopen(path, "rb"); + if (!f) return false; + char riff[12]; + if (fread(riff, 1, 12, f) != 12 || memcmp(riff, "RIFF", 4) || memcmp(riff + 8, "WAVE", 4)) { + fclose(f); return false; + } + int bits = 0; sr = 0; ch = 0; + while (true) { + char id[4]; uint32_t sz; + if (fread(id, 1, 4, f) != 4) break; + if (fread(&sz, 4, 1, f) != 1) break; + if (!memcmp(id, "fmt ", 4)) { + uint16_t fmt, nch, bps; uint32_t rate, brate; uint16_t align; + fread(&fmt, 2, 1, f); fread(&nch, 2, 1, f); fread(&rate, 4, 1, f); + fread(&brate, 4, 1, f); fread(&align, 2, 1, f); fread(&bps, 2, 1, f); + ch = nch; sr = (int)rate; bits = bps; + if (sz > 16) fseek(f, (long)sz - 16, SEEK_CUR); + } else if (!memcmp(id, "data", 4)) { + if (bits != 16) { fclose(f); return false; } + out.resize(sz / 2); + fread(out.data(), 1, sz, f); + fclose(f); + return true; + } else { + fseek(f, (long)sz + (sz & 1), SEEK_CUR); + } + } + fclose(f); + return false; +} + +int main(int argc, char** argv) { + if (argc < 2) { fprintf(stderr, "usage: dump_pitch \n"); return 1; } + + std::vector pcm; int sr = 0, ch = 0; + if (!read_wav_i16(argv[1], pcm, sr, ch)) { fprintf(stderr, "bad wav\n"); return 1; } + if (sr != 16000 || ch != 1) { fprintf(stderr, "need 16k mono, got %d/%d\n", sr, ch); return 1; } + + const int HOP = 256, FFT = 1024, WIN = 768, NBINS = FFT / 2 + 1; + + void* analyzer = NULL; + if (AUP_Analyzer_create(&analyzer) < 0) return 1; + Analyzer_StaticCfg acfg; + AUP_Analyzer_getStaticCfg(analyzer, &acfg); + acfg.win_len = WIN; acfg.hop_size = HOP; acfg.fft_size = FFT; + acfg.ana_win_coeff = AUP_AED_STFTWindow_Hann768; + if (AUP_Analyzer_memAllocate(analyzer, &acfg) < 0) return 1; + if (AUP_Analyzer_init(analyzer) < 0) return 1; + + void* pe = NULL; + if (AUP_PE_create(&pe) < 0) return 1; + PE_StaticCfg pcfg; + AUP_PE_getStaticCfg(pe, &pcfg); + pcfg.fftSz = FFT; pcfg.anaWindowSz = WIN; pcfg.hopSz = HOP; + pcfg.useLPCPreFiltering = 1; pcfg.procFs = 4000; + if (AUP_PE_memAllocate(pe, &pcfg) < 0) return 1; + if (AUP_PE_init(pe) < 0) return 1; + PE_DynamCfg dcfg; AUP_PE_getDynamCfg(pe, &dcfg); + dcfg.voicedThr = 0.4f; + AUP_PE_setDynamCfg(pe, &dcfg); + + std::vector raw(HOP), emph(HOP), spec(FFT), binPow(NBINS); + float pre = 0.0f; + size_t nFrames = pcm.size() / HOP; + + for (size_t fr = 0; fr < nFrames; fr++) { + for (int i = 0; i < HOP; i++) { + float x = (float)pcm[fr * HOP + i]; + raw[i] = x; + emph[i] = x - 0.97f * pre; + pre = x; + } + + Analyzer_InputData ain; ain.input = emph.data(); ain.iLength = HOP; + Analyzer_OutputData aout; aout.output = spec.data(); aout.oLength = FFT; + if (AUP_Analyzer_proc(analyzer, &ain, &aout) < 0) return 1; + + // FFTW half-complex unpack, matching AUP_Aed_CalcBinPow. + binPow[0] = spec[0] * spec[0]; + binPow[NBINS - 1] = spec[1] * spec[1]; + for (int i = 1; i < NBINS - 1; i++) { + binPow[i] = spec[2 * i] * spec[2 * i] + spec[2 * i + 1] * spec[2 * i + 1]; + } + + PE_InputData pin; + pin.timeSignal = raw.data(); pin.hopSz = HOP; + pin.inBinPow = binPow.data(); pin.nBins = NBINS; + PE_OutputData pout = {0, 0}; + if (AUP_PE_proc(pe, &pin, &pout) < 0) return 1; + + printf("%zu %.9g %d\n", fr, pout.pitchFreq, pout.voiced); + } + + AUP_PE_destroy(&pe); + AUP_Analyzer_destroy(&analyzer); + return 0; +} diff --git a/crates/bunsen/testdata/ten/pitch.json b/crates/bunsen/testdata/ten/pitch.json new file mode 100644 index 00000000..f38b6586 --- /dev/null +++ b/crates/bunsen/testdata/ten/pitch.json @@ -0,0 +1 @@ +[0.0, 236.305634, 185.766739, 165.370819, 162.740585, 173.863266, 176.755951, 173.913086, 173.91304, 170.879242, 154.120285, 132.944672, 124.231209, 0.0, 173.826492, 174.746933, 168.49086, 174.72197, 169.403168, 213.351257, 183.606445, 175.185272, 190.476135, 190.476227, 190.476196, 186.02478, 171.959015, 151.640503, 142.80777, 127.070587, 122.848747, 190.47612, 190.476196, 190.476196, 190.476135, 190.476227, 190.476257, 190.476196, 190.476242, 200.817734, 210.243225, 213.635956, 225.009857, 234.551605, 239.180161, 253.695938, 251.015823, 232.937073, 241.855499, 255.045502, 266.688354, 273.096924, 266.666656, 254.558975, 245.177017, 247.330215, 250.0, 250.0, 242.714188, 250.028625, 252.755737, 234.487778, 222.174896, 222.160828, 228.274689, 181.251801, 169.388184, 181.818192, 190.66748, 204.079605, 211.695618, 214.170792, 225.520462, 227.159821, 216.591949, 203.456116, 180.490723, 0.0, 211.146896, 221.350891, 226.815018, 225.963318, 217.623077, 224.365143, 196.905045, 189.365387, 191.462769, 178.857437, 178.253326, 166.915237, 148.783279, 131.465866, 136.604156, 134.098297, 202.101898, 180.402802, 175.440628, 188.397263, 200.245773, 211.241074, 215.247421, 213.246872, 210.526321, 203.654221, 179.463135, 159.964066, 143.513977, 157.485184, 165.469009, 161.443451, 152.449402, 146.548431, 140.834534, 138.201172, 133.906067, 128.660919, 120.917137, 113.957741, 106.666656, 99.8888474, 104.050163, 100.560471, 96.8465424, 93.0832977, 100.4478, 102.815971, 99.3955383, 94.3346634, 96.8852615, 0.0, 0.0, 0.0, 91.4744492, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 79.1586227, 88.8572922, 92.9733582, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 112.849922, 94.0779419, 0.0, 0.0, 0.0, 0.0, 78.7552795, 0.0, 0.0, 0.0, 73.5747986, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 95.7609253, 166.561615, 161.959259, 166.057999, 166.666672, 163.981415, 162.50386, 165.492294, 166.666687, 166.666641, 158.855347, 158.039078, 167.607086, 176.995941, 184.332825, 184.832962, 181.818207, 173.073242, 176.448105, 189.273376, 201.340485, 225.582642, 239.922501, 242.226944, 247.715683, 254.182404, 254.791458, 250.0, 235.231277, 227.181992, 236.285324, 237.006638, 0.0, 235.722885, 223.674454, 235.294113, 225.804932, 215.21904, 203.326859, 210.526352, 210.526291, 210.526321, 210.526382, 210.526291, 199.228485, 195.941727, 193.120209, 164.216064, 223.711945, 0.0, 210.526276, 210.526291, 210.526352, 210.526321, 210.526382, 190.966461, 186.23587, 183.016556, 179.215912, 175.265106, 181.818176, 181.818146, 181.818192, 177.59288, 167.382935, 156.68425, 137.7108, 128.411865, 121.08696, 129.299881, 0.0, 0.0, 144.053345, 156.119217, 190.351089, 0.0, 0.0, 200.647369, 178.920837, 170.06105, 173.913055, 173.913055, 173.91301, 173.91304, 173.913071, 173.912994, 173.913025, 169.79216, 164.686813, 171.221024, 138.71936, 137.909088, 136.982544, 173.913025, 173.91304, 157.726669, 183.084183, 172.577133, 161.723312, 163.848297, 166.666656, 166.666702, 166.666672, 158.116989, 154.979568, 152.397812, 142.904602, 121.749687, 120.896461, 165.359283, 168.035812, 166.049088, 169.4758, 166.666687, 166.666718, 166.666611, 166.666687, 166.666672, 166.666718, 174.231247, 112.141747, 0.0, 169.571579, 163.636398, 157.78334, 171.351181, 0.0, 93.6799164, 0.0, 0.0, 152.074036, 123.050034, 111.221588, 97.7656708, 0.0, 0.0, 0.0, 0.0, 0.0, 116.686768, 0.0, 0.0, 0.0, 188.444977, 165.992752, 172.295593, 181.483322, 175.575439, 173.098495, 173.91304, 173.91304, 181.49939, 184.193756, 177.859772, 171.727036, 173.91304, 173.913086, 173.912994, 173.91304, 170.830978, 169.9431, 173.346573, 182.83194, 209.182999, 229.637512, 244.48494, 256.416534, 260.658234, 250.0, 268.445984, 270.223022, 256.217987, 245.351822, 260.915955, 272.611389, 260.521667, 232.247269, 234.45134, 253.383957, 251.420959, 250.0, 250.0, 266.406464, 265.570129, 245.92012, 239.798462, 234.779617, 217.198242, 217.26152, 222.222244, 222.222153, 222.222198, 222.222244, 222.222168, 222.222229, 222.222198, 211.572296, 196.224747, 217.290359, 230.944366, 222.222229, 222.222229, 222.222198, 284.054443, 307.764954, 233.129135, 196.100983, 193.091965, 180.72374, 172.369247, 165.389145, 159.808365, 137.759689, 125.592026, 116.932976, 109.129021, 124.140656, 188.447617, 200.813187, 191.210037, 195.834076, 202.230606, 202.136963, 200.000046, 199.999969, 200.0, 200.000046, 188.303223, 175.133881, 159.919464, 135.222, 139.19873, 201.606842, 198.532608, 190.268433, 185.754745, 194.838318, 200.023788, 179.785889, 163.409332, 155.15889, 148.094055, 144.904709, 143.44754, 133.893723, 135.655869, 134.283051, 129.496796, 125.402565, 113.256752, 107.622726, 101.265785, 92.2017975, 85.9127426, 83.9371643, 85.4037247, 87.9024277, 0.0, 0.0, 0.0, 114.205803, 129.065964, 147.496964, 0.0, 0.0, 93.7852478, 98.1013489, 0.0, 0.0, 91.0110168, 89.8495178, 89.2221375, 90.8929749, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 102.815781, 102.50061, 97.868576, 85.974411, 75.2401199, 78.5986633, 0.0, 195.240433, 190.476227, 190.476135, 95.2381058, 96.888382, 191.657516, 171.660339, 161.638245, 0.0, 0.0, 0.0, 0.0, 189.3125, 179.640594, 0.0, 0.0, 0.0, 0.0, 0.0, 263.727783, 220.519882, 0.0, 0.0, 0.0, 244.003845, 0.0, 256.434265, 282.4487, 0.0, 257.297241, 0.0, 263.130188, 0.0, 0.0, 0.0, 0.0, 238.979996, 237.865433, 194.786636, 225.643829, 247.635086, 242.591156, 198.778488, 245.073837, 288.37561, 0.0, 0.0, 179.631821, 177.255753, 180.288895, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 193.613968, 173.790955, 199.525574, 0.0, 0.0, 0.0, 218.338562, 0.0, 195.379761, 0.0, 248.513855, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 72.8454132, 73.5682831, 77.2593231, 91.0831833, 102.612236, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 75.3774109, 97.6036758, 0.0, 109.587006, 0.0, 0.0, 93.8106003, 98.6367722, 95.0866623, 0.0, 0.0, 135.017609, 167.205307, 0.0, 0.0, 0.0, 151.213623, 146.286499, 127.841759, 0.0, 107.245674, 109.064583, 134.335373, 148.55574, 146.54274, 150.388809, 131.984207, 99.7053146, 86.7923889, 81.1009598, 71.8542557, 120.201431, 0.0, 237.014084, 214.726074, 183.721268, 149.946152, 132.98053, 94.5137558, 94.4321289, 0.0, 176.935043, 182.866302, 194.407944, 204.338028, 209.684372, 213.325241, 221.34964, 225.956161, 231.87529, 238.798004, 254.652634, 242.248322, 239.404465, 255.011627, 240.824783, 223.966949, 214.271271, 231.575668, 240.800079, 243.19046, 239.20433, 244.97554, 256.364563, 253.381729, 231.883026, 221.54541, 216.585846, 198.320007, 185.117355, 175.847794, 217.944733, 235.818893, 227.606552, 222.222229, 238.90004, 229.033707, 220.595367, 212.986465, 206.533508, 206.706406, 210.526382, 210.526321, 210.526245, 210.526337, 210.526276, 210.526291, 204.411163, 197.115219, 191.552597, 184.804962, 164.356262, 151.626038, 225.939896, 210.526291, 210.526321, 206.263092, 210.526337, 204.632935, 210.526276, 210.526321, 210.526245, 204.538116, 206.537247, 210.79718, 199.040436, 211.198364, 196.687134, 169.823334, 160.701553, 166.666611, 166.666672, 166.666672, 174.52504, 181.622116, 183.982498, 192.637207, 203.850937, 214.835968, 222.273575, 234.548157, 251.813553, 268.949463, 275.680145, 260.560364, 266.666565, 255.983841, 221.814636, 0.0, 0.0, 0.0, 246.51947, 242.785324, 261.864777, 242.385345, 251.079758, 257.301331, 0.0, 0.0, 0.0, 188.363174, 203.796341, 207.814255, 0.0, 130.95433, 127.422562, 126.045898, 228.645798, 199.902283, 185.208618, 186.676682, 189.370132, 190.476288, 190.476242, 190.476196, 190.476212, 190.476196, 190.476196, 190.476242, 190.476242, 190.476242, 190.476196, 151.635178, 190.476212, 0.0, 190.476242, 190.476166, 190.476166, 190.47612, 190.476212, 190.476227, 0.0, 0.0, 0.0, 0.0, 192.470642, 197.03363, 187.218903, 176.092926, 168.403091, 164.817993, 164.272522, 174.926743, 184.303986, 194.977005, 202.6492, 215.380508, 213.560486, 210.526291, 218.826157, 225.073608, 223.471985, 222.222336, 204.572067, 191.412369, 192.42305, 0.0, 222.054672, 0.0, 0.0, 0.0, 211.219696, 194.063385, 181.627747, 174.436218, 178.948624, 181.818176, 181.818176, 181.818192, 174.712906, 170.877029, 173.913055, 141.476334, 129.242615, 177.181427, 190.001343, 179.077377, 173.306839, 183.281601, 184.921341, 171.612457, 181.87648, 184.392105, 177.567841, 168.89679, 164.76355, 166.682602, 157.174576, 151.715103, 151.058014, 150.829086, 147.026382, 146.663589, 153.619202, 167.97403, 166.929443, 175.691879, 187.204529, 194.170227, 198.99733, 202.495819, 201.923477, 200.0, 212.141571, 213.790283, 210.526291, 201.753677, 194.408844, 207.185867, 200.0, 206.152573, 199.999969, 207.97435, 237.884811, 235.941696, 192.180954, 176.829758, 160.196701, 165.154892, 133.75, 172.344742, 181.403183, 182.065216, 182.528046, 0.0, 241.408539, 0.0, 0.0, 147.976456, 140.155548, 135.180328, 135.600403, 137.93103, 132.894897, 131.656113, 133.333328, 133.333328, 133.333328, 133.333313, 133.333374, 133.333328, 133.333328, 137.070053, 139.29277, 133.724823, 139.411087, 129.389648, 131.209702, 134.202377, 0.0, 0.0, 134.261765, 144.199081, 149.011749, 0.0, 0.0, 0.0, 0.0, 93.75103, 104.578957, 0.0, 132.073624, 130.592514, 135.362167, 203.442413, 180.047089, 167.960251, 164.517044, 164.640579, 157.34903, 141.4776, 133.134781, 125.500549, 133.490341, 154.591507, 172.137024, 189.251083, 185.207855, 191.451996, 199.640335, 203.462097, 213.804962, 215.687546, 215.603683, 225.430206, 225.40242, 222.222275, 222.222153, 222.222168, 222.222275, 222.22229, 222.222076, 222.222168, 0.0, 222.222229, 213.147217, 0.0, 93.9785843, 113.812653, 114.916504, 111.111076, 107.948677, 222.222275, 222.222321, 222.22229, 222.222229, 0.0, 0.0, 116.516068, 112.610847, 109.386681, 111.111137, 111.111099, 0.0, 111.111115, 111.111115, 0.0, 0.0, 0.0, 235.334412, 232.132721, 238.807419, 235.294037, 235.294113, 235.294098, 235.294144, 235.294113, 235.294098, 229.95166, 214.359512, 202.990234, 196.881607, 187.349197, 174.582275, 165.07782, 156.504761, 149.899628, 145.998718, 140.89505, 141.501953, 142.857132, 142.857147, 142.857208, 148.200592, 156.133286, 162.441925, 169.068451, 160.49054, 148.100723, 145.987991, 165.692047, 199.051224, 0.0, 0.0, 153.971542, 154.573227, 158.412857, 153.846191, 146.614365, 143.622849, 154.692291, 136.507324, 112.090759, 102.907516, 106.547668, 98.7610397, 93.6974335, 95.6813507, 0.0, 0.0, 0.0, 0.0, 0.0, 152.117661, 0.0, 0.0, 0.0, 258.56192, 188.991516, 161.815094, 155.401489, 166.254028, 169.181, 163.16806, 158.492142, 158.313385, 160.0, 160.000031, 160.000031, 160.0, 160.0, 160.0, 160.0, 160.000015, 159.999954, 160.0, 162.862411, 168.383133, 167.658096, 158.712097, 156.381821, 0.0, 0.0, 0.0, 78.3349991, 81.1692657, 116.447945, 0.0, 0.0, 0.0, 0.0, 118.409294, 0.0, 131.550095, 124.262466, 126.519119, 0.0, 0.0, 79.638443, 80.2706528, 92.978447, 0.0, 0.0, 0.0, 86.2039108, 90.2519073, 0.0, 0.0, 67.3331299, 60.200367, 0.0, 63.4920616, 0.0, 150.165771, 154.610901, 0.0, 83.0646591, 0.0, 173.940659, 169.479034, 178.359665, 185.170364, 175.37236, 171.464584, 173.91301, 173.913055, 173.91304, 173.912994, 173.913132, 173.913193, 0.0, 173.91304, 173.91301, 173.912994, 173.913025, 0.0, 173.91304, 173.912979, 182.072144, 207.150345, 203.25502, 200.000061, 199.999985, 199.999985, 199.999969, 200.000046, 192.281693, 184.016449, 153.069031, 200.000046, 200.000061, 199.999969, 174.283142, 158.276489, 159.364029, 160.48201, 168.101944, 169.416565, 166.666626, 172.861359, 173.148148, 174.982391, 184.454163, 199.809677, 212.446762, 224.423874, 237.421127, 242.011627, 238.019424, 235.294113, 248.507584, 257.013855, 250.0, 250.0, 234.286331, 211.878525, 211.980103, 236.483658, 0.0, 243.137405, 243.42598, 250.0, 250.0, 250.0, 243.547989, 203.743179, 202.266571, 210.390854, 210.526382, 225.652252, 224.502945, 222.222198, 210.696182, 196.447083, 182.894226, 166.02272, 152.130829, 140.350891, 128.343475, 119.165817, 118.587685, 119.748466, 121.212151, 119.260094, 122.826591, 118.423737, 132.986099, 122.524109, 116.790764, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 62.421196, 77.613739, 0.0, 0.0, 0.0, 0.0, 120.171295, 0.0, 0.0, 0.0, 0.0, 0.0, 80.4837646, 83.0080032, 84.2415085, 88.7075653, 84.0106354, 0.0, 119.078918, 0.0, 0.0, 0.0, 74.5202103, 71.0040588, 75.3416901, 77.6109695, 68.4541245, 0.0, 0.0, 0.0, 79.1593933, 193.134613, 174.904312, 167.712982, 173.009796, 173.91304, 173.913086, 173.912964, 173.91301, 166.175339, 164.193985, 166.666626, 166.666687, 166.666672, 166.666672, 166.666641, 160.449692, 157.018768, 151.768387, 136.800125, 126.984123, 163.598404, 174.1474, 169.556107, 162.92157, 166.666672, 162.243805, 162.706024, 165.850647, 0.0, 0.0, 0.0, 166.666672, 166.666672, 161.976715, 164.939056, 239.536133, 212.207581, 175.063873, 161.442444, 165.507385, 157.459854, 150.949142, 152.381805, 153.846191, 153.846115, 153.846161, 153.846115, 153.846161, 148.081161, 146.252182, 148.148117, 148.148132, 142.84256, 140.477295, 142.857132, 142.857178, 140.812012, 136.910217, 139.308243, 135.382217, 129.794342, 126.758408, 127.580498, 129.032242, 129.032257, 123.438881, 121.351784, 119.816399, 121.21212, 119.177452, 118.148956, 132.861359, 130.637192, 138.440109, 127.987579, 137.507217, 123.540886, 125.70266, 0.0, 136.367691, 129.505051, 123.041336, 136.514511, 131.39003, 125.5942, 140.846619, 135.697525, 120.079971, 118.240921, 125.337418, 133.258682, 144.328293, 145.58403, 109.162148, 0.0, 0.0, 0.0, 0.0, 90.4759521, 96.7560577, 110.615784, 0.0, 0.0, 0.0, 84.5022964, 77.9272995, 81.2372894, 91.6435471, 0.0, 112.268021, 122.688454, 0.0, 87.549942, 0.0, 93.0410767, 0.0, 74.5619507, 78.5225143, 0.0, 0.0, 0.0, 153.621368, 148.357391, 154.100861, 168.494904, 175.373596, 193.618729, 196.104156, 198.612717, 202.821304, 208.486221, 213.242828, 212.969955, 210.52623, 210.52623, 210.526276, 222.356735, 225.791031, 222.222168, 209.521393, 201.316818, 198.003754, 190.94342, 187.077316, 183.591293, 195.219437, 0.0, 0.0, 189.399811, 170.745758, 168.864746, 179.311127, 190.155426, 201.745819, 211.328186, 214.751526, 208.84285, 203.770004, 205.94223, 200.937302, 197.47789, 205.7892, 206.716156, 196.428772, 178.290634, 211.680359, 212.807297, 216.532486, 0.0, 191.509781, 171.571381, 161.090714, 173.913025, 173.912964, 173.91304, 168.792984, 164.680481, 158.271133, 157.816452, 166.759521, 170.338715, 164.228287, 165.134064, 169.876587, 170.039856, 152.604797, 151.7164, 153.8461, 153.846161, 153.846161, 153.846237, 153.846191, 153.846161, 153.846161, 150.320496, 146.517227, 146.477066, 148.148117, 148.148148, 148.148178, 148.148148, 151.535461, 155.603592, 158.442657, 151.286453, 146.92952, 0.0, 0.0, 0.0, 0.0, 170.955505, 158.929932, 144.428482, 150.454651, 156.733795, 146.895142, 145.373123, 136.378571, 130.729523, 127.335854, 127.062996, 145.623672, 149.952362, 0.0, 208.859924, 188.75946, 194.990906, 202.462814, 200.0, 200.000046, 199.999969, 193.716644, 188.226547, 188.258209, 190.476166, 190.476151, 190.476227, 190.476196, 190.476242, 190.47612, 190.476212, 190.476242, 148.090698, 147.153427, 146.863556, 157.881866, 182.572662, 184.443695, 166.782089, 150.838211, 142.896362, 122.551575, 118.631248, 127.800179, 0.0, 125.636009, 190.977234, 0.0, 0.0, 219.630569, 197.934464, 186.332657, 187.641708, 190.476196, 199.279419, 204.103973, 199.999939, 188.521179, 184.969238, 190.476212, 190.476212, 185.268997, 179.668747, 180.226074, 178.768921, 173.296585, 169.286804, 158.10968, 150.737061, 136.283936, 126.984169, 117.209366, 118.608688, 126.894287, 0.0, 95.0450592, 0.0, 80.7949524, 0.0, 184.08609, 175.162949, 164.464264, 159.573456, 158.271591, 158.361053, 153.381989, 154.08728, 161.510422, 163.154022, 154.404709, 148.3582, 160.0, 163.804337, 165.56134, 160.175476, 158.64357, 160.000076, 166.411682, 188.786407, 203.983627, 198.901199, 181.677521, 174.799118, 164.645966, 154.189362, 156.542755, 0.0, 195.061981, 192.747665, 190.955322, 173.560318, 166.974655, 157.490982, 157.82103, 160.000046, 160.0, 158.539337, 157.282089, 153.830353, 0.0, 103.437302, 176.468933, 161.190582, 163.879196, 173.751007, 176.551605, 182.809662, 184.585007, 176.602768, 170.773605, 167.99614, 173.91301, 157.791245, 166.666672, 174.591934, 176.234634, 173.913071, 173.913177, 173.913086, 173.91304, 170.283203, 164.730148, 155.163803, 135.593246, 129.4617, 134.017151, 174.653046, 162.671402, 156.967941, 159.192017, 160.0, 155.594025, 152.049896, 152.240845, 153.846161, 160.358231, 162.574249, 159.999985, 160.000031, 160.0, 160.000031, 160.0, 161.524414, 159.576462, 159.161865, 159.999969, 160.0, 156.840897, 152.422119, 151.660858, 153.846054, 153.846191, 153.846161, 153.84613, 147.129395, 146.756958, 148.148132, 148.148117, 141.435287, 137.58783, 132.186768, 120.843628, 125.418442, 153.723892, 154.901642, 0.0, 163.674423, 166.841675, 166.808456, 166.666687, 166.666718, 158.466034, 158.476273, 167.163651, 168.755295, 173.462189, 177.038513, 173.913116, 170.097763, 164.787384, 160.612534, 158.553162, 159.013306, 159.999954, 159.999939, 159.999969, 159.999954, 159.999954, 160.000031, 153.647766, 145.656662, 117.409264, 105.921104, 169.051056, 162.079514, 160.000031, 136.652863, 155.425262, 163.275482, 144.08255, 146.086868, 143.075928, 140.50148, 142.857147, 142.857147, 142.857147, 142.857132, 142.857147, 142.857101, 142.857101, 142.857193, 146.939072, 161.463379, 169.005859, 174.619766, 180.150009, 190.775467, 202.304626, 224.403488, 250.500641, 271.040558, 272.563568, 291.314911, 291.937775, 285.714294, 285.714417, 285.714386, 285.714172, 264.05246, 243.898392, 229.742722, 217.731445, 203.132355, 185.527954, 176.261292, 250.364151, 258.653107, 268.949463, 225.344177, 260.82428, 264.098541, 200.719437, 182.803253, 165.840195, 158.708969, 145.534912, 145.310501, 146.693054, 148.148148, 146.410202, 142.020142, 120.347893, 120.323669, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 71.828125, 0.0, 119.264, 146.858582, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 152.452026, 203.238754, 230.524261, 209.321426, 207.615097, 230.79277, 253.71373, 249.969421, 222.990311, 209.596848, 222.222229, 222.222244, 228.039642, 222.647461, 223.153748, 242.504395, 236.969681, 204.94574, 192.108963, 186.950699, 174.057037, 167.940598, 209.882324, 135.151535, 142.735916, 237.230316, 203.206573, 186.475281, 185.056549, 190.476105, 153.196808, 143.369598, 140.278717, 140.08754, 149.889053, 166.013504, 178.708649, 177.953842, 173.37085, 172.286346, 173.91304, 173.912994, 173.91304, 173.913101, 180.473618, 186.263321, 181.818237, 190.441483, 198.220169, 184.06604, 180.592834, 180.256119, 181.818192, 181.818207, 186.711807, 195.377869, 175.156616, 161.959152, 172.333832, 111.285988, 0.0, 180.759811, 189.676056, 187.929672, 179.912231, 169.423386, 166.179199, 174.698288, 173.912964, 173.913086, 173.912964, 173.913101, 165.808746, 165.042938, 166.666672, 166.666656, 158.796204, 155.545868, 140.49205, 126.984154, 153.185913, 168.025436, 180.231842, 169.839966, 160.162109, 151.937729, 156.028122, 161.80368, 161.232712, 160.000076, 159.999969, 160.0, 159.999863, 159.999969, 153.344849, 146.04744, 146.764236, 153.638367, 147.012741, 142.106812, 135.357147, 136.613693, 141.279221, 147.542725, 156.874832, 157.234985, 150.050308, 150.719986, 147.083054, 148.148132, 148.148148, 148.148148, 148.148148, 148.148178, 148.148148, 148.148163, 148.148148, 148.148193, 143.525986, 130.725906, 119.402969, 147.784348, 154.487366, 148.148117, 148.148178, 148.148163, 148.148148, 148.148163, 148.148148, 148.148193, 148.148178, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 148.148178, 148.148148, 148.148148, 145.572098, 131.425385, 0.0, 173.918411, 159.116501, 153.393646, 148.910431, 143.738525, 142.20726, 153.828217, 163.353241, 154.291351, 150.63118, 176.817703, 167.875366, 142.071182, 137.478119, 140.51593, 141.705887, 152.844604, 158.922531, 162.143539, 162.154724, 159.999969, 153.109604, 152.087112, 147.752899, 140.940002, 136.164337, 131.677826, 127.488411, 127.233192, 124.952217, 123.860703, 125.0, 131.256378, 141.344681, 152.85379, 148.085968, 0.0, 0.0, 0.0, 180.633545, 177.65329, 180.042419, 184.152878, 180.886032, 176.067841, 201.739349, 190.476166, 234.732254, 239.696762, 254.74028, 254.219345, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 222.248367, 214.560135, 228.996628, 239.212936, 238.011047, 235.294113, 235.294144, 222.814941, 217.829132, 222.222229, 222.222198, 214.216949, 198.416962, 174.948715, 151.49794, 133.990768, 0.0, 226.913254, 225.226044, 207.290421, 188.704727, 176.738022, 177.887344, 180.554871, 190.601807, 203.454681, 214.733383, 226.97261, 225.290283, 239.269913, 225.264297, 216.454422, 206.898651, 196.708145, 168.18544, 150.996719, 162.010986, 183.474091, 200.333008, 196.108704, 180.134216, 178.711945, 172.343262, 171.949631, 173.913025, 164.619583, 158.165558, 143.383423, 139.509232, 0.0, 0.0, 0.0, 173.654465, 174.938599, 170.746933, 189.546967, 173.233826, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 119.392708, 107.523628, 214.708023, 209.493423, 210.526398, 223.792267, 227.314011, 228.662399, 239.984741, 269.53653, 198.538025, 224.028656, 0.0, 0.0, 0.0, 0.0, 279.684937, 231.147934, 203.677094, 200.57074, 197.103424, 193.33815, 192.405655, 187.515472, 190.47612, 188.014816, 186.643417, 192.022202, 205.163727, 202.524429, 211.465195, 213.137726, 210.526382, 210.526215, 210.526321, 210.526276, 210.526337, 219.307129, 221.586853, 210.214798, 207.622467, 210.526352, 210.526382, 210.526337, 210.526321, 210.526321, 203.637695, 193.971191, 199.999969, 200.000015, 200.0, 199.999924, 199.999863, 189.027481, 162.686691, 155.433609, 177.400253, 203.431473, 223.304672, 200.000015, 202.635757, 0.0, 222.107651, 190.290466, 183.287216, 188.889664, 190.476151, 190.476196, 190.476242, 190.476151, 186.026031, 184.649399, 188.478241, 168.962601, 156.04895, 173.661499, 197.571045, 194.082016, 190.476212, 190.476135, 190.476227, 190.476196, 190.476212, 190.476227, 190.476135, 190.476196, 180.861389, 179.334534, 190.963791, 196.296387, 190.476135, 181.133179, 174.65535, 0.0, 0.0, 192.008408, 192.87941, 182.469894, 180.922012, 192.760559, 188.764847, 190.476196, 185.045593, 0.0, 184.142303, 0.0, 89.8254623, 0.0, 0.0, 190.476196, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 153.243164, 155.660843, 166.769058, 168.669891, 175.258881, 174.528931, 180.416763, 184.485809, 183.932465, 191.161835, 194.060577, 190.476166, 201.003235, 202.606827, 200.000061, 199.999939, 200.000061, 190.131638, 186.58577, 190.476212, 190.476242, 190.476196, 186.402176, 151.2995, 141.677216, 157.893738, 168.479248, 162.359833, 135.593231, 128.924057, 133.012375, 144.292648, 151.242447, 148.148148, 148.148117, 182.354126, 183.248642, 173.734543, 170.758286, 166.062973, 165.978973, 166.666672, 166.666672, 166.666611, 166.666626, 166.666687, 166.666702, 166.666748, 174.048172, 176.727432, 173.913071, 173.913055, 173.913071, 173.91304, 173.91304, 173.91304, 173.912994, 170.135239, 164.735886, 164.761917, 166.666626, 172.718918, 178.033798, 173.913025, 173.91304, 173.913055, 0.0, 0.0, 104.095032, 188.733139, 164.670959, 159.347946, 158.201447, 158.301697, 160.000092, 160.0, 160.000076, 164.440292, 168.637466, 168.434814, 166.666672, 166.666672, 166.666672, 166.666656, 166.666702, 166.666702, 166.666626, 166.666611, 172.054718, 187.608124, 172.461349, 232.083633, 227.095032, 209.510147, 196.007111, 210.555862, 237.093735, 253.657013, 261.392395, 256.633362, 227.828476, 235.294144, 255.785278, 250.47731, 247.471069, 250.0, 250.0, 250.0, 250.0, 235.940643, 229.807983, 223.856583, 217.533478, 213.566818, 204.597366, 210.526398, 210.526321, 179.531952, 164.957382, 0.0, 250.0, 250.0, 250.0, 250.0, 250.0, 241.818436, 0.0, 0.0, 0.0, 0.0, 225.411285, 267.608582, 250.0, 0.0, 223.686142, 211.329147, 197.945496, 196.443222, 187.438614, 187.157211, 186.619507, 179.865707, 179.121979, 174.485611, 170.690201, 170.932968, 164.858566, 157.831345, 151.647415, 139.441864, 132.111862, 126.337486, 122.628723, 119.895767, 119.535469, 121.212105, 121.212105, 121.212135, 121.21212, 118.630577, 0.0, 0.0, 0.0, 122.35083, 118.358559, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 100.228539, 0.0, 0.0, 0.0, 205.159897, 213.584366, 197.544144, 200.000015, 199.999939, 200.000015, 199.999924, 200.0, 203.892654, 215.755219, 210.526321, 210.526291, 214.791824, 203.459274, 189.853088, 160.698349, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 200.000015, 253.275848, 258.514832, 224.411896, 206.214172, 187.822372, 191.541687, 214.113235, 0.0, 0.0, 231.39772, 217.595154, 225.172501, 249.71756, 254.67128, 253.877045, 240.540543, 228.844452, 206.235428, 235.294098, 235.294006, 228.562637, 236.526474, 243.865295, 236.564011, 204.994247, 190.374008, 161.018097, 150.36525, 143.776718, 138.703308, 132.46843, 130.865402, 132.097885, 135.00058, 142.936462, 169.945374, 157.527496, 236.569046, 281.597961, 298.606049, 261.960693, 208.165649, 188.763412, 175.904816, 179.683563, 173.470413, 171.174423, 181.842438, 185.292603, 178.781128, 176.816269, 180.687561, 181.818176, 181.818207, 164.321442, 151.751053, 147.520203, 122.280716, 117.60804, 148.484436, 178.546555, 199.047333, 191.619583, 190.476227, 190.476166, 185.938019, 179.686234, 179.184174, 181.818192, 181.818146, 173.902435, 170.786942, 173.913071, 165.030914, 169.115555, 127.877762, 129.897507, 109.265854, 118.284714, 124.749451, 115.044159, 171.388397, 181.830261, 185.420059, 181.818161, 181.81813, 181.818115, 178.482712, 142.268814, 0.0, 0.0, 0.0, 72.2406158, 0.0, 0.0, 92.9286118, 0.0, 0.0, 84.7425537, 75.6991119, 81.6735535, 0.0, 81.7762756, 0.0, 80.7975693, 73.9593582, 0.0, 0.0, 0.0, 0.0, 90.5318756, 90.2107544, 0.0, 95.0225067, 96.0334015, 180.972122, 170.268906, 172.777069, 173.91304, 173.913086, 169.870285, 164.772308, 164.944626, 166.666626, 166.666687, 166.666718, 166.666672, 159.873993, 157.852142, 159.999954, 160.000015, 160.0, 155.974503, 152.00882, 152.255447, 153.846115, 150.188965, 146.537079, 138.468735, 131.861176, 124.670662, 118.959244, 106.666611, 161.379684, 155.7798, 153.846191, 168.143951, 185.526047, 189.586304, 183.776443, 186.969162, 192.887436, 192.368881, 190.476151, 190.476257, 181.329163, 178.332535, 194.457413, 190.476196, 190.476105, 190.476196, 190.476196, 190.476288, 190.476242, 190.476166, 163.413437, 143.238342, 132.034149, 120.235268, 110.476906, 112.475304, 190.476166, 190.476151, 190.476212, 190.476166, 190.476135, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 187.210739, 0.0, 0.0, 0.0, 0.0, 103.608955, 121.479774, 132.202301, 143.44136, 136.520218, 124.510353, 115.068512, 107.896446, 121.185783, 128.132935, 145.983856, 0.0, 0.0, 123.853271, 143.933914, 132.120544, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 153.022079, 0.0, 0.0, 0.0, 0.0, 175.0728, 0.0, 87.0820923, 91.4717102, 94.0197296, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 237.803589, 0.0, 173.678513, 177.470886, 171.080521, 173.194626, 183.455994, 184.601868, 190.658768, 189.574951, 179.053177, 167.980286, 181.818207, 181.818161, 181.818146, 224.11705, 203.883224, 185.078995, 162.684265, 168.906967, 193.6716, 200.893478, 193.620438, 190.476227, 190.476196, 190.476212, 185.059738, 179.124237, 179.832672, 177.815109, 182.530365, 174.880447, 171.09082, 173.91304, 173.913086, 173.913101, 173.913071, 173.91304, 173.91304, 173.913116, 165.463898, 161.542419, 150.579147, 121.600029, 177.825531, 0.0, 173.132797, 165.522934, 225.923889, 241.529404, 189.861069, 178.720673, 181.971497, 183.379913, 192.886581, 190.295181, 186.438065, 190.832733, 180.202972, 178.442352, 184.127808, 144.710968, 133.737091, 192.231781, 186.046112, 180.886826, 0.0, 0.0, 0.0, 188.05188, 190.476242, 190.476196, 190.476135, 180.894501, 170.984055, 170.081619, 138.666901, 127.117424, 122.128059, 194.182999, 193.064468, 190.476166, 190.476151, 225.063934, 219.212036, 215.894043, 226.069473, 225.882401, 230.045624, 239.144653, 240.202362, 235.294067, 224.988739, 180.088531, 157.5298, 213.979095, 221.62532, 226.424301, 222.222198, 222.222107, 222.22229, 222.222275, 217.910782, 190.374039, 175.223801, 162.185867, 160.176132, 158.230667, 158.715775, 160.000031, 160.000092, 155.910599, 162.078934, 165.840958, 159.479996, 159.999924, 160.000061, 0.0, 0.0, 175.360367, 180.754669, 0.0, 0.0, 0.0, 99.8459396, 0.0, 98.8544464, 0.0, 0.0, 367.237183, 398.369415, 294.686829, 0.0, 206.721741, 187.969055, 186.059402, 207.157364, 217.086685, 225.6315, 225.66658, 222.222229, 222.222153, 222.222229, 201.715347, 186.588364, 200.000015, 208.744125, 237.728027, 246.702408, 228.90802, 218.515717, 218.194092, 222.222244, 203.110123, 200.002045, 210.586929, 226.867966, 223.807175, 217.852127, 216.188889, 221.492676, 229.458176, 212.18959, 208.274216, 238.091766, 0.0, 235.299683, 222.143463, 192.675552, 197.367325, 200.000061, 200.0, 200.000076, 200.000015, 200.000046, 199.999969, 205.787781, 202.71167, 190.424316, 179.325241, 196.127136, 211.214844, 200.000046, 199.999939, 190.669464, 188.487762, 189.571167, 190.476212, 190.476196, 190.476151, 190.476166, 185.731552, 179.647842, 179.003265, 177.21402, 171.982346, 172.052963, 173.913025, 163.40358, 149.962082, 144.297791, 128.03923, 0.0, 185.930908, 177.939804, 176.271622, 176.826904, 173.26416, 176.211868, 176.844543, 176.769073, 176.357605, 176.611465, 0.0, 177.971558, 179.015488, 175.540298, 172.842239, 168.929352, 91.406929, 84.3987961, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 71.5270309, 0.0, 0.0, 0.0, 0.0, 188.251282, 166.300003, 158.805252, 162.834061, 152.609085, 128.555771, 168.924927, 176.642838, 162.320282, 164.279053, 167.994476, 229.410172, 237.10672, 201.937866, 210.336792, 225.757767, 233.442352, 248.607269, 257.490295, 253.848038, 250.0, 237.684967, 225.098526, 242.909286, 246.187225, 250.110443, 251.378647, 220.088318, 215.649124, 220.473785, 209.106079, 194.530228, 249.148331, 258.41452, 254.445923, 228.105667, 231.87973, 235.294144, 247.24501, 227.450882, 203.681503, 202.291779, 210.526337, 210.526245, 210.526382, 210.526382, 210.526291, 210.526321, 210.526321, 210.526337, 210.526321, 163.455566, 207.691284, 218.15036, 210.526321, 210.526352, 210.526337, 210.526321, 210.526215, 210.526352, 210.526276, 205.078979, 210.526337, 210.526337, 210.526321, 210.526276, 210.526321, 210.526291, 210.526321, 210.526291, 0.0, 0.0, 0.0, 0.0, 211.844559, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 75.2689438, 70.4582367, 67.6860657, 0.0, 0.0, 164.62059, 169.125687, 170.697876, 166.666672, 166.666595, 166.666626, 171.987518, 181.855133, 191.497467, 201.443909, 212.443527, 216.145081, 225.757462, 226.2211, 231.558228, 239.038559, 238.018173, 235.294113, 235.294098, 235.294067, 210.678528, 192.143661, 185.255829, 198.911041, 237.815475, 243.890533, 236.552689, 235.294113, 235.294113, 227.947708, 218.855209, 218.256577, 222.222198, 222.222168, 222.222244, 222.222153, 215.245483, 206.825562, 196.547379, 177.499619, 160.532898, 158.17218, 151.790695, 151.490677, 140.791046, 140.950424, 148.173828, 155.976486, 166.800262, 183.892792, 194.560577, 201.312851, 225.586517, 240.549866, 258.338257, 275.915558, 271.22464, 285.475586, 286.55835, 256.245667, 237.394089, 229.800293, 198.878815, 176.967651, 0.0, 238.511398, 266.666626, 0.0, 266.666656, 249.539093, 266.666748, 209.726547, 198.418671, 184.22908, 176.445129, 179.004303, 174.312912, 160.184128, 146.701828, 135.593262, 126.984146, 119.402969, 113.900185, 110.238632, 106.101746, 105.265862, 104.531944, 99.1180496, 101.818123, 109.699829, 111.331078, 0.0, 0.0, 0.0, 0.0, 111.897652, 115.729393, 0.0, 0.0, 0.0, 99.4258728, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 62.0956764, 0.0, 0.0, 124.397926, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 108.535141, 105.974892, 0.0, 0.0, 86.1865463, 107.262871, 114.057579, 128.824173, 110.679985, 154.621155, 0.0, 0.0, 0.0, 84.4888306, 101.660782, 96.6636353, 86.2687836, 73.2729721, 69.9571686, 92.5190811, 89.5181732, 61.0346832, 61.9158859, 62.5, 0.0, 0.0, 0.0, 90.0022202, 178.988464, 205.682785, 231.581802, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 87.6857529, 106.73381, 106.878113, 0.0, 0.0, 0.0, 75.8305511, 0.0, 0.0, 0.0, 76.8611145, 95.3771133, 103.907906, 86.9923782, 0.0, 92.9519119, 0.0, 0.0, 0.0, 0.0, 87.2165527, 84.8820877, 0.0, 0.0, 0.0, 75.4453888, 80.3005142, 0.0, 0.0, 0.0, 93.9616318, 81.2396164, 100.553398, 108.765953, 0.0, 117.754288, 0.0, 0.0, 0.0, 97.8514252, 0.0, 79.4888535, 87.1135712, 95.8142014, 0.0, 0.0, 138.732895, 131.174362, 119.18898, 103.744919, 102.812286, 0.0, 0.0, 106.256172, 72.5558395, 0.0, 0.0, 0.0, 0.0, 101.160339, 0.0, 113.518089, 94.981369, 85.8227005, 68.6204529, 0.0, 73.2027588, 91.7411804, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 321.442841, 196.302216, 204.128891, 212.762726, 225.783051, 226.65976, 222.222168, 222.222244, 215.761459, 208.040131, 206.690125, 187.982971, 154.370621, 154.409195, 193.771011, 218.210068, 228.31546, 226.120224, 227.731003, 228.890701, 204.854889, 211.53064, 218.576492, 225.888931, 224.959518, 237.326797, 238.851959, 235.294113, 235.294174, 244.836197, 235.294037, 223.861313, 235.29422, 235.294144, 235.294098, 235.294113, 235.294006, 235.294174, 212.361343, 204.437515, 208.616898, 202.793655, 195.424072, 176.514938, 190.476196, 224.879013, 258.269806, 254.814163, 260.674103, 271.988159, 272.823334, 258.055786, 238.288406, 215.452499, 195.443176, 176.313492, 156.215668, 136.600967, 123.737434, 123.828377, 129.033188, 135.15863, 127.279701, 117.676659, 119.444153, 128.774918, 131.792374, 130.320999, 126.554581, 127.517647, 129.242752, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 102.409454, 91.9602509, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 107.986, 94.8079147, 92.6115417, 100.333321, 123.193703, 129.460907, 138.204697, 0.0, 0.0, 91.653183, 91.0362701, 94.1753235, 88.5019226, 95.2038345, 268.162689, 250.48407, 254.287445, 0.0, 0.0, 0.0, 0.0, 73.7061539, 0.0, 0.0, 0.0, 0.0, 227.64769, 208.480469, 198.048859, 0.0, 0.0, 235.081451, 0.0, 0.0, 0.0, 0.0, 122.515289, 110.496628, 101.274811, 104.226692, 101.839813, 104.041351, 130.953415, 146.850708, 0.0, 99.3893967, 98.3506851, 108.607155, 155.553085, 151.398056, 192.131042, 173.827698, 0.0, 137.06012, 151.078369, 178.036072, 202.263412, 218.952393, 230.827087, 234.406158, 238.537949, 237.933975, 227.154465, 215.145248, 217.999603, 211.682755, 229.250076, 217.783157, 214.24115, 232.510071, 231.553696, 236.864014, 234.793335, 233.328247, 235.294067, 232.996262, 235.294189, 235.294067, 221.855576, 218.903381, 222.222229, 222.222198, 222.222229, 210.439316, 220.167908, 225.022537, 230.940704, 239.825806, 239.441864, 258.196564, 245.986206, 225.822479, 223.216019, 216.145264, 237.925858, 234.152557, 220.608917, 251.447632, 227.770554, 204.789902, 204.517609, 210.526398, 221.949173, 225.730988, 237.608521, 237.45549, 235.294098, 227.930054, 218.626602, 216.995575, 210.94072, 196.799271, 195.326706, 191.108566, 188.404724, 198.607727, 202.446457, 200.225479, 168.909698, 0.0, 0.0, 0.0, 114.442879, 117.647072, 0.0, 221.525101, 204.753494, 210.526321, 224.09993, 240.789688, 239.951462, 235.294113, 229.805054, 216.295334, 222.222229, 222.222168, 220.0401, 222.33931, 211.997131, 193.161667, 181.674072, 205.003922, 233.3526, 249.960938, 236.436676, 0.0, 0.0, 253.94957, 221.164001, 218.435364, 222.222229, 217.924072, 208.858185, 198.398239, 169.838242, 151.772507, 214.807816, 233.93161, 222.222168, 192.109238, 172.360611, 159.230408, 186.457077, 205.403488, 217.459335, 228.580841, 225.774643, 236.601593, 239.716873, 222.190231, 212.599472, 212.070755, 204.671799, 207.214493, 241.307373, 242.222061, 235.294113, 235.294144, 235.294067, 235.294144, 235.294113, 235.294067, 222.021866, 203.48439, 186.850494, 196.484268, 241.752396, 244.081589, 235.294174, 235.294067, 235.294174, 217.522003, 217.876953, 0.0, 0.0, 0.0, 0.0, 256.785461, 235.294098, 235.294144, 235.294113, 221.794662, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 199.055725, 191.719818, 223.4599, 244.938278, 268.400391, 275.342285, 291.122162, 291.705963, 271.349091, 252.952499, 231.804169, 0.0, 0.0, 256.273682, 266.666565, 266.666656, 276.835846, 227.091705, 194.483887, 177.082718, 171.125214, 173.938034, 160.687912, 144.810684, 131.101181, 119.427353, 116.945976, 115.260979, 113.891273, 121.179718, 124.4272, 115.348503, 0.0, 0.0, 0.0, 92.0027618, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 68.1642151, 67.6439972, 82.6921387, 90.2988586, 96.6288071, 84.2688675, 0.0, 0.0, 0.0, 0.0, 106.489571, 0.0, 0.0, 0.0, 0.0, 96.7772217, 95.303215, 90.0015335, 92.4090881, 91.263649, 0.0, 0.0, 0.0, 66.548111, 68.5286942, 67.9504776, 67.3791885, 67.7966232, 67.796608, 66.6564865, 66.2555389, 66.6666565, 67.7671432, 68.1601868, 68.5487366, 204.511551, 189.680267, 186.894592, 201.501144, 204.002869, 200.000076, 231.170883, 192.816376, 195.568176, 190.379395, 188.616714, 190.476135, 197.149673, 202.386215, 209.53511, 213.849243, 212.486206, 216.280258, 225.371048, 225.251617, 236.396439, 239.544647, 235.294144, 235.294113, 235.294174, 235.294113, 235.294067, 227.616684, 218.873795, 217.663177, 216.21312, 207.397858, 182.656815, 168.385834, 245.855515, 227.287796, 232.950134, 244.063049, 230.716217, 221.338943, 234.87146, 195.42775, 181.252686, 170.365891, 180.192856, 184.518661, 191.677078, 199.482071, 202.561005, 201.987518, 199.999985, 211.105133, 214.426163, 210.526337, 210.526382, 210.526321, 210.526245, 210.526245, 203.526962, 196.96669, 197.243179, 199.999939, 200.0, 195.821259, 182.632004, 182.072021, 213.294296, 208.67276, 236.666183, 216.849396, 185.097107, 173.399612, 179.590668, 176.582108, 171.895004, 172.035049, 173.91304, 173.91301, 173.913025, 173.91304, 166.206909, 164.397949, 166.666702, 166.666672, 166.666718, 166.666626, 166.666672, 166.666672, 166.666702, 166.666656, 159.39447, 154.272507, 155.805923, 160.091629, 163.257462, 158.344391, 160.000046, 160.0, 153.871063, 148.495529, 154.916946, 165.495544, 0.0, 158.097519, 172.683319, 157.046677, 323.428436, 164.706482, 323.311951, 300.063416, 307.692322, 328.480377, 155.907684, 0.0, 181.84343, 164.924301, 164.078583, 170.644073, 176.098053, 168.415329, 159.250336, 165.988129, 174.010269, 175.754272, 174.536346, 173.913086, 181.419296, 176.156952, 171.663071, 178.155045, 189.504181, 195.063583, 203.866409, 201.905685, 205.940994, 213.680344, 212.539383, 210.52623, 210.526321, 210.526321, 222.709244, 227.121277, 222.22229, 222.22229, 222.22229, 222.222229, 216.904251, 207.273926, 204.556976, 219.516647, 212.257172, 210.624557, 221.362656, 220.649109, 223.961716, 140.91478, 150.231155, 145.791367, 142.264969, 143.769958, 138.509491, 0.0, 210.526276, 210.526337, 210.526352, 210.526352, 210.526352, 0.0, 0.0, 223.05159, 222.648422, 222.22229, 230.999893, 239.027802, 258.632416, 270.305756, 281.76416, 250.0, 216.109741, 229.503754, 229.60141, 288.937286, 254.29068, 229.450821, 206.737961, 201.165985, 195.051315, 168.298645, 148.052795, 145.245956, 194.313339, 179.846146, 161.488434, 159.281708, 158.174744, 158.437424, 153.977203, 155.05719, 161.725143, 165.005814, 164.815598, 162.798462, 155.018692, 170.773788, 156.468903, 164.952713, 163.620193, 154.455338, 151.071503, 152.170639, 153.846161, 153.846115, 157.639847, 161.552536, 168.501602, 176.29567, 184.72963, 200.057587, 224.825211, 239.860428, 252.04805, 268.574432, 274.005646, 282.328979, 291.446381, 295.416931, 285.714264, 296.619019, 285.714294, 290.117462, 262.980042, 241.605255, 229.732483, 214.261337, 211.752045, 200.906067, 184.953094, 171.762283, 154.34082, 134.734482, 136.380569, 108.220451, 97.7876892, 0.0, 136.509766, 0.0, 0.0, 0.0, 208.641068, 176.649261, 159.70256, 146.166153, 142.222763, 132.293152, 127.359932, 127.026833, 127.869453, 129.032227, 107.801895, 117.639046, 129.993454, 144.842728, 156.341385, 156.043793, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 117.340546, 117.05307, 116.713638, 121.521545, 131.357483, 139.033203, 149.431168, 149.150711, 147.863464, 148.987045, 148.148163, 141.39183, 143.413818, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 128.667328, 146.306488, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 189.7043, 168.499054, 75.6319504, 83.7892761, 67.5125732, 0.0, 0.0, 129.975754, 111.262291, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 256.535309, 199.382599, 186.776978, 187.381561, 187.826645, 172.494446] \ No newline at end of file From 5a3e6757f80e781548d56310db6aa95a0da1b411 Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Fri, 21 Aug 2026 19:26:24 -0700 Subject: [PATCH 03/32] refactor(ten-vad): make the pitch seam tensor-native and isolate the readback The ten-vad front end is a burn tensor pipeline except for feature `40`: `TenVadPitchEstimator` is host-side scalar code, so the driver read the raw hops and the full bin-power array back from the device, stepped the estimator, and uploaded the result. That device-host round trip was spread across `features.rs`, which made the front end's one synchronization point hard to see and impossible to swap out. This is groundwork for a device-side pitch implementation, and changes no numerics: `test_pitch_estimator_reference_golden` passes unchanged. The seam is now three traits: * `TenVadPitchSource` - tensor-in, tensor-out; `forward` / `forward_sequence`, matching every other streaming layer in the tree * `TenVadPitchSourceInit` - a factory. Required, not incidental: a tensor-native source must *allocate* its carried buffers for a `(batch, device)` pair, so it cannot be a prototype cloned per row * `TenVadPitchScalarSource` - the per-stream host contract the reference estimator implements `HostPitch

` (new `pitch/host.rs`) adapts a scalar source across that seam and is **the only place the front end synchronizes** -- one readback per call, not one per hop. `ZeroPitch` becomes tensor-native, so `inspects_input` is deleted rather than replaced: with a tensor seam it never touches its arguments, and `dims()` / `device()` are metadata, not sync points. `TenVadFeatureContext` holds one `P` instead of a `Vec

`, and `batch_size()` now comes from the STFT queue rather than the pitch vector, which would have gone circular once the source became batch-aware. `pitch_column`, `pitch_column_sequence` and `inspects_input` are gone (~75 lines). Adds host-audio entry points -- `TenVadFeatureContext::forward_audio_sequence`, `TenVad::context_forward_audio` and `context_forward_audio_sequence` -- which frame and upload `&[f32]` rows and take the device from the context, so the caller never names one. Pure API; the tensor forms are unchanged. Also: * every `ten_vad::context` test module moves to `PerformanceBackend`. They were pinned to `CpuBackend`, so 112 of 115 tests ran on Flex no matter what feature was passed and `--features wgpu` bought almost nothing for this kit. This required removing a hardcoded `FlexDevice` from `driver.rs`'s test helper. * `PROC_RESAMPLE_RATE` moves to `coeff` beside `PROC_FS`, removing a test that reached backwards from `coeff` into `estimator`. * `frame_pitch_with_lpc` exposes the one stage boundary that carries no state, plus a `#[cfg(test)]` oracle surface (`exc_buf`, `xcorr_slot`, `path_score`, ...) for differential testing. `xcorr_slot` takes a *stream* index and applies the ring translation internally. * corrects `celt_lpc`'s rustdoc: coefficients past the early break are `0.0`, not "whatever the recursion had reached" -- index `k` is only written at step `k`, and the symmetric update touches only `0..i-1`. Tests: 115 in the kit (from 97), 511 across the workspace, all green under `--features wgpu`. New coverage where it was missing: both `test_forward_sequence_matches_stepwise` and `test_batch_rows_are_independent` used `ZeroPitch` and so proved nothing about the pitch branch -- they gain estimator arms that assert residual per-row state, which is newly load-bearing now that the source is batch-aware rather than a `Vec`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../src/kits/speech/ten_vad/context/driver.rs | 222 +++++++++- .../kits/speech/ten_vad/context/features.rs | 412 ++++++++++++------ .../src/kits/speech/ten_vad/context/mel.rs | 10 +- .../src/kits/speech/ten_vad/context/mod.rs | 34 +- .../speech/ten_vad/context/pitch/coeff.rs | 10 +- .../speech/ten_vad/context/pitch/estimator.rs | 289 ++++++++++-- .../kits/speech/ten_vad/context/pitch/host.rs | 295 +++++++++++++ .../kits/speech/ten_vad/context/pitch/lpc.rs | 9 +- .../kits/speech/ten_vad/context/pitch/mod.rs | 43 +- .../speech/ten_vad/context/pitch/source.rs | 273 +++++++++--- .../speech/ten_vad/context/pre_emphasis.rs | 10 +- 11 files changed, 1339 insertions(+), 268 deletions(-) create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/pitch/host.rs diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs b/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs index 98570229..bfd66e8d 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs @@ -15,10 +15,9 @@ //! ## Mutable, not moved //! //! [`SileroVadContext`] is a burn `Module` moved in and out by value. This -//! context cannot be: it owns a [`SlidingStftContext`] and one -//! [`TenVadPitchSource`] per stream, neither of which is a tensor. It is a -//! plain struct driven through `&mut`, matching [`SlidingStftContext`]'s own -//! style. +//! context cannot be: it owns a [`SlidingStftContext`] and a +//! [`TenVadPitchSource`], neither of which is a tensor. It is a plain struct +//! driven through `&mut`, matching [`SlidingStftContext`]'s own style. //! //! ## Batch size //! @@ -54,8 +53,10 @@ use crate::{ TenVadFeatureMeta, }, pitch::{ + HostPitch, TenVadPitchEstimator, TenVadPitchSource, + TenVadPitchSourceInit, }, }, }, @@ -171,7 +172,7 @@ impl TenVadContextConfig { /// /// Built by [`TenVad::init_context`]. Implements [`TenVadContextMeta`]. #[derive(Debug, Clone)] -pub struct TenVadContext { +pub struct TenVadContext = HostPitch> { /// The audio front-end streaming state. pub features: TenVadFeatureContext, @@ -188,7 +189,7 @@ pub struct TenVadContext, } -impl TenVadContextMeta for TenVadContext { +impl> TenVadContextMeta for TenVadContext { fn sample_rate(&self) -> usize { self.features.sample_rate() } @@ -210,7 +211,7 @@ impl TenVadContextMeta for TenVadContext } } -impl TenVadContext { +impl> TenVadContext { /// The recurrent hidden width. pub fn d_hidden(&self) -> usize { self.state1.hidden.dims()[1] @@ -272,8 +273,10 @@ impl TenVad { /// Builds a zeroed driving context with the reference pitch estimator. /// /// This is the faithful front end: all 41 features match the reference - /// implementation. Feature `40` is a host-side recurrence, so every frame - /// synchronizes the raw hop and the bin powers back from the device. + /// implementation. Feature `40` is a host-side recurrence, so the driver + /// reads the raw hops and the bin powers back from the device to step it — + /// once per [`context_forward_sequence`](Self::context_forward_sequence) + /// call, not once per hop. /// /// To trade that fidelity for an entirely on-device sequence path, pass /// [`ZeroPitch`](super::ZeroPitch) to @@ -288,7 +291,7 @@ impl TenVad { &self, cfg: &TenVadContextConfig, device: &B::Device, - ) -> BunsenResult> { + ) -> BunsenResult>> { self.init_context_with(cfg, TenVadPitchEstimator::new(), device) } @@ -296,18 +299,18 @@ impl TenVad { /// /// # Arguments /// * `cfg`: the context geometry. - /// * `pitch`: the prototype pitch source, cloned once per stream. + /// * `pitch`: builds the pitch source; see [`TenVadPitchSourceInit`]. /// /// # Errors /// /// [`BunsenError::Invalid`] if the config is invalid, or if its context /// depth or feature width disagrees with this model. - pub fn init_context_with( + pub fn init_context_with>( &self, cfg: &TenVadContextConfig, - pitch: P, + pitch: I, device: &B::Device, - ) -> BunsenResult> { + ) -> BunsenResult> { cfg.validate()?; if cfg.d_ctx() != self.d_ctx() { @@ -349,7 +352,7 @@ impl TenVad { /// /// # Returns /// `[batch]` speech probabilities in `[0, 1]`. - pub fn context_forward( + pub fn context_forward>( &self, hop: Tensor, ctx: &mut TenVadContext, @@ -392,7 +395,7 @@ impl TenVad { /// /// # Returns /// `[steps, batch]` speech probabilities in `[0, 1]`. - pub fn context_forward_sequence( + pub fn context_forward_sequence>( &self, hop_seq: Tensor, ctx: &mut TenVadContext, @@ -441,6 +444,101 @@ impl TenVad { // [steps, batch, 1] -> [steps, batch] Tensor::stack::<3>(probs, 0).squeeze_dim(2) } + + /// Uploads host audio and drives it through the model. + /// + /// The convenience form of + /// [`context_forward_sequence`](Self::context_forward_sequence) for + /// callers whose audio is already host-side — a decoded file, or a capture + /// buffer. Equivalent to framing the audio into hops, uploading it, and + /// calling that method; the device comes from `ctx`, so the caller never + /// names one. + /// + /// # Arguments + /// * `audio`: one row per stream, each a whole number of hops of mono audio + /// in `[-1, 1]`, at [`sample_rate`](TenVadContextMeta::sample_rate). + /// Every row must be the same length. + /// * `ctx`: the driving context, advanced in place. + /// + /// # Returns + /// `[steps, batch]` speech probabilities, one per hop per stream. + /// + /// # Errors + /// [`BunsenError::Invalid`] if the row count disagrees with the context's + /// batch size, if the rows differ in length, or if a row is empty or not a + /// whole number of hops. + pub fn context_forward_audio_sequence>( + &self, + audio: &[&[f32]], + ctx: &mut TenVadContext, + ) -> BunsenResult> { + let hop_size = ctx.hop_size(); + let batch = ctx.batch_size(); + + if audio.len() != batch { + return Err(BunsenError::Invalid(format!( + "TenVad expected {batch} audio rows, got {}", + audio.len(), + ))); + } + let samples = audio[0].len(); + if samples == 0 || !samples.is_multiple_of(hop_size) { + return Err(BunsenError::Invalid(format!( + "TenVad audio length ({samples}) must be a non-zero multiple of the hop \ + size ({hop_size})", + ))); + } + if let Some(bad) = audio.iter().position(|row| row.len() != samples) { + return Err(BunsenError::Invalid(format!( + "TenVad audio rows must be equal length; row {bad} has {} samples, row 0 \ + has {samples}", + audio[bad].len(), + ))); + } + + let steps = samples / hop_size; + let mut flat = Vec::with_capacity(steps * batch * hop_size); + for step in 0..steps { + for row in audio { + flat.extend_from_slice(&row[step * hop_size..(step + 1) * hop_size]); + } + } + + let device = ctx.stack.device(); + let hop_seq = Tensor::from_data(TensorData::new(flat, [steps, batch, hop_size]), &device); + + Ok(self.context_forward_sequence(hop_seq, ctx)) + } + + /// Uploads a single stream of host audio and drives it through the model. + /// + /// The one-stream form of + /// [`context_forward_audio_sequence`](Self::context_forward_audio_sequence). + /// + /// # Arguments + /// * `audio`: a whole number of hops of mono audio in `[-1, 1]`. + /// * `ctx`: the driving context, which must be over a single stream. + /// + /// # Returns + /// `[steps, 1]` speech probabilities, one per hop. + /// + /// # Errors + /// [`BunsenError::Invalid`] if `ctx` is not single-stream, or if `audio` + /// is empty or not a whole number of hops. + pub fn context_forward_audio>( + &self, + audio: &[f32], + ctx: &mut TenVadContext, + ) -> BunsenResult> { + if ctx.batch_size() != 1 { + return Err(BunsenError::Invalid(format!( + "context_forward_audio needs a single-stream context, got batch {}; use \ + context_forward_audio_sequence", + ctx.batch_size(), + ))); + } + self.context_forward_audio_sequence(&[audio], ctx) + } } #[cfg(test)] @@ -459,13 +557,13 @@ mod tests { context::coeff::N_FREQ, }, prelude::*, - support::testing::CpuBackend, + support::testing::PerformanceBackend, }; - type B = CpuBackend; + type B = PerformanceBackend; type F = ::FloatElem; - fn model() -> (TenVad, burn::backend::flex::FlexDevice) { + fn model() -> (TenVad, ::Device) { let device = Default::default(); let vad: TenVad = TenVadStructureConfig::default().init(&device); (vad, device) @@ -722,6 +820,92 @@ mod tests { .assert_approx_eq::(&step_ctx.stack.to_data_as::(), tol); } + #[test] + fn test_context_forward_audio_matches_the_tensor_path() { + // The host-audio entry point is a framing convenience, nothing more: + // it must agree exactly with uploading the hops yourself. + let (vad, device) = model(); + let cfg = TenVadContextConfig::new(); + let hop_size = cfg.hop_size(); + let steps = 5; + + let audio: Vec = (0..steps * hop_size) + .map(|i| 0.2 * (i as f32 * 0.03).sin()) + .collect(); + + let mut audio_ctx = vad.init_context(&cfg, &device).unwrap(); + let from_audio = vad.context_forward_audio(&audio, &mut audio_ctx).unwrap(); + + let mut tensor_ctx = vad.init_context(&cfg, &device).unwrap(); + let hops = + Tensor::::from_floats(audio.as_slice(), &device).reshape([steps, 1, hop_size]); + let from_tensor = vad.context_forward_sequence(hops, &mut tensor_ctx); + + assert_eq!(from_audio.dims(), [steps, 1]); + from_audio + .to_data_as::() + .assert_eq(&from_tensor.to_data_as::(), true); + } + + #[test] + fn test_context_forward_audio_sequence_takes_row_slices() { + // The multi-row form, exercised at the only batch the stock graph + // accepts. The feature context handles batch > 1 today; the model does + // not -- its LSTM batch is pinned to 1, so a wider driver context + // cannot be stepped until that is unblocked. + let (vad, device) = model(); + let cfg = TenVadContextConfig::new(); + let hop_size = cfg.hop_size(); + let steps = 4; + + let audio: Vec = (0..steps * hop_size) + .map(|i| 0.2 * (i as f32 * 0.03).sin()) + .collect(); + + let mut ctx = vad.init_context(&cfg, &device).unwrap(); + let out = vad + .context_forward_audio_sequence(&[&audio], &mut ctx) + .unwrap(); + assert_eq!(out.dims(), [steps, 1]); + } + + #[test] + fn test_context_forward_audio_rejects_bad_input() { + let (vad, device) = model(); + let cfg = TenVadContextConfig::new(); + let hop_size = cfg.hop_size(); + let mut ctx = vad.init_context(&cfg, &device).unwrap(); + + // Not a whole number of hops. + let ragged = vec![0.0f32; hop_size + 3]; + assert!(vad.context_forward_audio(&ragged, &mut ctx).is_err()); + // Empty. + assert!(vad.context_forward_audio(&[], &mut ctx).is_err()); + // Wrong row count for the batch. + let good = vec![0.0f32; hop_size]; + assert!( + vad.context_forward_audio_sequence(&[&good, &good], &mut ctx) + .is_err() + ); + // And the good case works. + assert!(vad.context_forward_audio(&good, &mut ctx).is_ok()); + } + + #[test] + fn test_context_forward_audio_rejects_multi_stream_context() { + let (vad, device) = model(); + let cfg = TenVadContextConfig::new().with_batch_size(2); + let hop_size = cfg.hop_size(); + let mut ctx = vad.init_context(&cfg, &device).unwrap(); + + let audio = vec![0.0f32; hop_size]; + let err = vad.context_forward_audio(&audio, &mut ctx).unwrap_err(); + assert!( + format!("{err}").contains("single-stream"), + "expected a single-stream diagnostic, got: {err}", + ); + } + #[test] fn test_sequence_resumes_across_chunks() { // Splitting a stream into two sequence calls must match one call over diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/features.rs b/crates/bunsen/src/kits/speech/ten_vad/context/features.rs index 918f9a26..813c2310 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/features.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/features.rs @@ -59,6 +59,7 @@ use crate::{ }, pitch::{ TenVadPitchSource, + TenVadPitchSourceInit, ZeroPitch, }, pre_emphasis::{ @@ -72,10 +73,6 @@ use crate::{ SlidingStftContext, SlidingStftMeta, }, - prelude::{ - TensorDataToVecAsExt, - TensorElemOpExt, - }, }; /// Common meta for [`TenVadFeatureConfig`], [`TenVadFeatures`], and @@ -125,15 +122,15 @@ pub struct TenVadFeatureConfig { /// /// Its defaults are already the ten-vad analyzer /// (`win_len = 768`, `hop_size = 256`, `fft_size = 1024`, periodic Hann). - #[config(default = "SlidingStftConfig::new()")] + #[config(default = "Default::default()")] pub stft: SlidingStftConfig, /// The mel filterbank geometry. - #[config(default = "TenVadMelConfig::new()")] + #[config(default = "Default::default()")] pub mel: TenVadMelConfig, /// The pre-emphasis filter applied to the STFT branch. - #[config(default = "PreEmphasisConfig::new()")] + #[config(default = "Default::default()")] pub pre_emphasis: PreEmphasisConfig, } @@ -252,18 +249,19 @@ impl TenVadFeatureConfig { /// /// # Arguments /// * `batch_size`: the number of independent streams; must be non-zero. - /// * `pitch`: the prototype pitch source, cloned once per stream. + /// * `pitch`: builds the pitch source; see [`TenVadPitchSourceInit`]. /// /// # Errors /// - /// See [`validate`](Self::validate). - pub fn try_init_context( + /// See [`validate`](Self::validate), plus anything the pitch source's + /// [`try_init_source`](TenVadPitchSourceInit::try_init_source) reports. + pub fn try_init_context>( &self, batch_size: usize, - pitch: P, + pitch: I, device: &B::Device, - ) -> BunsenResult> { - Ok(self.try_init(device)?.init_state(batch_size, pitch)) + ) -> BunsenResult> { + self.try_init(device)?.try_init_state(batch_size, pitch) } } @@ -323,20 +321,40 @@ impl TenVadFeatures { /// /// # Arguments /// * `batch_size`: the number of independent streams; must be non-zero. - /// * `pitch`: the prototype pitch source, cloned once per stream. - pub fn init_state( + /// * `pitch`: builds the pitch source; see [`TenVadPitchSourceInit`]. + /// + /// # Errors + /// [`BunsenError::Invalid`] if `batch_size` is zero, plus anything the + /// pitch source's + /// [`try_init_source`](TenVadPitchSourceInit::try_init_source) reports. + pub fn try_init_state>( &self, batch_size: usize, - pitch: P, - ) -> TenVadFeatureContext { - assert_ne!(batch_size, 0, "TenVadFeatures batch_size must be non-zero"); + pitch: I, + ) -> BunsenResult> { + if batch_size == 0 { + return Err(BunsenError::Invalid( + "TenVadFeatures batch_size must be non-zero".to_string(), + )); + } let device = self.means.device(); - TenVadFeatureContext { + Ok(TenVadFeatureContext { stft: self.stft.init_state(batch_size), pre_emphasis: self.pre_emphasis.init(batch_size, &device), - pitch: vec![pitch; batch_size], + pitch: pitch.try_init_source(batch_size, &device)?, coef: self.clone(), - } + }) + } + + /// Builds a [`TenVadFeatureContext`], panicking on error. + /// + /// See [`try_init_state`](Self::try_init_state). + pub fn init_state>( + &self, + batch_size: usize, + pitch: I, + ) -> TenVadFeatureContext { + self.try_init_state(batch_size, pitch).ok_or_panic() } } @@ -350,7 +368,7 @@ impl TenVadFeatures { /// /// Built by [`TenVadFeatures::init_state`]. Implements [`TenVadFeatureMeta`]. #[derive(Debug, Clone)] -pub struct TenVadFeatureContext { +pub struct TenVadFeatureContext = ZeroPitch> { /// The fixed analysis coefficients. pub coef: TenVadFeatures, @@ -360,11 +378,11 @@ pub struct TenVadFeatureContext { /// The pre-emphasis carry. pub pre_emphasis: PreEmphasisContext, - /// The per-stream pitch sources; one entry per batch row. - pub pitch: Vec

, + /// The pitch source, covering every stream in the batch. + pub pitch: P, } -impl TenVadFeatureMeta for TenVadFeatureContext { +impl> TenVadFeatureMeta for TenVadFeatureContext { fn sample_rate(&self) -> usize { self.coef.sample_rate() } @@ -386,19 +404,17 @@ impl TenVadFeatureMeta for TenVadFeatureContex } } -impl TenVadFeatureContext { +impl> TenVadFeatureContext { /// The batch size; each batch row is an independent stream. pub fn batch_size(&self) -> usize { - self.pitch.len() + self.stft.batch_size() } /// Resets every streaming buffer to the start-of-stream condition. pub fn reset(&mut self) { self.stft.reset(); self.pre_emphasis.reset(); - for pitch in &mut self.pitch { - pitch.reset(); - } + self.pitch.reset(); } /// Extracts the feature frame for one hop. @@ -430,7 +446,7 @@ impl TenVadFeatureContext { // Pitch reads the raw hop and the *un-normalized* bin power. // [batch, 1] - let pitch = self.pitch_column(&raw, &bin_power); + let pitch = self.pitch.forward(raw, bin_power.clone()); self.finish_frame(bin_power, pitch) } @@ -476,7 +492,7 @@ impl TenVadFeatureContext { .squeeze_dim(3); // [steps, batch, 1] - let pitch = self.pitch_column_sequence(&raw, &bin_power); + let pitch = self.pitch.forward_sequence(raw, bin_power.clone()); // Fold the step axis into the row axis for the batched stages. let feat = self.finish_frame( @@ -487,6 +503,70 @@ impl TenVadFeatureContext { feat.reshape([steps, batch, n_freq]) } + /// Uploads host audio and extracts its feature frames. + /// + /// The convenience form of [`forward_sequence`](Self::forward_sequence) + /// for callers whose audio is already host-side, which is the usual case + /// for a file or a capture stream. The device is taken from the context, + /// so the caller never names one. + /// + /// # Arguments + /// * `audio`: one row per stream, each a whole number of hops of mono audio + /// in `[-1, 1]`. Every row must be the same length. + /// + /// # Returns + /// `[steps, batch, n_freq]` normalized features. + /// + /// # Errors + /// [`BunsenError::Invalid`] if the row count disagrees with the batch + /// size, if the rows differ in length, or if a row is empty or not a whole + /// number of hops. + pub fn forward_audio_sequence( + &mut self, + audio: &[&[f32]], + ) -> BunsenResult> { + let batch = self.batch_size(); + let hop_size = self.hop_size(); + + if audio.len() != batch { + return Err(BunsenError::Invalid(format!( + "TenVadFeatureContext expected {batch} audio rows, got {}", + audio.len(), + ))); + } + + let samples = audio[0].len(); + if let Some(bad) = audio.iter().position(|row| row.len() != samples) { + return Err(BunsenError::Invalid(format!( + "TenVadFeatureContext audio rows must be equal length; row {bad} has {} \ + samples, row 0 has {samples}", + audio[bad].len(), + ))); + } + if samples == 0 || !samples.is_multiple_of(hop_size) { + return Err(BunsenError::Invalid(format!( + "TenVadFeatureContext audio length ({samples}) must be a non-zero multiple \ + of the hop size ({hop_size})", + ))); + } + + let steps = samples / hop_size; + + // Interleave into the `[steps, batch, hop_size]` the sequence path + // wants; the caller's rows are per-stream contiguous. + let mut flat = Vec::with_capacity(steps * batch * hop_size); + for step in 0..steps { + for row in audio { + flat.extend_from_slice(&row[step * hop_size..(step + 1) * hop_size]); + } + } + + let device = self.coef.means.device(); + let hops = Tensor::from_data(TensorData::new(flat, [steps, batch, hop_size]), &device); + + Ok(self.forward_sequence(hops)) + } + /// The shared tail of the per-frame pipeline: power normalization, mel /// filterbank, log, pitch concatenation, and standardization. /// @@ -514,89 +594,6 @@ impl TenVadFeatureContext { (feat - self.coef.means.clone().unsqueeze::<2>()) * self.coef.stds_recip.clone().unsqueeze::<2>() } - - /// The `[batch, 1]` pitch column for one frame. - fn pitch_column( - &mut self, - raw: &Tensor, - bin_power: &Tensor, - ) -> Tensor { - let batch = self.batch_size(); - let device = raw.device(); - - if !self.inspects_input() { - let value = self.pitch[0].frame_pitch(&[], &[]); - return Tensor::full([batch, 1], value, &device); - } - - let hop_size = self.hop_size(); - let n_bins = self.n_bins(); - let raw_host: Vec = raw.to_data_as::().to_vec_as::().ok_or_panic(); - let power_host: Vec = bin_power - .to_data_as::() - .to_vec_as::() - .ok_or_panic(); - - let values: Vec = (0..batch) - .map(|b| { - self.pitch[b].frame_pitch( - &raw_host[b * hop_size..(b + 1) * hop_size], - &power_host[b * n_bins..(b + 1) * n_bins], - ) - }) - .collect(); - - Tensor::from_data(TensorData::new(values, [batch, 1]), &device) - } - - /// The `[steps, batch, 1]` pitch column for a sequence. - /// - /// Pitch is a per-stream recurrence, so when the source inspects its input - /// the frames are walked in order, one host-side call per stream per step. - fn pitch_column_sequence( - &mut self, - raw: &Tensor, - bin_power: &Tensor, - ) -> Tensor { - let steps = raw.dims()[0]; - let batch = self.batch_size(); - let device = raw.device(); - - if !self.inspects_input() { - let value = self.pitch[0].frame_pitch(&[], &[]); - return Tensor::full([steps, batch, 1], value, &device); - } - - let hop_size = self.hop_size(); - let n_bins = self.n_bins(); - let raw_host: Vec = raw.to_data_as::().to_vec_as::().ok_or_panic(); - let power_host: Vec = bin_power - .to_data_as::() - .to_vec_as::() - .ok_or_panic(); - - let mut values = Vec::with_capacity(steps * batch); - for step in 0..steps { - for b in 0..batch { - let raw_at = (step * batch + b) * hop_size; - let pow_at = (step * batch + b) * n_bins; - values.push(self.pitch[b].frame_pitch( - &raw_host[raw_at..raw_at + hop_size], - &power_host[pow_at..pow_at + n_bins], - )); - } - } - - Tensor::from_data(TensorData::new(values, [steps, batch, 1]), &device) - } - - /// Whether any stream's pitch source inspects its input. - /// - /// When no source does, the driver skips the device-to-host readback the - /// pitch branch would otherwise force. - fn inspects_input(&self) -> bool { - self.pitch.iter().any(|p| p.inspects_input()) - } } #[cfg(test)] @@ -609,16 +606,24 @@ mod tests { use super::*; use crate::{ - kits::speech::ten_vad::context::coeff::N_MELS, + kits::speech::ten_vad::context::{ + coeff::N_MELS, + pitch::{ + HostPitch, + HostPitchInit, + TenVadPitchEstimator, + TenVadPitchScalarSource, + }, + }, ops::signal::{ SamplingWindowBuilder, StftWindowConfig, }, prelude::*, - support::testing::CpuBackend, + support::testing::PerformanceBackend, }; - type B = CpuBackend; + type B = PerformanceBackend; type F = ::FloatElem; /// A small geometry whose naive-DFT reference is cheap to evaluate. @@ -729,7 +734,7 @@ mod tests { assert_eq!(ctx.n_freq(), 41); assert_eq!(ctx.stft.batch_size(), 2); assert_eq!(ctx.pre_emphasis.batch_size(), 2); - assert_eq!(ctx.pitch.len(), 2); + assert_eq!(ctx.pitch, ZeroPitch); } #[test] @@ -998,6 +1003,167 @@ mod tests { } } + /// A 16 kHz pulse train at `f0`, in `[-1, 1]` — structured enough that the + /// pitch branch actually tracks something. + fn pulse_audio( + f0: f32, + samples: usize, + ) -> Vec { + let period = 16000.0 / f0; + (0..samples) + .map(|i| { + let pos = i as f32 % period; + 0.25 * (-pos / (period * 0.08)).exp() + }) + .collect() + } + + #[test] + fn test_forward_sequence_matches_stepwise_with_estimator() { + // The ZeroPitch arm of `test_forward_sequence_matches_stepwise` cannot + // see the pitch branch at all. This is the arm that does: the source + // carries a per-stream recurrence, so the sequence path has to walk it + // in exactly the stepwise order and leave identical residual state. + let device = Default::default(); + let cfg = TenVadFeatureConfig::new(); + let hop_size = cfg.hop_size(); + let steps = 8; + let batch = 2; + + let mut flat = Vec::with_capacity(steps * batch * hop_size); + let rows: Vec> = (0..batch) + .map(|b| pulse_audio(140.0 + 30.0 * b as f32, steps * hop_size)) + .collect(); + for step in 0..steps { + for row in &rows { + flat.extend_from_slice(&row[step * hop_size..(step + 1) * hop_size]); + } + } + let hops = + Tensor::::from_data(TensorData::new(flat, [steps, batch, hop_size]), &device); + + let mut seq_ctx: TenVadFeatureContext> = cfg + .try_init_context(batch, TenVadPitchEstimator::new(), &device) + .unwrap(); + let mut step_ctx = seq_ctx.clone(); + + let seq_out = seq_ctx.forward_sequence(hops.clone()); + let mut step_outs = Vec::with_capacity(steps); + for step in 0..steps { + step_outs.push(step_ctx.forward(hops.clone().select_dim::<2>(0, step))); + } + let step_out: Tensor = Tensor::stack(step_outs, 0); + + seq_out + .to_data_as::() + .assert_approx_eq::(&step_out.to_data_as::(), Tolerance::permissive()); + + // The pitch branch's residual state has to agree, not just its output. + for b in 0..batch { + let seq = &seq_ctx.pitch.sources[b]; + let step = &step_ctx.pitch.sources[b]; + assert_eq!(seq.exc_buf(), step.exc_buf(), "row {b} excitation history"); + assert_eq!(seq.path_score(), step.path_score(), "row {b} tracker state"); + assert_eq!(seq.best_period(), step.best_period(), "row {b} best period"); + } + + // Guard the guard: a tracker that never ran would compare equal too. + assert!( + seq_ctx.pitch.sources[0] + .path_score() + .iter() + .any(|v| *v < -1e-6), + "the tracker should have accumulated over 8 hops", + ); + } + + #[test] + fn test_batch_rows_are_independent_with_estimator() { + // With `Vec

` per-row isolation was structural. With a batch-aware + // source it is a property that has to be tested. + let device = Default::default(); + let cfg = TenVadFeatureConfig::new(); + let hop_size = cfg.hop_size(); + let steps = 6; + let batch = 3; + + let rows: Vec> = (0..batch) + .map(|b| pulse_audio(120.0 + 40.0 * b as f32, steps * hop_size)) + .collect(); + let row_refs: Vec<&[f32]> = rows.iter().map(|r| r.as_slice()).collect(); + + let mut batched: TenVadFeatureContext> = cfg + .try_init_context(batch, TenVadPitchEstimator::new(), &device) + .unwrap(); + let batched_out = batched.forward_audio_sequence(&row_refs).unwrap(); + + for (b, row) in row_refs.iter().enumerate() { + let mut solo: TenVadFeatureContext> = cfg + .try_init_context(1, TenVadPitchEstimator::new(), &device) + .unwrap(); + let solo_out = solo.forward_audio_sequence(&[row]).unwrap(); + + let expected = batched_out + .clone() + .slice_dim(1, b as isize..(b + 1) as isize); + solo_out + .to_data_as::() + .assert_approx_eq::(&expected.to_data_as::(), Tolerance::permissive()); + } + } + + #[test] + fn test_forward_audio_sequence_matches_the_tensor_path() { + let device = Default::default(); + let cfg = TenVadFeatureConfig::new(); + let hop_size = cfg.hop_size(); + let steps = 5; + + let audio = pulse_audio(150.0, steps * hop_size); + + let mut via_audio: TenVadFeatureContext> = cfg + .try_init_context(1, TenVadPitchEstimator::new(), &device) + .unwrap(); + let from_audio = via_audio.forward_audio_sequence(&[&audio]).unwrap(); + + let mut via_tensor: TenVadFeatureContext> = cfg + .try_init_context(1, TenVadPitchEstimator::new(), &device) + .unwrap(); + let hops = + Tensor::::from_floats(audio.as_slice(), &device).reshape([steps, 1, hop_size]); + let from_tensor = via_tensor.forward_sequence(hops); + + from_audio + .to_data_as::() + .assert_eq(&from_tensor.to_data_as::(), true); + } + + #[test] + fn test_forward_audio_sequence_rejects_bad_input() { + let device = Default::default(); + let cfg = TenVadFeatureConfig::new(); + let hop_size = cfg.hop_size(); + + let mut ctx: TenVadFeatureContext> = cfg + .try_init_context(2, TenVadPitchEstimator::new(), &device) + .unwrap(); + + let good = vec![0.0f32; hop_size * 2]; + let short = vec![0.0f32; hop_size]; + let ragged = vec![0.0f32; hop_size + 7]; + + // Wrong row count. + assert!(ctx.forward_audio_sequence(&[&good]).is_err()); + // Rows of differing length. + assert!(ctx.forward_audio_sequence(&[&good, &short]).is_err()); + // Not a whole number of hops. + assert!(ctx.forward_audio_sequence(&[&ragged, &ragged]).is_err()); + // Empty. + assert!(ctx.forward_audio_sequence(&[&[], &[]]).is_err()); + // And the good case still works. + assert!(ctx.forward_audio_sequence(&[&good, &good]).is_ok()); + } + #[test] fn test_batch_rows_are_independent() { let device = Default::default(); @@ -1061,7 +1227,7 @@ mod tests { calls: usize, } - impl TenVadPitchSource for FixedPitch { + impl TenVadPitchScalarSource for FixedPitch { fn frame_pitch( &mut self, _raw_hop: &[f32], @@ -1080,8 +1246,8 @@ mod tests { let cfg = TenVadFeatureConfig::new(); let hz = 220.0f32; - let mut ctx: TenVadFeatureContext = cfg - .try_init_context(1, FixedPitch { hz, calls: 0 }, &device) + let mut ctx: TenVadFeatureContext> = cfg + .try_init_context(1, HostPitchInit(FixedPitch { hz, calls: 0 }), &device) .unwrap(); let steps = 3; @@ -1100,6 +1266,6 @@ mod tests { } // One call per frame, in order. - assert_eq!(ctx.pitch[0].calls, steps); + assert_eq!(ctx.pitch.sources[0].calls, steps); } } diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/mel.rs b/crates/bunsen/src/kits/speech/ten_vad/context/mel.rs index 88d3a837..15834c69 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/mel.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/mel.rs @@ -87,6 +87,12 @@ pub struct TenVadMelConfig { pub f_max: f32, } +impl Default for TenVadMelConfig { + fn default() -> Self { + Self::new() + } +} + impl TenVadMelMeta for TenVadMelConfig { fn n_mels(&self) -> usize { self.n_mels @@ -305,10 +311,10 @@ mod tests { use super::*; use crate::{ prelude::*, - support::testing::CpuBackend, + support::testing::PerformanceBackend, }; - type B = CpuBackend; + type B = PerformanceBackend; type F = ::FloatElem; /// The reference band edges, in FFT bins, for the stock ten-vad geometry. diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs index 5fade27f..804f758a 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs @@ -38,8 +38,9 @@ //! * [`coeff`](self) — the reference constants and normalization tables. //! * [`PreEmphasisContext`] — the first-order high-pass, with carry. //! * [`TenVadMelBank`] — the 40-band triangular filterbank. -//! * [`TenVadPitchSource`] — the pitch seam; [`TenVadPitchEstimator`] is the -//! reference estimator, [`ZeroPitch`] the constant stub. +//! * [`TenVadPitchSource`] — the pitch seam, tensor-in and tensor-out; +//! [`TenVadPitchEstimator`] behind [`HostPitch`] is the reference estimator, +//! [`ZeroPitch`] the constant stub. //! * [`TenVadFeatureContext`] — the 41-dim feature extractor and its state. //! * [`TenVadContext`] — the driving context: features, frame stack, and both //! LSTM states. @@ -47,6 +48,17 @@ //! The sliding STFT itself is [`SlidingStftContext`], which already ports the //! reference analyzer. //! +//! ## Feeding it audio +//! +//! [`TenVad::context_forward`] and `_sequence` take hops as tensors, for +//! callers already holding device-resident audio. +//! [`TenVad::context_forward_audio`] and `_audio_sequence` take host `&[f32]` +//! rows, frame and upload them, and take the device from the context — the +//! usual path for a decoded file or a capture buffer. +//! +//! [`TenVad::context_forward_audio`]: crate::kits::speech::ten_vad::TenVad::context_forward_audio +//! [`TenVad::context_forward_audio_sequence`]: crate::kits::speech::ten_vad::TenVad::context_forward_audio_sequence +//! //! ## Choosing a pitch source //! //! Feature `40` is a serial host-side recurrence, so the driver reaches it @@ -54,14 +66,20 @@ //! //! * [`TenVadPitchEstimator`] — the reference estimator, and what //! [`TenVad::init_context`](crate::kits::speech::ten_vad::TenVad::init_context) -//! builds. Every frame synchronizes the raw hop and the bin powers back from -//! the device to step it. -//! * [`ZeroPitch`] — pins feature `40` to a constant and reports -//! [`inspects_input`](TenVadPitchSource::inspects_input) as `false`, which -//! lets the sequence path stay entirely on-device. The other 40 features are -//! exact either way. Select it via +//! builds. It runs on the host, so the driver must read the raw hops and the +//! bin powers back from the device to step it: once per +//! `context_forward_sequence` call for the whole sequence, or once per hop on +//! the single-step path, which pays that cost anyway. +//! * [`ZeroPitch`] — pins feature `40` to a constant and never inspects its +//! arguments, so the sequence path skips the readback and stays entirely +//! on-device. The other 40 features are exact either way. Select it via //! [`TenVad::init_context_with`](crate::kits::speech::ten_vad::TenVad::init_context_with). //! +//! Both are reached through the same tensor-in, tensor-out +//! [`TenVadPitchSource`] seam; [`HostPitch`] is the adapter that carries a +//! host-side estimator across it, and is the only place the front end +//! synchronizes. +//! //! ## Known deviations from the reference driver //! //! * **No periodic state reset.** The C driver zeroes both LSTM states every diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/coeff.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/coeff.rs index 3158c082..f21b2bab 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/coeff.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/coeff.rs @@ -7,6 +7,8 @@ //! tunables: the estimator's output feeds feature `40`, whose mean and //! standard deviation were fitted against exactly these values. +use crate::kits::speech::ten_vad::context::coeff::SAMPLE_RATE; + /// The number of bands the pitch estimator's LPC front end works in. /// /// Unrelated to the 40 mel bands of the feature path: these 18 bands exist @@ -46,6 +48,9 @@ pub const FEAT_MAX_NFRM: usize = 12; /// makes the period search 64 lags wide instead of 256. pub const PROC_FS: usize = 4000; +/// The decimation factor from 16 kHz to the correlation branch's rate. +pub const PROC_RESAMPLE_RATE: usize = SAMPLE_RATE / PROC_FS; + /// The frame-correlation above which a frame is called voiced. /// /// The reference's `pitchEstVoicedThr` for a 4 kHz processing rate. @@ -148,9 +153,8 @@ mod tests { #[test] fn test_period_bounds_divide_the_resample_rate() { - let rate = super::super::estimator::PROC_RESAMPLE_RATE; - assert_eq!(MIN_PERIOD_16KHZ % rate, 0); - assert_eq!(MAX_PERIOD_16KHZ % rate, 0); + assert_eq!(MIN_PERIOD_16KHZ % PROC_RESAMPLE_RATE, 0); + assert_eq!(MAX_PERIOD_16KHZ % PROC_RESAMPLE_RATE, 0); } #[test] diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/estimator.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/estimator.rs index 8da374eb..3d82f452 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/estimator.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/estimator.rs @@ -12,9 +12,9 @@ //! 2. **Excitation.** The raw hop — delayed by //! [`XCORR_TRAINING_OFFSET`](super::coeff::XCORR_TRAINING_OFFSET) samples so //! it lines up with the correlation window — is whitened by that filter, -//! smoothed by a one-pole leak, anti-alias filtered, and decimated 16 kHz → -//! 4 kHz. Whitening is what leaves a clean impulse train at the pitch -//! period. +//! smoothed by a 2-tap FIR (`y[n] = w[n] + 0.7·w[n-1]`), anti-alias +//! filtered, and decimated 16 kHz → 4 kHz. Whitening is what leaves a clean +//! impulse train at the pitch period. //! 3. **Correlation.** Two half-hop windows per hop are correlated against 64 //! candidate lags, each normalized by the energy under the lagged window, //! then sharpened against their own half-lag to suppress octave doubling. @@ -46,6 +46,8 @@ //! `0/0`. That case is reachable only on digital silence, where the frame is //! already unvoiced and the quotient is discarded, so no output changes. +use burn::prelude::Backend; + use super::{ biquad::BiquadCascade, coeff::{ @@ -60,25 +62,33 @@ use super::{ MIN_PERIOD_16KHZ, PITCH_MAX_PATH_W, PROC_FS, + PROC_RESAMPLE_RATE, VOICED_THRESHOLD, XCORR_TRAINING_OFFSET, }, + host::{ + HostPitch, + HostPitchInit, + }, lpc::{ Autocorrelator, DctTable, band_energy, lpc_from_cepstrum, }, - source::TenVadPitchSource, + source::{ + TenVadPitchScalarSource, + TenVadPitchSourceInit, + }, }; -use crate::kits::speech::ten_vad::context::coeff::{ - HOP_SIZE, - SAMPLE_RATE, +use crate::{ + kits::speech::ten_vad::context::coeff::{ + HOP_SIZE, + SAMPLE_RATE, + }, + prelude::BunsenResult, }; -/// The decimation factor from 16 kHz to the correlation branch's rate. -pub const PROC_RESAMPLE_RATE: usize = SAMPLE_RATE / PROC_FS; - /// The ten-vad STFT size, which sets the bin count the estimator expects. const FFT_SIZE: usize = 1024; @@ -87,16 +97,23 @@ const WINDOW_SIZE: usize = 768; /// The reference pitch estimator. /// -/// Implements [`TenVadPitchSource`], so it drops into -/// [`TenVadFeatureContext`](crate::kits::speech::ten_vad::context::TenVadFeatureContext) -/// in place of [`ZeroPitch`](super::ZeroPitch): +/// A [`TenVadPitchScalarSource`]: one instance per stream, stepped one hop at +/// a time. It reaches the driver's tensor seam through +/// [`HostPitch`](super::HostPitch), which this type's +/// [`TenVadPitchSourceInit`] impl wraps it in — so it can be named directly +/// where a pitch source is expected: /// /// ```rust,ignore -/// let ctx = config.init_context(batch_size, TenVadPitchEstimator::new(), &device)?; +/// let ctx = vad.init_context_with(&cfg, TenVadPitchEstimator::new(), &device)?; /// ``` /// -/// One instance per stream; the driver clones the prototype per batch row. -/// Built by [`new`](Self::new), rewound by [`reset`](Self::reset). +/// This is also the permanent oracle a device-side port is validated against: +/// it is pinned to the C reference by `testdata/ten/pitch.json`, and +/// [`frame_pitch_with_lpc`](Self::frame_pitch_with_lpc) exposes the one stage +/// boundary that carries no state. +/// +/// Built by [`new`](Self::new), rewound by +/// [`reset`](TenVadPitchScalarSource::reset). #[derive(Debug, Clone)] pub struct TenVadPitchEstimator { // --- Fixed geometry, all derived from the ten-vad configuration. --- @@ -138,7 +155,7 @@ pub struct TenVadPitchEstimator { /// The whitening filter's sample memory. pitch_mem: [f32; LPC_ORDER], - /// The one-pole leak carried across samples and hops. + /// The previous whitened sample, carried across samples and hops. pitch_filt: f32, /// The whitened hop, before anti-aliasing. @@ -296,6 +313,45 @@ impl TenVadPitchEstimator { &self.lpc } + /// Estimates the pitch of one hop against an externally supplied + /// pre-filter, skipping the stage that would design one. + /// + /// [`frame_pitch`](TenVadPitchScalarSource::frame_pitch) is exactly + /// "design the pre-filter from `bin_power`, then this". The stage that + /// designs it carries no state, so the split is exact: feeding this the + /// coefficients that stage would have produced reproduces `frame_pitch` + /// bit for bit. + /// + /// Exposed so a device-side pre-filter can be validated end to end against + /// the reference golden with the remaining stages held fixed. + /// + /// # Arguments + /// * `raw_hop`: the hop's samples at the reference's int16 scale. + /// * `lpc`: the whitening filter to use for this hop. + /// + /// # Returns + /// The pitch in Hz, or `0.0` when nothing voiced was detected. + /// + /// # Panics + /// If `raw_hop` is not [`hop_size`](Self::hop_size) long. + pub fn frame_pitch_with_lpc( + &mut self, + raw_hop: &[f32], + lpc: &[f32; LPC_ORDER], + ) -> f32 { + assert_eq!( + raw_hop.len(), + self.hop_size(), + "TenVadPitchEstimator expects a {}-sample hop", + self.hop_size(), + ); + + self.lpc = *lpc; + self.extract_excitation(raw_hop); + self.correlate(); + self.track() + } + /// Stage 1: fits the whitening filter to this hop's spectrum. fn design_prefilter( &mut self, @@ -347,8 +403,8 @@ impl TenVadPitchEstimator { self.aligned_in .copy_from_slice(&self.input_q[offset..offset + hop]); - // FIR whitening, plus a one-pole leak that keeps the excitation from - // going fully impulsive. + // FIR whitening, then a 2-tap smoother that keeps the excitation from + // going fully impulsive. Both are FIR, so both are convolutions. for i in 0..hop { let sample = self.aligned_in[i]; let mut whitened = sample; @@ -573,7 +629,67 @@ impl TenVadPitchEstimator { } } -impl TenVadPitchSource for TenVadPitchEstimator { +/// The oracle surface: read access to the stage boundaries a device-side port +/// is checked against. +/// +/// Deliberately test-only and `pub(crate)`. These expose the estimator's +/// internal layout — `exc_buf`'s length, `path_prev`'s slot shape — which +/// should not become public API just to let a differential test read them. +#[cfg(test)] +impl TenVadPitchEstimator { + /// The decimated excitation history. + /// + /// Part of the oracle surface: this is what a device-side excitation stage + /// is checked against. + pub(crate) fn exc_buf(&self) -> &[f32] { + &self.exc_buf + } + + /// The normalized correlations of stream slot `sub`, `[max_period]`. + /// + /// `sub` is a **stream** index, oldest at `0`. The circular-buffer + /// translation is applied here, so callers never see the ring offset — + /// which is exactly the bookkeeping a tensor port stores away. + pub(crate) fn xcorr_slot( + &self, + sub: usize, + ) -> &[f32] { + &self.xcorr[self.slot_of(sub)] + } + + /// The per-half-hop energies, in stream order. + pub(crate) fn frm_weight(&self) -> &[f32] { + &self.frm_weight + } + + /// The Viterbi accumulator carried into the next hop, `[dif_period]`. + pub(crate) fn path_score(&self) -> &[f32] { + &self.path_score[0] + } + + /// The backpointers of stream slot `sub`, `[dif_period]`. + /// + /// `sub` is a stream index, oldest at `0`; unlike the correlation ring + /// this buffer is already kept in stream order. + pub(crate) fn path_prev_slot( + &self, + sub: usize, + ) -> &[usize] { + &self.path_prev[sub] + } + + /// The best period index reached so far, carried across hops. + pub(crate) fn best_period(&self) -> usize { + self.best_period + } + + /// The best path score reached so far, carried across hops. + pub(crate) fn path_best_all(&self) -> f32 { + self.path_best_all + } +} + +impl TenVadPitchScalarSource for TenVadPitchEstimator { /// Estimates the pitch of one hop. /// /// # Panics @@ -638,6 +754,27 @@ impl TenVadPitchSource for TenVadPitchEstimator { } } +/// Builds the estimator behind a [`HostPitch`] adapter, one instance per +/// stream. +/// +/// This is what lets `init_context_with(cfg, TenVadPitchEstimator::new(), dev)` +/// read naturally while the driver's seam stays tensor-in, tensor-out. +impl TenVadPitchSourceInit for TenVadPitchEstimator { + type Source = HostPitch; + + fn try_init_source( + &self, + batch_size: usize, + device: &B::Device, + ) -> BunsenResult { + TenVadPitchSourceInit::::try_init_source( + &HostPitchInit(self.clone()), + batch_size, + device, + ) + } +} + #[cfg(test)] mod tests { use super::*; @@ -913,13 +1050,6 @@ mod tests { } } - #[test] - fn test_inspects_input_is_true() { - // The driver keys its device-to-host readback off this; a real - // estimator must opt in. - assert!(TenVadPitchEstimator::new().inspects_input()); - } - #[test] #[should_panic(expected = "expects a 256-sample hop")] fn test_wrong_hop_length_panics() { @@ -933,9 +1063,108 @@ mod tests { } #[test] - fn test_usable_as_a_trait_object() { - let mut source: Box = Box::new(TenVadPitchEstimator::new()); - assert!(source.inspects_input()); + fn test_frame_pitch_with_lpc_reproduces_frame_pitch() { + // The hybrid decomposition the device-side pre-filter is validated + // through: designing the filter carries no state, so replaying a hop + // against the filter that hop produced must be exact. + let signal = pulse_train(150.0, HOP_SIZE * 24, 0); + + let mut whole = TenVadPitchEstimator::new(); + let mut split = TenVadPitchEstimator::new(); + let mut stft = HostStft::new(); + + for hop in hops(&signal) { + let power = stft.push(hop); + let from_whole = whole.frame_pitch(hop, &power); + // The filter `whole` designed for this hop; nothing after the + // design stage writes it. + let lpc = *whole.lpc(); + let from_split = split.frame_pitch_with_lpc(hop, &lpc); + assert_eq!(from_whole, from_split); + } + + // The residual state must agree too, not just the per-hop output. + assert_eq!(whole.exc_buf(), split.exc_buf()); + assert_eq!(whole.path_score(), split.path_score()); + assert_eq!(whole.best_period(), split.best_period()); + } + + #[test] + fn test_oracle_accessors_report_the_documented_shapes() { + let signal = pulse_train(140.0, HOP_SIZE * 8, 0); + let mut est = TenVadPitchEstimator::new(); + let mut stft = HostStft::new(); + for hop in hops(&signal) { + est.frame_pitch(hop, &stft.push(hop)); + } + + assert_eq!(est.exc_buf().len(), est.max_period + 64 + 1); + assert_eq!(est.frm_weight().len(), est.slots()); + assert_eq!(est.path_score().len(), est.dif_period); + for sub in 0..est.slots() { + assert_eq!(est.xcorr_slot(sub).len(), est.max_period); + assert_eq!(est.path_prev_slot(sub).len(), est.dif_period); + } + assert!(est.best_period() < est.dif_period); + } + + #[test] + fn test_xcorr_slot_walks_in_stream_order() { + // Each hop appends two half-hop slots, so the newest pair lands at + // `slots-2` and `slots-1`, and everything older shifts left by two. + // This is the translation the ring offset exists to hide. + let signal = pulse_train(160.0, HOP_SIZE * 12, 0); + let mut est = TenVadPitchEstimator::new(); + let mut stft = HostStft::new(); + + let mut hop_iter = hops(&signal); + for hop in hop_iter.by_ref().take(6) { + est.frame_pitch(hop, &stft.push(hop)); + } + + let slots = est.slots(); + let before: Vec> = (0..slots).map(|s| est.xcorr_slot(s).to_vec()).collect(); + + let hop = hop_iter.next().unwrap(); + est.frame_pitch(hop, &stft.push(hop)); + + for sub in 0..(slots - 2) { + assert_eq!( + est.xcorr_slot(sub), + before[sub + 2].as_slice(), + "slot {sub} should hold what slot {} held before the hop", + sub + 2, + ); + } + } + + #[test] + fn test_path_score_carries_across_hops() { + // The Viterbi accumulator is renormalized, never cleared — so after a + // voiced run it must be non-trivial, with its peak pinned at zero. + let signal = pulse_train(170.0, HOP_SIZE * 20, 0); + let mut est = TenVadPitchEstimator::new(); + let mut stft = HostStft::new(); + for hop in hops(&signal) { + est.frame_pitch(hop, &stft.push(hop)); + } + + let score = est.path_score(); + let peak = score.iter().copied().fold(f32::NEG_INFINITY, f32::max); + assert!( + (peak - 0.0).abs() < 1e-5, + "peak should be renormalized to 0, got {peak}" + ); + assert!( + score.iter().any(|v| *v < -1e-6), + "the accumulator should spread once the tracker has history", + ); + assert!(est.path_best_all().is_finite()); + } + + #[test] + fn test_usable_as_a_scalar_trait_object() { + let mut source: Box = Box::new(TenVadPitchEstimator::new()); let p = source.frame_pitch(&[0.0; HOP_SIZE], &[0.0; N_BINS]); assert_eq!(p, 0.0); source.reset(); diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/host.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/host.rs new file mode 100644 index 00000000..b8e4e5fb --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/host.rs @@ -0,0 +1,295 @@ +//! # Host-side pitch sources, adapted to the device seam. +//! +//! [`HostPitch`] wraps a per-stream [`TenVadPitchScalarSource`] — notably +//! [`TenVadPitchEstimator`](super::TenVadPitchEstimator), the reference port — +//! and presents it as a [`TenVadPitchSource`]. +//! +//! ## The readback lives here, and only here +//! +//! The reference pitch algorithm is a serial recurrence over scalars, so +//! stepping it means bringing the raw hops and the bin powers back to the +//! host. [`HostPitch`] is the one place in the front end that synchronizes, +//! which is the point of isolating it: everything upstream and downstream of +//! this file is device-resident tensor code. +//! +//! The cost is **one** readback per call, not one per hop — +//! [`forward_sequence`](HostPitch::forward_sequence) reads the whole +//! `[steps, batch, ..]` block once, walks it host-side, and uploads a single +//! `[steps, batch, 1]` column. + +use burn::prelude::*; + +use super::source::{ + TenVadPitchScalarSource, + TenVadPitchSource, + TenVadPitchSourceInit, +}; +use crate::{ + errors::{ + BunsenError, + BunsenResult, + WithOkOrPanic, + }, + prelude::{ + TensorDataToVecAsExt, + TensorElemOpExt, + }, +}; + +/// Adapts a per-stream host [`TenVadPitchScalarSource`] to the device seam. +/// +/// Holds one scalar estimator per batch row, cloned from a prototype. Built by +/// [`HostPitch::new`] or, through the driver, by [`HostPitchInit`]. +#[derive(Debug, Clone, PartialEq)] +pub struct HostPitch { + /// The per-stream estimators; one entry per batch row. + pub sources: Vec

, +} + +impl HostPitch

{ + /// Builds a host adapter over `batch_size` independent streams. + /// + /// # Arguments + /// * `prototype`: cloned once per batch row. + /// * `batch_size`: the number of independent streams; must be non-zero. + /// + /// # Panics + /// If `batch_size` is zero. + pub fn new( + prototype: P, + batch_size: usize, + ) -> Self { + assert_ne!(batch_size, 0, "HostPitch batch_size must be non-zero"); + Self { + sources: vec![prototype; batch_size], + } + } +} + +impl HostPitch

{ + /// The batch size; each entry is an independent stream. + pub fn batch_size(&self) -> usize { + self.sources.len() + } +} + +impl TenVadPitchSource for HostPitch

{ + fn forward( + &mut self, + raw: Tensor, + bin_power: Tensor, + ) -> Tensor { + let [batch, hop_size] = raw.dims(); + let [power_batch, n_bins] = bin_power.dims(); + assert_eq!(batch, self.batch_size(), "HostPitch batch mismatch"); + assert_eq!(power_batch, batch, "raw and bin_power disagree on batch"); + + let device = raw.device(); + let raw_host: Vec = raw.to_data_as::().to_vec_as::().ok_or_panic(); + let power_host: Vec = bin_power + .to_data_as::() + .to_vec_as::() + .ok_or_panic(); + + let values: Vec = (0..batch) + .map(|b| { + self.sources[b].frame_pitch( + &raw_host[b * hop_size..(b + 1) * hop_size], + &power_host[b * n_bins..(b + 1) * n_bins], + ) + }) + .collect(); + + Tensor::from_data(TensorData::new(values, [batch, 1]), &device) + } + + /// Pitch is a per-stream recurrence, so the frames are walked in order, + /// one host-side call per stream per step — but the device is read back + /// only once, for the whole sequence. + fn forward_sequence( + &mut self, + raw: Tensor, + bin_power: Tensor, + ) -> Tensor { + let [steps, batch, hop_size] = raw.dims(); + let [power_steps, power_batch, n_bins] = bin_power.dims(); + assert_eq!(batch, self.batch_size(), "HostPitch batch mismatch"); + assert_eq!(power_batch, batch, "raw and bin_power disagree on batch"); + assert_eq!(power_steps, steps, "raw and bin_power disagree on steps"); + + let device = raw.device(); + let raw_host: Vec = raw.to_data_as::().to_vec_as::().ok_or_panic(); + let power_host: Vec = bin_power + .to_data_as::() + .to_vec_as::() + .ok_or_panic(); + + let mut values = Vec::with_capacity(steps * batch); + for step in 0..steps { + for b in 0..batch { + let raw_at = (step * batch + b) * hop_size; + let pow_at = (step * batch + b) * n_bins; + values.push(self.sources[b].frame_pitch( + &raw_host[raw_at..raw_at + hop_size], + &power_host[pow_at..pow_at + n_bins], + )); + } + } + + Tensor::from_data(TensorData::new(values, [steps, batch, 1]), &device) + } + + fn reset(&mut self) { + for source in &mut self.sources { + source.reset(); + } + } +} + +/// Builds a [`HostPitch`] from a scalar prototype. +/// +/// Third-party [`TenVadPitchScalarSource`] implementations reach the driver +/// through this; the ten-vad reference estimator has its own +/// [`TenVadPitchSourceInit`] impl so that +/// `init_context_with(cfg, TenVadPitchEstimator::new(), device)` reads +/// naturally. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct HostPitchInit

(pub P); + +impl TenVadPitchSourceInit for HostPitchInit

{ + type Source = HostPitch

; + + fn try_init_source( + &self, + batch_size: usize, + _device: &B::Device, + ) -> BunsenResult { + if batch_size == 0 { + return Err(BunsenError::Invalid( + "HostPitch batch_size must be non-zero".to_string(), + )); + } + Ok(HostPitch::new(self.0.clone(), batch_size)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::support::testing::PerformanceBackend; + + type B = PerformanceBackend; + + /// Reports the hop's first sample, so a test can tell rows apart, and + /// counts calls so a test can tell whether it was stepped once per frame. + #[derive(Debug, Clone, Default, PartialEq)] + struct EchoPitch { + calls: usize, + last: f32, + } + + impl TenVadPitchScalarSource for EchoPitch { + fn frame_pitch( + &mut self, + raw_hop: &[f32], + _bin_power: &[f32], + ) -> f32 { + self.calls += 1; + self.last = raw_hop[0]; + self.last + } + + fn reset(&mut self) { + self.calls = 0; + self.last = 0.0; + } + } + + #[test] + fn test_forward_routes_each_row_to_its_own_source() { + let device = Default::default(); + let mut pitch = HostPitch::new(EchoPitch::default(), 3); + + let raw = Tensor::::from_floats([[10.0, 0.0], [20.0, 0.0], [30.0, 0.0]], &device); + let power = Tensor::::ones([3, 4], &device); + + let out = TenVadPitchSource::::forward(&mut pitch, raw, power); + out.into_data() + .assert_eq(&TensorData::from([[10.0f32], [20.0], [30.0]]), true); + + for source in &pitch.sources { + assert_eq!(source.calls, 1); + } + } + + #[test] + fn test_forward_sequence_matches_stepwise() { + let device = Default::default(); + let steps = 4; + let batch = 2; + + let raw = Tensor::::from_floats( + [ + [[1.0, 0.0, 0.0], [2.0, 0.0, 0.0]], + [[3.0, 0.0, 0.0], [4.0, 0.0, 0.0]], + [[5.0, 0.0, 0.0], [6.0, 0.0, 0.0]], + [[7.0, 0.0, 0.0], [8.0, 0.0, 0.0]], + ], + &device, + ); + let power = Tensor::::ones([steps, batch, 5], &device); + + let mut seq = HostPitch::new(EchoPitch::default(), batch); + let seq_out = + TenVadPitchSource::::forward_sequence(&mut seq, raw.clone(), power.clone()); + + let mut step = HostPitch::new(EchoPitch::default(), batch); + let mut rows = Vec::new(); + for s in 0..steps { + let r = raw + .clone() + .slice_dim(0, s as isize..(s + 1) as isize) + .squeeze_dim::<2>(0); + let p = power + .clone() + .slice_dim(0, s as isize..(s + 1) as isize) + .squeeze_dim::<2>(0); + rows.push(TenVadPitchSource::::forward(&mut step, r, p)); + } + let step_out: Tensor = Tensor::stack(rows, 0); + + seq_out.into_data().assert_eq(&step_out.into_data(), true); + // The residual state must match too, not just the output. + assert_eq!(seq.sources, step.sources); + } + + #[test] + fn test_reset_rewinds_every_row() { + let device = Default::default(); + let mut pitch = HostPitch::new(EchoPitch::default(), 2); + + let raw = Tensor::::from_floats([[5.0], [6.0]], &device); + let power = Tensor::::ones([2, 2], &device); + TenVadPitchSource::::forward(&mut pitch, raw, power); + assert!(pitch.sources.iter().all(|s| s.calls == 1)); + + TenVadPitchSource::::reset(&mut pitch); + assert!(pitch.sources.iter().all(|s| *s == EchoPitch::default())); + } + + #[test] + fn test_init_rejects_zero_batch() { + let device = Default::default(); + let init = HostPitchInit(EchoPitch::default()); + assert!(TenVadPitchSourceInit::::try_init_source(&init, 0, &device).is_err()); + assert!(TenVadPitchSourceInit::::try_init_source(&init, 2, &device).is_ok()); + } + + #[test] + fn test_init_clones_the_prototype_per_row() { + let device = Default::default(); + let init = HostPitchInit(EchoPitch::default()); + let built = TenVadPitchSourceInit::::init_source(&init, 3, &device); + assert_eq!(built.batch_size(), 3); + } +} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/lpc.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/lpc.rs index bc86741d..0669b6a3 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/lpc.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/lpc.rs @@ -292,8 +292,13 @@ impl Autocorrelator { /// /// The reference's `AUP_PE_celt_lpc`, itself CELT's. Returns the coefficients /// of the whitening filter, and bails out early once the residual error has -/// dropped 30 dB below lag 0 — leaving the remaining coefficients at whatever -/// the recursion had reached. +/// dropped 30 dB below lag 0. +/// +/// Coefficients past the bail-out are left at **zero**, not at a partial +/// value: index `k` is only ever written by `lpc[i] = r` at step `k`, and the +/// symmetric update at step `i` touches only `0..i-1`. That matters for a +/// vectorized port, which cannot branch and must instead freeze each row under +/// a mask — the frozen tail is the zero initializer. /// /// Scale-invariant: multiplying `ac` through by a constant leaves the result /// unchanged, including the bail-out point. diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/mod.rs index a2c93c3b..fb6cf82f 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/mod.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/mod.rs @@ -3,16 +3,18 @@ //! Feature `40` of the ten-vad feature vector: a pitch estimate in Hz, `0.0` //! when unvoiced (`ALGO_TRACE.md` §3.5). //! -//! Unlike the rest of the front end this branch does not vectorize. The -//! reference estimator is a deeply serial recurrence over scalars — an LPC -//! fit, an IIR cascade, a lag search, and a Viterbi tracker, each carrying -//! state across hops — so it runs host-side, per stream, behind the -//! [`TenVadPitchSource`] seam. +//! The driver reaches this branch through [`TenVadPitchSource`], which is +//! tensor-in, tensor-out so the rest of the front end can stay +//! device-resident. //! //! ## The pieces //! -//! * [`TenVadPitchSource`] — the seam the driver calls through. -//! * [`TenVadPitchEstimator`] — the port of the reference estimator. +//! * [`TenVadPitchSource`] — the device seam the driver calls through, built by +//! [`TenVadPitchSourceInit`]. +//! * [`TenVadPitchEstimator`] — the port of the reference estimator, and the +//! permanent oracle the rest of the branch is validated against. +//! * [`HostPitch`] — adapts a host-side [`TenVadPitchScalarSource`] to the +//! device seam. **The only place in the front end that synchronizes.** //! * [`ZeroPitch`] — a constant stub that skips the branch entirely. //! * [`BiquadCascade`] — the anti-alias filter before decimation. //! * [`coeff`](self) — the reference constants. @@ -22,19 +24,28 @@ //! //! ## Choosing a source //! -//! [`TenVadPitchEstimator`] is the faithful choice and what -//! [`TenVadContext`](super::TenVadContext) should carry for reference -//! parity. It costs a device-to-host readback of the raw hop and the bin -//! powers on every frame, which pins the sequence path to a host-side walk. +//! [`TenVadPitchEstimator`] is the faithful choice: all 41 features then match +//! the reference. Because it is a serial recurrence over scalars, stepping it +//! means reading the raw hops and the bin powers back from the device — once +//! per `forward_sequence` call for the whole sequence, or once per hop on the +//! single-step path, which pays that cost anyway. //! -//! [`ZeroPitch`] pins feature `40` to a constant and reports -//! [`inspects_input`](TenVadPitchSource::inspects_input) as `false`, letting -//! the driver keep the whole sequence path on-device. The other 40 features -//! are exact either way. +//! [`ZeroPitch`] pins feature `40` to a constant and never inspects its +//! arguments, so the sequence path stays entirely on-device. The other 40 +//! features are exact either way. +//! +//! ## Why the estimator is not a tensor op +//! +//! The reference estimator is four stages, and only the first is free of +//! carried state: an LPC fit (stateless), an excitation branch carrying a FIFO +//! and an IIR cascade, a lag search carrying a correlation ring, and a Viterbi +//! tracker carrying its accumulator across hops. The last is a genuine +//! recurrence over 56 states with two steps per hop. mod biquad; mod coeff; mod estimator; +mod host; mod lpc; mod source; @@ -45,6 +56,8 @@ pub use coeff::*; #[doc(inline)] pub use estimator::*; #[doc(inline)] +pub use host::*; +#[doc(inline)] pub use lpc::*; #[doc(inline)] pub use source::*; diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/source.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/source.rs index c812347e..aa3daa28 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/source.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/source.rs @@ -3,38 +3,133 @@ //! Feature `40` of the ten-vad feature vector is a pitch estimate, in Hz, //! with `0.0` meaning "unvoiced" (`ALGO_TRACE.md` §3.5). //! -//! The driver reaches it through [`TenVadPitchSource`], which has two -//! implementations: +//! The driver reaches it through [`TenVadPitchSource`], which is tensor-in, +//! tensor-out so the front end can stay device-resident. Implementations: //! -//! * [`TenVadPitchEstimator`](super::TenVadPitchEstimator) — the port of the -//! reference estimator, and what a faithful front end wants. -//! * [`ZeroPitch`] — a constant stub, for callers that want the 40 mel features -//! without paying for the pitch branch's host-side recurrence. - -use crate::kits::speech::ten_vad::context::coeff::{ - FEATURE_EPS, - FEATURE_MEANS, - FEATURE_STDS, - N_MELS, +//! * [`ZeroPitch`] — a constant stub that never inspects its input. +//! * [`HostPitch`](super::HostPitch) — adapts a host-side +//! [`TenVadPitchScalarSource`] (notably +//! [`TenVadPitchEstimator`](super::TenVadPitchEstimator), the reference port) +//! at the cost of a device-to-host readback. +//! +//! ## Why there are three traits +//! +//! * [`TenVadPitchSource`] is the device seam the driver calls. +//! * [`TenVadPitchSourceInit`] builds one. A tensor-native source has to +//! *allocate* its carried buffers for a `(batch_size, device)` pair, so it +//! cannot be a prototype cloned per batch row the way a host source can. +//! * [`TenVadPitchScalarSource`] is the per-stream host contract the reference +//! estimator implements, kept separate because the reference algorithm is a +//! serial recurrence over scalars, not a tensor op. + +use burn::prelude::*; + +use crate::{ + errors::WithOkOrPanic, + kits::speech::ten_vad::context::coeff::{ + FEATURE_EPS, + FEATURE_MEANS, + FEATURE_STDS, + N_MELS, + }, + prelude::BunsenResult, }; /// A source for the ten-vad pitch feature. /// -/// Implemented by [`TenVadPitchEstimator`](super::TenVadPitchEstimator) and -/// [`ZeroPitch`]. +/// The driver holds exactly one of these per context, covering every stream in +/// the batch. Built by [`TenVadPitchSourceInit`]. +pub trait TenVadPitchSource { + /// Estimates the pitch of one hop. + /// + /// # Arguments + /// * `raw`: `[batch, hop_size]` samples at the reference's int16 scale. + /// These are the **raw** samples: pre-emphasis is applied only to the + /// STFT branch, never to the pitch branch (`ALGO_TRACE.md` §3.3). + /// * `bin_power`: `[batch, n_bins]` bin powers, `re^2 + im^2`, **before** + /// the `1 / 32768^2` normalization the mel branch applies. + /// + /// # Returns + /// `[batch, 1]` pitch in Hz, `0.0` where nothing voiced was detected. The + /// trailing axis is the feature column, so the driver's concatenation onto + /// the log-mel block is free. + fn forward( + &mut self, + raw: Tensor, + bin_power: Tensor, + ) -> Tensor; + + /// Estimates the pitch of `steps` consecutive hops. + /// + /// Equivalent to `steps` calls of [`forward`](Self::forward). + /// + /// # Arguments + /// * `raw`: `[steps, batch, hop_size]` consecutive raw hops. + /// * `bin_power`: `[steps, batch, n_bins]` consecutive bin powers. + /// + /// # Returns + /// `[steps, batch, 1]` pitch in Hz. + fn forward_sequence( + &mut self, + raw: Tensor, + bin_power: Tensor, + ) -> Tensor; + + /// Resets any carried state to the start-of-stream condition. + fn reset(&mut self); +} + +/// Builds a [`TenVadPitchSource`] bound to a batch size and a device. /// -/// The interface is host-side by necessity: the reference algorithm is a -/// serial recurrence over scalars, not a tensor op. Implementations are -/// per-stream — the driver runs one instance per batch row. -pub trait TenVadPitchSource { +/// This is the seam +/// [`TenVadFeatures::init_state`](crate::kits::speech::ten_vad::context::TenVadFeatures::init_state) +/// threads through. It exists because a tensor-native source allocates its +/// carried buffers at construction and so cannot be cloned per batch row. +pub trait TenVadPitchSourceInit { + /// The source this builds. + type Source: TenVadPitchSource; + + /// Builds a start-of-stream source over `batch_size` independent streams. + /// + /// # Arguments + /// * `batch_size`: the number of independent streams; must be non-zero. + /// * `device`: the device the carried buffers are allocated on. + /// + /// # Errors + /// [`BunsenError::Invalid`](crate::prelude::BunsenError::Invalid) if the + /// source geometry is invalid. + fn try_init_source( + &self, + batch_size: usize, + device: &B::Device, + ) -> BunsenResult; + + /// Builds a start-of-stream source, panicking on error. + /// + /// See [`try_init_source`](Self::try_init_source). + fn init_source( + &self, + batch_size: usize, + device: &B::Device, + ) -> Self::Source { + self.try_init_source(batch_size, device).ok_or_panic() + } +} + +/// A host-side, per-stream scalar pitch estimator. +/// +/// The reference algorithm is a serial recurrence over scalars rather than a +/// tensor op, so it is expressed here and adapted to the device seam by +/// [`HostPitch`](super::HostPitch). Implemented by +/// [`TenVadPitchEstimator`](super::TenVadPitchEstimator). +pub trait TenVadPitchScalarSource { /// Estimates the pitch of one hop. /// /// # Arguments - /// * `raw_hop` - the hop's samples at the reference's int16 scale. These - /// are the **raw** samples: pre-emphasis is applied only to the STFT - /// branch, never to the pitch branch (`ALGO_TRACE.md` §3.3). - /// * `bin_power` - the `[n_bins]` bin powers, `re^2 + im^2`, **before** the - /// `1 / 32768^2` normalization the mel branch applies. + /// * `raw_hop` - the hop's samples at the reference's int16 scale, **raw** + /// rather than pre-emphasized. + /// * `bin_power` - the `[n_bins]` bin powers, **before** the `1 / 32768^2` + /// normalization the mel branch applies. /// /// # Returns /// The pitch in Hz, or `0.0` when nothing voiced was detected. @@ -44,18 +139,6 @@ pub trait TenVadPitchSource { bin_power: &[f32], ) -> f32; - /// Whether [`frame_pitch`](Self::frame_pitch) inspects its arguments. - /// - /// The driver reads `raw_hop` and `bin_power` back from the device to - /// call [`frame_pitch`](Self::frame_pitch). Returning `false` lets it skip - /// that synchronization entirely and keep the sequence path on-device. - /// - /// An implementation returning `false` must produce the same value for - /// every input, and must not depend on being called once per frame. - fn inspects_input(&self) -> bool { - true - } - /// Resets any carried state to the start-of-stream condition. fn reset(&mut self); } @@ -68,8 +151,8 @@ pub trait TenVadPitchSource { /// /// The other 40 features are unaffected: nothing upstream of the pitch branch /// reads its output. This is a deliberate approximation, not a placeholder — -/// [`TenVadPitchEstimator`](super::TenVadPitchEstimator) is the faithful -/// source, and costs a device-to-host readback per hop that this avoids. +/// it never inspects its arguments, so the whole front end stays on-device, +/// which the faithful sources cannot offer. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct ZeroPitch; @@ -80,48 +163,102 @@ impl ZeroPitch { } } -impl TenVadPitchSource for ZeroPitch { - fn frame_pitch( +impl TenVadPitchSource for ZeroPitch { + fn forward( &mut self, - _raw_hop: &[f32], - _bin_power: &[f32], - ) -> f32 { - 0.0 + raw: Tensor, + _bin_power: Tensor, + ) -> Tensor { + Tensor::zeros([raw.dims()[0], 1], &raw.device()) } - fn inspects_input(&self) -> bool { - false + fn forward_sequence( + &mut self, + raw: Tensor, + _bin_power: Tensor, + ) -> Tensor { + let [steps, batch, _] = raw.dims(); + Tensor::zeros([steps, batch, 1], &raw.device()) } fn reset(&mut self) {} } +impl TenVadPitchSourceInit for ZeroPitch { + type Source = ZeroPitch; + + fn try_init_source( + &self, + _batch_size: usize, + _device: &B::Device, + ) -> BunsenResult { + Ok(ZeroPitch) + } +} + #[cfg(test)] mod tests { + use burn::tensor::Tensor; + use super::*; + use crate::support::testing::PerformanceBackend; + + type B = PerformanceBackend; #[test] fn test_zero_pitch_is_always_unvoiced() { + let device = Default::default(); + let mut pitch = ZeroPitch; + + let raw = Tensor::::from_floats([[1.0, -2.0, 3.0]], &device); + let power = Tensor::::ones([1, 513], &device); + + let out = TenVadPitchSource::::forward(&mut pitch, raw, power); + assert_eq!(out.dims(), [1, 1]); + assert_eq!(out.into_scalar().elem::(), 0.0); + } + + #[test] + fn test_zero_pitch_sequence_shape_and_value() { + let device = Default::default(); let mut pitch = ZeroPitch; - assert_eq!(pitch.frame_pitch(&[], &[]), 0.0); - assert_eq!(pitch.frame_pitch(&[1.0, -2.0, 3.0], &[9.0; 513]), 0.0); - assert_eq!(pitch.frame_pitch(&[f32::MAX], &[f32::MIN]), 0.0); + let raw = Tensor::::zeros([5, 2, 256], &device); + let power = Tensor::::ones([5, 2, 513], &device); + + let out = TenVadPitchSource::::forward_sequence(&mut pitch, raw, power); + assert_eq!(out.dims(), [5, 2, 1]); + assert_eq!(out.sum().into_scalar().elem::(), 0.0); } #[test] - fn test_zero_pitch_skips_readback() { - // The driver keys its device-to-host sync off this. - assert!(!ZeroPitch.inspects_input()); + fn test_zero_pitch_ignores_its_input() { + // The whole point of ZeroPitch: it reads shape and device, never data, + // so the driver's sequence path never has to synchronize. + let device = Default::default(); + let mut pitch = ZeroPitch; + + let quiet = Tensor::::zeros([1, 256], &device); + let loud = Tensor::::full([1, 256], 30000.0, &device); + let power = Tensor::::ones([1, 513], &device); + + let a = TenVadPitchSource::::forward(&mut pitch, quiet, power.clone()); + let b = TenVadPitchSource::::forward(&mut pitch, loud, power); + a.into_data().assert_eq(&b.into_data(), true); } #[test] fn test_zero_pitch_reset_is_stateless() { + let device = Default::default(); let mut pitch = ZeroPitch; - let before = pitch.frame_pitch(&[1.0], &[1.0]); - pitch.reset(); - let after = pitch.frame_pitch(&[1.0], &[1.0]); - assert_eq!(before, after); + let raw = Tensor::::ones([1, 256], &device); + let power = Tensor::::ones([1, 513], &device); + + let before = TenVadPitchSource::::forward(&mut pitch, raw.clone(), power.clone()); + TenVadPitchSource::::reset(&mut pitch); + let after = TenVadPitchSource::::forward(&mut pitch, raw, power); + + before.into_data().assert_eq(&after.into_data(), true); assert_eq!(pitch, ZeroPitch); } @@ -138,20 +275,29 @@ mod tests { #[test] fn test_usable_as_a_trait_object() { - let mut pitch: Box = Box::new(ZeroPitch); - assert_eq!(pitch.frame_pitch(&[0.5], &[0.5]), 0.0); - assert!(!pitch.inspects_input()); + let device = Default::default(); + let mut pitch: Box> = Box::new(ZeroPitch); + + let raw = Tensor::::zeros([1, 256], &device); + let power = Tensor::::zeros([1, 513], &device); + assert_eq!(pitch.forward(raw, power).dims(), [1, 1]); pitch.reset(); } - /// A minimal non-trivial implementation, to prove the seam is usable and - /// that `inspects_input` defaults to `true`. - #[derive(Default)] + #[test] + fn test_zero_pitch_init_ignores_batch_and_device() { + let device = Default::default(); + let built = TenVadPitchSourceInit::::init_source(&ZeroPitch, 4, &device); + assert_eq!(built, ZeroPitch); + } + + /// A minimal scalar implementation, to prove that seam is usable too. + #[derive(Debug, Clone, Default)] struct CountingPitch { calls: usize, } - impl TenVadPitchSource for CountingPitch { + impl TenVadPitchScalarSource for CountingPitch { fn frame_pitch( &mut self, raw_hop: &[f32], @@ -167,9 +313,8 @@ mod tests { } #[test] - fn test_custom_source_defaults_to_inspecting_input() { + fn test_scalar_seam_is_usable() { let mut pitch = CountingPitch::default(); - assert!(pitch.inspects_input()); assert_eq!(pitch.frame_pitch(&[0.0; 4], &[]), 4.0); assert_eq!(pitch.calls, 1); diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pre_emphasis.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pre_emphasis.rs index e278d0f6..0b70792c 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pre_emphasis.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pre_emphasis.rs @@ -48,6 +48,12 @@ pub struct PreEmphasisConfig { pub coeff: f32, } +impl Default for PreEmphasisConfig { + fn default() -> Self { + Self::new() + } +} + impl PreEmphasisConfig { /// Initializes a zeroed [`PreEmphasisContext`] for `batch_size` streams. /// @@ -192,10 +198,10 @@ mod tests { use super::*; use crate::{ prelude::*, - support::testing::CpuBackend, + support::testing::PerformanceBackend, }; - type B = CpuBackend; + type B = PerformanceBackend; type F = ::FloatElem; /// Host reference: the scalar filter, over one stream, with carry. From 866f7ad1af4f7788305e6d66d185f8e376dbf2af Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Fri, 21 Aug 2026 19:26:31 -0700 Subject: [PATCH 04/32] chore: narrower wgpu run configs, fewer test threads, SlidingStftConfig::default Crutcher's changes, committed alongside the ten-vad pitch work they were made during. * Two narrower run configurations, `Test (wgpu) bunsen` and `Test (wgpu) bunsen::kits::speech`, for a faster iteration loop than the whole-workspace `Test (wgpu)`. * `RUST_TEST_THREADS` 8 -> 4, reducing contention for the device now that the speech kits actually run on it. * `impl Default for SlidingStftConfig`, delegating to `new()`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .cargo/config.toml | 2 +- .../runConfigurations/Test__wgpu__bunsen.xml | 20 +++++++++++++++++++ .../Test__wgpu__bunsen__kits__speech.xml | 20 +++++++++++++++++++ crates/bunsen/src/ops/signal/sliding_stft.rs | 6 ++++++ 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 .idea/runConfigurations/Test__wgpu__bunsen.xml create mode 100644 .idea/runConfigurations/Test__wgpu__bunsen__kits__speech.xml diff --git a/.cargo/config.toml b/.cargo/config.toml index 4c13db2b..913f047d 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -2,4 +2,4 @@ public-dependency = true [env] -RUST_TEST_THREADS = "8" +RUST_TEST_THREADS = "4" diff --git a/.idea/runConfigurations/Test__wgpu__bunsen.xml b/.idea/runConfigurations/Test__wgpu__bunsen.xml new file mode 100644 index 00000000..74a5d86f --- /dev/null +++ b/.idea/runConfigurations/Test__wgpu__bunsen.xml @@ -0,0 +1,20 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Test__wgpu__bunsen__kits__speech.xml b/.idea/runConfigurations/Test__wgpu__bunsen__kits__speech.xml new file mode 100644 index 00000000..a4a8950b --- /dev/null +++ b/.idea/runConfigurations/Test__wgpu__bunsen__kits__speech.xml @@ -0,0 +1,20 @@ + + + + \ No newline at end of file diff --git a/crates/bunsen/src/ops/signal/sliding_stft.rs b/crates/bunsen/src/ops/signal/sliding_stft.rs index 7dd97530..28c61dfa 100644 --- a/crates/bunsen/src/ops/signal/sliding_stft.rs +++ b/crates/bunsen/src/ops/signal/sliding_stft.rs @@ -97,6 +97,12 @@ pub struct SlidingStftConfig { pub window: StftWindowConfig, } +impl Default for SlidingStftConfig { + fn default() -> Self { + Self::new() + } +} + impl SlidingStftMeta for SlidingStftConfig { fn win_len(&self) -> usize { self.win_len From ba9ce64383b1b0e1eb7cbcd3b18f3a0207f915f1 Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Fri, 21 Aug 2026 19:50:17 -0700 Subject: [PATCH 05/32] feat(ten-vad): design the pitch pre-filter on device, and gate it on the C golden Phase 1 of moving the pitch estimator off the host. Adds `context::pitch::tensor`, and within it the first of the estimator's four stages: the whitening-filter design, `[rows, n_bins]` bin powers to `[rows, lpc_order]` coefficients. That stage is the natural first move because it **carries no state**. It reads one hop's spectrum and writes one hop's filter with nothing threaded between hops, so a whole `steps x batch` sequence flattens into the row axis and the stage runs in a single pass -- a coefficient-only object with no context, structurally like `TenVadMelBank`. **Tables built by probing, not transcription.** Each projection matrix is the linearization of a host function, and every one of those is linear by construction, so each column is obtained by evaluating the host function on a basis vector. Agreement with the host becomes a property of the construction rather than something a test has to establish, and the awkward parts -- `band_span`'s independently rounded ends, `interp_band_gain`'s "later span wins" assignment -- stay defined in exactly one place. **Three matrices, not four.** `interp_band_gain` -> zero Nyquist -> `autocorrelate` are three linear steps from 18 bands to 17 lags, so they fold into one `[18, 17]` matrix. That removes the 513-wide intermediate from the device path, and sidesteps a precision trap: `Autocorrelator` deliberately accumulates in `f64` over 511 `f32` terms because a flat `f32` sum there is worse than the FFT it replaces, and a naive device matmul over 513 bins would reintroduce exactly that error. Folding on the host keeps the `f64` accumulation and leaves the device an 18-term contraction. The DCT is likewise one matrix serving both directions. **The two parts that are not matmuls.** The log-compression clamp is a coupled scan over 18 bands -- unrolled, but batched across the whole sequence, so it costs 18 tiny kernels per call rather than per hop. The Levinson-Durbin recursion has a data-dependent early exit, which a batched version cannot branch on; it runs all 16 steps and freezes each row under a mask instead. Two details make that faithful: the reference checks *after* completing an iteration, so the freeze is applied after the update; and no sticky bookkeeping is needed, because a frozen `error` stays below its threshold and so the mask is already monotone. **The gate.** `HybridPitch` (test-only) runs this stage on the device and the remaining three on the host, and `test_tensor_prefilter_hybrid_reference_golden` drives it through the same C golden the host estimator is pinned to. That is the question the stage-level differential tests cannot answer: whether a tolerance survives the tracker's `argmax` and voicing threshold, both discrete, where a single argmax step of +/-1 moves the reported pitch by roughly half a percent. It does -- **voicing agrees with the C reference on all 3750 frames**. The value bound is relaxed to 1e-3 for the hybrid arm, since the device contracts the band projections in `f32`; the voicing bound is not relaxed. The two golden tests now share a driver and an assertion helper, so the host arm and any device arm are compared the same way. Also exposes `dc0_bias` to the crate, and notes that burn offers only a natural logarithm, so `log10` is spelled `log(x)/ln(10)` with the matching `exp(x*ln(10))` on the way back out. Tests: 148 in `kits::speech` under `--features wgpu`, all green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../kits/speech/ten_vad/context/pitch/lpc.rs | 2 +- .../kits/speech/ten_vad/context/pitch/mod.rs | 4 + .../ten_vad/context/pitch/tensor/hybrid.rs | 183 ++++++ .../ten_vad/context/pitch/tensor/mod.rs | 65 +++ .../ten_vad/context/pitch/tensor/prefilter.rs | 539 ++++++++++++++++++ .../ten_vad/context/pitch/tensor/tables.rs | 479 ++++++++++++++++ .../src/kits/speech/ten_vad/cross_test.rs | 95 ++- 7 files changed, 1341 insertions(+), 26 deletions(-) create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/hybrid.rs create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/prefilter.rs create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/tables.rs diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/lpc.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/lpc.rs index 0669b6a3..977654c9 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/lpc.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/lpc.rs @@ -58,7 +58,7 @@ use super::coeff::{ /// /// The reference computes `windowSz / 12 / 38.0f` with an **integer** first /// division (`src/pitch_est.cc`, `DC0_BIAS`). -fn dc0_bias(window_size: usize) -> f32 { +pub(crate) fn dc0_bias(window_size: usize) -> f32 { (window_size / 12) as f32 / 38.0 } diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/mod.rs index fb6cf82f..edc91e87 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/mod.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/mod.rs @@ -18,6 +18,8 @@ //! * [`ZeroPitch`] — a constant stub that skips the branch entirely. //! * [`BiquadCascade`] — the anti-alias filter before decimation. //! * [`coeff`](self) — the reference constants. +//! * [`tensor`] — the device-side port, built stage by stage against the host +//! estimator as its oracle. //! //! [`lpc`] holds the pre-filter design: band folding, the cepstrum, the //! autocorrelation, and the Levinson-Durbin solve. @@ -49,6 +51,8 @@ mod host; mod lpc; mod source; +pub mod tensor; + #[doc(inline)] pub use biquad::*; #[doc(inline)] diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/hybrid.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/hybrid.rs new file mode 100644 index 00000000..76f735c5 --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/hybrid.rs @@ -0,0 +1,183 @@ +//! # The staged-migration harness. +//! +//! [`HybridPitch`] runs the pre-filter design on the device and the remaining +//! three stages on the host, so a device stage can be measured against the C +//! reference **end to end** while everything downstream of it is held fixed. +//! +//! That is the point: the stage-level differential tests say a device stage +//! reproduces its host counterpart to a tolerance, but they cannot say whether +//! that tolerance survives the tracker's `argmax` and voicing threshold. This +//! can, by driving the real golden through a pipeline that differs from the +//! pinned one in exactly one stage. +//! +//! The split is exact rather than approximate: the pre-filter design carries +//! no state, and nothing downstream rewrites the coefficients it produces, so +//! [`TenVadPitchEstimator::frame_pitch_with_lpc`] resumes from precisely the +//! point [`frame_pitch`](TenVadPitchScalarSource::frame_pitch) would have +//! reached. +//! +//! Test-only, and deliberately so — it is scaffolding for the migration, not +//! a configuration anyone should ship. It comes out when the last stage lands. + +use burn::prelude::*; + +use super::{ + super::{ + TenVadPitchEstimator, + TenVadPitchSource, + TenVadPitchSourceInit, + coeff::LPC_ORDER, + }, + prefilter::{ + PitchPrefilter, + PitchPrefilterConfig, + }, +}; +use crate::{ + errors::{ + BunsenResult, + WithOkOrPanic, + }, + prelude::{ + TensorDataToVecAsExt, + TensorElemOpExt, + }, +}; + +/// Device pre-filter design, host everything else. +/// +/// See the module docs. Built by [`HybridPitch::new`]. +#[derive(Debug, Clone)] +pub(crate) struct HybridPitch { + /// The device-side stage under test. + pub prefilter: PitchPrefilter, + + /// The host estimators, one per stream, resumed past their own stage 1. + pub sources: Vec, +} + +impl HybridPitch { + /// Builds a hybrid source over `batch_size` independent streams. + /// + /// # Panics + /// If `batch_size` is zero. + pub fn new( + prefilter: PitchPrefilter, + batch_size: usize, + ) -> Self { + assert_ne!(batch_size, 0, "HybridPitch batch_size must be non-zero"); + Self { + prefilter, + sources: vec![TenVadPitchEstimator::new(); batch_size], + } + } + + /// The batch size. + pub fn batch_size(&self) -> usize { + self.sources.len() + } + + /// Steps every stream over one hop's worth of already-read-back host data. + /// + /// # Arguments + /// * `raw_host`: `rows * hop_size` raw samples, row-major. + /// * `lpc_host`: `rows * LPC_ORDER` device-designed coefficients. + /// * `rows`: how many `(step, stream)` pairs are present. + /// * `hop_size`: samples per hop. + /// * `batch`: the stream count, so row `r` maps to stream `r % batch`. + fn step_rows( + &mut self, + raw_host: &[f32], + lpc_host: &[f32], + rows: usize, + hop_size: usize, + batch: usize, + ) -> Vec { + (0..rows) + .map(|row| { + let mut lpc = [0.0f32; LPC_ORDER]; + lpc.copy_from_slice(&lpc_host[row * LPC_ORDER..(row + 1) * LPC_ORDER]); + self.sources[row % batch] + .frame_pitch_with_lpc(&raw_host[row * hop_size..(row + 1) * hop_size], &lpc) + }) + .collect() + } +} + +impl TenVadPitchSource for HybridPitch { + fn forward( + &mut self, + raw: Tensor, + bin_power: Tensor, + ) -> Tensor { + let [batch, hop_size] = raw.dims(); + assert_eq!(batch, self.batch_size(), "HybridPitch batch mismatch"); + + let device = raw.device(); + let lpc = self.prefilter.forward(bin_power); + + let raw_host: Vec = raw.to_data_as::().to_vec_as::().ok_or_panic(); + let lpc_host: Vec = lpc.to_data_as::().to_vec_as::().ok_or_panic(); + + let values = self.step_rows(&raw_host, &lpc_host, batch, hop_size, batch); + Tensor::from_data(TensorData::new(values, [batch, 1]), &device) + } + + fn forward_sequence( + &mut self, + raw: Tensor, + bin_power: Tensor, + ) -> Tensor { + let [steps, batch, hop_size] = raw.dims(); + let n_bins = bin_power.dims()[2]; + assert_eq!(batch, self.batch_size(), "HybridPitch batch mismatch"); + + let device = raw.device(); + + // Stage 1 is stateless, so the whole sequence designs in one pass. + let lpc = self + .prefilter + .forward(bin_power.reshape([steps * batch, n_bins])); + + let raw_host: Vec = raw.to_data_as::().to_vec_as::().ok_or_panic(); + let lpc_host: Vec = lpc.to_data_as::().to_vec_as::().ok_or_panic(); + + // Row-major `[steps, batch]` means row `r` is stream `r % batch`, which + // walks each stream's hops in order. + let values = self.step_rows(&raw_host, &lpc_host, steps * batch, hop_size, batch); + Tensor::from_data(TensorData::new(values, [steps, batch, 1]), &device) + } + + fn reset(&mut self) { + use super::super::TenVadPitchScalarSource; + for source in &mut self.sources { + source.reset(); + } + } +} + +/// Builds a [`HybridPitch`] for the driver. +/// +/// Holds only the stage's config: the prefilter's tables are device-resident, +/// so they cannot be built until `try_init_source` is handed a device. +#[derive(Debug, Clone)] +pub(crate) struct HybridPitchInit(pub PitchPrefilterConfig); + +impl HybridPitchInit { + /// Builds an init over the default ten-vad pre-filter geometry. + pub fn new() -> Self { + Self(PitchPrefilterConfig::new()) + } +} + +impl TenVadPitchSourceInit for HybridPitchInit { + type Source = HybridPitch; + + fn try_init_source( + &self, + batch_size: usize, + device: &B::Device, + ) -> BunsenResult { + Ok(HybridPitch::new(self.0.try_init(device)?, batch_size)) + } +} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs new file mode 100644 index 00000000..625a217e --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs @@ -0,0 +1,65 @@ +//! # The ten-vad pitch estimator, as tensor ops. +//! +//! A device-side implementation of the reference pitch estimator, built +//! stage by stage against +//! [`TenVadPitchEstimator`](super::TenVadPitchEstimator) as its oracle. The +//! host implementation stays: it is pinned to the C reference by +//! `testdata/ten/pitch.json`, and every stage here is validated differentially +//! against the corresponding stage there. +//! +//! ## Why this exists +//! +//! The host estimator is a serial recurrence over scalars, so the driver has +//! to read the raw hops and the bin powers back from the device to step it — +//! a host island in the middle of an otherwise device-resident front end. It +//! is also one instance per stream walked serially, which will not scale when +//! the batch axis opens up. A tensor implementation batches over streams for +//! free. +//! +//! ## The shape of the problem +//! +//! Four stages, and they are not alike: +//! +//! | stage | carried state | parallel across a sequence | +//! |---|---|---| +//! | 1 · pre-filter design | **none** | **yes, fully** | +//! | 2 · excitation | FIFO, smoother, anti-alias filter | partly | +//! | 3 · correlation | correlation ring, per-slot energies | yes, given the history | +//! | 4 · tracking | Viterbi accumulator and backpointers | **no** | +//! +//! Of the state that stages 2-4 carry, most is a sliding window and folds +//! into the same "prepend the carry, run the batched op once, re-slice the +//! tail" idiom [`SlidingStftContext`](crate::ops::signal::SlidingStftContext) +//! already uses. Two things genuinely are not: the anti-alias filter's IIR +//! state, and the Viterbi triple. Do not assume the whole port is a `cat`. +//! +//! ## Numerics +//! +//! Stage boundaries are `f32` tensors; internal precision is each stage's own +//! business. The formulations here were chosen to add no error of their own — +//! see [`prefilter`] for why the Levinson recursion is masked rather than +//! branched, and [`tables`] for why the autocorrelation is folded on the host +//! in `f64` rather than contracted over 513 bins on the device. +//! +//! That matters more than it looks. The tracker's output passes through an +//! `argmax` over 56 states and a threshold on the frame correlation, and a +//! single `argmax` step of ±1 moves the reported pitch by roughly half a +//! percent — some fifty times the golden's tolerance. There is no such thing +//! as a small error in a discrete decision, so the design goal is to add +//! *none*, leaving only the perturbation the golden has already been shown to +//! survive. +//! +//! Tests here run on `PerformanceBackend` and assert relative tolerances, not +//! bit-exact equality: a dev may be on a backend that enables fast-math, where +//! exact equality against a host scalar reference cannot hold. + +pub mod prefilter; +pub mod tables; + +#[cfg(test)] +pub(crate) mod hybrid; + +#[doc(inline)] +pub use prefilter::*; +#[doc(inline)] +pub use tables::*; diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/prefilter.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/prefilter.rs new file mode 100644 index 00000000..fca2eb88 --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/prefilter.rs @@ -0,0 +1,539 @@ +//! # Stage 1: the whitening filter design, on device. +//! +//! Maps `[rows, n_bins]` bin powers to `[rows, lpc_order]` whitening filter +//! coefficients — the tensor form of +//! [`TenVadPitchEstimator`](super::super::TenVadPitchEstimator)'s pre-filter +//! stage. +//! +//! **This stage carries no state.** It reads one hop's spectrum and writes one +//! hop's filter, with nothing threaded between hops, so `rows` can be a whole +//! `steps × batch` sequence flattened into the row axis and the entire stage +//! runs in one pass. That is what makes it a coefficient-only object with no +//! `*Context`, structurally like +//! [`TenVadMelBank`](crate::kits::speech::ten_vad::context::TenVadMelBank). +//! +//! ```text +//! bands = bin_power @ BANDS # [rows, 18] +//! ly = clamped log10(bands + 1e-2) # a 18-step scan +//! cep = ly @ DCT # [rows, 18] +//! gain = 10^(cep @ DCTᵀ) * BAND_LPC_COMP # [rows, 18] +//! ac = gain @ AC_FROM_BANDS # [rows, 17] +//! ac[0] += ac[0]*1e-4 + dc0_bias; ac[i] *= lagwin[i] +//! lpc = levinson(ac) # [rows, 16] +//! ``` +//! +//! ## The two parts that are not matmuls +//! +//! **The log-compression clamp is a coupled scan** across the 18 bands: each +//! band's floor depends on the running peak and on a decaying follower, and +//! both are updated from the *clamped* value. It is only 18 steps, so it is +//! unrolled — but those 18 steps are batched across every row of the sequence +//! at once, so the cost is 18 tiny kernels per call, not per hop. +//! +//! **The Levinson-Durbin recursion has a data-dependent early exit.** The +//! reference breaks out once the residual error drops 30 dB below lag zero, +//! leaving the remaining coefficients at their zero initializer. A batched +//! tensor version cannot branch per row, so it runs all 16 steps and *freezes* +//! each row under a mask once its condition trips. Two details make that +//! faithful: +//! +//! * The reference checks **after** completing an iteration, so iteration `i` +//! always commits and only `i+1..` are skipped. The freeze is therefore +//! applied after the update, and the mask is recomputed after the freeze. +//! * The freeze needs no separate "sticky" bookkeeping: once a row's `error` is +//! frozen below the threshold it stays below it, so re-deriving the mask from +//! `error` each step is already monotone. +//! +//! The reference's other guard — `ac[0] == 0`, where it returns all zeros — is +//! unreachable here. `dc0_bias` adds an absolute floor of `window/12/38` to lag +//! zero before the solve, so `ac[0] >= 1.68` regardless of input, and the +//! threshold is always strictly positive. + +use burn::{ + config::Config, + prelude::*, +}; + +use super::{ + super::{ + coeff::{ + LPC_ORDER, + NB_BANDS, + }, + lpc::dc0_bias, + }, + tables::{ + PitchTables, + PitchTablesConfig, + }, +}; +use crate::errors::{ + BunsenResult, + WithOkOrPanic, +}; + +/// Config for [`PitchPrefilter`]. +#[derive(Config, Debug)] +pub struct PitchPrefilterConfig { + /// The projection-table geometry. + #[config(default = "PitchTablesConfig::new()")] + pub tables: PitchTablesConfig, +} + +impl PitchPrefilterConfig { + /// Validates the geometry. + /// + /// # Errors + /// + /// See [`PitchTablesConfig::validate`]. + pub fn validate(&self) -> BunsenResult<()> { + self.tables.validate() + } + + /// The number of frequency bins this stage expects. + pub fn n_bins(&self) -> usize { + self.tables.n_bins() + } + + /// Builds the stage, uploading its tables. + /// + /// # Errors + /// + /// See [`validate`](Self::validate). + pub fn try_init( + &self, + device: &B::Device, + ) -> BunsenResult> { + Ok(PitchPrefilter { + tables: self.tables.try_init(device)?, + }) + } + + /// Builds the stage, panicking on error. + pub fn init( + &self, + device: &B::Device, + ) -> PitchPrefilter { + self.try_init(device).ok_or_panic() + } +} + +/// The whitening-filter design stage. +/// +/// Stateless, so one instance serves any number of streams. Built by +/// [`PitchPrefilterConfig::try_init`]. +#[derive(Debug, Clone)] +pub struct PitchPrefilter { + /// The fixed projection tables. + pub tables: PitchTables, +} + +impl PitchPrefilter { + /// The number of frequency bins this stage expects. + pub fn n_bins(&self) -> usize { + self.tables.n_bins() + } + + /// The FFT size the bin powers come from. + pub fn fft_size(&self) -> usize { + self.tables.fft_size() + } + + /// Designs a whitening filter per row. + /// + /// # Arguments + /// * `bin_power`: `[rows, n_bins]` bin powers, **un-normalized** — the same + /// values the pitch branch receives, before the mel branch's `1 / + /// 32768^2` division. + /// + /// # Returns + /// `[rows, lpc_order]` whitening filter coefficients. + /// + /// # Panics + /// If `bin_power`'s trailing axis is not [`n_bins`](Self::n_bins). + pub fn forward( + &self, + bin_power: Tensor, + ) -> Tensor { + let [rows, n_bins] = bin_power.dims(); + assert_eq!( + n_bins, + self.n_bins(), + "PitchPrefilter expects {} bins", + self.n_bins(), + ); + + // [rows, nb_bands] + let bands = bin_power.matmul(self.tables.bands.clone()); + let ly = self.log_compress(bands); + + // Round-trip through the cepstrum, which is where the envelope gets + // smoothed; the same matrix serves both directions. + let cepstrum = ly.matmul(self.tables.dct.clone()); + let log_gain = cepstrum.matmul(self.tables.dct.clone().transpose()); + + // 10^x, then the per-band compensation. + let gain = log_gain.mul_scalar(core::f32::consts::LN_10).exp() + * self.tables.band_lpc_comp.clone().unsqueeze::<2>(); + + // [rows, lpc_order + 1] + let ac = gain.matmul(self.tables.ac_from_bands.clone()); + let ac = self.apply_noise_floor(ac); + + levinson_durbin(ac, rows) + } + + /// The clamped log compression, as an unrolled 18-step scan. + /// + /// Nothing may sit more than 8 decades below the running peak, nor fall + /// faster than 2.5 decades per band. Both trackers update from the + /// *clamped* value, which is what couples them. + /// + /// burn exposes only a natural logarithm, so `log10` is `log(x)/ln(10)`. + /// That differs from the host's `f32::log10` by a ULP or so — well inside + /// this stage's tolerance, and the `10^x` on the way back out is spelled + /// as its matching inverse, `exp(x·ln(10))`. + fn log_compress( + &self, + bands: Tensor, + ) -> Tensor { + let [rows, _] = bands.dims(); + let device = bands.device(); + + let mut log_max: Tensor = Tensor::full([rows, 1], -2.0, &device); + let mut follow: Tensor = Tensor::full([rows, 1], -2.0, &device); + let mut out = Vec::with_capacity(NB_BANDS); + + for band in 0..NB_BANDS { + let raw = bands + .clone() + .slice_dim(1, band as isize..(band + 1) as isize) + .add_scalar(1e-2f32) + .log() + .div_scalar(core::f32::consts::LN_10); + + let decayed = follow.sub_scalar(2.5f32); + let ly = log_max + .clone() + .sub_scalar(8.0f32) + .max_pair(decayed.clone().max_pair(raw)); + + log_max = log_max.max_pair(ly.clone()); + follow = decayed.max_pair(ly.clone()); + out.push(ly); + } + + Tensor::cat(out, 1) + } + + /// The `-40 dB` noise floor on lag zero, then the lag window. + /// + /// The reference's operation order is preserved: lag zero takes + /// `ac0 + (ac0*1e-4 + dc0_bias)` as three separate roundings, and the + /// window's entry `0` is `1.0` so it leaves lag zero alone. + fn apply_noise_floor( + &self, + ac: Tensor, + ) -> Tensor { + let windowed = ac * self.tables.lag_window.clone().unsqueeze::<2>(); + + let ac0 = windowed.clone().slice_dim(1, 0..1); + let floored = ac0.clone().add( + ac0.mul_scalar(1e-4f32) + .add_scalar(dc0_bias(self.tables.window_size())), + ); + + Tensor::cat(vec![floored, windowed.slice_dim(1, 1..)], 1) + } +} + +/// Solves for whitening coefficients by Levinson-Durbin, batched over rows. +/// +/// See the module docs for why the reference's early exit becomes a mask. +/// +/// # Arguments +/// * `ac`: `[rows, lpc_order + 1]` autocorrelation lags, already floored and +/// windowed. +/// * `rows`: `ac`'s leading extent. +/// +/// # Returns +/// `[rows, lpc_order]` coefficients. +fn levinson_durbin( + ac: Tensor, + rows: usize, +) -> Tensor { + let device = ac.device(); + + let ac0 = ac.clone().slice_dim(1, 0..1); + let threshold = ac0.clone().mul_scalar(0.001f32); + + let mut error = ac0; + let mut lpc: Tensor = Tensor::zeros([rows, LPC_ORDER], &device); + // Live at the start: `dc0_bias` guarantees `ac[0] > 0`, so no row can + // begin already converged. + let mut done = error.clone().lower(threshold.clone()); + + for i in 0..LPC_ORDER { + // rr = Σ_{j = Tensor::zeros([rows, 1], &device); + if i > 0 { + // `ac[i], ac[i-1], …, ac[1]`, so element `j` pairs with `lpc[j]`. + let reversed = ac.clone().slice_dim(1, 1..(i + 1) as isize).flip([1]); + let products = lpc.clone().slice_dim(1, 0..i as isize) * reversed; + for j in 0..i { + rr = rr + products.clone().slice_dim(1, j as isize..(j + 1) as isize); + } + } + rr = rr + ac.clone().slice_dim(1, (i + 1) as isize..(i + 2) as isize); + + // Frozen rows divide by 1 rather than by a stale error, so no inf or + // NaN is ever produced; the result is discarded either way. + let safe = error.clone().mask_fill(done.clone(), 1.0f32); + let r = rr.neg() / safe.clone(); + + // The reference's symmetric update, `lpc[j] += r·lpc[i-1-j]` over the + // first half, is exactly `head + flip(head)·r` over the whole prefix — + // including the middle element when `i` is odd, which the loop form + // writes twice with identical operands. + let mut parts = Vec::with_capacity(3); + if i > 0 { + let head = lpc.clone().slice_dim(1, 0..i as isize); + parts.push(head.clone() + head.flip([1]) * r.clone()); + } + parts.push(r.clone()); + if i + 1 < LPC_ORDER { + parts.push(Tensor::zeros([rows, LPC_ORDER - i - 1], &device)); + } + let next_lpc = Tensor::cat(parts, 1); + + let next_error = safe.clone() - (r.clone() * r) * safe; + + // Commit, then freeze: the reference checks after the iteration. + let wide = done.clone().expand([rows, LPC_ORDER]); + lpc = next_lpc.mask_where(wide, lpc); + error = next_error.mask_where(done, error); + done = error.clone().lower(threshold.clone()); + } + + lpc +} + +#[cfg(test)] +mod tests { + use burn::tensor::Tolerance; + + use super::{ + super::super::{ + TenVadPitchEstimator, + TenVadPitchScalarSource, + lpc::celt_lpc, + }, + *, + }; + use crate::{ + prelude::*, + support::testing::PerformanceBackend, + }; + + type B = PerformanceBackend; + type D = ::Device; + + const N_BINS: usize = 513; + + /// A plausible hop spectrum: smooth, non-negative, decaying, at the + /// reference's int16 power scale. + fn spectrum(seed: f32) -> Vec { + (0..N_BINS) + .map(|k| { + let k = k as f32; + 1e7 * (-k / (60.0 + 20.0 * seed)).exp() * (1.0 + 0.5 * (k * 0.05 + seed).sin()) + }) + .collect() + } + + fn stage(device: &D) -> PitchPrefilter { + PitchPrefilterConfig::new().init(device) + } + + /// The host stage, reached through the oracle: `frame_pitch` runs the + /// pre-filter design first and nothing afterwards rewrites `lpc`. + fn host_lpc(bin_power: &[f32]) -> [f32; LPC_ORDER] { + let mut est = TenVadPitchEstimator::new(); + est.frame_pitch(&[0.0; 256], bin_power); + *est.lpc() + } + + #[test] + fn test_config_meta() { + let cfg = PitchPrefilterConfig::new(); + assert_eq!(cfg.n_bins(), N_BINS); + assert!(cfg.validate().is_ok()); + } + + #[test] + fn test_validate_rejects_bad_geometry() { + let bad = + PitchPrefilterConfig::new().with_tables(PitchTablesConfig::new().with_fft_size(0)); + assert!(bad.validate().is_err()); + } + + #[test] + fn test_init_meta_matches_config() { + let device = Default::default(); + let stage = stage(&device); + assert_eq!(stage.n_bins(), N_BINS); + assert_eq!(stage.fft_size(), 1024); + } + + #[test] + fn test_forward_matches_host_stage() { + // The differential test this phase exists for. + let device = Default::default(); + let stage = stage(&device); + + for seed in [0.0f32, 0.7, 1.9, 3.3] { + let power = spectrum(seed); + let want = host_lpc(&power); + + let input = Tensor::::from_floats(power.as_slice(), &device).reshape([1, N_BINS]); + let got: Vec = stage + .forward(input) + .to_data_as::() + .to_vec_as::() + .unwrap(); + + for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() { + let rel = (g - w).abs() / w.abs().max(1e-3); + assert!(rel < 1e-3, "seed {seed}, lpc[{i}]: {g} vs {w} (rel {rel})"); + } + } + } + + #[test] + fn test_forward_batches_rows_independently() { + // Stage 1 carries no state, so a batched call must equal per-row calls. + let device = Default::default(); + let stage = stage(&device); + + let seeds = [0.2f32, 1.4, 2.6]; + let mut flat = Vec::new(); + for s in seeds { + flat.extend_from_slice(&spectrum(s)); + } + let batched = + Tensor::::from_floats(flat.as_slice(), &device).reshape([seeds.len(), N_BINS]); + let batched_out = stage.forward(batched); + + for (row, seed) in seeds.iter().enumerate() { + let solo = Tensor::::from_floats(spectrum(*seed).as_slice(), &device) + .reshape([1, N_BINS]); + let solo_out = stage.forward(solo); + + batched_out + .clone() + .slice_dim(0, row as isize..(row + 1) as isize) + .to_data() + .assert_approx_eq::(&solo_out.to_data(), Tolerance::permissive()); + } + } + + #[test] + fn test_silence_produces_a_finite_filter() { + // An all-zero spectrum drives every band to the log floor. The noise + // floor on lag zero is what keeps the solve well-posed. + let device = Default::default(); + let stage = stage(&device); + + let out = stage.forward(Tensor::::zeros([1, N_BINS], &device)); + let got: Vec = out.to_data_as::().to_vec_as::().unwrap(); + + assert_eq!(got.len(), LPC_ORDER); + for (i, c) in got.iter().enumerate() { + assert!(c.is_finite(), "lpc[{i}] = {c}"); + } + // And it agrees with the host on the same degenerate input. + let want = host_lpc(&[0.0; N_BINS]); + for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() { + assert!((g - w).abs() < 1e-3, "lpc[{i}]: {g} vs {w}"); + } + } + + #[test] + fn test_levinson_reproduces_the_early_break() { + // The masked freeze is what is under test here, so the fixture has to + // be one that genuinely trips the reference's early exit. The sum of + // two sinusoids is exactly predictable by a 4th-order recursion, so + // the residual collapses and the break fires with most of the + // coefficients still at their zero initializer. + // + // An AR(1) autocorrelation does *not* work: at `a = 0.98` the residual + // settles near `1 - a²`, four decades above the 30 dB threshold, and + // the recursion runs all sixteen steps. + let device = Default::default(); + + let ac: [f32; LPC_ORDER + 1] = + core::array::from_fn(|i| 500.0 * ((0.3 * i as f32).cos() + (0.8 * i as f32).cos())); + let want = celt_lpc(&ac); + + // Guard the guard, without pinning *where* the break lands: the host + // runs in f32 and may exit a step earlier or later than an f64 + // analysis suggests. What matters is that it exited at all, which + // shows as an untouched zero tail. + let live = want.iter().rposition(|c| *c != 0.0).map_or(0, |i| i + 1); + assert!( + live < LPC_ORDER, + "fixture should trip the early break, got {want:?}", + ); + + let input = Tensor::::from_floats(ac.as_slice(), &device).reshape([1, LPC_ORDER + 1]); + let got: Vec = levinson_durbin::(input, 1) + .to_data_as::() + .to_vec_as::() + .unwrap(); + + for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() { + assert!((g - w).abs() < 1e-3, "lpc[{i}]: {g} vs {w}"); + } + // And the frozen tail is exactly zero on the device too, not merely + // small: a freeze that leaked would show up here. + for (i, g) in got.iter().enumerate().skip(live) { + assert_eq!(*g, 0.0, "lpc[{i}] should be frozen at zero, got {g}"); + } + } + + #[test] + fn test_levinson_matches_the_host_across_shapes() { + let device = Default::default(); + + let cases: Vec<[f32; LPC_ORDER + 1]> = vec![ + core::array::from_fn(|i| 1000.0 * 0.8f32.powi(i as i32)), + core::array::from_fn(|i| 500.0 / (1.0 + i as f32)), + core::array::from_fn(|i| 100.0 * (1.0 + (i as f32 * 0.9).cos())), + core::array::from_fn(|i| if i == 0 { 42.0 } else { 0.0 }), + ]; + + let mut flat = Vec::new(); + for c in &cases { + flat.extend_from_slice(c); + } + let input = Tensor::::from_floats(flat.as_slice(), &device) + .reshape([cases.len(), LPC_ORDER + 1]); + let got: Vec = levinson_durbin::(input, cases.len()) + .to_data_as::() + .to_vec_as::() + .unwrap(); + + for (row, case) in cases.iter().enumerate() { + let want = celt_lpc(case); + for (i, w) in want.iter().enumerate() { + let g = got[row * LPC_ORDER + i]; + assert!((g - w).abs() < 1e-4, "case {row}, lpc[{i}]: {g} vs {w}"); + } + } + } +} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/tables.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/tables.rs new file mode 100644 index 00000000..6b9fad80 --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/tables.rs @@ -0,0 +1,479 @@ +//! # Projection tables for the tensor pitch front end. +//! +//! Three fixed matrices, plus two coefficient vectors, covering every linear +//! step of the pre-filter design. +//! +//! ## Built by probing, not by transcription +//! +//! Each matrix is the linearization of a host function in the `lpc` module, +//! and every one of those functions is linear by +//! construction. So rather than re-deriving the index arithmetic — the +//! rounded band spans, the doubled edge bands, the "later span wins" overlap +//! rule — each column is obtained by *evaluating the host function on a basis +//! vector*. +//! +//! That makes agreement with the host a property of the construction rather +//! than something a test has to establish, and it means the awkward parts +//! (`band_span`'s independent end rounding, `interp_band_gain`'s assignment +//! semantics) are defined in exactly one place. +//! +//! ## The composition that matters +//! +//! `interp_band_gain` → zero Nyquist → `autocorrelate` is three linear steps +//! from `[nb_bands]` to `[lpc_order + 1]`, so it folds into a single +//! `[nb_bands, lpc_order + 1]` matrix. That removes the `n_bins`-wide +//! intermediate from the device path entirely: the device contracts 18 terms +//! where the host contracts 513. +//! +//! It also sidesteps a precision trap. [`Autocorrelator::autocorrelate`] +//! deliberately accumulates in `f64` over 511 `f32` terms, because a flat +//! `f32` sum there would be materially worse than the FFT it replaces. A naive +//! device `f32` matmul over 513 bins would reintroduce exactly that error. +//! Folding the composition on the host keeps the `f64` accumulation where it +//! belongs and leaves the device an 18-term contraction. + +use burn::prelude::*; + +use super::super::{ + coeff::{ + BAND_LPC_COMP, + LPC_ORDER, + NB_BANDS, + }, + lpc::{ + Autocorrelator, + DctTable, + band_energy, + interp_band_gain, + }, +}; +use crate::errors::{ + BunsenError, + BunsenResult, +}; + +/// Config for [`PitchTables`]. +/// +/// Defaults match the ten-vad front end: a 1024-point STFT over a 768-sample +/// analysis window. +#[derive(Config, Debug, Copy)] +pub struct PitchTablesConfig { + /// The FFT size the bin powers come from. + #[config(default = "1024")] + pub fft_size: usize, + + /// The STFT analysis window length, which sets the LPC noise floor. + #[config(default = "768")] + pub window_size: usize, +} + +impl PitchTablesConfig { + /// The number of frequency bins, `fft_size / 2 + 1`. + pub fn n_bins(&self) -> usize { + self.fft_size / 2 + 1 + } + + /// Validates the geometry. + /// + /// # Errors + /// [`BunsenError::Invalid`] if `fft_size` is not a positive even number, + /// or if `window_size` is zero. + pub fn validate(&self) -> BunsenResult<()> { + if self.fft_size == 0 || !self.fft_size.is_multiple_of(2) { + return Err(BunsenError::Invalid(format!( + "PitchTables fft_size ({}) must be a positive even number", + self.fft_size, + ))); + } + if self.window_size == 0 { + return Err(BunsenError::Invalid( + "PitchTables window_size must be non-zero".to_string(), + )); + } + Ok(()) + } + + /// The `[n_bins, nb_bands]` band-folding matrix, row-major. + /// + /// Column `i` is [`band_energy`] evaluated on the unit spectrum at bin + /// `i`, so the doubled edge bands and the clamped tail index come along + /// for free. + pub fn to_vec_bands(&self) -> Vec { + let n_bins = self.n_bins(); + let mut out = vec![0.0f32; n_bins * NB_BANDS]; + let mut probe = vec![0.0f32; n_bins]; + + for bin in 0..n_bins { + probe[bin] = 1.0; + let row = band_energy(&probe, self.fft_size); + out[bin * NB_BANDS..(bin + 1) * NB_BANDS].copy_from_slice(&row); + probe[bin] = 0.0; + } + out + } + + /// The `[nb_bands, nb_bands]` DCT matrix, row-major. + /// + /// One matrix serves both directions: `cepstrum = bands @ dct` and + /// `log_gain = cepstrum @ dctᵀ`, because the reference's forward and + /// inverse transforms differ only in which index of the table they walk. + pub fn to_vec_dct(&self) -> Vec { + let table = DctTable::new(); + let mut out = vec![0.0f32; NB_BANDS * NB_BANDS]; + let mut probe = [0.0f32; NB_BANDS]; + + for j in 0..NB_BANDS { + probe[j] = 1.0; + let row = table.dct(&probe); + out[j * NB_BANDS..(j + 1) * NB_BANDS].copy_from_slice(&row); + probe[j] = 0.0; + } + out + } + + /// The `[nb_bands, lpc_order + 1]` band-gain to autocorrelation matrix, + /// row-major. + /// + /// The composition of [`interp_band_gain`], the Nyquist zeroing, and + /// [`Autocorrelator::autocorrelate`] — the whole linear part of + /// `lpc_from_bands`, up to the elementwise affine on the lags. + pub fn to_vec_ac_from_bands(&self) -> Vec { + let n_bins = self.n_bins(); + let autocorrelator = Autocorrelator::new(self.fft_size); + + let mut out = vec![0.0f32; NB_BANDS * (LPC_ORDER + 1)]; + let mut probe = [0.0f32; NB_BANDS]; + let mut bins = vec![0.0f32; n_bins]; + let mut lags = [0.0f32; LPC_ORDER + 1]; + + for band in 0..NB_BANDS { + probe[band] = 1.0; + interp_band_gain(&probe, &mut bins); + // As `lpc_from_bands` does, before transforming. + bins[n_bins - 1] = 0.0; + autocorrelator.autocorrelate(&bins, &mut lags); + out[band * (LPC_ORDER + 1)..(band + 1) * (LPC_ORDER + 1)].copy_from_slice(&lags); + probe[band] = 0.0; + } + out + } + + /// The `[lpc_order + 1]` lag window, `1 - 6e-5·i²`. + /// + /// Entry `0` is `1.0`: lag zero takes the noise-floor affine instead, and + /// applying that separately keeps the reference's operation order. + pub fn to_vec_lag_window(&self) -> Vec { + let mut out = vec![1.0f32; LPC_ORDER + 1]; + for (i, slot) in out.iter_mut().enumerate().skip(1) { + *slot = 1.0 - 6e-5 * i as f32 * i as f32; + } + out + } + + /// Uploads the tables. + /// + /// # Errors + /// + /// See [`validate`](Self::validate). + pub fn try_init( + &self, + device: &B::Device, + ) -> BunsenResult> { + self.validate()?; + let n_bins = self.n_bins(); + + Ok(PitchTables { + fft_size: self.fft_size, + window_size: self.window_size, + bands: Tensor::from_data( + TensorData::new(self.to_vec_bands(), [n_bins, NB_BANDS]), + device, + ), + dct: Tensor::from_data( + TensorData::new(self.to_vec_dct(), [NB_BANDS, NB_BANDS]), + device, + ), + ac_from_bands: Tensor::from_data( + TensorData::new(self.to_vec_ac_from_bands(), [NB_BANDS, LPC_ORDER + 1]), + device, + ), + lag_window: Tensor::from_data( + TensorData::new(self.to_vec_lag_window(), [LPC_ORDER + 1]), + device, + ), + band_lpc_comp: Tensor::from_data( + TensorData::new(BAND_LPC_COMP.to_vec(), [NB_BANDS]), + device, + ), + }) + } + + /// Uploads the tables, panicking on error. + pub fn init( + &self, + device: &B::Device, + ) -> PitchTables { + use crate::errors::WithOkOrPanic; + self.try_init(device).ok_or_panic() + } +} + +/// The fixed projection tables of the tensor pitch pre-filter. +/// +/// Stateless; one instance can be shared by any number of streams. Built by +/// [`PitchTablesConfig::try_init`]. +#[derive(Debug, Clone)] +pub struct PitchTables { + fft_size: usize, + window_size: usize, + + /// `[n_bins, nb_bands]`: `bands = bin_power @ this`. + pub bands: Tensor, + + /// `[nb_bands, nb_bands]`: `cepstrum = bands @ this`, and + /// `log_gain = cepstrum @ this.transpose()`. + pub dct: Tensor, + + /// `[nb_bands, lpc_order + 1]`: `ac = band_gain @ this`. + pub ac_from_bands: Tensor, + + /// `[lpc_order + 1]` lag window; entry `0` is `1.0`. + pub lag_window: Tensor, + + /// `[nb_bands]` per-band LPC compensation. + pub band_lpc_comp: Tensor, +} + +impl PitchTables { + /// The FFT size these tables were built for. + pub fn fft_size(&self) -> usize { + self.fft_size + } + + /// The number of frequency bins. + pub fn n_bins(&self) -> usize { + self.fft_size / 2 + 1 + } + + /// The STFT analysis window length. + pub fn window_size(&self) -> usize { + self.window_size + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::Tolerance; + + use super::{ + super::super::lpc::{ + celt_lpc, + dc0_bias, + lpc_from_bands, + }, + *, + }; + use crate::support::testing::PerformanceBackend; + + type B = PerformanceBackend; + + fn config() -> PitchTablesConfig { + PitchTablesConfig::new() + } + + /// A plausible non-negative band envelope. + fn band_gains() -> [f32; NB_BANDS] { + core::array::from_fn(|i| 100.0 * (-(i as f32) / 3.5).exp() * (1.0 + 0.3 * i as f32).sqrt()) + } + + #[test] + fn test_config_meta() { + let cfg = config(); + assert_eq!(cfg.fft_size, 1024); + assert_eq!(cfg.window_size, 768); + assert_eq!(cfg.n_bins(), 513); + } + + #[test] + fn test_validate_rejects_bad_geometry() { + assert!(config().validate().is_ok()); + assert!(config().with_fft_size(0).validate().is_err()); + assert!(config().with_fft_size(1023).validate().is_err()); + assert!(config().with_window_size(0).validate().is_err()); + } + + #[test] + fn test_init_meta_matches_config() { + let device = Default::default(); + let tables: PitchTables = config().init(&device); + + assert_eq!(tables.fft_size(), 1024); + assert_eq!(tables.n_bins(), 513); + assert_eq!(tables.window_size(), 768); + assert_eq!(tables.bands.dims(), [513, NB_BANDS]); + assert_eq!(tables.dct.dims(), [NB_BANDS, NB_BANDS]); + assert_eq!(tables.ac_from_bands.dims(), [NB_BANDS, LPC_ORDER + 1]); + assert_eq!(tables.lag_window.dims(), [LPC_ORDER + 1]); + assert_eq!(tables.band_lpc_comp.dims(), [NB_BANDS]); + } + + #[test] + fn test_bands_matrix_matches_band_energy() { + // Linearity is the premise of the whole file; check it on a spectrum + // that is nothing like the basis vectors it was probed with. + let cfg = config(); + let n_bins = cfg.n_bins(); + let matrix = cfg.to_vec_bands(); + + let spectrum: Vec = (0..n_bins) + .map(|k| 1e3 * (-(k as f32) / 90.0).exp() * (1.0 + 0.4 * (k as f32 * 0.07).sin())) + .collect(); + + let expected = band_energy(&spectrum, cfg.fft_size); + for (band, want) in expected.iter().enumerate() { + let got: f32 = (0..n_bins) + .map(|k| spectrum[k] * matrix[k * NB_BANDS + band]) + .sum(); + let rel = (got - want).abs() / want.abs().max(1.0); + assert!(rel < 1e-5, "band {band}: {got} vs {want} (rel {rel})"); + } + } + + #[test] + fn test_bands_matrix_doubles_the_edge_bands() { + // The reference doubles bands 0 and 17 because each only ever receives + // one side of a ramp. If the probe lost that, every log-mel-adjacent + // number downstream would shift. + let cfg = config(); + let matrix = cfg.to_vec_bands(); + // Bin 0 sits at the head of band 0's ramp, weight (1 - 0) doubled. + // Row-major, so band 0 of bin 0 is simply entry 0. + assert!((matrix[0] - 2.0).abs() < 1e-6, "{}", matrix[0]); + } + + #[test] + fn test_dct_matrix_matches_the_host_table_both_directions() { + let cfg = config(); + let matrix = cfg.to_vec_dct(); + let table = DctTable::new(); + let input = band_gains(); + + let forward = table.dct(&input); + let inverse = table.idct(&input); + + for i in 0..NB_BANDS { + // dct: row-major contraction over the first index. + let got_fwd: f32 = (0..NB_BANDS) + .map(|j| input[j] * matrix[j * NB_BANDS + i]) + .sum(); + // idct: the same matrix, transposed. + let got_inv: f32 = (0..NB_BANDS) + .map(|j| input[j] * matrix[i * NB_BANDS + j]) + .sum(); + + assert!( + (got_fwd - forward[i]).abs() < 1e-4, + "dct[{i}]: {got_fwd} vs {}", + forward[i], + ); + assert!( + (got_inv - inverse[i]).abs() < 1e-4, + "idct[{i}]: {got_inv} vs {}", + inverse[i], + ); + } + } + + #[test] + fn test_ac_from_bands_matches_the_host_composition() { + // The load-bearing simplification: interp -> zero Nyquist -> + // autocorrelate, folded into one [18, 17] matrix. + let cfg = config(); + let n_bins = cfg.n_bins(); + let matrix = cfg.to_vec_ac_from_bands(); + let gains = band_gains(); + + let mut bins = vec![0.0f32; n_bins]; + interp_band_gain(&gains, &mut bins); + bins[n_bins - 1] = 0.0; + let mut expected = [0.0f32; LPC_ORDER + 1]; + Autocorrelator::new(cfg.fft_size).autocorrelate(&bins, &mut expected); + + for lag in 0..=LPC_ORDER { + let got: f32 = (0..NB_BANDS) + .map(|b| gains[b] * matrix[b * (LPC_ORDER + 1) + lag]) + .sum(); + let rel = (got - expected[lag]).abs() / expected[lag].abs().max(1.0); + assert!( + rel < 1e-5, + "lag {lag}: {got} vs {} (rel {rel})", + expected[lag], + ); + } + } + + #[test] + fn test_ac_from_bands_feeds_the_same_lpc_as_the_host() { + // End-to-end for the linear half: apply the reference's affine to the + // matrix-derived lags and check the solve agrees. + let cfg = config(); + let matrix = cfg.to_vec_ac_from_bands(); + let lag_window = cfg.to_vec_lag_window(); + let gains = band_gains(); + + let mut ac = [0.0f32; LPC_ORDER + 1]; + for (lag, slot) in ac.iter_mut().enumerate() { + *slot = (0..NB_BANDS) + .map(|b| gains[b] * matrix[b * (LPC_ORDER + 1) + lag]) + .sum(); + } + // The reference's ordering: lag 0 takes the noise floor, the rest the + // lag window. + ac[0] += ac[0] * 1e-4 + dc0_bias(cfg.window_size); + for (i, slot) in ac.iter_mut().enumerate().skip(1) { + *slot *= lag_window[i]; + } + let got = celt_lpc(&ac); + + let mut scratch = vec![0.0f32; cfg.n_bins()]; + let want = lpc_from_bands( + &gains, + cfg.window_size, + &Autocorrelator::new(cfg.fft_size), + &mut scratch, + ); + + for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() { + assert!((g - w).abs() < 1e-4, "lpc[{i}]: {g} vs {w}"); + } + } + + #[test] + fn test_lag_window_matches_the_reference_formula() { + let window = config().to_vec_lag_window(); + assert_eq!(window[0], 1.0, "lag 0 takes the noise floor instead"); + for (i, w) in window.iter().enumerate().skip(1) { + let want = 1.0 - 6e-5 * i as f32 * i as f32; + assert_eq!(*w, want, "lag {i}"); + } + // Monotonically tapering, and never inverting the sign of a lag. + assert!(window[LPC_ORDER] > 0.0); + assert!(window[LPC_ORDER] < window[1]); + } + + #[test] + fn test_uploaded_tables_match_their_host_vectors() { + let device = Default::default(); + let cfg = config(); + let tables: PitchTables = cfg.init(&device); + + tables.bands.to_data().assert_approx_eq::( + &TensorData::new(cfg.to_vec_bands(), [cfg.n_bins(), NB_BANDS]), + Tolerance::permissive(), + ); + tables.ac_from_bands.to_data().assert_approx_eq::( + &TensorData::new(cfg.to_vec_ac_from_bands(), [NB_BANDS, LPC_ORDER + 1]), + Tolerance::permissive(), + ); + } +} diff --git a/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs b/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs index 29368c09..4cd4fc89 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs @@ -22,6 +22,8 @@ mod tests { TenVadFeatureConfig, TenVadFeatureMeta, TenVadPitchEstimator, + TenVadPitchSourceInit, + tensor::hybrid::HybridPitchInit, }, reference::ReferenceModel, }, @@ -242,31 +244,18 @@ mod tests { Ok(()) } - /// Pins the ported pitch estimator against the ten-vad C reference. + /// Drives the whole golden fixture through a pitch source and recovers the + /// per-hop pitch in Hz, alongside the reference values to compare against. /// /// `testdata/ten/pitch.json` holds one pitch estimate per 256-sample hop - /// over the whole 60 s fixture, produced by driving the reference - /// `AUP_PE_proc` (`src/pitch_est.cc`) from the reference `AUP_Analyzer` - /// STFT — the same wiring `AUP_Aed_runOneFrm` uses, so the estimator sees - /// the raw hop and the un-normalized bin powers exactly as it does in the - /// C driver. - /// - /// The two arms reach their bin powers through different FFTs, so they are - /// not bit-identical. Two things are asserted instead: - /// - /// * the **voicing decision** agrees on every frame — the discrete output, - /// and the one a porting error would flip; and - /// * where both call a frame voiced, the estimates agree to well inside f32 - /// rounding. - /// - /// This covers feature `40`. The other 40 features are pinned by - /// [`TenVadFeatureContext`]'s own tests, against an independent host - /// implementation of the mel path. - /// - /// [`TenVadFeatureContext`]: crate::kits::speech::ten_vad::context::TenVadFeatureContext - #[test] - #[serial_test::serial] - fn test_pitch_estimator_reference_golden() -> Result<(), Box> { + /// over the 60 s fixture, produced by driving the reference `AUP_PE_proc` + /// (`src/pitch_est.cc`) from the reference `AUP_Analyzer` STFT — the same + /// wiring `AUP_Aed_runOneFrm` uses, so the estimator sees the raw hop and + /// the un-normalized bin powers exactly as it does in the C driver. + fn golden_pitch_hz(pitch: I) -> Result<(Vec, Vec), Box> + where + I: TenVadPitchSourceInit, + { type B = PerformanceBackend; type F = ::FloatElem; @@ -296,7 +285,7 @@ mod tests { Tensor::::from_floats(&wav_vec[..steps * hop_size], &device) .reshape([steps, 1, hop_size]); - let mut ctx = cfg.try_init_context::(1, TenVadPitchEstimator::new(), &device)?; + let mut ctx = cfg.try_init_context::(1, pitch, &device)?; // [steps, batch=1, n_freq] let feats = ctx.forward_sequence(hop_seq); @@ -310,6 +299,24 @@ mod tests { .map(|t| flat[t * n_freq + N_MELS] * scale + FEATURE_MEANS[N_MELS]) .collect(); + Ok((got, expected)) + } + + /// Asserts a recovered pitch track against the C reference. + /// + /// The two arms reach their bin powers through different FFTs, so they are + /// never bit-identical. Two things are asserted instead: + /// + /// * the **voicing decision** agrees on every frame — the discrete output, + /// and the one a porting error would flip; and + /// * where both call a frame voiced, the estimates agree within + /// `rel_bound`. + fn assert_golden_pitch( + got: &[f32], + expected: &[f32], + rel_bound: f32, + ) { + let steps = expected.len(); let mut voiced_frames = 0usize; for (t, (&g, &e)) in got.iter().zip(expected.iter()).enumerate() { assert_eq!( @@ -321,7 +328,7 @@ mod tests { voiced_frames += 1; let rel = (g - e).abs() / e; assert!( - rel < 1e-4, + rel < rel_bound, "frame {t}: {g} Hz vs reference {e} Hz (rel err {rel})", ); } @@ -333,7 +340,45 @@ mod tests { voiced_frames * 2 > steps, "fixture should be mostly voiced, got {voiced_frames} of {steps}", ); + } + /// Pins the ported host pitch estimator against the ten-vad C reference. + /// + /// This is the anchor of the whole chain: every device stage is validated + /// differentially against the host estimator, and the host estimator is + /// validated here. It covers feature `40`; the other 40 are pinned by + /// [`TenVadFeatureContext`]'s own tests against an independent host + /// implementation of the mel path. + /// + /// [`TenVadFeatureContext`]: crate::kits::speech::ten_vad::context::TenVadFeatureContext + #[test] + #[serial_test::serial] + fn test_pitch_estimator_reference_golden() -> Result<(), Box> { + let (got, expected) = golden_pitch_hz(TenVadPitchEstimator::new())?; + assert_golden_pitch(&got, &expected, 1e-4); + Ok(()) + } + + /// The go/no-go gate for the device-side port: stage 1 on the device, the + /// remaining three stages on the host, measured against the same C golden. + /// + /// The stage-level differential tests establish that the device pre-filter + /// reproduces the host one to a tolerance. They cannot establish that the + /// tolerance survives the tracker's `argmax` and its voicing threshold, + /// which are discrete — a single `argmax` step of ±1 moves the reported + /// pitch by roughly half a percent, some fifty times the bound above. This + /// test answers that, by differing from the pinned pipeline in exactly one + /// stage. + /// + /// The value bound is looser than the host arm's because the device + /// contracts the band projections in `f32` where the host accumulates the + /// autocorrelation in `f64`. The **voicing** bound is not loosened: that is + /// the assertion with teeth. + #[test] + #[serial_test::serial] + fn test_tensor_prefilter_hybrid_reference_golden() -> Result<(), Box> { + let (got, expected) = golden_pitch_hz(HybridPitchInit::new())?; + assert_golden_pitch(&got, &expected, 1e-3); Ok(()) } } From d6af208f9775c5d2db3a4bf7130380d10ec08470 Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Fri, 21 Aug 2026 20:05:13 -0700 Subject: [PATCH 06/32] feat(ten-vad): run the pitch lag search on device Phase 2: the correlation stage, `[rows, exc_len]` excitation history to `[rows, 2, max_period]` normalized correlations plus `[rows, 2]` weights. Taken before the excitation stage deliberately. Stage 3 is **stateless given the history** -- everything it needs lives inside the window the excitation stage maintains, and the correlation *ring* belongs to the tracker rather than here -- so its differential test needs no tensor stage 2 at all: drive the host estimator, read its excitation buffer out through the oracle accessors, upload that, and compare both arms. Genuinely independent, and it means stage 2 lands with a consumer already waiting for it. Three things shaped the implementation: * **The correlation is a shift-and-accumulate, not a matmul.** As `[max_period, half]` windows against a reference vector it would materialize a `rows x 64 x 32` intermediate -- tens of megabytes over a long sequence. As `half` broadcast multiply-adds over `[rows, max_period]` slices it is ~30x smaller, and it accumulates in the reference's order rather than a tree reduce's. * **The lagged energy is not a `cumsum`.** The `max(..., 0)` sits inside the recurrence, so it is not associative and a prefix-sum difference diverges whenever the clamp fires. It stays a 64-step sequential loop, batched across every row, so it costs ~190 tiny kernels for a whole sequence rather than per hop. * **The octave suppression vectorizes exactly**, which was not obvious. The reference rescales `xcorr[lag]` in place while reading three neighbours near `lag/2 + 32`. Those reads turn out to be *always strictly ahead* of the write -- tightest margin 8, at `lag = 46` -- so no iteration ever observes a value an earlier one changed, and reading the unmodified array is equivalent rather than approximate. `SHARPEN_IS_WRITE_SAFE` pins the invariant as a const, and a test re-derives it from the index vectors so a geometry change cannot quietly invalidate it. Also adopts `SileroVad::forward_sequence`'s buffer discipline for the scans in both device stages: write into a preallocated tensor with `slice_assign` instead of collecting slices and concatenating, and -- where the output shares its input's shape, as the log-compression scan does -- cannibalize that input when `B::ad_enabled` is false, so the single live reference can lower to an in-place update. The correlation's sliding energy has no same-shaped input to reuse, but still beats a 64-way `cat`. One test of mine was wrong and is corrected: an impulse train does not peak at lags that are multiples of its period, because the reference window starts at `max_period + base` rather than at the buffer start. The alignment condition is `lag = max_period (mod period)`, which for the ten-vad geometry means lags 4, 24, 44 -- not 0, 20, 40. Tests: 151 across `silero_vad` and `ten_vad` under `--features wgpu`, green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- ...bunsen__kits__speech___silero_ten__vad.xml | 20 + .../ten_vad/context/pitch/tensor/correlate.rs | 635 ++++++++++++++++++ .../ten_vad/context/pitch/tensor/mod.rs | 3 + .../ten_vad/context/pitch/tensor/prefilter.rs | 19 +- 4 files changed, 674 insertions(+), 3 deletions(-) create mode 100644 .idea/runConfigurations/Test__wgpu__bunsen__kits__speech___silero_ten__vad.xml create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/correlate.rs diff --git a/.idea/runConfigurations/Test__wgpu__bunsen__kits__speech___silero_ten__vad.xml b/.idea/runConfigurations/Test__wgpu__bunsen__kits__speech___silero_ten__vad.xml new file mode 100644 index 00000000..7f0e78a7 --- /dev/null +++ b/.idea/runConfigurations/Test__wgpu__bunsen__kits__speech___silero_ten__vad.xml @@ -0,0 +1,20 @@ + + + + \ No newline at end of file diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/correlate.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/correlate.rs new file mode 100644 index 00000000..79215ee7 --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/correlate.rs @@ -0,0 +1,635 @@ +//! # Stage 3: the normalized lag search, on device. +//! +//! Maps one hop's excitation history to the two half-hop correlation slots it +//! contributes, plus the energies that weight them: +//! +//! ```text +//! exc [rows, exc_len] -> xcorr [rows, 2, max_period], energy [rows, 2] +//! ``` +//! +//! **Stateless given the history.** Everything this stage needs is inside the +//! `exc_len`-sample window the excitation stage maintains, so it carries +//! nothing of its own — the correlation *ring* belongs to the tracker, which +//! consumes a sliding window of these slots. That is why this stage can be +//! built and tested before the excitation stage exists: drive the host, +//! read its excitation buffer out, and correlate that. +//! +//! ## Per half-hop +//! +//! ```text +//! inst[lag] = Σ_{j usize { + MAX_PERIOD_16KHZ / PROC_RESAMPLE_RATE + } + + /// The shortest candidate period, in samples at the correlation rate. + pub fn min_period(&self) -> usize { + MIN_PERIOD_16KHZ / PROC_RESAMPLE_RATE + } + + /// The correlation window length: half a decimated hop. + pub fn half_hop(&self) -> usize { + self.hop_size / (PROC_RESAMPLE_RATE * SUBS_PER_HOP) + } + + /// The excitation history length this stage reads. + pub fn exc_len(&self) -> usize { + self.max_period() + self.hop_size.div_ceil(PROC_RESAMPLE_RATE) + 1 + } + + /// How many lags the octave suppression rescales. + pub fn sharpen_len(&self) -> usize { + self.max_period() - SUBS_PER_HOP * self.min_period() + } + + /// Validates the geometry. + /// + /// # Errors + /// [`BunsenError::Invalid`] if the hop does not divide into whole half-hops + /// at the decimated rate, or if the resulting search range is empty. + pub fn validate(&self) -> BunsenResult<()> { + let divisor = PROC_RESAMPLE_RATE * SUBS_PER_HOP; + if self.hop_size == 0 || !self.hop_size.is_multiple_of(divisor) { + return Err(BunsenError::Invalid(format!( + "PitchCorrelate hop_size ({}) must be a non-zero multiple of {divisor}", + self.hop_size, + ))); + } + if self.max_period() <= SUBS_PER_HOP * self.min_period() { + return Err(BunsenError::Invalid(format!( + "PitchCorrelate period range ({}..{}) leaves nothing to sharpen", + self.min_period(), + self.max_period(), + ))); + } + Ok(()) + } + + /// The three neighbour indices the octave suppression compares each lag + /// against, as `(a, b, c)` vectors of length + /// [`sharpen_len`](Self::sharpen_len). + /// + /// Exposed so a test can check the write-safety invariant the vectorized + /// form depends on. + pub fn to_vec_sharpen_indices(&self) -> (Vec, Vec, Vec) { + let max_period = self.max_period(); + let n = self.sharpen_len(); + let mut a = Vec::with_capacity(n); + let mut b = Vec::with_capacity(n); + let mut c = Vec::with_capacity(n); + for lag in 0..n { + a.push(((max_period + lag) / 2) as i32); + b.push(((max_period + lag + 2) / 2) as i32); + c.push(((max_period + lag - 1) / 2) as i32); + } + (a, b, c) + } + + /// Builds the stage. + /// + /// # Errors + /// + /// See [`validate`](Self::validate). + pub fn try_init( + &self, + device: &B::Device, + ) -> BunsenResult> { + self.validate()?; + let (a, b, c) = self.to_vec_sharpen_indices(); + + Ok(PitchCorrelate { + hop_size: self.hop_size, + max_period: self.max_period(), + min_period: self.min_period(), + half_hop: self.half_hop(), + exc_len: self.exc_len(), + sharpen_a: Tensor::from_ints(a.as_slice(), device), + sharpen_b: Tensor::from_ints(b.as_slice(), device), + sharpen_c: Tensor::from_ints(c.as_slice(), device), + }) + } + + /// Builds the stage, panicking on error. + pub fn init( + &self, + device: &B::Device, + ) -> PitchCorrelate { + self.try_init(device).ok_or_panic() + } +} + +/// The normalized lag-search stage. +/// +/// Stateless, so one instance serves any number of streams. Built by +/// [`PitchCorrelateConfig::try_init`]. +#[derive(Debug, Clone)] +pub struct PitchCorrelate { + hop_size: usize, + max_period: usize, + min_period: usize, + half_hop: usize, + exc_len: usize, + + sharpen_a: Tensor, + sharpen_b: Tensor, + sharpen_c: Tensor, +} + +impl PitchCorrelate { + /// The hop size, in samples at 16 kHz. + pub fn hop_size(&self) -> usize { + self.hop_size + } + + /// The longest candidate period, at the correlation rate. + pub fn max_period(&self) -> usize { + self.max_period + } + + /// The shortest candidate period, at the correlation rate. + pub fn min_period(&self) -> usize { + self.min_period + } + + /// The excitation history length this stage reads. + pub fn exc_len(&self) -> usize { + self.exc_len + } + + /// Correlates both half-hops of every row. + /// + /// # Arguments + /// * `exc`: `[rows, exc_len]` decimated excitation history, newest last — + /// the state the excitation stage holds after appending a hop. + /// + /// # Returns + /// `(xcorr, energy)`, shaped `[rows, 2, max_period]` and `[rows, 2]`. The + /// slot axis is in stream order: index `0` is the earlier half-hop. + /// + /// # Panics + /// If `exc`'s trailing axis is not [`exc_len`](Self::exc_len). + pub fn forward( + &self, + exc: Tensor, + ) -> (Tensor, Tensor) { + let [rows, len] = exc.dims(); + assert_eq!( + len, self.exc_len, + "PitchCorrelate expects {} excitation samples", + self.exc_len, + ); + + let squared = exc.clone() * exc.clone(); + + let mut slots = Vec::with_capacity(SUBS_PER_HOP); + let mut energies = Vec::with_capacity(SUBS_PER_HOP); + for sub in 0..SUBS_PER_HOP { + let (xcorr, energy) = self.correlate_sub(&exc, &squared, sub, rows); + slots.push(xcorr.unsqueeze_dim::<3>(1)); + energies.push(energy); + } + + (Tensor::cat(slots, 1), Tensor::cat(energies, 1)) + } + + /// One half-hop's correlations and reference energy. + fn correlate_sub( + &self, + exc: &Tensor, + squared: &Tensor, + sub: usize, + rows: usize, + ) -> (Tensor, Tensor) { + let base = sub * self.half_hop; + let max_period = self.max_period; + let half = self.half_hop; + let device = exc.device(); + + // Shift-and-accumulate, in the reference's `j` order. Each step is one + // broadcast multiply-add over `[rows, max_period]`. + let mut inst: Tensor = Tensor::zeros([rows, max_period], &device); + for j in 0..half { + let at = max_period + base + j; + let reference = exc.clone().slice_dim(1, at as isize..(at + 1) as isize); + let lagged = exc + .clone() + .slice_dim(1, (base + j) as isize..(base + j + max_period) as isize); + inst = inst + lagged * reference; + } + + // The reference window's energy, which is also this slot's weight in + // the tracker. + let reference_energy = squared + .clone() + .slice_dim( + 1, + (max_period + base) as isize..(max_period + base + half) as isize, + ) + .sum_dim(1); + + let lagged_energy = self.sliding_energy(squared, base); + + let denominator = + (lagged_energy + reference_energy.clone().add_scalar(1.0f32)).clamp_min(1e-12f32); + let xcorr = inst.mul_scalar(2.0f32) / denominator; + + (self.suppress_octaves(xcorr), reference_energy) + } + + /// The energy under each lagged window, as a clamped sliding sum. + /// + /// Sequential by necessity: the `max(…, 0)` inside the recurrence makes it + /// non-associative, so no prefix-sum reformulation reproduces it. + /// + /// Written into a preallocated `[rows, max_period]` buffer rather than + /// collected and concatenated: there is no same-shaped input to + /// cannibalize here, but `slice_assign` still beats a 64-way `cat` of + /// `[rows, 1]` slices. + fn sliding_energy( + &self, + squared: &Tensor, + base: usize, + ) -> Tensor { + let half = self.half_hop; + let rows = squared.dims()[0]; + + let mut running = squared + .clone() + .slice_dim(1, base as isize..(base + half) as isize) + .sum_dim(1); + + let mut out: Tensor = Tensor::zeros([rows, self.max_period], &squared.device()); + out = out.slice_assign(s![.., 0..1], running.clone()); + + for lag in 1..self.max_period { + let leaving = squared + .clone() + .slice_dim(1, (base + lag - 1) as isize..(base + lag) as isize); + let entering = squared.clone().slice_dim( + 1, + (base + lag + half - 1) as isize..(base + lag + half) as isize, + ); + running = (running - leaving).clamp_min(0.0f32) + entering; + out = out.slice_assign(s![.., lag..lag + 1], running.clone()); + } + + out + } + + /// Discounts lags that fail to clearly beat their own half-lag + /// neighbourhood, which is where period doubling shows up. + /// + /// Vectorized against the unmodified input, which is exact: see the module + /// docs and [`SHARPEN_IS_WRITE_SAFE`]. + fn suppress_octaves( + &self, + xcorr: Tensor, + ) -> Tensor { + let sharpen_len = self.max_period - SUBS_PER_HOP * self.min_period; + + let rival = xcorr + .clone() + .select(1, self.sharpen_a.clone()) + .max_pair(xcorr.clone().select(1, self.sharpen_b.clone())) + .max_pair(xcorr.clone().select(1, self.sharpen_c.clone())); + + let head = xcorr.clone().slice_dim(1, 0..sharpen_len as isize); + let doubled = head.clone().lower(rival.mul_scalar(1.1f32)); + let discounted = head.clone().mul_scalar(0.8f32); + + Tensor::cat( + vec![ + head.mask_where(doubled, discounted), + xcorr.slice_dim(1, sharpen_len as isize..), + ], + 1, + ) + } +} + +/// Pins the invariant the vectorized octave suppression relies on. +/// +/// The reference rescales `xcorr[lag]` in place while reading neighbours; this +/// is `true` only if every such read is strictly ahead of the write, so that +/// no iteration can observe a value an earlier one changed. +pub const SHARPEN_IS_WRITE_SAFE: bool = { + let max_period = MAX_PERIOD_16KHZ / PROC_RESAMPLE_RATE; + let min_period = MIN_PERIOD_16KHZ / PROC_RESAMPLE_RATE; + let sharpen_len = max_period - SUBS_PER_HOP * min_period; + + let mut lag = 0; + let mut safe = true; + while lag < sharpen_len { + // The smallest of the three neighbour indices. + let lowest = (max_period + lag - 1) / 2; + if lowest <= lag || lowest >= max_period { + safe = false; + } + lag += 1; + } + safe +}; + +#[cfg(test)] +mod tests { + use burn::tensor::Tolerance; + + use super::{ + super::super::{ + TenVadPitchEstimator, + TenVadPitchScalarSource, + }, + *, + }; + use crate::{ + prelude::*, + support::testing::PerformanceBackend, + }; + + type B = PerformanceBackend; + + const HOP: usize = 256; + const N_BINS: usize = 513; + + fn config() -> PitchCorrelateConfig { + PitchCorrelateConfig::new() + } + + /// A glottal-like pulse train at the reference's int16 scale. + fn pulse_hop( + f0: f32, + at: usize, + ) -> Vec { + let period = 16000.0 / f0; + (0..HOP) + .map(|i| { + let pos = (at + i) as f32 % period; + 8000.0 * (-pos / (period * 0.08)).exp() + }) + .collect() + } + + /// A plausible hop spectrum; the correlation stage only needs the + /// excitation to have structure, not for the spectrum to be its transform. + fn spectrum(step: usize) -> Vec { + (0..N_BINS) + .map(|k| { + let k = k as f32; + 1e7 * (-k / 70.0).exp() * (1.0 + 0.4 * (k * 0.05 + step as f32 * 0.3).sin()) + }) + .collect() + } + + /// Drives the host estimator and captures, per hop, the excitation history + /// it correlated and the two slots it produced. + fn host_reference(steps: usize) -> (Vec, Vec, Vec) { + let mut est = TenVadPitchEstimator::new(); + let slots = est.slots(); + + let mut exc = Vec::new(); + let mut xcorr = Vec::new(); + let mut energy = Vec::new(); + + for step in 0..steps { + est.frame_pitch(&pulse_hop(150.0, step * HOP), &spectrum(step)); + + exc.extend_from_slice(est.exc_buf()); + // After the call, stream slots `slots-2` and `slots-1` are the two + // this hop just wrote, in order. + for sub in 0..SUBS_PER_HOP { + xcorr.extend_from_slice(est.xcorr_slot(slots - SUBS_PER_HOP + sub)); + energy.push(est.frm_weight()[slots - SUBS_PER_HOP + sub]); + } + } + (exc, xcorr, energy) + } + + #[test] + fn test_config_meta() { + let cfg = config(); + assert_eq!(cfg.hop_size, 256); + assert_eq!(cfg.max_period(), 64); + assert_eq!(cfg.min_period(), 8); + assert_eq!(cfg.half_hop(), 32); + assert_eq!(cfg.exc_len(), 129); + assert_eq!(cfg.sharpen_len(), 48); + assert!(cfg.validate().is_ok()); + } + + #[test] + fn test_validate_rejects_bad_geometry() { + assert!(config().with_hop_size(0).validate().is_err()); + // Not a whole number of half-hops once decimated. + assert!(config().with_hop_size(100).validate().is_err()); + assert!(config().with_hop_size(256).validate().is_ok()); + } + + #[test] + fn test_init_meta_matches_config() { + let device = Default::default(); + let stage: PitchCorrelate = config().init(&device); + assert_eq!(stage.hop_size(), 256); + assert_eq!(stage.max_period(), 64); + assert_eq!(stage.min_period(), 8); + assert_eq!(stage.exc_len(), 129); + } + + #[test] + fn test_octave_suppression_reads_are_write_safe() { + // The whole vectorized form rests on this. Checked as a const, and + // re-derived here so a geometry change cannot quietly invalidate it. + assert!(SHARPEN_IS_WRITE_SAFE); + + let cfg = config(); + let (a, b, c) = cfg.to_vec_sharpen_indices(); + for lag in 0..cfg.sharpen_len() { + let lowest = a[lag].min(b[lag]).min(c[lag]); + assert!( + lowest as usize > lag, + "lag {lag} reads neighbour {lowest}, which it may already have written", + ); + let highest = a[lag].max(b[lag]).max(c[lag]) as usize; + assert!(highest < cfg.max_period(), "lag {lag} reads out of range"); + } + } + + #[test] + fn test_forward_matches_host_stage() { + // The differential test: feed the device exactly the excitation the + // host correlated, and compare both outputs. + let device = Default::default(); + let stage: PitchCorrelate = config().init(&device); + let steps = 12; + let (exc, want_xcorr, want_energy) = host_reference(steps); + + let exc_t = + Tensor::::from_floats(exc.as_slice(), &device).reshape([steps, stage.exc_len()]); + let (got_xcorr, got_energy) = stage.forward(exc_t); + + assert_eq!(got_xcorr.dims(), [steps, SUBS_PER_HOP, stage.max_period()]); + assert_eq!(got_energy.dims(), [steps, SUBS_PER_HOP]); + + got_xcorr.to_data().assert_approx_eq::( + &TensorData::new(want_xcorr, [steps, SUBS_PER_HOP, stage.max_period()]), + Tolerance::relative(1e-4), + ); + got_energy.to_data().assert_approx_eq::( + &TensorData::new(want_energy, [steps, SUBS_PER_HOP]), + Tolerance::relative(1e-4), + ); + } + + #[test] + fn test_forward_batches_rows_independently() { + let device = Default::default(); + let stage: PitchCorrelate = config().init(&device); + let steps = 6; + let (exc, _, _) = host_reference(steps); + + let batched = + Tensor::::from_floats(exc.as_slice(), &device).reshape([steps, stage.exc_len()]); + let (batched_xcorr, batched_energy) = stage.forward(batched.clone()); + + for row in 0..steps { + let solo = batched + .clone() + .slice_dim(0, row as isize..(row + 1) as isize); + let (solo_xcorr, solo_energy) = stage.forward(solo); + + batched_xcorr + .clone() + .slice_dim(0, row as isize..(row + 1) as isize) + .to_data() + .assert_approx_eq::(&solo_xcorr.to_data(), Tolerance::permissive()); + batched_energy + .clone() + .slice_dim(0, row as isize..(row + 1) as isize) + .to_data() + .assert_approx_eq::(&solo_energy.to_data(), Tolerance::permissive()); + } + } + + #[test] + fn test_silence_is_quiet_and_finite() { + // The `1 +` in the denominator is what keeps an all-zero excitation + // from dividing zero by zero into a confident peak. + let device = Default::default(); + let stage: PitchCorrelate = config().init(&device); + + let (xcorr, energy) = stage.forward(Tensor::::zeros([1, stage.exc_len()], &device)); + + let flat: Vec = xcorr.to_data_as::().to_vec_as::().unwrap(); + for (i, v) in flat.iter().enumerate() { + assert!(v.is_finite(), "xcorr[{i}] = {v}"); + assert_eq!(*v, 0.0, "silence should correlate to zero, got {v}"); + } + assert_eq!(energy.sum().into_scalar().elem::(), 0.0); + } + + #[test] + fn test_a_periodic_excitation_peaks_at_its_period() { + // A sanity check that the stage measures what it claims to: an + // impulse train at period p should score highest near lag p. + let device = Default::default(); + let stage: PitchCorrelate = config().init(&device); + let period = 20usize; + + let exc: Vec = (0..stage.exc_len()) + .map(|i| if i % period == 0 { 1000.0 } else { 0.0 }) + .collect(); + + let exc_t = + Tensor::::from_floats(exc.as_slice(), &device).reshape([1, stage.exc_len()]); + let (xcorr, _) = stage.forward(exc_t); + + // Slot 0, searching lags that are multiples of the period. + let row: Vec = xcorr + .slice_dim(1, 0..1) + .to_data_as::() + .to_vec_as::() + .unwrap(); + + let best = row + .iter() + .enumerate() + .skip(stage.min_period()) + .max_by(|a, b| a.1.partial_cmp(b.1).unwrap()) + .map(|(i, _)| i) + .unwrap(); + + // The reference window starts at `max_period + base`, not at the start + // of the buffer, so two impulse trains align when + // `max_period - lag ≡ 0 (mod period)` — not when `lag` itself is a + // multiple of the period. + assert_eq!( + best % period, + stage.max_period() % period, + "peak at lag {best} does not align the reference window against \ + a pulse (period {period}, reference offset {})", + stage.max_period(), + ); + } +} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs index 625a217e..94bff490 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs @@ -53,12 +53,15 @@ //! bit-exact equality: a dev may be on a backend that enables fast-math, where //! exact equality against a host scalar reference cannot hold. +pub mod correlate; pub mod prefilter; pub mod tables; #[cfg(test)] pub(crate) mod hybrid; +#[doc(inline)] +pub use correlate::*; #[doc(inline)] pub use prefilter::*; #[doc(inline)] diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/prefilter.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/prefilter.rs index fca2eb88..5931d6a0 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/prefilter.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/prefilter.rs @@ -193,6 +193,14 @@ impl PitchPrefilter { /// That differs from the host's `f32::log10` by a ULP or so — well inside /// this stage's tolerance, and the `10^x` on the way back out is spelled /// as its matching inverse, `exp(x·ln(10))`. + /// + /// The scan writes into a preallocated buffer rather than collecting 18 + /// slices and concatenating. When autodiff is off it cannibalizes its own + /// input, which is the same shape — following + /// [`SileroVad::forward_sequence`](crate::kits::speech::silero_vad::SileroVad::forward_sequence), + /// where the single live reference lets `slice_assign` lower to an in-place + /// update. With autodiff on it accumulates into a fresh tensor instead, so + /// the graph stays intact. fn log_compress( &self, bands: Tensor, @@ -202,7 +210,12 @@ impl PitchPrefilter { let mut log_max: Tensor = Tensor::full([rows, 1], -2.0, &device); let mut follow: Tensor = Tensor::full([rows, 1], -2.0, &device); - let mut out = Vec::with_capacity(NB_BANDS); + + let mut out = if B::ad_enabled(&device) { + Tensor::zeros_like(&bands) + } else { + bands.clone() + }; for band in 0..NB_BANDS { let raw = bands @@ -220,10 +233,10 @@ impl PitchPrefilter { log_max = log_max.max_pair(ly.clone()); follow = decayed.max_pair(ly.clone()); - out.push(ly); + out = out.slice_assign(s![.., band..band + 1], ly); } - Tensor::cat(out, 1) + out } /// The `-40 dB` noise floor on lag zero, then the lag window. From 0bb8556cc9c308defdce57a7246282a58cfe5ecb Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Fri, 21 Aug 2026 20:40:43 -0700 Subject: [PATCH 07/32] feat(ten-vad): add the anti-alias filter in two tensor formulations The first half of the excitation stage, and the one part of the pitch estimator with two genuinely different tensor formulations -- so it is where the reference and optimized tiers diverge. Every other stage has a single natural translation, which is why the tier distinction lives here rather than as a discriminant on the whole pipeline. `PitchAntiAliasConfig` is a `#[derive(Config)]` enum, following `StftWindowConfig`'s idiom of an enum config dispatching to concrete formulations: * `Recurrence` -- the exact five-section IIR, transcribed literally. Batched across streams but not across samples, so `5 x samples` dependent steps. Written for legibility rather than speed: it exists to answer the one question the optimized tier cannot, namely whether the *translation* is right. Comparing only against the host would conflate a translation error with the deliberate approximation. * `TruncatedFir { taps }` -- the same LTI system as a truncated impulse response with decimation folded into the kernel, evaluated as one GEMM. The default, at 2048 taps. The response is generated by driving the exact host `BiquadCascade` with a unit impulse, so truncation is the *only* difference between the tiers. And it is not a speed-for-accuracy trade: measured against an f64 ground truth the truncated FIR is 25-50% *more* accurate than the host's own f32 DF-II cascade, because it is a better-conditioned realization of the same filter rather than an approximation of it. A GEMM rather than a `conv1d` because `burn-flex`'s depthwise path fires at `channels == groups == 1` and parallelizes over `batch x channels` -- which is then one task, so a 2048-tap convolution would run single-threaded. **Two geometry findings.** The carry is `taps - 1`, not `window - hop`. Those differ by the decimation factor; using the latter would both drop the three oldest taps and desync the carry from the Toeplitz offset. Both now derive from one constant, and a test pins the distinction. More seriously: **`unfold` derives its batch-row stride from the span the windows cover, `(steps-1)*step + size`, rather than from the row's real length.** When a row is longer than that span -- as it is here, by three samples -- every row after the first is placed early, silently. Row 0 came out bit-exact while row 1 was garbage, which is exactly the shape of bug that survives a batch-1 test suite. `run_fir` trims to the covered span before unfolding; the trimmed tail is still carried forward. `test_unfold_row_stride_assumption` pins the underlying behaviour so that a future burn fixing it fails loudly rather than leaving dead defensive code, and `test_batch_row_offset_regression` pins the symptom at the real geometry. Cross-tests: each tier against the exact host cascade, the two tiers against each other, streaming against a single call for both, and batch rows against solo runs. Tests: 163 across `silero_vad` and `ten_vad` under `--features wgpu`, green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../ten_vad/context/pitch/tensor/antialias.rs | 800 ++++++++++++++++++ .../ten_vad/context/pitch/tensor/mod.rs | 3 + 2 files changed, 803 insertions(+) create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/antialias.rs diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/antialias.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/antialias.rs new file mode 100644 index 00000000..1a692ae1 --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/antialias.rs @@ -0,0 +1,800 @@ +//! # The anti-alias filter, in two tensor formulations. +//! +//! The reference decimates 16 kHz to 4 kHz, and guards the new Nyquist with a +//! five-section Direct-Form-II IIR cascade. This is the one part of the pitch +//! estimator with two genuinely different tensor formulations, so it is where +//! the reference and optimized tiers diverge — every other stage has a single +//! natural translation. +//! +//! ```text +//! [batch, steps·hop] -> [batch, steps·hop/4] +//! ``` +//! +//! ## [`PitchAntiAliasConfig::Recurrence`] — the reference tier +//! +//! The cascade transcribed literally: five sections, each stepping sample by +//! sample through `w0 = x − a₁w₁ − a₂w₂`. Batched across streams but **not** +//! across samples — the recurrence forbids it — so it costs `5 × samples` +//! sequential steps. At 256 samples per hop that is 1280 steps per hop, which +//! is fine for a short equivalence check and hopeless for a real workload. +//! +//! It exists to answer one question the optimized tier cannot: *was the +//! algorithm translated correctly?* Comparing only against the host would +//! conflate a translation error with the deliberate approximation below. +//! +//! ## [`PitchAntiAliasConfig::TruncatedFir`] — the default +//! +//! The same LTI system realized as a truncated impulse response, with the ×4 +//! decimation folded into the kernel, evaluated as one GEMM: +//! +//! ```text +//! y[m] = Σ_{k Self { + Self::TruncatedFir { + taps: DEFAULT_FIR_TAPS, + } + } +} + +impl PitchAntiAliasConfig { + /// Validates the geometry against a hop size. + /// + /// # Errors + /// [`BunsenError::Invalid`] if the hop does not decimate evenly, or if a + /// truncated response is too short to cover one hop. + pub fn validate( + &self, + hop_size: usize, + ) -> BunsenResult<()> { + if hop_size == 0 || !hop_size.is_multiple_of(PROC_RESAMPLE_RATE) { + return Err(BunsenError::Invalid(format!( + "PitchAntiAlias hop_size ({hop_size}) must be a non-zero multiple of \ + {PROC_RESAMPLE_RATE}", + ))); + } + if let Self::TruncatedFir { taps } = self + && *taps <= PROC_RESAMPLE_RATE + { + return Err(BunsenError::Invalid(format!( + "PitchAntiAlias taps ({taps}) must exceed the decimation factor \ + {PROC_RESAMPLE_RATE}", + ))); + } + Ok(()) + } + + /// How many samples of input history the filter carries between calls. + /// + /// Zero for [`Self::Recurrence`], whose state is the sections' delay + /// registers rather than past samples. + pub fn carry_len(&self) -> usize { + match self { + Self::Recurrence => 0, + Self::TruncatedFir { taps } => taps - 1, + } + } + + /// The window length one hop's outputs are contracted from. + /// + /// Meaningful only for [`Self::TruncatedFir`]. + pub fn window_len( + &self, + hop_size: usize, + ) -> usize { + match self { + Self::Recurrence => hop_size, + Self::TruncatedFir { taps } => hop_size + taps - PROC_RESAMPLE_RATE, + } + } + + /// The exact cascade's impulse response, `taps` long. + /// + /// Generated by driving the host [`BiquadCascade`] with a unit impulse, so + /// the FIR is the same filter and truncation is the only difference. + pub fn to_vec_impulse_response( + &self, + taps: usize, + ) -> Vec { + let mut cascade = exact_cascade(); + let mut buf = vec![0.0f32; taps]; + buf[0] = 1.0; + cascade.process_in_place(&mut buf); + buf + } + + /// The `[window_len, hop_size / 4]` decimating Toeplitz matrix, row-major. + /// + /// Column `m` is the impulse response positioned so the contraction lands + /// output `m` at input phase `4m`. + pub fn to_vec_toeplitz( + &self, + hop_size: usize, + ) -> Vec { + let Self::TruncatedFir { taps } = self else { + return Vec::new(); + }; + let response = self.to_vec_impulse_response(*taps); + let carry = taps - 1; + let rows = self.window_len(hop_size); + let cols = hop_size / PROC_RESAMPLE_RATE; + + let mut out = vec![0.0f32; rows * cols]; + for m in 0..cols { + let head = PROC_RESAMPLE_RATE * m + carry; + for j in 0..rows { + if head >= j && head - j < *taps { + out[j * cols + m] = response[head - j]; + } + } + } + out + } + + /// Builds the filter. + /// + /// # Errors + /// + /// See [`validate`](Self::validate). + pub fn try_init( + &self, + hop_size: usize, + device: &B::Device, + ) -> BunsenResult> { + self.validate(hop_size)?; + Ok(match self { + Self::Recurrence => PitchAntiAlias::Recurrence { hop_size }, + Self::TruncatedFir { taps } => { + let rows = self.window_len(hop_size); + let cols = hop_size / PROC_RESAMPLE_RATE; + PitchAntiAlias::TruncatedFir { + hop_size, + taps: *taps, + toeplitz: Tensor::from_data( + TensorData::new(self.to_vec_toeplitz(hop_size), [rows, cols]), + device, + ), + } + } + }) + } + + /// Builds the filter, panicking on error. + pub fn init( + &self, + hop_size: usize, + device: &B::Device, + ) -> PitchAntiAlias { + self.try_init(hop_size, device).ok_or_panic() + } +} + +/// The host cascade with the reference's 4 kHz coefficients. +fn exact_cascade() -> BiquadCascade { + BiquadCascade::from_tables(&ANTI_ALIAS_B_4KHZ, &ANTI_ALIAS_A_4KHZ, &ANTI_ALIAS_G_4KHZ) +} + +/// A realized anti-alias filter. Built by [`PitchAntiAliasConfig::try_init`]. +#[derive(Debug, Clone)] +pub enum PitchAntiAlias { + /// The exact sample-sequential cascade. + Recurrence { + /// The hop size, in samples at 16 kHz. + hop_size: usize, + }, + + /// The truncated-FIR GEMM. + TruncatedFir { + /// The hop size, in samples at 16 kHz. + hop_size: usize, + /// The impulse-response length. + taps: usize, + /// `[window_len, hop_size / 4]` decimating kernel. + toeplitz: Tensor, + }, +} + +/// The state an anti-alias filter carries between calls. +/// +/// The two formulations carry genuinely different things — delay registers +/// versus past samples — which is why this is an enum rather than a buffer. +#[derive(Debug, Clone)] +pub enum PitchAntiAliasState { + /// `[batch, sections, 2]` per-section `(w1, w2)` delay registers. + Recurrence { + /// The delay registers. + registers: Tensor, + }, + + /// `[batch, taps - 1]` of past input. + TruncatedFir { + /// The retained input history. + history: Tensor, + }, +} + +impl PitchAntiAlias { + /// The hop size, in samples at 16 kHz. + pub fn hop_size(&self) -> usize { + match self { + Self::Recurrence { hop_size } | Self::TruncatedFir { hop_size, .. } => *hop_size, + } + } + + /// A zeroed start-of-stream state for `batch_size` streams. + pub fn init_state( + &self, + batch_size: usize, + device: &B::Device, + ) -> PitchAntiAliasState { + match self { + Self::Recurrence { .. } => PitchAntiAliasState::Recurrence { + registers: Tensor::zeros([batch_size, ANTI_ALIAS_SECTIONS, 2], device), + }, + Self::TruncatedFir { taps, .. } => PitchAntiAliasState::TruncatedFir { + history: Tensor::zeros([batch_size, taps - 1], device), + }, + } + } + + /// Filters and decimates a run of whole hops. + /// + /// # Arguments + /// * `input`: `[batch, steps · hop_size]` samples at 16 kHz. + /// * `state`: the carried filter state, from a previous call or + /// [`init_state`](Self::init_state). + /// + /// # Returns + /// `[batch, steps · hop_size / 4]` decimated output, and the state to + /// carry forward. + /// + /// # Panics + /// If `input` is not a whole number of hops, or if `state` is the other + /// formulation's. + pub fn forward( + &self, + input: Tensor, + state: PitchAntiAliasState, + ) -> (Tensor, PitchAntiAliasState) { + let [_, len] = input.dims(); + assert!( + len > 0 && len.is_multiple_of(self.hop_size()), + "PitchAntiAlias expects whole hops, got {len} samples", + ); + + match (self, state) { + (Self::Recurrence { .. }, PitchAntiAliasState::Recurrence { registers }) => { + let (filtered, registers) = Self::run_recurrence(input, registers); + // The reference samples every 4th filtered sample, and every + // hop starts on a multiple of 4, so a stride over the whole + // stream is the same as striding each hop. + ( + filtered.slice(s![.., ..;PROC_RESAMPLE_RATE as isize]), + PitchAntiAliasState::Recurrence { registers }, + ) + } + ( + Self::TruncatedFir { + hop_size, + taps, + toeplitz, + }, + PitchAntiAliasState::TruncatedFir { history }, + ) => { + let (out, history) = + Self::run_fir(input, history, toeplitz.clone(), *hop_size, *taps); + (out, PitchAntiAliasState::TruncatedFir { history }) + } + _ => panic!("PitchAntiAlias state does not match its formulation"), + } + } + + /// The exact cascade, section by section, sample by sample. + /// + /// Deliberately written for legibility rather than speed: this is the tier + /// whose job is to be obviously the reference algorithm. It reads from one + /// buffer and writes to a fresh one per section, with no aliasing tricks. + fn run_recurrence( + input: Tensor, + registers: Tensor, + ) -> (Tensor, Tensor) { + let [batch, len] = input.dims(); + let device = input.device(); + + let mut signal = input; + let mut next_registers = Vec::with_capacity(ANTI_ALIAS_SECTIONS); + + for section in 0..ANTI_ALIAS_SECTIONS { + let BiquadSection { b, a, g } = BiquadSection { + b: ANTI_ALIAS_B_4KHZ[section], + a: ANTI_ALIAS_A_4KHZ[section], + g: ANTI_ALIAS_G_4KHZ[section], + }; + + let sect = section as isize; + let mut w1 = registers + .clone() + .slice(s![.., sect..sect + 1, 0..1]) + .reshape([batch, 1]); + let mut w2 = registers + .clone() + .slice(s![.., sect..sect + 1, 1..2]) + .reshape([batch, 1]); + + let mut out: Tensor = Tensor::zeros([batch, len], &device); + for n in 0..len { + let n = n as isize; + let x = signal.clone().slice_dim(1, n..n + 1); + + let w0 = x - w1.clone().mul_scalar(a[1]) - w2.clone().mul_scalar(a[2]); + let y = (w0.clone().mul_scalar(b[0]) + + w1.clone().mul_scalar(b[1]) + + w2.clone().mul_scalar(b[2])) + .mul_scalar(g); + + w2 = w1; + w1 = w0; + out = out.slice_assign(s![.., n..n + 1], y); + } + + signal = out; + next_registers.push(Tensor::cat(vec![w1, w2], 1).unsqueeze_dim::<3>(1)); + } + + (signal, Tensor::cat(next_registers, 1)) + } + + /// The truncated response, contracted against windowed input. + fn run_fir( + input: Tensor, + history: Tensor, + toeplitz: Tensor, + hop_size: usize, + taps: usize, + ) -> (Tensor, Tensor) { + let [batch, len] = input.dims(); + let steps = len / hop_size; + let carry = taps - 1; + let window = hop_size + taps - PROC_RESAMPLE_RATE; + let per_hop = hop_size / PROC_RESAMPLE_RATE; + + // The carried history in front, so window `s` starts `carry` samples + // before hop `s`. + let extended = Tensor::cat(vec![history, input], 1); + + // `unfold` derives its batch-row stride from the span the windows + // cover, `(steps - 1) * hop + window`, rather than from the row's real + // length. When a row is longer than that span — which it is here, by + // `carry - (window - hop)` samples — every row after the first is + // placed that many samples early, silently. Trimming to the covered + // span first makes the two agree. The trimmed tail is not lost: it is + // still part of the carry below. + // + // `tests::test_unfold_row_stride_assumption` pins this; if a future + // burn fixes the stride, that test fails loudly rather than leaving + // dead defensive code. + let covered = (steps - 1) * hop_size + window; + + // [batch, steps, window] + let windows = extended + .clone() + .slice_dim(1, 0..covered as isize) + .unfold::<3, _>(1, window, hop_size); + + let out = windows + .reshape([batch * steps, window]) + .matmul(toeplitz) + .reshape([batch, steps * per_hop]); + + let next_history = extended.slice_dim(1, -(carry as isize)..); + + (out, next_history) + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::Tolerance; + + use super::*; + use crate::{ + prelude::*, + support::testing::PerformanceBackend, + }; + + type B = PerformanceBackend; + + const HOP: usize = 256; + + /// The host cascade applied to a signal, then decimated — the thing both + /// tiers must reproduce. + fn host_filter_decimate(signal: &[f32]) -> Vec { + let mut buf = signal.to_vec(); + exact_cascade().process_in_place(&mut buf); + buf.iter().step_by(PROC_RESAMPLE_RATE).copied().collect() + } + + fn signal(len: usize) -> Vec { + (0..len) + .map(|i| { + let t = i as f32; + 3000.0 * (t * 0.05).sin() + 1500.0 * (t * 0.41).cos() + 800.0 * (t * 1.7).sin() + }) + .collect() + } + + fn run( + cfg: PitchAntiAliasConfig, + input: &[f32], + device: &B2::Device, + ) -> Vec { + let filter = cfg.init::(HOP, device); + let state = filter.init_state(1, device); + let tensor = Tensor::::from_floats(input, device).reshape([1, input.len()]); + let (out, _) = filter.forward(tensor, state); + out.to_data_as::().to_vec_as::().unwrap() + } + + #[test] + fn test_default_is_the_truncated_fir() { + assert_eq!( + PitchAntiAliasConfig::default(), + PitchAntiAliasConfig::TruncatedFir { + taps: DEFAULT_FIR_TAPS + }, + ); + } + + #[test] + fn test_validate_rejects_bad_geometry() { + let fir = PitchAntiAliasConfig::default(); + assert!(fir.validate(HOP).is_ok()); + assert!(fir.validate(0).is_err()); + // 102 does not decimate evenly; 100 does, and is accepted. + assert!(fir.validate(102).is_err()); + assert!(fir.validate(100).is_ok()); + assert!( + PitchAntiAliasConfig::TruncatedFir { taps: 2 } + .validate(HOP) + .is_err() + ); + assert!(PitchAntiAliasConfig::Recurrence.validate(HOP).is_ok()); + } + + #[test] + fn test_carry_and_window_derive_from_one_constant() { + // The carry is `taps - 1`, not `window - hop`; those differ by the + // decimation factor, and mixing them would drop the oldest taps. + let cfg = PitchAntiAliasConfig::TruncatedFir { taps: 2048 }; + assert_eq!(cfg.carry_len(), 2047); + assert_eq!(cfg.window_len(HOP), 2300); + assert_ne!(cfg.carry_len(), cfg.window_len(HOP) - HOP); + + assert_eq!(PitchAntiAliasConfig::Recurrence.carry_len(), 0); + } + + #[test] + fn test_impulse_response_decays_below_f32_resolution() { + let cfg = PitchAntiAliasConfig::default(); + let response = cfg.to_vec_impulse_response(DEFAULT_FIR_TAPS); + + let peak = response.iter().fold(0.0f32, |m, v| m.max(v.abs())); + assert!(peak > 0.0); + + // The L1 tail is what bounds the truncation error, not the pointwise + // magnitude. + let tail: f32 = response[DEFAULT_FIR_TAPS - 64..] + .iter() + .map(|v| v.abs()) + .sum(); + assert!( + tail / peak < 1e-8, + "tail {tail} is not negligible against peak {peak}", + ); + } + + #[test] + fn test_recurrence_matches_the_exact_cascade() { + // The reference tier's whole purpose: prove the translation is right, + // independent of any approximation. + let device = Default::default(); + let input = signal(2 * HOP); + + let got = run::(PitchAntiAliasConfig::Recurrence, &input, &device); + let want = host_filter_decimate(&input); + + assert_eq!(got.len(), want.len()); + // Scaled by the signal's peak rather than pointwise: the output crosses + // zero, where a relative bound is meaningless, and the cascade's poles + // sit near the unit circle so intermediate values are far larger than + // the samples they produce. + let peak = want.iter().fold(0.0f32, |m, v| m.max(v.abs())); + for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() { + assert!( + (g - w).abs() / peak < 2e-6, + "sample {i}: {g} vs {w} (peak {peak})", + ); + } + } + + #[test] + fn test_fir_matches_the_exact_cascade() { + // Would catch a carry/offset slip: a three-sample desync shows up + // immediately as a gross mismatch, not a rounding difference. + let device = Default::default(); + let input = signal(4 * HOP); + + let got = run::(PitchAntiAliasConfig::default(), &input, &device); + let want = host_filter_decimate(&input); + + assert_eq!(got.len(), want.len()); + let peak = want.iter().fold(0.0f32, |m, v| m.max(v.abs())); + for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() { + assert!( + (g - w).abs() / peak < 2e-6, + "sample {i}: {g} vs {w} (peak {peak})", + ); + } + } + + #[test] + fn test_the_two_tiers_agree() { + // The cross-test that makes three tiers worth keeping: reference and + // optimized must agree with each other, not merely with the host. + let device = Default::default(); + let input = signal(2 * HOP); + + let exact = run::(PitchAntiAliasConfig::Recurrence, &input, &device); + let fast = run::(PitchAntiAliasConfig::default(), &input, &device); + + let peak = exact.iter().fold(0.0f32, |m, v| m.max(v.abs())); + for (i, (a, b)) in exact.iter().zip(fast.iter()).enumerate() { + assert!((a - b).abs() / peak < 2e-6, "sample {i}: {a} vs {b}"); + } + } + + #[test] + fn test_streaming_matches_a_single_call() { + // Both formulations must carry their state correctly, or a chunked + // stream diverges from the same audio filtered at once. + let device = Default::default(); + let input = signal(4 * HOP); + + for cfg in [ + PitchAntiAliasConfig::Recurrence, + PitchAntiAliasConfig::default(), + ] { + let whole = run::(cfg, &input, &device); + + let filter = cfg.init::(HOP, &device); + let mut state = filter.init_state(1, &device); + let mut chunked = Vec::new(); + for chunk in input.chunks(2 * HOP) { + let t = Tensor::::from_floats(chunk, &device).reshape([1, chunk.len()]); + let (out, next) = filter.forward(t, state); + state = next; + chunked.extend(out.to_data_as::().to_vec_as::().unwrap()); + } + + assert_eq!(whole.len(), chunked.len(), "{cfg:?}"); + TensorData::from(whole.as_slice()).assert_approx_eq::( + &TensorData::from(chunked.as_slice()), + Tolerance::relative(1e-5), + ); + } + } + + #[test] + fn test_batch_rows_are_independent() { + let device = Default::default(); + let cfg = PitchAntiAliasConfig::default(); + let filter = cfg.init::(HOP, &device); + + let a = signal(2 * HOP); + let b: Vec = signal(2 * HOP).iter().map(|v| -0.5 * v).collect(); + + let mut flat = a.clone(); + flat.extend_from_slice(&b); + let batched = Tensor::::from_floats(flat.as_slice(), &device).reshape([2, 2 * HOP]); + let (batched_out, _) = filter.forward(batched, filter.init_state(2, &device)); + + for (row, one) in [a, b].iter().enumerate() { + let solo = run::(cfg, one, &device); + batched_out + .clone() + .slice_dim(0, row as isize..(row + 1) as isize) + .to_data() + .assert_approx_eq::( + &TensorData::new(solo, [1, one.len() / PROC_RESAMPLE_RATE]), + Tolerance::permissive(), + ); + } + } + + #[test] + #[should_panic(expected = "does not match its formulation")] + fn test_mismatched_state_is_rejected() { + let device = Default::default(); + let fir = PitchAntiAliasConfig::default().init::(HOP, &device); + let wrong = PitchAntiAliasConfig::Recurrence + .init::(HOP, &device) + .init_state(1, &device); + + fir.forward(Tensor::::zeros([1, HOP], &device), wrong); + } + + #[test] + fn test_unfold_row_stride_assumption() { + // `unfold` derives its batch-row stride from the span the windows cover + // rather than from the row's real length, so a row with a leftover tail + // places every subsequent row early. `run_fir` trims to the covered + // span to avoid it; this pins the behaviour that makes the trim + // necessary. + // + // If this starts failing, `unfold` has been fixed and the trim in + // `run_fir` is merely redundant rather than load-bearing. + let device = Default::default(); + let (win, step, steps, tail) = (12usize, 4usize, 3usize, 3usize); + let covered = (steps - 1) * step + win; + let len = covered + tail; + + // Row 1 carries a marker at its very first element. + let mut flat = vec![0.0f32; 2 * len]; + flat[len] = 1.0; + let t = Tensor::::from_data(TensorData::new(flat, [2, len]), &device); + + let rows: Vec = t + .clone() + .unfold::<3, _>(1, win, step) + .reshape([2 * steps, win]) + .to_data_as::() + .to_vec_as::() + .unwrap(); + + // Window `steps` is (batch 1, step 0); the marker should sit at 0. + let found = rows[steps * win..(steps + 1) * win] + .iter() + .position(|v| *v != 0.0); + assert_eq!( + found, + Some(tail), + "expected the row-1 marker displaced by the leftover tail ({tail}); \ + Some(0) would mean unfold now uses the true row stride", + ); + + // And with no tail, the stride is correct. + let mut exact = vec![0.0f32; 2 * covered]; + exact[covered] = 1.0; + let t = Tensor::::from_data(TensorData::new(exact, [2, covered]), &device); + let rows: Vec = t + .unfold::<3, _>(1, win, step) + .reshape([2 * steps, win]) + .to_data_as::() + .to_vec_as::() + .unwrap(); + assert_eq!( + rows[steps * win..(steps + 1) * win] + .iter() + .position(|v| *v != 0.0), + Some(0), + ); + } + + #[test] + fn test_batch_row_offset_regression() { + // The bug the trim fixes, at the real geometry: before it, row 1's + // output was garbage while row 0 was bit-exact. + let device = Default::default(); + let cfg = PitchAntiAliasConfig::default(); + let filter = cfg.init::(HOP, &device); + + let a = signal(2 * HOP); + let b: Vec = a.iter().map(|v| -0.5 * v).collect(); + + let mut flat = a.clone(); + flat.extend_from_slice(&b); + let batched = Tensor::::from_data(TensorData::new(flat, [2, 2 * HOP]), &device); + let (out, _) = filter.forward(batched, filter.init_state(2, &device)); + let got: Vec = out.to_data_as::().to_vec_as::().unwrap(); + + // The filter is linear, so row 1 must be exactly -0.5x row 0. + let per = got.len() / 2; + let peak = got[..per].iter().fold(0.0f32, |m, v| m.max(v.abs())); + for i in 0..per { + let want = -0.5 * got[i]; + assert!( + (got[per + i] - want).abs() / peak < 1e-6, + "row 1 sample {i}: {} vs {want}", + got[per + i], + ); + } + } +} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs index 94bff490..f609d53f 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs @@ -53,6 +53,7 @@ //! bit-exact equality: a dev may be on a backend that enables fast-math, where //! exact equality against a host scalar reference cannot hold. +pub mod antialias; pub mod correlate; pub mod prefilter; pub mod tables; @@ -60,6 +61,8 @@ pub mod tables; #[cfg(test)] pub(crate) mod hybrid; +#[doc(inline)] +pub use antialias::*; #[doc(inline)] pub use correlate::*; #[doc(inline)] From 58ec1c326bcf1c60de9ad3cfadf73dc404b21332 Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Fri, 21 Aug 2026 21:42:00 -0700 Subject: [PATCH 08/32] feat(ten-vad): complete the device pitch stages, and pin the driver end to end Finishes stages 2 and 4, so all four now run as tensor ops, and adds the strongest golden in the tree. **Stage 2, excitation.** Raw hops plus a per-hop whitening filter to the decimated excitation history. Both FIRs are shift-and-accumulate rather than convolutions: the whitening taps change every hop, which rules out `conv1d`, and the window-times-taps form would materialize a `rows x 256 x 17` intermediate. Accumulating one tap at a time also keeps the reference's operation order. The raw FIFO subsumes the reference's `pitch_mem` outright -- whitening is a pure order-16 FIR over the aligned input, not a recurrence, so the samples it needs are already sixteen positions behind the aligned window. **Stage 4, tracking.** The Viterbi pass is the one genuinely sequential stage: the accumulator carries across hops and is renormalized by subtracting its running maximum, so it can be neither cleared nor reassociated. The backtrace, though, is *not* sequential across hops -- keeping the slot histories flat and non-circular turns it into six strided gathers instead of `6 x steps` scalar ones, and deletes the reference's circular-buffer bookkeeping. The transition is a dense penalty matrix because the reference's candidate window is asymmetric and unbounded below (`min(0, 4 - idx)`), running 5 wide at one end and 52 at the other; almost certainly a reference bug, but load-bearing for parity. **A second burn stride bug.** `gather` ignores strides on a non-contiguous *index* tensor: given a transposed index it reads element 0 of each row rather than the indexed element. The backtrace cursor was built with `cat` + `swap_dims`, so hop 0 came out correct and every later hop walked into forbidden states -- the same "first row fine, rest garbage" signature as the `unfold` row-stride bug found earlier. Built with `stack` instead, which is contiguous. `test_gather_needs_a_contiguous_index` pins the underlying behaviour and fails loudly if burn fixes it. Worth recording how it was found: the forward pass wrote correct backpointers and the slot history read back correct, yet the gather returned wrong values -- so each primitive was probed in isolation (`unfold`+`reshape` across batch, strided slicing on a middle dim, slice-then-`swap_dims`, `gather` on a swapped strided view) until the one untested combination was left, the index tensor itself. **The end-to-end golden.** `testdata/ten/probs.json` holds one speech probability per hop, produced by driving the shipped `libten_vad.so` through the reference Python binding. Nothing is shared with bunsen except the audio: the reference's own front end and inference engine against ours. Measured over 400 hops: mean probability error 3.2e-5, worst 2.8e-4, and the speech/no-speech decision agrees on **every** hop. Bounds are set an order of magnitude above the measurement. The test is capped at 400 hops because `context_forward_sequence` runs the model once per hop on device; the full 3750 runs for over a quarter of an hour. The golden file holds all of them, so the cap is a dial. `testdata/ten/README.md` documents both goldens and their recipes, including that the prebuilt `.so` needs LLVM's libc++ -- obtainable without root via `apt-get download` + `dpkg -x`, and needing `libunwind-18` rather than Ubuntu's `libunwind8`, since `libc++abi` wants `libunwind.so.1`. Tests: 179 across `silero_vad` and `ten_vad` under `--features wgpu`, green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../context/pitch/tensor/excitation.rs | 620 +++++++++++++ .../ten_vad/context/pitch/tensor/mod.rs | 6 + .../ten_vad/context/pitch/tensor/track.rs | 837 ++++++++++++++++++ .../src/kits/speech/ten_vad/cross_test.rs | 97 ++ crates/bunsen/testdata/ten/README.md | 57 ++ crates/bunsen/testdata/ten/gen_probs.py | 46 + crates/bunsen/testdata/ten/probs.json | 1 + 7 files changed, 1664 insertions(+) create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/excitation.rs create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs create mode 100644 crates/bunsen/testdata/ten/gen_probs.py create mode 100644 crates/bunsen/testdata/ten/probs.json diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/excitation.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/excitation.rs new file mode 100644 index 00000000..13ca39c4 --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/excitation.rs @@ -0,0 +1,620 @@ +//! # Stage 2: whitening and decimation, on device. +//! +//! Turns raw hops plus this hop's whitening filter into the decimated +//! excitation history the lag search reads: +//! +//! ```text +//! raw [steps, batch, hop] + lpc [steps, batch, 16] -> exc [steps, batch, exc_len] +//! ``` +//! +//! ## What the reference does +//! +//! ```text +//! aligned = raw delayed by XCORR_TRAINING_OFFSET +//! w[n] = aligned[n] + Σ_{j<16} lpc[j]·aligned[n-1-j] # whitening FIR +//! u[n] = w[n] + 0.7·w[n-1] # 2-tap smoother +//! exc = decimate(antialias(u), 4) +//! ``` +//! +//! Whitening is what leaves a clean impulse train at the pitch period; the +//! smoother keeps the excitation from going fully impulsive. +//! +//! ## Carried state +//! +//! Four things, and one of them is smaller than it looks: +//! +//! * `input_q` — the raw FIFO. **This subsumes the reference's `pitch_mem`**: +//! the whitening is a pure order-16 FIR over the *aligned* input rather than +//! a recurrence, so the 16 samples it needs are already in the FIFO, sixteen +//! positions behind the aligned window. +//! * `smoother` — the previous whitened sample. +//! * the anti-alias filter's own state, whose shape depends on which +//! formulation is selected ([`super::antialias`]). +//! * `exc_buf` — the decimated history the lag search windows over. +//! +//! ## Both FIRs are shift-and-accumulate +//! +//! The whitening taps change every hop, which rules out a plain `conv1d`. +//! Written as `[hop, 17]` windows against a per-hop tap vector it would +//! materialize a `rows × 256 × 17` intermediate; written as 16 broadcast +//! multiply-adds over `[rows, hop]` slices it is far smaller *and* accumulates +//! in the reference's order, which makes it bit-exact rather than merely +//! close. +//! +//! ## Window alignment +//! +//! With the FIFO holding `max(offset, hop) + hop` samples, the aligned window +//! for hop `t` sits at `t·hop + 2·hop - offset` in the extended stream, and the +//! FIR needs 16 samples before it. Both the aligned windows and the excitation +//! windows are trimmed to their covered span before `unfold`, for the row +//! stride reason documented in [`super::antialias`]. + +use burn::{ + config::Config, + prelude::*, +}; + +use super::{ + super::coeff::{ + LPC_ORDER, + MAX_PERIOD_16KHZ, + PROC_RESAMPLE_RATE, + XCORR_TRAINING_OFFSET, + }, + antialias::{ + PitchAntiAlias, + PitchAntiAliasConfig, + PitchAntiAliasState, + }, +}; +use crate::{ + errors::{ + BunsenError, + BunsenResult, + WithOkOrPanic, + }, + kits::speech::ten_vad::context::coeff::HOP_SIZE, +}; + +/// The 2-tap smoother's feedback coefficient. +pub const SMOOTHER_COEFF: f32 = 0.7; + +/// Config for [`PitchExcitation`]. +#[derive(Config, Debug)] +pub struct PitchExcitationConfig { + /// The hop size, in samples at 16 kHz. + #[config(default = "HOP_SIZE")] + pub hop_size: usize, + + /// How the anti-alias filter before decimation is realized. + /// + /// This is where the reference and optimized tiers diverge; see + /// [`PitchAntiAliasConfig`]. + #[config(default = "PitchAntiAliasConfig::default()")] + pub anti_alias: PitchAntiAliasConfig, +} + +impl PitchExcitationConfig { + /// The raw FIFO length. + pub fn fifo_len(&self) -> usize { + XCORR_TRAINING_OFFSET.max(self.hop_size) + self.hop_size + } + + /// Where the aligned window starts, relative to a hop in the extended + /// stream. + pub fn aligned_offset(&self) -> usize { + 2 * self.hop_size - XCORR_TRAINING_OFFSET + } + + /// The decimated excitation history length the lag search reads. + pub fn exc_len(&self) -> usize { + MAX_PERIOD_16KHZ / PROC_RESAMPLE_RATE + self.hop_size.div_ceil(PROC_RESAMPLE_RATE) + 1 + } + + /// How many decimated samples one hop contributes. + pub fn exc_stride(&self) -> usize { + self.hop_size / PROC_RESAMPLE_RATE + } + + /// Validates the geometry. + /// + /// # Errors + /// [`BunsenError::Invalid`] if the hop is too short to carry the + /// correlation delay, or if the anti-alias geometry is invalid. + pub fn validate(&self) -> BunsenResult<()> { + if self.hop_size < XCORR_TRAINING_OFFSET { + return Err(BunsenError::Invalid(format!( + "PitchExcitation hop_size ({}) must be at least the correlation delay ({})", + self.hop_size, XCORR_TRAINING_OFFSET, + ))); + } + if self.exc_len() <= self.exc_stride() { + return Err(BunsenError::Invalid( + "PitchExcitation excitation history must exceed one hop".to_string(), + )); + } + self.anti_alias.validate(self.hop_size) + } + + /// Builds the stage. + /// + /// # Errors + /// + /// See [`validate`](Self::validate). + pub fn try_init( + &self, + device: &B::Device, + ) -> BunsenResult> { + self.validate()?; + Ok(PitchExcitation { + hop_size: self.hop_size, + fifo_len: self.fifo_len(), + aligned_offset: self.aligned_offset(), + exc_len: self.exc_len(), + exc_stride: self.exc_stride(), + anti_alias: self.anti_alias.try_init(self.hop_size, device)?, + }) + } + + /// Builds the stage, panicking on error. + pub fn init( + &self, + device: &B::Device, + ) -> PitchExcitation { + self.try_init(device).ok_or_panic() + } +} + +/// The whitening and decimation stage. +/// +/// Stateless coefficients; the carried buffers live in +/// [`PitchExcitationState`]. Built by [`PitchExcitationConfig::try_init`]. +#[derive(Debug, Clone)] +pub struct PitchExcitation { + hop_size: usize, + fifo_len: usize, + aligned_offset: usize, + exc_len: usize, + exc_stride: usize, + + /// The anti-alias filter before decimation. + pub anti_alias: PitchAntiAlias, +} + +/// The state [`PitchExcitation`] carries between calls. +#[derive(Debug, Clone)] +pub struct PitchExcitationState { + /// `[batch, fifo_len]` raw sample FIFO. Also supplies the whitening FIR's + /// sample history, which is why there is no separate buffer for it. + pub fifo: Tensor, + + /// `[batch, 1]` previous whitened sample, for the 2-tap smoother. + pub smoother: Tensor, + + /// The anti-alias filter's carried state. + pub anti_alias: PitchAntiAliasState, + + /// `[batch, exc_len - exc_stride]` decimated history not yet consumed. + pub exc_carry: Tensor, +} + +impl PitchExcitation { + /// The hop size, in samples at 16 kHz. + pub fn hop_size(&self) -> usize { + self.hop_size + } + + /// The decimated excitation history length this stage emits. + pub fn exc_len(&self) -> usize { + self.exc_len + } + + /// A zeroed start-of-stream state. + pub fn init_state( + &self, + batch_size: usize, + device: &B::Device, + ) -> PitchExcitationState { + PitchExcitationState { + fifo: Tensor::zeros([batch_size, self.fifo_len], device), + smoother: Tensor::zeros([batch_size, 1], device), + anti_alias: self.anti_alias.init_state(batch_size, device), + exc_carry: Tensor::zeros([batch_size, self.exc_len - self.exc_stride], device), + } + } + + /// Extracts the excitation history for a run of hops. + /// + /// # Arguments + /// * `raw`: `[steps, batch, hop_size]` samples at the reference's int16 + /// scale — **raw**, not pre-emphasized. + /// * `lpc`: `[steps, batch, LPC_ORDER]` whitening filters, one per hop. + /// * `state`: carried state, from a previous call or + /// [`init_state`](Self::init_state). + /// + /// # Returns + /// `[steps, batch, exc_len]` excitation histories — the state after each + /// hop, which is what the lag search consumes — and the state to carry + /// forward. + pub fn forward( + &self, + raw: Tensor, + lpc: Tensor, + state: PitchExcitationState, + ) -> (Tensor, PitchExcitationState) { + let [steps, batch, hop] = raw.dims(); + assert_eq!(hop, self.hop_size, "PitchExcitation hop mismatch"); + assert_eq!( + lpc.dims(), + [steps, batch, LPC_ORDER], + "PitchExcitation lpc shape mismatch", + ); + + let PitchExcitationState { + fifo, + smoother, + anti_alias, + exc_carry, + } = state; + + // Per-stream contiguous: [batch, steps * hop]. + let stream = raw.swap_dims(0, 1).flatten::<2>(1, 2); + let extended = Tensor::cat(vec![fifo, stream], 1); + + let whitened = self.whiten(&extended, lpc, steps, batch); + + // [batch, steps * hop] + let whitened_stream = whitened + .reshape([steps, batch, hop]) + .swap_dims(0, 1) + .flatten::<2>(1, 2); + + let (smoothed, smoother) = Self::smooth(whitened_stream, smoother); + let (decimated, anti_alias) = self.anti_alias.forward(smoothed, anti_alias); + + let (windows, exc_carry) = self.window_excitation(decimated, exc_carry, steps, batch); + + let fifo = extended.slice_dim(1, -(self.fifo_len as isize)..); + + ( + windows, + PitchExcitationState { + fifo, + smoother, + anti_alias, + exc_carry, + }, + ) + } + + /// The order-16 whitening FIR, with per-hop taps. + /// + /// Accumulated one tap at a time in increasing `j`, matching the + /// reference's order rather than a tree reduce's. + fn whiten( + &self, + extended: &Tensor, + lpc: Tensor, + steps: usize, + batch: usize, + ) -> Tensor { + let hop = self.hop_size; + let rows = steps * batch; + // The aligned window, plus the LPC_ORDER samples of history the FIR + // reaches back into. + let window = LPC_ORDER + hop; + let start = self.aligned_offset - LPC_ORDER; + let covered = (steps - 1) * hop + window; + + // [batch, steps, window] -> [steps, batch, window] -> [rows, window] + let aligned = extended + .clone() + .slice_dim(1, start as isize..(start + covered) as isize) + .unfold::<3, _>(1, window, hop) + .swap_dims(0, 1) + .reshape([rows, window]); + + let taps = lpc.reshape([rows, LPC_ORDER]); + + let mut acc = aligned + .clone() + .slice_dim(1, LPC_ORDER as isize..(LPC_ORDER + hop) as isize); + for j in 0..LPC_ORDER { + let tap = taps.clone().slice_dim(1, j as isize..(j + 1) as isize); + let lo = (LPC_ORDER - 1 - j) as isize; + let lagged = aligned.clone().slice_dim(1, lo..lo + hop as isize); + acc = acc + lagged * tap; + } + acc + } + + /// The 2-tap smoother, `y[n] = w[n] + 0.7·w[n-1]`, carrying `w[-1]`. + fn smooth( + whitened: Tensor, + carry: Tensor, + ) -> (Tensor, Tensor) { + let len = whitened.dims()[1] as isize; + + let previous = Tensor::cat(vec![carry, whitened.clone().slice_dim(1, 0..len - 1)], 1); + let out = whitened.clone() + previous.mul_scalar(SMOOTHER_COEFF); + let next_carry = whitened.slice_dim(1, len - 1..len); + + (out, next_carry) + } + + /// Slides the decimated stream into per-hop excitation histories. + fn window_excitation( + &self, + decimated: Tensor, + carry: Tensor, + steps: usize, + batch: usize, + ) -> (Tensor, Tensor) { + let stride = self.exc_stride; + let len = self.exc_len; + let covered = (steps - 1) * stride + len; + + let extended = Tensor::cat(vec![carry, decimated], 1); + + let windows = extended + .clone() + .slice_dim(1, 0..covered as isize) + .unfold::<3, _>(1, len, stride) + .swap_dims(0, 1) + .reshape([steps, batch, len]); + + let next_carry = extended.slice_dim(1, -((len - stride) as isize)..); + + (windows, next_carry) + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::Tolerance; + + use super::{ + super::super::{ + TenVadPitchEstimator, + TenVadPitchScalarSource, + }, + *, + }; + use crate::{ + prelude::*, + support::testing::PerformanceBackend, + }; + + type B = PerformanceBackend; + type D = ::Device; + + const HOP: usize = 256; + const N_BINS: usize = 513; + + fn pulse_hop( + f0: f32, + at: usize, + ) -> Vec { + let period = 16000.0 / f0; + (0..HOP) + .map(|i| { + let pos = (at + i) as f32 % period; + 8000.0 * (-pos / (period * 0.08)).exp() + }) + .collect() + } + + fn spectrum(step: usize) -> Vec { + (0..N_BINS) + .map(|k| { + let k = k as f32; + 1e7 * (-k / 70.0).exp() * (1.0 + 0.4 * (k * 0.05 + step as f32 * 0.3).sin()) + }) + .collect() + } + + /// Drives the host and captures, per hop, the raw input, the filter it + /// designed, and the excitation history it produced. + fn host_reference(steps: usize) -> (Vec, Vec, Vec) { + let mut est = TenVadPitchEstimator::new(); + let (mut raw, mut lpc, mut exc) = (Vec::new(), Vec::new(), Vec::new()); + + for step in 0..steps { + let hop = pulse_hop(150.0, step * HOP); + est.frame_pitch(&hop, &spectrum(step)); + + raw.extend_from_slice(&hop); + lpc.extend_from_slice(est.lpc()); + exc.extend_from_slice(est.exc_buf()); + } + (raw, lpc, exc) + } + + fn config() -> PitchExcitationConfig { + PitchExcitationConfig::new() + } + + #[test] + fn test_config_meta() { + let cfg = config(); + assert_eq!(cfg.hop_size, 256); + assert_eq!(cfg.fifo_len(), 512); + assert_eq!(cfg.aligned_offset(), 432); + assert_eq!(cfg.exc_len(), 129); + assert_eq!(cfg.exc_stride(), 64); + assert!(cfg.validate().is_ok()); + } + + #[test] + fn test_validate_rejects_bad_geometry() { + assert!(config().with_hop_size(64).validate().is_err()); + assert!( + config() + .with_anti_alias(PitchAntiAliasConfig::TruncatedFir { taps: 1 }) + .validate() + .is_err() + ); + } + + #[test] + fn test_init_meta_matches_config() { + let device = Default::default(); + let stage: PitchExcitation = config().init(&device); + assert_eq!(stage.hop_size(), 256); + assert_eq!(stage.exc_len(), 129); + } + + /// Runs the stage over a whole run of hops, against host-designed filters. + fn run_stage( + cfg: &PitchExcitationConfig, + steps: usize, + raw: &[f32], + lpc: &[f32], + device: &D, + ) -> Vec { + let stage: PitchExcitation = cfg.init(device); + let state = stage.init_state(1, device); + + let raw_t = Tensor::::from_floats(raw, device).reshape([steps, 1, HOP]); + let lpc_t = Tensor::::from_floats(lpc, device).reshape([steps, 1, LPC_ORDER]); + + let (out, _) = stage.forward(raw_t, lpc_t, state); + out.to_data_as::().to_vec_as::().unwrap() + } + + #[test] + fn test_forward_matches_host_stage() { + let device = Default::default(); + let steps = 8; + let (raw, lpc, want) = host_reference(steps); + + for anti_alias in [ + PitchAntiAliasConfig::Recurrence, + PitchAntiAliasConfig::default(), + ] { + let cfg = config().with_anti_alias(anti_alias); + let got = run_stage(&cfg, steps, &raw, &lpc, &device); + + assert_eq!(got.len(), want.len(), "{anti_alias:?}"); + let peak = want.iter().fold(0.0f32, |m, v| m.max(v.abs())); + for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() { + assert!( + (g - w).abs() / peak < 5e-6, + "{anti_alias:?} sample {i}: {g} vs {w} (peak {peak})", + ); + } + } + } + + #[test] + fn test_forward_sequence_matches_stepwise() { + let device = Default::default(); + let steps = 6; + let (raw, lpc, _) = host_reference(steps); + let cfg = config(); + + let whole = run_stage(&cfg, steps, &raw, &lpc, &device); + + let stage: PitchExcitation = cfg.init(&device); + let mut state = stage.init_state(1, &device); + let mut stepwise = Vec::new(); + for step in 0..steps { + let r = Tensor::::from_floats(&raw[step * HOP..(step + 1) * HOP], &device) + .reshape([1, 1, HOP]); + let l = Tensor::::from_floats( + &lpc[step * LPC_ORDER..(step + 1) * LPC_ORDER], + &device, + ) + .reshape([1, 1, LPC_ORDER]); + let (out, next) = stage.forward(r, l, state); + state = next; + stepwise.extend(out.to_data_as::().to_vec_as::().unwrap()); + } + + TensorData::from(whole.as_slice()).assert_approx_eq::( + &TensorData::from(stepwise.as_slice()), + Tolerance::relative(1e-5), + ); + } + + #[test] + fn test_batch_rows_are_independent() { + let device = Default::default(); + let steps = 4; + let (raw, lpc, _) = host_reference(steps); + let cfg = config(); + let stage: PitchExcitation = cfg.init(&device); + + // Row 1 is a scaled copy, which the whitening FIR maps linearly. + let raw_b: Vec = raw.iter().map(|v| -0.5 * v).collect(); + + let mut interleaved = Vec::new(); + let mut lpc_pair = Vec::new(); + for step in 0..steps { + interleaved.extend_from_slice(&raw[step * HOP..(step + 1) * HOP]); + interleaved.extend_from_slice(&raw_b[step * HOP..(step + 1) * HOP]); + for _ in 0..2 { + lpc_pair.extend_from_slice(&lpc[step * LPC_ORDER..(step + 1) * LPC_ORDER]); + } + } + + let raw_t = + Tensor::::from_floats(interleaved.as_slice(), &device).reshape([steps, 2, HOP]); + let lpc_t = Tensor::::from_floats(lpc_pair.as_slice(), &device) + .reshape([steps, 2, LPC_ORDER]); + + let (out, _) = stage.forward(raw_t, lpc_t, stage.init_state(2, &device)); + let got: Vec = out.to_data_as::().to_vec_as::().unwrap(); + + let solo = run_stage(&cfg, steps, &raw, &lpc, &device); + let len = stage.exc_len(); + let peak = solo.iter().fold(0.0f32, |m, v| m.max(v.abs())); + + for step in 0..steps { + for i in 0..len { + let row0 = got[(step * 2) * len + i]; + let row1 = got[(step * 2 + 1) * len + i]; + let want0 = solo[step * len + i]; + assert!( + (row0 - want0).abs() / peak < 1e-6, + "step {step} sample {i}: row0 {row0} vs solo {want0}", + ); + assert!( + (row1 - -0.5 * row0).abs() / peak < 1e-6, + "step {step} sample {i}: row1 {row1} is not -0.5x row0 {row0}", + ); + } + } + } + + #[test] + fn test_reset_rewinds_the_stream() { + let device = Default::default(); + let steps = 3; + let (raw, lpc, _) = host_reference(steps); + let cfg = config(); + let stage: PitchExcitation = cfg.init(&device); + + let raw_t = Tensor::::from_floats(raw.as_slice(), &device).reshape([steps, 1, HOP]); + let lpc_t = + Tensor::::from_floats(lpc.as_slice(), &device).reshape([steps, 1, LPC_ORDER]); + + let (first, state) = + stage.forward(raw_t.clone(), lpc_t.clone(), stage.init_state(1, &device)); + // Continuing carries state, so the same input gives a different answer. + let (carried, _) = stage.forward(raw_t.clone(), lpc_t.clone(), state); + assert_ne!( + first + .clone() + .to_data_as::() + .to_vec_as::() + .unwrap(), + carried.to_data_as::().to_vec_as::().unwrap(), + ); + + let (again, _) = stage.forward(raw_t, lpc_t, stage.init_state(1, &device)); + first + .to_data() + .assert_approx_eq::(&again.to_data(), Tolerance::permissive()); + } +} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs index f609d53f..dfbd83a1 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs @@ -55,8 +55,10 @@ pub mod antialias; pub mod correlate; +pub mod excitation; pub mod prefilter; pub mod tables; +pub mod track; #[cfg(test)] pub(crate) mod hybrid; @@ -66,6 +68,10 @@ pub use antialias::*; #[doc(inline)] pub use correlate::*; #[doc(inline)] +pub use excitation::*; +#[doc(inline)] pub use prefilter::*; #[doc(inline)] pub use tables::*; +#[doc(inline)] +pub use track::*; diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs new file mode 100644 index 00000000..c8fd62a8 --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs @@ -0,0 +1,837 @@ +//! # Stage 4: period tracking and the pitch estimate, on device. +//! +//! ```text +//! xcorr [steps, batch, 2, max_period] + energy [steps, batch, 2] +//! -> pitch [steps, batch, 1] (Hz, 0 = unvoiced) +//! ``` +//! +//! A Viterbi pass over the last `2·n_feat` half-hop slots picks a period path, +//! penalizing `PITCH_MAX_PATH_W · step²` for jumping between candidates. The +//! backtrace yields both a voicing score and a weighted least-squares fit over +//! the recovered periods, extrapolated half a frame forward. +//! +//! ## The one genuinely sequential stage +//! +//! The accumulator carries across hops and is renormalized by subtracting its +//! running maximum, so it cannot be cleared per hop and cannot be reassociated. +//! Two dependent steps per hop, each `[batch, dif_period]`. +//! +//! That is affordable in context: the driver already runs one sequential device +//! iteration per hop for the LSTM, and each of those dispatches far more work +//! than a step here does. This adds roughly twice the iteration count on +//! tensors three orders of magnitude smaller. +//! +//! **The backtrace, by contrast, is not sequential across hops.** At hop `t`, +//! backpointer row `sub` is absolute slot `2t + sub − 4`, so keeping the +//! histories in a flat, non-circular layout turns the whole backtrace into six +//! strided gathers rather than `6 × steps` scalar ones. That also deletes the +//! reference's circular-buffer bookkeeping outright. +//! +//! ## The candidate window is asymmetric, and wider than it looks +//! +//! The reference computes `SIDXT = min(0, 4 − idx)`, which is unbounded below: +//! +//! ```text +//! cand ∈ [ min(idx, 4), min(idx + 4, dif_period − 1) ] +//! ``` +//! +//! so the window is 5 wide at the short-period end and **52 wide** at the long +//! end, where `jdx` reaches −51 and the penalty reaches 52. That is very likely +//! a bug in the reference, but it is load-bearing for output parity, so it is +//! reproduced exactly. It is also why the transition is a dense +//! `[dif_period, dif_period]` penalty matrix rather than a narrow band: invalid +//! transitions are simply given a penalty large enough to lose every `max`. + +use burn::{ + config::Config, + prelude::*, +}; + +use super::{ + super::coeff::{ + FEAT_MAX_NFRM, + FEAT_TIME_WINDOW_MS, + MAX_PERIOD_16KHZ, + MIN_PERIOD_16KHZ, + PITCH_MAX_PATH_W, + PROC_FS, + PROC_RESAMPLE_RATE, + VOICED_THRESHOLD, + }, + correlate::SUBS_PER_HOP, +}; +use crate::{ + errors::{ + BunsenError, + BunsenResult, + WithOkOrPanic, + }, + kits::speech::ten_vad::context::coeff::{ + HOP_SIZE, + SAMPLE_RATE, + }, +}; + +/// The penalty assigned to a transition the reference would never consider. +/// +/// Large enough to lose every `max` against the running floor, which is +/// `path_best_all - 1e10`, so validity falls out of the reduction instead of +/// needing a mask. +const INVALID_PENALTY: f32 = 1e30; + +/// Config for [`PitchTrack`]. +#[derive(Config, Debug, Copy)] +pub struct PitchTrackConfig { + /// The hop size, in samples at 16 kHz. + #[config(default = "HOP_SIZE")] + pub hop_size: usize, +} + +impl PitchTrackConfig { + /// The longest candidate period, at the correlation rate. + pub fn max_period(&self) -> usize { + MAX_PERIOD_16KHZ / PROC_RESAMPLE_RATE + } + + /// The width of the tracker's state space. + pub fn dif_period(&self) -> usize { + self.max_period() - MIN_PERIOD_16KHZ / PROC_RESAMPLE_RATE + } + + /// How many whole hops of correlation history the tracker spans. + pub fn n_feat(&self) -> usize { + FEAT_MAX_NFRM.min( + ((FEAT_TIME_WINDOW_MS * SAMPLE_RATE) as f32 / (self.hop_size * 1000) as f32).ceil() + as usize, + ) + } + + /// How many half-hop slots the tracker spans. + pub fn slots(&self) -> usize { + self.n_feat() * SUBS_PER_HOP + } + + /// How many slots are carried between calls. + pub fn carry_slots(&self) -> usize { + self.slots() - SUBS_PER_HOP + } + + /// Validates the geometry. + /// + /// # Errors + /// [`BunsenError::Invalid`] if the hop yields fewer than two history slots + /// or an empty period range. + pub fn validate(&self) -> BunsenResult<()> { + if self.n_feat() < 2 { + return Err(BunsenError::Invalid(format!( + "PitchTrack hop_size ({}) yields {} history frames; at least 2 are needed", + self.hop_size, + self.n_feat(), + ))); + } + if self.dif_period() == 0 { + return Err(BunsenError::Invalid( + "PitchTrack period range is empty".to_string(), + )); + } + Ok(()) + } + + /// The `[dif_period, dif_period]` transition penalty matrix, row-major. + /// + /// Entry `[idx][cand]` is `PITCH_MAX_PATH_W · jdx²` where reachable, and + /// [`INVALID_PENALTY`] where not. The arithmetic order matches the + /// reference's `(W · |j|) · |j|`. + pub fn to_vec_penalty(&self) -> Vec { + let dif = self.dif_period(); + let mut out = vec![INVALID_PENALTY; dif * dif]; + + for idx in 0..dif { + let first = 0.min(4 - idx as i32); + for jdx in first..=4 { + let cand = idx as i32 + jdx; + if cand < 0 || cand as usize >= dif { + continue; + } + let magnitude = jdx.abs() as f32; + out[idx * dif + cand as usize] = PITCH_MAX_PATH_W * magnitude * magnitude; + } + } + out + } + + /// Builds the tracker. + /// + /// # Errors + /// + /// See [`validate`](Self::validate). + pub fn try_init( + &self, + device: &B::Device, + ) -> BunsenResult> { + self.validate()?; + let dif = self.dif_period(); + + Ok(PitchTrack { + max_period: self.max_period(), + dif_period: dif, + n_feat: self.n_feat(), + penalty: Tensor::from_data(TensorData::new(self.to_vec_penalty(), [dif, dif]), device), + }) + } + + /// Builds the tracker, panicking on error. + pub fn init( + &self, + device: &B::Device, + ) -> PitchTrack { + self.try_init(device).ok_or_panic() + } +} + +/// The period tracker. +/// +/// Built by [`PitchTrackConfig::try_init`]; state lives in +/// [`PitchTrackState`]. +#[derive(Debug, Clone)] +pub struct PitchTrack { + max_period: usize, + dif_period: usize, + n_feat: usize, + + /// `[dif_period, dif_period]` transition penalties. + pub penalty: Tensor, +} + +/// The state [`PitchTrack`] carries between calls. +#[derive(Debug, Clone)] +pub struct PitchTrackState { + /// `[batch, dif_period]` Viterbi accumulator, renormalized to peak zero. + pub path_score: Tensor, + + /// `[batch, 1]` best score reached so far. + pub path_best: Tensor, + + /// `[batch, 1]` best period index reached so far. + pub best_period: Tensor, + + /// `[batch, carry_slots, max_period]` correlations not yet aged out. + pub slot_xcorr: Tensor, + + /// `[batch, carry_slots]` slot energies. + pub slot_energy: Tensor, + + /// `[batch, carry_slots, dif_period]` backpointers. + pub slot_prev: Tensor, +} + +impl PitchTrack { + /// The width of the tracker's state space. + pub fn dif_period(&self) -> usize { + self.dif_period + } + + /// How many half-hop slots the tracker spans. + pub fn slots(&self) -> usize { + self.n_feat * SUBS_PER_HOP + } + + /// A zeroed start-of-stream state. + pub fn init_state( + &self, + batch_size: usize, + device: &B::Device, + ) -> PitchTrackState { + let carry = self.slots() - SUBS_PER_HOP; + PitchTrackState { + path_score: Tensor::zeros([batch_size, self.dif_period], device), + path_best: Tensor::zeros([batch_size, 1], device), + best_period: Tensor::zeros([batch_size, 1], device), + slot_xcorr: Tensor::zeros([batch_size, carry, self.max_period], device), + slot_energy: Tensor::zeros([batch_size, carry], device), + slot_prev: Tensor::zeros([batch_size, carry, self.dif_period], device), + } + } + + /// Tracks a run of hops and reports their pitch. + /// + /// # Arguments + /// * `xcorr`: `[steps, batch, 2, max_period]` normalized correlations. + /// * `energy`: `[steps, batch, 2]` per-slot weights. + /// * `state`: carried state. + /// + /// # Returns + /// `[steps, batch, 1]` pitch in Hz, `0.0` where unvoiced, and the state to + /// carry forward. + pub fn forward( + &self, + xcorr: Tensor, + energy: Tensor, + state: PitchTrackState, + ) -> (Tensor, PitchTrackState) { + let [steps, batch, subs, max_period] = xcorr.dims(); + assert_eq!(subs, SUBS_PER_HOP, "PitchTrack expects two slots per hop"); + assert_eq!(max_period, self.max_period, "PitchTrack lag width mismatch"); + + let slots = self.slots(); + let carry = slots - SUBS_PER_HOP; + let dif = self.dif_period; + let total_slots = carry + steps * SUBS_PER_HOP; + + // Flat, non-circular slot histories: [batch, carry + 2*steps, ..]. + let xcorr_hist = Tensor::cat( + vec![ + state.slot_xcorr, + xcorr + .swap_dims(0, 1) + .reshape([batch, steps * SUBS_PER_HOP, max_period]), + ], + 1, + ); + let energy_hist = Tensor::cat( + vec![ + state.slot_energy, + energy + .swap_dims(0, 1) + .reshape([batch, steps * SUBS_PER_HOP]), + ], + 1, + ); + + // Each hop normalizes its own `slots`-wide window, so the weights are + // per hop, not per slot: [batch, steps, slots]. + let weights = Self::normalize_weights(energy_hist.clone(), slots, steps); + + // --- forward pass: two dependent steps per hop --- + let mut path_score = state.path_score; + let mut path_best = state.path_best; + let mut best_period = state.best_period; + let mut new_prev = Vec::with_capacity(steps * SUBS_PER_HOP); + let mut hop_best = Vec::with_capacity(steps); + + for step in 0..steps { + for sub in 0..SUBS_PER_HOP { + let slot = carry + step * SUBS_PER_HOP + sub; + let xc = xcorr_hist + .clone() + .slice_dim(1, slot as isize..(slot + 1) as isize) + .squeeze_dim::<2>(1) + .slice_dim(1, 0..dif as isize); + let w = weights + .clone() + .slice(s![ + .., + step as isize..(step + 1) as isize, + (carry + sub) as isize..(carry + sub + 1) as isize + ]) + .reshape([batch, 1]); + + let (score, best, arg, prev) = + self.viterbi_step(path_score, path_best, best_period, xc, w); + path_score = score; + path_best = best; + best_period = arg; + new_prev.push(prev.unsqueeze_dim::<3>(1)); + } + hop_best.push(best_period.clone()); + } + + let prev_hist = Tensor::cat(vec![state.slot_prev, Tensor::cat(new_prev, 1)], 1); + + // --- backtrace and fit, batched across hops --- + // Stacked, not `cat` + `swap_dims`: the backtrace feeds this to + // `gather` as an index, and `gather` ignores strides on a + // non-contiguous index tensor -- it reads element 0 of every row + // instead of the indexed one. `stack` builds `[steps, batch, 1]` + // contiguously; a transposed view silently corrupts every hop after + // the first. See `tests::test_gather_needs_a_contiguous_index`. + let cursor: Tensor = Tensor::stack::<3>(hop_best.clone(), 0).squeeze_dim::<2>(2); + let pitch = self.backtrace_and_fit( + &xcorr_hist, + &prev_hist, + &weights, + cursor, + steps, + batch, + total_slots, + ); + + let keep = total_slots - carry; + ( + pitch.reshape([steps, batch, 1]), + PitchTrackState { + path_score, + path_best, + best_period, + slot_xcorr: xcorr_hist.slice_dim(1, keep as isize..), + slot_energy: energy_hist.slice_dim(1, keep as isize..), + slot_prev: prev_hist.slice_dim(1, keep as isize..), + }, + ) + } + + /// Scales each hop's window of slot energies to average one. + /// + /// The `1e-15` seed matches the reference and keeps an all-silent window + /// from dividing by zero. + /// + /// The slot history is exactly `(steps - 1)·2 + slots` long, so this + /// `unfold` has no leftover tail and needs no trim — unlike the ones in + /// [`super::antialias`] and [`super::excitation`], where a tail would + /// misplace every row after the first. + fn normalize_weights( + energy_hist: Tensor, + slots: usize, + steps: usize, + ) -> Tensor { + debug_assert_eq!( + energy_hist.dims()[1], + (steps - 1) * SUBS_PER_HOP + slots, + "slot history must exactly cover its windows", + ); + + // [batch, steps, slots] + let windows = energy_hist.unfold::<3, _>(1, slots, SUBS_PER_HOP); + let total = windows.clone().sum_dim(2).add_scalar(1e-15f32); + windows * (total.recip().mul_scalar(slots as f32)) + } + + /// One Viterbi step: the dense transition max, then the renormalization. + fn viterbi_step( + &self, + path_score: Tensor, + path_best: Tensor, + best_period: Tensor, + xcorr: Tensor, + weight: Tensor, + ) -> ( + Tensor, + Tensor, + Tensor, + Tensor, + ) { + // [batch, dif, dif]: score of arriving at `idx` from `cand`. + let transitions = + path_score.unsqueeze_dim::<3>(1) - self.penalty.clone().unsqueeze_dim::<3>(0); + let (best_in, arg_in) = transitions.max_dim_with_indices(2); + + // The reference seeds its search at `path_best - 1e10` and keeps the + // previous best period when nothing beats it; invalid transitions sit + // far below that floor, so they never win. + let floor = path_best.sub_scalar(1e10f32).unsqueeze_dim::<3>(2); + let stalled = best_in.clone().lower_equal(floor.clone()); + let prev = arg_in.mask_where(stalled, best_period.clone().unsqueeze_dim::<3>(2)); + + let scored = best_in.max_pair(floor).squeeze_dim::<2>(2) + xcorr * weight; + let (top, arg_top) = scored.clone().max_dim_with_indices(1); + + (scored - top.clone(), top, arg_top, prev.squeeze_dim::<2>(2)) + } + + /// Walks each hop's path back six slots and fits a period contour. + #[allow(clippy::too_many_arguments)] + fn backtrace_and_fit( + &self, + xcorr_hist: &Tensor, + prev_hist: &Tensor, + weights: &Tensor, + cursor: Tensor, + steps: usize, + batch: usize, + total_slots: usize, + ) -> Tensor { + let slots = self.slots(); + let dif = self.dif_period; + let device = cursor.device(); + + let mut cursor = cursor; + let mut frame_corr: Tensor = Tensor::zeros([steps, batch], &device); + let mut sums = [ + Tensor::::zeros([steps, batch], &device), // sw + Tensor::::zeros([steps, batch], &device), // sx + Tensor::::zeros([steps, batch], &device), // sxx + Tensor::::zeros([steps, batch], &device), // sxy + Tensor::::zeros([steps, batch], &device), // sy + ]; + + // `k` counts back from the newest slot, so `sub = slots - 1 - k`, and + // hop `t` reads absolute slot `2t + sub`. + for k in 0..slots { + let sub = slots - 1 - k; + let lo = sub as isize; + let hi = lo + (SUBS_PER_HOP * steps) as isize - 1; + debug_assert!(hi <= total_slots as isize); + + // [batch, steps, ..] -> [steps, batch, ..] + let xc = xcorr_hist + .clone() + .slice(s![.., lo..hi;SUBS_PER_HOP as isize, 0..dif as isize]) + .swap_dims(0, 1); + let pp = prev_hist + .clone() + .slice(s![.., lo..hi;SUBS_PER_HOP as isize, ..]) + .swap_dims(0, 1); + let w = weights + .clone() + .slice(s![.., .., lo..lo + 1]) + .squeeze_dim::<2>(2) + .swap_dims(0, 1); + + let index = cursor.clone().unsqueeze_dim::<3>(2); + frame_corr = frame_corr + w.clone() * xc.gather(2, index.clone()).squeeze_dim::<2>(2); + + // period = max_period - cursor, as a float. + let period = cursor + .clone() + .float() + .neg() + .add_scalar(self.max_period as f32); + let x = sub as f32; + + sums[0] = sums[0].clone() + w.clone(); + sums[1] = sums[1].clone() + w.clone().mul_scalar(x); + sums[2] = sums[2].clone() + w.clone().mul_scalar(x).mul_scalar(x); + sums[3] = sums[3].clone() + w.clone().mul_scalar(x) * period.clone(); + sums[4] = sums[4].clone() + w * period; + + cursor = pp.gather(2, index).squeeze_dim::<2>(2); + } + + let [sw, sx, sxx, sxy, sy] = sums; + + let frame_corr = frame_corr.div_scalar(slots as f32).clamp_min(0.0f32); + let voiced = frame_corr.greater_equal_elem(VOICED_THRESHOLD); + + let numerator = sw.clone() * sxy - sx.clone() * sy.clone(); + let denominator = sw.clone() * sxx - sx.clone() * sx.clone(); + let degenerate = denominator.clone().equal_elem(0.0f32); + let slope = numerator / denominator.mask_fill(degenerate, 1e-15f32); + + // Cap the contour slope so one bad slot cannot swing the estimate. + let limit = + (sy.clone() / sw.clone()).div_scalar(4.0 * SUBS_PER_HOP as f32 * self.n_feat as f32); + let clamped = slope.max_pair(limit.clone().neg()).min_pair(limit); + let slope = clamped.mask_fill(voiced.clone().bool_not(), 0.0f32); + + let intercept = (sy - slope.clone() * sx) / sw; + let period = intercept + slope.mul_scalar(5.5f32); + + let hz = period.clamp_min(1.0f32).recip().mul_scalar(PROC_FS as f32); + hz.mask_fill(voiced.bool_not(), 0.0f32) + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::Tolerance; + + use super::{ + super::super::{ + TenVadPitchEstimator, + TenVadPitchScalarSource, + }, + *, + }; + use crate::{ + prelude::*, + support::testing::PerformanceBackend, + }; + + type B = PerformanceBackend; + + const HOP: usize = 256; + const N_BINS: usize = 513; + + fn pulse_hop( + f0: f32, + at: usize, + ) -> Vec { + let period = 16000.0 / f0; + (0..HOP) + .map(|i| { + let pos = (at + i) as f32 % period; + 8000.0 * (-pos / (period * 0.08)).exp() + }) + .collect() + } + + fn spectrum(step: usize) -> Vec { + (0..N_BINS) + .map(|k| { + let k = k as f32; + 1e7 * (-k / 70.0).exp() * (1.0 + 0.4 * (k * 0.05 + step as f32 * 0.3).sin()) + }) + .collect() + } + + /// Drives the host and captures, per hop, the two correlation slots it + /// produced, their energies, and the pitch it reported. + fn host_reference(steps: usize) -> (Vec, Vec, Vec) { + let mut est = TenVadPitchEstimator::new(); + let slots = est.slots(); + let (mut xc, mut fw, mut hz) = (Vec::new(), Vec::new(), Vec::new()); + + for step in 0..steps { + let pitch = est.frame_pitch(&pulse_hop(150.0, step * HOP), &spectrum(step)); + hz.push(pitch); + for sub in 0..SUBS_PER_HOP { + xc.extend_from_slice(est.xcorr_slot(slots - SUBS_PER_HOP + sub)); + fw.push(est.frm_weight()[slots - SUBS_PER_HOP + sub]); + } + } + (xc, fw, hz) + } + + fn config() -> PitchTrackConfig { + PitchTrackConfig::new() + } + + #[test] + fn test_gather_needs_a_contiguous_index() { + // `gather` ignores strides on a non-contiguous index tensor: given a + // transposed view it reads element 0 of each row rather than the + // indexed element. The backtrace's cursor is exactly such an index, so + // it is built with `stack` rather than `cat` + `swap_dims`. + // + // If this starts failing, burn has fixed the stride handling and the + // comment at the cursor construction is stale -- the `stack` is then + // merely tidy rather than load-bearing. + let device = Default::default(); + let (steps, wide) = (3usize, 56usize); + + let mut v = vec![0i32; steps * wide]; + for t in 0..steps { + for j in 0..wide { + v[t * wide + j] = (t as i32) * 1000 + j as i32; + } + } + let data = Tensor::::from_data(TensorData::new(v, [steps, 1, wide]), &device); + + let contiguous = Tensor::::full([steps, 1, 1], 37, &device); + let rows: Vec> = (0..steps) + .map(|_| Tensor::full([1, 1], 37, &device)) + .collect(); + let transposed = Tensor::cat(rows, 1).swap_dims(0, 1).unsqueeze_dim::<3>(2); + + let good: Vec = data + .clone() + .gather(2, contiguous) + .to_data_as::() + .to_vec_as::() + .unwrap(); + let bad: Vec = data + .gather(2, transposed) + .to_data_as::() + .to_vec_as::() + .unwrap(); + + assert_eq!( + good, + vec![37, 1037, 2037], + "contiguous index should be exact" + ); + assert_ne!( + good, bad, + "gather now honours strides on the index; the `stack` in \ + `backtrace_and_fit` is no longer load-bearing", + ); + } + + #[test] + fn test_config_meta() { + let cfg = config(); + assert_eq!(cfg.max_period(), 64); + assert_eq!(cfg.dif_period(), 56); + assert_eq!(cfg.n_feat(), 3); + assert_eq!(cfg.slots(), 6); + assert_eq!(cfg.carry_slots(), 4); + assert!(cfg.validate().is_ok()); + } + + #[test] + fn test_validate_rejects_bad_geometry() { + // A very long hop leaves fewer than two history frames. + assert!(config().with_hop_size(1 << 16).validate().is_err()); + } + + #[test] + fn test_penalty_matrix_reproduces_the_asymmetric_window() { + let cfg = config(); + let dif = cfg.dif_period(); + let pen = cfg.to_vec_penalty(); + + for idx in 0..dif { + let lo = idx.min(4); + let hi = (idx + 4).min(dif - 1); + let mut widest = 0usize; + for cand in 0..dif { + let valid = cand >= lo && cand <= hi; + let entry = pen[idx * dif + cand]; + if valid { + widest += 1; + let j = (cand as i32 - idx as i32).abs() as f32; + assert_eq!(entry, PITCH_MAX_PATH_W * j * j, "idx {idx} cand {cand}"); + } else { + assert_eq!(entry, INVALID_PENALTY, "idx {idx} cand {cand}"); + } + } + assert_eq!(widest, hi - lo + 1); + } + + // The window really is 5 wide at one end and 52 at the other. + let width = |idx: usize| (idx + 4).min(dif - 1) - idx.min(4) + 1; + assert_eq!(width(0), 5); + assert_eq!(width(dif - 1), 52); + } + + #[test] + fn test_forward_matches_host_stage() { + let device = Default::default(); + let steps = 12; + let (xc, fw, want) = host_reference(steps); + let cfg = config(); + let track: PitchTrack = cfg.init(&device); + + let xc_t = Tensor::::from_floats(xc.as_slice(), &device).reshape([ + steps, + 1, + SUBS_PER_HOP, + cfg.max_period(), + ]); + let fw_t = + Tensor::::from_floats(fw.as_slice(), &device).reshape([steps, 1, SUBS_PER_HOP]); + + let (got, _) = track.forward(xc_t, fw_t, track.init_state(1, &device)); + let got: Vec = got.to_data_as::().to_vec_as::().unwrap(); + + for (t, (g, w)) in got.iter().zip(want.iter()).enumerate() { + assert_eq!(*g > 0.0, *w > 0.0, "hop {t}: voicing disagrees, {g} vs {w}"); + if *w > 0.0 { + let rel = (g - w).abs() / w; + assert!(rel < 1e-3, "hop {t}: {g} Hz vs {w} Hz (rel {rel})"); + } + } + assert!( + want.iter().filter(|v| **v > 0.0).count() * 2 > steps, + "fixture should be mostly voiced", + ); + } + + #[test] + fn test_forward_sequence_matches_stepwise() { + let device = Default::default(); + let steps = 8; + let (xc, fw, _) = host_reference(steps); + let cfg = config(); + let track: PitchTrack = cfg.init(&device); + + let xc_t = Tensor::::from_floats(xc.as_slice(), &device).reshape([ + steps, + 1, + SUBS_PER_HOP, + cfg.max_period(), + ]); + let fw_t = + Tensor::::from_floats(fw.as_slice(), &device).reshape([steps, 1, SUBS_PER_HOP]); + + let (whole, _) = track.forward(xc_t.clone(), fw_t.clone(), track.init_state(1, &device)); + + let mut state = track.init_state(1, &device); + let mut stepwise = Vec::new(); + for step in 0..steps { + let x = xc_t + .clone() + .slice_dim(0, step as isize..(step + 1) as isize); + let f = fw_t + .clone() + .slice_dim(0, step as isize..(step + 1) as isize); + let (out, next) = track.forward(x, f, state); + state = next; + stepwise.extend(out.to_data_as::().to_vec_as::().unwrap()); + } + + whole.to_data().assert_approx_eq::( + &TensorData::new(stepwise, [steps, 1, 1]), + Tolerance::relative(1e-4), + ); + } + + #[test] + fn test_silence_is_unvoiced() { + let device = Default::default(); + let cfg = config(); + let track: PitchTrack = cfg.init(&device); + let steps = 4; + + let (out, _) = track.forward( + Tensor::zeros([steps, 1, SUBS_PER_HOP, cfg.max_period()], &device), + Tensor::zeros([steps, 1, SUBS_PER_HOP], &device), + track.init_state(1, &device), + ); + + let got: Vec = out.to_data_as::().to_vec_as::().unwrap(); + for (t, v) in got.iter().enumerate() { + assert!(v.is_finite(), "hop {t} went non-finite on silence"); + assert_eq!(*v, 0.0, "hop {t} reported {v} Hz on silence"); + } + } + + #[test] + fn test_batch_rows_are_independent() { + let device = Default::default(); + let steps = 6; + let (xc, fw, _) = host_reference(steps); + let cfg = config(); + let track: PitchTrack = cfg.init(&device); + let lags = cfg.max_period(); + + // Row 0 is the real fixture; row 1 is silence, which must stay + // unvoiced regardless of what row 0 is doing. + let mut xc_pair = Vec::new(); + let mut fw_pair = Vec::new(); + for step in 0..steps { + let base = step * SUBS_PER_HOP * lags; + xc_pair.extend_from_slice(&xc[base..base + SUBS_PER_HOP * lags]); + xc_pair.extend(std::iter::repeat_n(0.0f32, SUBS_PER_HOP * lags)); + fw_pair.extend_from_slice(&fw[step * SUBS_PER_HOP..(step + 1) * SUBS_PER_HOP]); + fw_pair.extend(std::iter::repeat_n(0.0f32, SUBS_PER_HOP)); + } + + let (out, _) = track.forward( + Tensor::::from_floats(xc_pair.as_slice(), &device).reshape([ + steps, + 2, + SUBS_PER_HOP, + lags, + ]), + Tensor::::from_floats(fw_pair.as_slice(), &device).reshape([ + steps, + 2, + SUBS_PER_HOP, + ]), + track.init_state(2, &device), + ); + let got: Vec = out.to_data_as::().to_vec_as::().unwrap(); + + let xc_t = Tensor::::from_floats(xc.as_slice(), &device).reshape([ + steps, + 1, + SUBS_PER_HOP, + lags, + ]); + let fw_t = + Tensor::::from_floats(fw.as_slice(), &device).reshape([steps, 1, SUBS_PER_HOP]); + let (solo, _) = track.forward(xc_t, fw_t, track.init_state(1, &device)); + let solo: Vec = solo.to_data_as::().to_vec_as::().unwrap(); + + for step in 0..steps { + assert!( + (got[step * 2] - solo[step]).abs() < 1e-3, + "hop {step}: row 0 {} vs solo {}", + got[step * 2], + solo[step], + ); + assert_eq!(got[step * 2 + 1], 0.0, "hop {step}: silent row 1 is voiced"); + } + } +} diff --git a/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs b/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs index 4cd4fc89..fee67633 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs @@ -342,6 +342,103 @@ mod tests { ); } + /// Pins the whole driver against the reference implementation. + /// + /// `testdata/ten/probs.json` holds one speech probability per hop over the + /// 60 s fixture, produced by driving the shipped `libten_vad.so` through + /// the reference Python binding -- the same `ten_vad_process` path every + /// other binding takes. See `testdata/ten/README.md` for the recipe. + /// + /// Unlike the other tests here, this one is end to end: the reference's own + /// front end and its own inference engine, against bunsen's front end and + /// its burn port of the graph. Nothing is shared between the two arms + /// except the audio. + #[test] + #[serial_test::serial] + fn test_reference_probability_golden() -> Result<(), Box> { + type B = PerformanceBackend; + type F = ::FloatElem; + + let device = Default::default(); + let vad: TenVad = TenVad::load_pretrained(&device)?; + let cfg = TenVadContextConfig::new(); + + let wav_path = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata/silero/test.wav"); + let golden_path = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata/ten/probs.json"); + + let (_, wav_vec) = load_audio_mono_sr(wav_path, cfg.sample_rate())?; + let expected: Vec = serde_json::from_reader( + std::fs::File::open(golden_path).map_err(BunsenError::external)?, + ) + .map_err(BunsenError::external)?; + + // The golden covers the whole 60 s fixture, but this arm runs the model + // once per hop on the device -- `context_forward_sequence` is a + // sequential loop, by necessity, since the LSTM state threads through + // it. So it is capped like the neighbouring cross test. Raising the cap + // costs patience, not correctness: the full 3750 hops runs for over a + // quarter of an hour, and the golden file holds all of them. + const STEPS: usize = 400; + + let steps = STEPS.min(expected.len()); + let samples = steps * cfg.hop_size(); + assert!( + wav_vec.len() >= samples, + "fixture too short for {steps} hops" + ); + + let mut ctx = vad.init_context(&cfg, &device)?; + let probs = vad.context_forward_audio(&wav_vec[..samples], &mut ctx)?; + let got: Vec = probs.to_data_as::().to_vec_as::().ok_or_panic(); + + let mut worst = 0.0f32; + let mut worst_at = 0usize; + let mut sum = 0.0f64; + let mut decisions = 0usize; + for (t, (&g, &e)) in got.iter().zip(expected.iter()).enumerate() { + let d = (g - e).abs(); + sum += d as f64; + if d > worst { + worst = d; + worst_at = t; + } + if (g >= 0.5) == (e >= 0.5) { + decisions += 1; + } + } + let mean = sum / steps as f64; + let agreement = decisions as f64 / steps as f64; + + eprintln!( + "reference probability golden: mean |diff| = {mean:.3e}, worst = {worst:.3e} \ + at hop {worst_at} (got {}, want {}), decisions agree {:.3}%", + got[worst_at], + expected[worst_at], + 100.0 * agreement, + ); + + // Measured, not guessed: two independent front ends and two independent + // inference engines land within 3e-5 of each other on average, and never + // disagree on the decision. The bounds sit an order of magnitude above + // that, so ordinary backend drift passes and a real regression does not. + assert_eq!( + decisions, + steps, + "speech/no-speech decisions disagree on {} of {steps} hops", + steps - decisions, + ); + assert!( + worst < 5e-3, + "worst probability error {worst:.3e} at hop {worst_at} is too large" + ); + assert!( + mean < 1e-3, + "mean probability error {mean:.3e} is too large" + ); + + Ok(()) + } + /// Pins the ported host pitch estimator against the ten-vad C reference. /// /// This is the anchor of the whole chain: every device stage is validated diff --git a/crates/bunsen/testdata/ten/README.md b/crates/bunsen/testdata/ten/README.md index 9d11d07f..dbe60786 100644 --- a/crates/bunsen/testdata/ten/README.md +++ b/crates/bunsen/testdata/ten/README.md @@ -1,5 +1,17 @@ # ten-vad reference fixtures +Two goldens over the same audio, `../silero/test.wav` (16 kHz mono, 60 s, +3750 hops of 256 samples): + +| file | what it pins | produced by | +|---|---|---| +| `probs.json` | the **whole driver** — front end and model | the reference Python binding | +| `pitch.json` | feature `40` alone | a harness around the reference C pitch estimator | + +`probs.json` is the stronger of the two: nothing is shared between it and +bunsen except the audio. `pitch.json` isolates one feature, which is what makes +a pitch regression legible instead of showing up as a drifting probability. + ## `pitch.json` One pitch estimate in Hz per 256-sample hop — `0.0` meaning unvoiced — over @@ -37,3 +49,48 @@ g++ -O2 -w -I"$TENVAD/src" -o dump_pitch dump_pitch.cc window.cc \ The dump prints `frameIndex pitchHz voiced` per line; only the pitch column is checked in, since the voicing flag is recoverable as `pitch > 0`. + + +## `probs.json` + +One speech probability per hop, from the reference implementation end to end: +its own front end, its own inference engine. Consumed by +`kits::speech::ten_vad::cross_test::tests::test_reference_probability_golden`. + +### Regenerating + +`gen_probs.py` drives the shipped `lib/Linux/x64/libten_vad.so` through +`include/ten_vad.py` — the same `ten_vad_process` entry point every binding +uses — from the reference repo's own venv. + +```sh +TENVAD=/path/to/ten-vad # https://github.com/TEN-framework/ten-vad +cd "$TENVAD" && .venv/bin/python /path/to/gen_probs.py # see the snippet below +``` + +`gen_probs.py` exposes `main(wav_path, out_path)`; call it with this repo's +fixture and `probs.json`. + +**The prebuilt `.so` needs LLVM's libc++, which Ubuntu does not install by +default.** It is not in the base image and the failure is an opaque +`OSError: libc++.so.1: cannot open shared object file`. You do not need root — +fetch the packages and unpack them locally: + +```sh +mkdir -p /tmp/libcxx && cd /tmp/libcxx +apt-get download libc++1-18 libc++abi1-18 libunwind-18 +for d in *.deb; do dpkg -x "$d" root; done +export LD_LIBRARY_PATH=/tmp/libcxx/root/usr/lib/x86_64-linux-gnu +``` + +Note `libunwind-18` specifically, not `libunwind8`: `libc++abi` wants +`libunwind.so.1`, and Ubuntu's `libunwind8` provides `libunwind.so.8`. +Verify with `ldd "$TENVAD/lib/Linux/x64/libten_vad.so" | grep "not found"` +returning nothing before running the generator. + +Building from source instead is *not* currently an option here: the ONNX +Runtime C headers `examples_onnx` needs are not on this machine, and the venv +ships the runtime shared library without them. + +At the time of writing the fixture yields 3750 hops, 76.2% flagged voiced, with +probabilities spanning `[0.158018, 0.992463]`. diff --git a/crates/bunsen/testdata/ten/gen_probs.py b/crates/bunsen/testdata/ten/gen_probs.py new file mode 100644 index 00000000..4f927d92 --- /dev/null +++ b/crates/bunsen/testdata/ten/gen_probs.py @@ -0,0 +1,46 @@ +"""Dump per-hop ten-vad speech probabilities from the reference Python API. + +Drives the shipped `libten_vad.so` through `include/ten_vad.py` -- the same +path every binding takes -- over a 16 kHz mono 16-bit WAV, and writes one +probability per 256-sample hop as a JSON array. +""" + +import json +import sys +import wave + +import numpy as np + +TENVAD = "/home/crutcher/git/ten-vad" +sys.path.insert(0, f"{TENVAD}/include") + +from ten_vad import TenVad # noqa: E402 + +HOP = 256 + + +def read_wav_i16(path): + with wave.open(path, "rb") as w: + assert w.getnchannels() == 1, "need mono" + assert w.getframerate() == 16000, "need 16 kHz" + assert w.getsampwidth() == 2, "need 16-bit" + return np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16) + + +def main(wav_path, out_path): + pcm = read_wav_i16(wav_path) + vad = TenVad(HOP) + + probs, flags = [], [] + for start in range(0, len(pcm) - HOP + 1, HOP): + p, f = vad.process(pcm[start : start + HOP]) + probs.append(float(p)) + flags.append(int(f)) + + with open(out_path, "w") as fh: + json.dump(probs, fh) + + voiced = sum(flags) + print(f"hops={len(probs)} voiced={voiced} ({100.0*voiced/len(probs):.1f}%)") + print(f"prob range = [{min(probs):.6f}, {max(probs):.6f}]") + print(f"first 6 = {[round(p, 6) for p in probs[:6]]}") diff --git a/crates/bunsen/testdata/ten/probs.json b/crates/bunsen/testdata/ten/probs.json new file mode 100644 index 00000000..d2ed52a4 --- /dev/null +++ b/crates/bunsen/testdata/ten/probs.json @@ -0,0 +1 @@ +[0.4347309172153473, 0.47515830397605896, 0.634301483631134, 0.6157386302947998, 0.7731263637542725, 0.8397694230079651, 0.8741436004638672, 0.9150571227073669, 0.9330025315284729, 0.9532800316810608, 0.9520593285560608, 0.9621211290359497, 0.9335916042327881, 0.9243623614311218, 0.9124754071235657, 0.8904513120651245, 0.869672417640686, 0.8829265236854553, 0.883919358253479, 0.9570224285125732, 0.9783459305763245, 0.9830892086029053, 0.9688560962677002, 0.9650677442550659, 0.9709174036979675, 0.9770057797431946, 0.9697279930114746, 0.9678077101707458, 0.9574220180511475, 0.9611145853996277, 0.9485361576080322, 0.909536600112915, 0.9056535363197327, 0.8970350623130798, 0.8600016832351685, 0.8563224077224731, 0.8153627514839172, 0.8024997115135193, 0.9393901824951172, 0.9777950048446655, 0.9819766283035278, 0.9733149409294128, 0.9720635414123535, 0.976760745048523, 0.9661584496498108, 0.9546088576316833, 0.9612016677856445, 0.9391518831253052, 0.9605535268783569, 0.9526790976524353, 0.9448208808898926, 0.949067234992981, 0.9626138806343079, 0.9625979661941528, 0.9542801380157471, 0.9522096514701843, 0.9646919369697571, 0.9717250466346741, 0.9773962497711182, 0.9644774198532104, 0.9570311903953552, 0.9549010396003723, 0.960566520690918, 0.9662436842918396, 0.9406200051307678, 0.9035511016845703, 0.932823896408081, 0.9143092036247253, 0.8685616850852966, 0.921394407749176, 0.9546607732772827, 0.9783619046211243, 0.9808034896850586, 0.977644681930542, 0.9786246418952942, 0.954021692276001, 0.8828718662261963, 0.8827206492424011, 0.8272019624710083, 0.7671042680740356, 0.7655988335609436, 0.6769703030586243, 0.8022854328155518, 0.8225851058959961, 0.7398791909217834, 0.9334598779678345, 0.949239194393158, 0.943031370639801, 0.9526827931404114, 0.9242148995399475, 0.9249481558799744, 0.9149012565612793, 0.8590354323387146, 0.7698906660079956, 0.8230780363082886, 0.9196460247039795, 0.9573173522949219, 0.9784214496612549, 0.9834638237953186, 0.9729050397872925, 0.9678264856338501, 0.9789609909057617, 0.9786746501922607, 0.942025899887085, 0.9108068943023682, 0.9076728224754333, 0.8669669032096863, 0.8663465976715088, 0.9437330365180969, 0.9492568969726562, 0.9586055874824524, 0.9790080785751343, 0.980204164981842, 0.974969208240509, 0.9686200618743896, 0.9659610986709595, 0.9395571947097778, 0.8826916217803955, 0.816008985042572, 0.7397842407226562, 0.7083166241645813, 0.6331366300582886, 0.5488724708557129, 0.4987609088420868, 0.39504045248031616, 0.39982303977012634, 0.35311222076416016, 0.3860498368740082, 0.3538299798965454, 0.3256303369998932, 0.29082849621772766, 0.2697599232196808, 0.25341832637786865, 0.2420981228351593, 0.2706129252910614, 0.218914195895195, 0.24458591639995575, 0.203888937830925, 0.21200372278690338, 0.2156609743833542, 0.19472208619117737, 0.23447343707084656, 0.1788518726825714, 0.29481902718544006, 0.21172955632209778, 0.19600749015808105, 0.24762196838855743, 0.20637696981430054, 0.18914814293384552, 0.19076231122016907, 0.17794065177440643, 0.19929318130016327, 0.2162361443042755, 0.18277156352996826, 0.2435605674982071, 0.1803799867630005, 0.2086007297039032, 0.2180902510881424, 0.2069670557975769, 0.1924772411584854, 0.21305695176124573, 0.17524589598178864, 0.18373239040374756, 0.172151118516922, 0.17528359591960907, 0.17028357088565826, 0.17970043420791626, 0.16447331011295319, 0.18017786741256714, 0.6150534152984619, 0.6084359288215637, 0.7713062763214111, 0.8726150393486023, 0.8927411437034607, 0.8995258212089539, 0.9228832125663757, 0.9706785678863525, 0.961256742477417, 0.9655203819274902, 0.9773051738739014, 0.9658750891685486, 0.9699926376342773, 0.9781372547149658, 0.9687091708183289, 0.9639894366264343, 0.9740104079246521, 0.9702774286270142, 0.9740222692489624, 0.9780114889144897, 0.9796692728996277, 0.9649354219436646, 0.9745440483093262, 0.9834263324737549, 0.9712008237838745, 0.9663116931915283, 0.9636574387550354, 0.9510175585746765, 0.9673471450805664, 0.9686323404312134, 0.9428260326385498, 0.9518412947654724, 0.9332392811775208, 0.9107797145843506, 0.872241199016571, 0.8411733508110046, 0.9619585871696472, 0.9770688414573669, 0.9845253825187683, 0.9727087020874023, 0.9800360798835754, 0.9861992597579956, 0.9890336990356445, 0.990841269493103, 0.9896613359451294, 0.9826291799545288, 0.975860059261322, 0.960662841796875, 0.8991693258285522, 0.8682814836502075, 0.8032066226005554, 0.7483699321746826, 0.8094578385353088, 0.76982182264328, 0.7623757719993591, 0.6966939568519592, 0.8169906139373779, 0.8630185723304749, 0.9585803747177124, 0.9627302885055542, 0.9610162377357483, 0.9435511231422424, 0.9358833432197571, 0.9310974478721619, 0.9364021420478821, 0.9330949783325195, 0.9551905989646912, 0.9166330695152283, 0.9056131839752197, 0.9233688116073608, 0.913018524646759, 0.894991934299469, 0.8021287322044373, 0.7574690580368042, 0.7481708526611328, 0.6354675889015198, 0.5355169773101807, 0.6928509473800659, 0.8587009310722351, 0.9441896080970764, 0.9715344309806824, 0.981023371219635, 0.9783995747566223, 0.975753903388977, 0.9738327264785767, 0.9680567979812622, 0.9677005410194397, 0.9679335951805115, 0.9553970694541931, 0.9581424593925476, 0.917259931564331, 0.8438509106636047, 0.7852317690849304, 0.757296085357666, 0.7427322268486023, 0.7223671674728394, 0.7093342542648315, 0.8827294707298279, 0.9491256475448608, 0.9658385515213013, 0.9769567847251892, 0.9860978126525879, 0.9860532879829407, 0.9797438383102417, 0.9822437167167664, 0.9855857491493225, 0.9396021366119385, 0.9108883738517761, 0.9184199571609497, 0.9227825999259949, 0.9197393655776978, 0.8888539671897888, 0.8960263729095459, 0.8803038597106934, 0.8348855972290039, 0.7726873159408569, 0.6649012565612793, 0.6081145405769348, 0.5431713461875916, 0.4930979609489441, 0.45554637908935547, 0.44084933400154114, 0.4104765057563782, 0.3933025300502777, 0.3923809826374054, 0.34522414207458496, 0.3456442058086395, 0.34217992424964905, 0.34546011686325073, 0.31917616724967957, 0.3143640160560608, 0.33036908507347107, 0.3052777349948883, 0.29686257243156433, 0.2915765047073364, 0.27525243163108826, 0.2662118077278137, 0.25369030237197876, 0.27407756447792053, 0.29197484254837036, 0.2989124655723572, 0.32209521532058716, 0.31506696343421936, 0.3057541847229004, 0.7253406643867493, 0.8467276692390442, 0.9488617777824402, 0.9785683751106262, 0.9852808117866516, 0.9834408760070801, 0.967033863067627, 0.9642782211303711, 0.9765268564224243, 0.975679337978363, 0.9748920798301697, 0.9798395037651062, 0.9739287495613098, 0.9705600738525391, 0.9729214906692505, 0.9580682516098022, 0.9608242511749268, 0.9808278679847717, 0.9800997376441956, 0.9768680334091187, 0.9708252549171448, 0.9708322286605835, 0.9771461486816406, 0.976373553276062, 0.9691992998123169, 0.9429720044136047, 0.9728500843048096, 0.963934063911438, 0.9656205177307129, 0.969840943813324, 0.96141517162323, 0.9695647358894348, 0.9777058959007263, 0.9797638654708862, 0.9742518067359924, 0.9666478633880615, 0.9635240435600281, 0.9339616298675537, 0.9108095765113831, 0.9195385575294495, 0.9060268402099609, 0.83519047498703, 0.8357665538787842, 0.9509608149528503, 0.9738761782646179, 0.9921995997428894, 0.9904487729072571, 0.9903098940849304, 0.9874173402786255, 0.9890243411064148, 0.9874640703201294, 0.9810232520103455, 0.9853094220161438, 0.9823784232139587, 0.9496814012527466, 0.8808132410049438, 0.8093429803848267, 0.8042737245559692, 0.8102610111236572, 0.7823472619056702, 0.8408312201499939, 0.9574429988861084, 0.9481812715530396, 0.9772754311561584, 0.969714879989624, 0.9659664034843445, 0.9658356308937073, 0.9692407250404358, 0.9808257222175598, 0.9671186208724976, 0.9222717881202698, 0.9206124544143677, 0.8714179396629333, 0.8067312836647034, 0.8816224932670593, 0.9626299738883972, 0.9790492057800293, 0.9776833057403564, 0.9798688292503357, 0.9757375717163086, 0.9812191128730774, 0.9817124009132385, 0.9740628600120544, 0.9687978029251099, 0.9682782292366028, 0.9696394801139832, 0.94125896692276, 0.8727837204933167, 0.9324745535850525, 0.9374993443489075, 0.9296034574508667, 0.910161554813385, 0.8956136703491211, 0.8808828592300415, 0.971560537815094, 0.9772690534591675, 0.9769104719161987, 0.9818488359451294, 0.9836852550506592, 0.97450190782547, 0.9735636711120605, 0.9693701863288879, 0.9717127680778503, 0.9760149121284485, 0.9693787097930908, 0.953919529914856, 0.9006743431091309, 0.9041758179664612, 0.8815745711326599, 0.849861741065979, 0.7863995432853699, 0.7108714580535889, 0.6677485704421997, 0.6063154935836792, 0.5420957803726196, 0.4594666659832001, 0.41416922211647034, 0.37292030453681946, 0.3589310646057129, 0.33008623123168945, 0.3210504651069641, 0.3118075728416443, 0.29261982440948486, 0.2863694131374359, 0.27159976959228516, 0.2586139440536499, 0.25962722301483154, 0.24915668368339539, 0.25802984833717346, 0.24018420279026031, 0.24982696771621704, 0.23950278759002686, 0.22644446790218353, 0.2975410521030426, 0.23789481818675995, 0.3013440668582916, 0.23704679310321808, 0.22423957288265228, 0.2572096884250641, 0.21666015684604645, 0.2189324051141739, 0.21812564134597778, 0.2740340530872345, 0.3120449483394623, 0.3735952079296112, 0.4156877100467682, 0.4009109139442444, 0.3902879059314728, 0.3891994059085846, 0.34108754992485046, 0.38543346524238586, 0.3594039976596832, 0.32017460465431213, 0.30460721254348755, 0.3153355121612549, 0.3042779266834259, 0.2803873121738434, 0.3102017343044281, 0.3480941951274872, 0.31539419293403625, 0.33460453152656555, 0.5327432751655579, 0.5296030044555664, 0.554072380065918, 0.5655971169471741, 0.5137251615524292, 0.5298445224761963, 0.535720944404602, 0.48923760652542114, 0.47748368978500366, 0.46416351199150085, 0.46762219071388245, 0.5659537315368652, 0.5312755107879639, 0.472050279378891, 0.46444767713546753, 0.43604013323783875, 0.43990838527679443, 0.40535208582878113, 0.43954282999038696, 0.41597387194633484, 0.4253772497177124, 0.40484747290611267, 0.47539493441581726, 0.42470183968544006, 0.5196078419685364, 0.4084310233592987, 0.3863700330257416, 0.4223930537700653, 0.39112967252731323, 0.43321165442466736, 0.3572658896446228, 0.360352486371994, 0.5768749117851257, 0.5610027313232422, 0.48428797721862793, 0.46386125683784485, 0.4125010371208191, 0.4033864438533783, 0.384443074464798, 0.3502306342124939, 0.3483252227306366, 0.33205732703208923, 0.3176964819431305, 0.30222323536872864, 0.28739479184150696, 0.28371530771255493, 0.29283514618873596, 0.3112122714519501, 0.28486692905426025, 0.3083345293998718, 0.3137710988521576, 0.3202096223831177, 0.27638736367225647, 0.2998162806034088, 0.3003966510295868, 0.30444711446762085, 0.3167206346988678, 0.27859461307525635, 0.2830662131309509, 0.29528340697288513, 0.2904144525527954, 0.2785608172416687, 0.29669684171676636, 0.3516319692134857, 0.31360986828804016, 0.26005783677101135, 0.251934289932251, 0.24472570419311523, 0.2734731435775757, 0.28681543469429016, 0.2670074701309204, 0.33691462874412537, 0.29404884576797485, 0.27510973811149597, 0.33560171723365784, 0.27015843987464905, 0.2762143909931183, 0.27164313197135925, 0.2571170926094055, 0.25090426206588745, 0.26062700152397156, 0.23600874841213226, 0.2603212296962738, 0.26076358556747437, 0.24640421569347382, 0.22755619883537292, 0.21270214021205902, 0.22442276775836945, 0.21905404329299927, 0.24852395057678223, 0.20648665726184845, 0.22007688879966736, 0.28033512830734253, 0.2269134521484375, 0.20382599532604218, 0.20358531177043915, 0.3284345865249634, 0.35166770219802856, 0.23699967563152313, 0.23441307246685028, 0.2483457773923874, 0.21966961026191711, 0.24109789729118347, 0.2020830512046814, 0.1955588459968567, 0.18979895114898682, 0.1811036914587021, 0.20080362260341644, 0.17867255210876465, 0.19813165068626404, 0.41937923431396484, 0.760829508304596, 0.8739201426506042, 0.9576482772827148, 0.9887987971305847, 0.9924631118774414, 0.9920421242713928, 0.9898576736450195, 0.9888455271720886, 0.9844822883605957, 0.9650546312332153, 0.9772464036941528, 0.983053982257843, 0.9790481328964233, 0.9658358693122864, 0.9605967402458191, 0.9405859112739563, 0.9600124359130859, 0.9189451336860657, 0.9272586107254028, 0.9022501707077026, 0.8974308967590332, 0.8650203943252563, 0.7950756549835205, 0.8021166324615479, 0.9240617156028748, 0.9573239088058472, 0.9886536002159119, 0.945398211479187, 0.9202943444252014, 0.9252008199691772, 0.9326017498970032, 0.9146451950073242, 0.8889470100402832, 0.8629505038261414, 0.8650996088981628, 0.9391250014305115, 0.9683542847633362, 0.9869001507759094, 0.9833021759986877, 0.9831716418266296, 0.9815961718559265, 0.9850084781646729, 0.9809901714324951, 0.9708157181739807, 0.9702393412590027, 0.9673976898193359, 0.9566164612770081, 0.9449154734611511, 0.9284470081329346, 0.9256223440170288, 0.84034264087677, 0.7351484298706055, 0.6966939568519592, 0.6645194888114929, 0.6309051513671875, 0.5604258179664612, 0.5502214431762695, 0.48374640941619873, 0.4244653284549713, 0.46472737193107605, 0.43451008200645447, 0.39615464210510254, 0.4269546568393707, 0.38785314559936523, 0.5584068894386292, 0.5270147919654846, 0.5310192108154297, 0.5497889518737793, 0.6632466912269592, 0.6673207879066467, 0.6697757244110107, 0.6829806566238403, 0.7183830142021179, 0.7385655045509338, 0.8303871750831604, 0.8379261493682861, 0.9261586666107178, 0.9594857096672058, 0.9686163663864136, 0.9651381373405457, 0.9559793472290039, 0.9565877914428711, 0.9600542187690735, 0.9746034741401672, 0.9047738313674927, 0.8624377846717834, 0.8436534404754639, 0.8310922384262085, 0.8633124232292175, 0.894628643989563, 0.8925532102584839, 0.8796141147613525, 0.8592367172241211, 0.8013467192649841, 0.8284722566604614, 0.7886117696762085, 0.9265817999839783, 0.9668485522270203, 0.9386457800865173, 0.9044296145439148, 0.834973931312561, 0.7304445505142212, 0.663960874080658, 0.6388273239135742, 0.8063559532165527, 0.8645942211151123, 0.9494290351867676, 0.9676709771156311, 0.9785812497138977, 0.9753198623657227, 0.9735521078109741, 0.9723594188690186, 0.9690086245536804, 0.9645776748657227, 0.9646881818771362, 0.9588437080383301, 0.944889485836029, 0.9292600750923157, 0.9443470239639282, 0.8768323659896851, 0.817201554775238, 0.746437132358551, 0.6963678002357483, 0.6452497243881226, 0.6137204766273499, 0.5956531167030334, 0.5741050839424133, 0.5573492646217346, 0.5601413249969482, 0.5038692355155945, 0.5345808267593384, 0.4502868354320526, 0.49571019411087036, 0.7843158841133118, 0.9093626141548157, 0.9556125998497009, 0.9573540687561035, 0.9593991041183472, 0.9593213200569153, 0.9644976258277893, 0.9598654508590698, 0.9540367722511292, 0.951960027217865, 0.9413352012634277, 0.9593043327331543, 0.9660422801971436, 0.9713550806045532, 0.9455697536468506, 0.970523476600647, 0.9551326036453247, 0.9549623727798462, 0.9535315632820129, 0.9054816365242004, 0.8748160004615784, 0.8008553385734558, 0.7480955719947815, 0.7925148010253906, 0.8149203658103943, 0.7615514993667603, 0.6841320395469666, 0.908970296382904, 0.9595618844032288, 0.9668670296669006, 0.9554417729377747, 0.9747529625892639, 0.9733940958976746, 0.9774980545043945, 0.9642102718353271, 0.9661735892295837, 0.9482677578926086, 0.9231400489807129, 0.8154504299163818, 0.771166980266571, 0.7933038473129272, 0.681037425994873, 0.6025776267051697, 0.5040963292121887, 0.43556466698646545, 0.4213205575942993, 0.44335827231407166, 0.41685110330581665, 0.7554832696914673, 0.8322114944458008, 0.8893023133277893, 0.9433383345603943, 0.9669613242149353, 0.9512847661972046, 0.9736010432243347, 0.9663941860198975, 0.9465352296829224, 0.9523863196372986, 0.9420344829559326, 0.9452541470527649, 0.9181493520736694, 0.9248393177986145, 0.9188230037689209, 0.9102655053138733, 0.9029735326766968, 0.9224616289138794, 0.954002857208252, 0.964686930179596, 0.9495260715484619, 0.9392251968383789, 0.9489132761955261, 0.9529579877853394, 0.9148277044296265, 0.9324617981910706, 0.9145174622535706, 0.8894158005714417, 0.8832728862762451, 0.8971477746963501, 0.9159551858901978, 0.9393796920776367, 0.959900975227356, 0.9677600264549255, 0.9607122540473938, 0.9090737700462341, 0.7976622581481934, 0.6887193322181702, 0.6180357336997986, 0.5102595686912537, 0.4660508632659912, 0.4089835584163666, 0.3606475293636322, 0.6982128620147705, 0.896109938621521, 0.9452812075614929, 0.9570749402046204, 0.9746448993682861, 0.9691120982170105, 0.9791237115859985, 0.9556480646133423, 0.9630535840988159, 0.9631954431533813, 0.9652307629585266, 0.9573624730110168, 0.9459826350212097, 0.8789263963699341, 0.7973383069038391, 0.7766985297203064, 0.7084320783615112, 0.6614581942558289, 0.5981994271278381, 0.558088481426239, 0.49716633558273315, 0.44093674421310425, 0.41412103176116943, 0.36525246500968933, 0.334041565656662, 0.31828445196151733, 0.3032197058200836, 0.2918481230735779, 0.3122580051422119, 0.3063274919986725, 0.29086270928382874, 0.28878796100616455, 0.3062109649181366, 0.3079194724559784, 0.32021835446357727, 0.31384050846099854, 0.31165438890457153, 0.29319390654563904, 0.5754246115684509, 0.7885477542877197, 0.8697357177734375, 0.9515659809112549, 0.9708242416381836, 0.9720312356948853, 0.9562342762947083, 0.9409674406051636, 0.929382860660553, 0.9339426159858704, 0.9262422323226929, 0.9432744979858398, 0.9680089950561523, 0.9781562089920044, 0.9814023375511169, 0.9656717777252197, 0.968864381313324, 0.9668260216712952, 0.9715549945831299, 0.9736868143081665, 0.9767255187034607, 0.9753783941268921, 0.9709507822990417, 0.9744423627853394, 0.9705496430397034, 0.9763745665550232, 0.9548593163490295, 0.937550961971283, 0.8758043050765991, 0.8637571930885315, 0.8690196871757507, 0.8058924674987793, 0.831350564956665, 0.8255152106285095, 0.8856863975524902, 0.894844114780426, 0.8849827647209167, 0.8801731467247009, 0.8546658158302307, 0.8081912994384766, 0.7748565077781677, 0.7182729840278625, 0.6884744763374329, 0.5890471935272217, 0.5274632573127747, 0.5261973142623901, 0.49515458941459656, 0.45490923523902893, 0.42426618933677673, 0.4096580147743225, 0.379944384098053, 0.3402133882045746, 0.30239707231521606, 0.34636780619621277, 0.8054733872413635, 0.9199417233467102, 0.9733191132545471, 0.9758120179176331, 0.9764957427978516, 0.9757338166236877, 0.9824437499046326, 0.9850704669952393, 0.9816884398460388, 0.9808535575866699, 0.9796701669692993, 0.9738053679466248, 0.9702284932136536, 0.9756419062614441, 0.956666886806488, 0.962721049785614, 0.951688826084137, 0.9735523462295532, 0.9683524966239929, 0.9672962427139282, 0.9462940096855164, 0.9446654319763184, 0.9452629089355469, 0.9548702836036682, 0.9460601210594177, 0.9441578984260559, 0.9422258734703064, 0.9462825059890747, 0.9277600646018982, 0.902238130569458, 0.9101614952087402, 0.9295827746391296, 0.9031249284744263, 0.8762879371643066, 0.8321371078491211, 0.8139147162437439, 0.7989272475242615, 0.756671667098999, 0.663919985294342, 0.6087402701377869, 0.5838429927825928, 0.4837989807128906, 0.44950199127197266, 0.4807884693145752, 0.450351357460022, 0.47038641571998596, 0.43298017978668213, 0.4127671718597412, 0.3998953402042389, 0.38644400238990784, 0.3786303699016571, 0.392229825258255, 0.3609742820262909, 0.39138272404670715, 0.4040311574935913, 0.387505441904068, 0.6081894040107727, 0.5917055010795593, 0.6039740443229675, 0.7543684840202332, 0.8196699619293213, 0.8625162839889526, 0.9055361747741699, 0.9285416007041931, 0.9413475394248962, 0.951726496219635, 0.9532330632209778, 0.9518670439720154, 0.9489439725875854, 0.9446156024932861, 0.9435141682624817, 0.9457388520240784, 0.9438509941101074, 0.9459414482116699, 0.9497382640838623, 0.9569255113601685, 0.9477983713150024, 0.9349594116210938, 0.9315558075904846, 0.9368135333061218, 0.8880116939544678, 0.8566408753395081, 0.7931568026542664, 0.7832033634185791, 0.721686601638794, 0.6486073136329651, 0.6261914372444153, 0.5816399455070496, 0.5338131785392761, 0.5128796100616455, 0.5027530789375305, 0.48301976919174194, 0.46879518032073975, 0.4146742522716522, 0.4744032919406891, 0.43155404925346375, 0.4022071659564972, 0.3852686285972595, 0.36209285259246826, 0.3699539303779602, 0.3550279140472412, 0.30694320797920227, 0.29676175117492676, 0.28260666131973267, 0.2514928877353668, 0.24595054984092712, 0.256268709897995, 0.22148093581199646, 0.26170727610588074, 0.22535942494869232, 0.22009685635566711, 0.25929680466651917, 0.4017038643360138, 0.4879840612411499, 0.35133352875709534, 0.5279508829116821, 0.6099032163619995, 0.43580275774002075, 0.47840696573257446, 0.4824923276901245, 0.7708032727241516, 0.894352912902832, 0.976507306098938, 0.9817859530448914, 0.9761261940002441, 0.9908754825592041, 0.9888250231742859, 0.9759239554405212, 0.9626786112785339, 0.9595414400100708, 0.9586009979248047, 0.903904914855957, 0.8371107578277588, 0.8499054908752441, 0.7912514209747314, 0.7355625033378601, 0.7230972647666931, 0.7651104927062988, 0.811451256275177, 0.6999116539955139, 0.8266658186912537, 0.8942216038703918, 0.9707410335540771, 0.983735203742981, 0.9794971942901611, 0.9784498810768127, 0.9758514165878296, 0.9718335866928101, 0.9771875739097595, 0.9618597626686096, 0.9150376915931702, 0.9105156660079956, 0.8854820728302002, 0.8403854370117188, 0.9213917255401611, 0.9055948853492737, 0.9509115815162659, 0.981383740901947, 0.952172040939331, 0.9632627964019775, 0.9713901877403259, 0.9757516384124756, 0.9755414724349976, 0.9800272583961487, 0.9736874103546143, 0.9661734700202942, 0.9561760425567627, 0.9678887724876404, 0.9640821814537048, 0.9634374976158142, 0.9698652625083923, 0.9615628719329834, 0.9568464756011963, 0.9530805349349976, 0.954140841960907, 0.9484478831291199, 0.9482684135437012, 0.9555251598358154, 0.9149298071861267, 0.8570690751075745, 0.8368924856185913, 0.8499436378479004, 0.8054186701774597, 0.7913954257965088, 0.8021271228790283, 0.7090511322021484, 0.7549440860748291, 0.7141533493995667, 0.8850876092910767, 0.9496605396270752, 0.979692816734314, 0.9856413006782532, 0.9849748015403748, 0.9816131591796875, 0.9859160780906677, 0.9842705130577087, 0.9770047664642334, 0.9651378989219666, 0.9619680047035217, 0.9600962996482849, 0.9192236661911011, 0.9156500697135925, 0.8894267678260803, 0.8407412767410278, 0.7913156747817993, 0.7977457642555237, 0.7509345412254333, 0.7085087895393372, 0.6478537917137146, 0.5634317398071289, 0.6446895599365234, 0.7357609868049622, 0.7588710784912109, 0.6155433058738708, 0.5312288999557495, 0.47677379846572876, 0.42929714918136597, 0.3691733181476593, 0.3561420440673828, 0.3404930830001831, 0.3215193748474121, 0.30332061648368835, 0.2813814580440521, 0.2850731611251831, 0.2584543526172638, 0.2973208427429199, 0.37051647901535034, 0.3640283942222595, 0.3398119807243347, 0.4106110632419586, 0.32725587487220764, 0.31648173928260803, 0.29269489645957947, 0.2771168351173401, 0.2766619324684143, 0.267632395029068, 0.2746061086654663, 0.293012410402298, 0.2606816291809082, 0.24588289856910706, 0.24500423669815063, 0.2470301389694214, 0.2370148003101349, 0.2393234819173813, 0.22355514764785767, 0.23121851682662964, 0.22540654242038727, 0.2886947989463806, 0.4843694865703583, 0.5148196220397949, 0.6567721366882324, 0.8319687843322754, 0.8824970126152039, 0.8943357467651367, 0.9315172433853149, 0.9392838478088379, 0.9463707804679871, 0.9609060287475586, 0.9640381932258606, 0.9531676173210144, 0.9480997323989868, 0.9442018866539001, 0.9513987898826599, 0.9547904133796692, 0.9536683559417725, 0.9476613402366638, 0.9555532336235046, 0.9579781889915466, 0.9561097621917725, 0.9280408024787903, 0.9027942419052124, 0.8789730668067932, 0.8535560369491577, 0.7907541394233704, 0.7715292572975159, 0.7266207933425903, 0.6783638000488281, 0.6164119243621826, 0.5760602355003357, 0.5267441272735596, 0.4674623906612396, 0.4302937686443329, 0.3841308355331421, 0.3551434874534607, 0.34056350588798523, 0.34045344591140747, 0.65069180727005, 0.5641891360282898, 0.7894467115402222, 0.839697539806366, 0.9270361065864563, 0.9569458961486816, 0.9684730768203735, 0.9617319703102112, 0.9562239050865173, 0.949955403804779, 0.9489307999610901, 0.9578976631164551, 0.9599618315696716, 0.9593009352684021, 0.9610852003097534, 0.9542064070701599, 0.947124183177948, 0.9540011882781982, 0.9590019583702087, 0.9603127241134644, 0.9531154036521912, 0.9378238916397095, 0.9401199817657471, 0.9450852870941162, 0.9466704726219177, 0.9433326125144958, 0.9351113438606262, 0.925359845161438, 0.9124582409858704, 0.890873372554779, 0.8826355338096619, 0.8400772213935852, 0.8015239834785461, 0.7472202181816101, 0.6397705674171448, 0.5485677719116211, 0.4840475618839264, 0.41285181045532227, 0.3819662928581238, 0.35628291964530945, 0.3318576514720917, 0.3120812773704529, 0.28458020091056824, 0.2723526954650879, 0.28067901730537415, 0.25520721077919006, 0.27378106117248535, 0.24977299571037292, 0.30000633001327515, 0.2660488188266754, 0.24874551594257355, 0.24038589000701904, 0.22439837455749512, 0.24119237065315247, 0.2498476803302765, 0.25326868891716003, 0.2526177763938904, 0.223526269197464, 0.23267248272895813, 0.2859538495540619, 0.2429407238960266, 0.2230018973350525, 0.22720922529697418, 0.21932634711265564, 0.21358467638492584, 0.21085453033447266, 0.28468087315559387, 0.19922097027301788, 0.22235320508480072, 0.214102104306221, 0.2125415951013565, 0.20601128041744232, 0.20472398400306702, 0.1996159702539444, 0.21810202300548553, 0.2120342254638672, 0.21587608754634857, 0.2119036167860031, 0.20152564346790314, 0.208140030503273, 0.235897034406662, 0.19597390294075012, 0.2344246357679367, 0.21867989003658295, 0.20585492253303528, 0.2486845701932907, 0.5654891729354858, 0.7457510828971863, 0.6966585516929626, 0.8807029724121094, 0.9474034905433655, 0.9775091409683228, 0.9858426451683044, 0.9845607280731201, 0.9817948341369629, 0.9780964255332947, 0.9709057211875916, 0.971498429775238, 0.9691793918609619, 0.9690424203872681, 0.9595292806625366, 0.9775590300559998, 0.9758819341659546, 0.9735586643218994, 0.972734808921814, 0.9807701110839844, 0.9741594791412354, 0.9628534913063049, 0.9441680312156677, 0.9518596529960632, 0.8964146375656128, 0.8780890703201294, 0.8394854068756104, 0.8801535367965698, 0.9356369972229004, 0.9599472880363464, 0.9704251289367676, 0.9623143672943115, 0.9506623148918152, 0.9520403146743774, 0.9655352830886841, 0.9662638306617737, 0.9291477799415588, 0.8534581661224365, 0.8474740386009216, 0.8528721332550049, 0.8495866656303406, 0.8551170825958252, 0.8598807454109192, 0.8623019456863403, 0.8254008889198303, 0.8199454545974731, 0.7953333258628845, 0.7531125545501709, 0.8441919684410095, 0.7814847230911255, 0.77562016248703, 0.8817114233970642, 0.9482693672180176, 0.9839527010917664, 0.9815022349357605, 0.9791259765625, 0.9780464768409729, 0.9750108122825623, 0.9759568572044373, 0.9471477270126343, 0.955076277256012, 0.9076077342033386, 0.9010933041572571, 0.8651494979858398, 0.9199130535125732, 0.9441470503807068, 0.9678016901016235, 0.9737535119056702, 0.9694380164146423, 0.9695680737495422, 0.9686051607131958, 0.9717308878898621, 0.9723715782165527, 0.9712432026863098, 0.9669153094291687, 0.9697525501251221, 0.969572901725769, 0.9674976468086243, 0.9673702120780945, 0.9663604497909546, 0.9646022915840149, 0.9674457311630249, 0.9650730490684509, 0.9184421896934509, 0.8501889109611511, 0.7025635242462158, 0.6737613677978516, 0.6543558239936829, 0.6714280843734741, 0.6645691394805908, 0.8772815465927124, 0.8919380903244019, 0.9475818872451782, 0.9672839045524597, 0.944229781627655, 0.9461450576782227, 0.9509994983673096, 0.9195000529289246, 0.8762838840484619, 0.8515474796295166, 0.7490358352661133, 0.7841236591339111, 0.7371806502342224, 0.9158537983894348, 0.9475558996200562, 0.979775071144104, 0.9825014472007751, 0.9818648099899292, 0.9822370409965515, 0.9799847602844238, 0.980108916759491, 0.9765616655349731, 0.9767187833786011, 0.9744155406951904, 0.9781453609466553, 0.9700219035148621, 0.9685354828834534, 0.9619933366775513, 0.9657567143440247, 0.9598094820976257, 0.9433161616325378, 0.9254295229911804, 0.9454783201217651, 0.9025640487670898, 0.9019063115119934, 0.949966311454773, 0.9617822170257568, 0.9772088527679443, 0.9771534204483032, 0.920791745185852, 0.8757771849632263, 0.8489853739738464, 0.8223941922187805, 0.767111599445343, 0.7005158066749573, 0.6925103664398193, 0.6377076506614685, 0.80690997838974, 0.9436255097389221, 0.9873411059379578, 0.9787455797195435, 0.9817558526992798, 0.976398766040802, 0.9760749936103821, 0.9778754711151123, 0.9760943055152893, 0.9622342586517334, 0.9495000839233398, 0.9405749440193176, 0.9505040645599365, 0.9610510468482971, 0.9735432863235474, 0.9760265946388245, 0.9786695241928101, 0.9756529927253723, 0.9750246405601501, 0.9601575136184692, 0.9380545616149902, 0.8366378545761108, 0.8196806907653809, 0.8412862420082092, 0.8936781287193298, 0.8553050756454468, 0.7695577144622803, 0.7129757404327393, 0.6746921539306641, 0.5841575860977173, 0.8079014420509338, 0.9070843458175659, 0.9688367247581482, 0.9755218625068665, 0.9877628087997437, 0.9843193888664246, 0.9794520139694214, 0.9699208736419678, 0.9624909162521362, 0.9657617211341858, 0.969264566898346, 0.9499032497406006, 0.8780487179756165, 0.8505847454071045, 0.7940227389335632, 0.7239900231361389, 0.6623162627220154, 0.7512370944023132, 0.6417914032936096, 0.613503098487854, 0.7399544715881348, 0.938641369342804, 0.9749290347099304, 0.9604828953742981, 0.9718331098556519, 0.9712147116661072, 0.9742062091827393, 0.8504905700683594, 0.7871770858764648, 0.7270814776420593, 0.6818640828132629, 0.8645983338356018, 0.9012147784233093, 0.9448687434196472, 0.9548655152320862, 0.9718582630157471, 0.9678483605384827, 0.9666253924369812, 0.9558316469192505, 0.8736246228218079, 0.826874852180481, 0.806049108505249, 0.8270283937454224, 0.9386371970176697, 0.9594258069992065, 0.9644824266433716, 0.9741545915603638, 0.9739651679992676, 0.9750508069992065, 0.9774351119995117, 0.9679669737815857, 0.9586553573608398, 0.950574517250061, 0.964322566986084, 0.9511865377426147, 0.9561848640441895, 0.955624520778656, 0.9676769971847534, 0.9794358015060425, 0.9717652201652527, 0.981234610080719, 0.9798306822776794, 0.9767065048217773, 0.9687745571136475, 0.9447720050811768, 0.9290333390235901, 0.9132704138755798, 0.8729478716850281, 0.9050441384315491, 0.9305678009986877, 0.9590732455253601, 0.9688008427619934, 0.9744150042533875, 0.9655559062957764, 0.9733201265335083, 0.9663428664207458, 0.9726772904396057, 0.9665431976318359, 0.9651381373405457, 0.9665298461914062, 0.901101291179657, 0.8864167928695679, 0.8570606708526611, 0.8631668090820312, 0.8395199775695801, 0.902178943157196, 0.9312687516212463, 0.9614580273628235, 0.9745517373085022, 0.9764038920402527, 0.9763975143432617, 0.9653806686401367, 0.9699762463569641, 0.9725295305252075, 0.9727484583854675, 0.9713118076324463, 0.9672238826751709, 0.9709082245826721, 0.9732436537742615, 0.9805749654769897, 0.9707453846931458, 0.9362950325012207, 0.9413785338401794, 0.9093613028526306, 0.8536832928657532, 0.8755443692207336, 0.9080640077590942, 0.9048090577125549, 0.9512402415275574, 0.9813825488090515, 0.9869520664215088, 0.9862980842590332, 0.984754204750061, 0.9828376770019531, 0.9881224036216736, 0.9832320213317871, 0.9801401495933533, 0.9816099405288696, 0.9788538217544556, 0.9779219627380371, 0.9744958877563477, 0.9681451916694641, 0.9677433967590332, 0.9689415097236633, 0.9616340398788452, 0.9623388051986694, 0.9713554382324219, 0.9759774208068848, 0.9725437164306641, 0.9712498188018799, 0.968161940574646, 0.9692177772521973, 0.9088467359542847, 0.9237954020500183, 0.9265376925468445, 0.9070208668708801, 0.8562467098236084, 0.8534302115440369, 0.9205986261367798, 0.944075345993042, 0.943327009677887, 0.9650123119354248, 0.9656237363815308, 0.9750347137451172, 0.9634724855422974, 0.9742736220359802, 0.9651108980178833, 0.9703237414360046, 0.9632161259651184, 0.969866931438446, 0.9618404507637024, 0.972919225692749, 0.9737610816955566, 0.9699034690856934, 0.9690722227096558, 0.9654086828231812, 0.9702638387680054, 0.9646649956703186, 0.9609599113464355, 0.955845296382904, 0.960192859172821, 0.967018723487854, 0.9712843298912048, 0.9662788510322571, 0.9688383340835571, 0.9815520644187927, 0.9812901616096497, 0.980661153793335, 0.9587464928627014, 0.9789935350418091, 0.960519552230835, 0.9507593512535095, 0.9505518674850464, 0.9680989980697632, 0.9661222100257874, 0.9504343867301941, 0.8569257259368896, 0.8014504313468933, 0.8508244156837463, 0.868436872959137, 0.834105908870697, 0.8208452463150024, 0.7896125316619873, 0.9049859642982483, 0.9312783479690552, 0.9596607685089111, 0.9490830302238464, 0.9654217958450317, 0.9555656313896179, 0.9658587574958801, 0.9558175206184387, 0.9516378045082092, 0.8836703896522522, 0.7923056483268738, 0.7269179821014404, 0.6652235984802246, 0.5952589511871338, 0.5827327370643616, 0.49459418654441833, 0.4640483856201172, 0.42722171545028687, 0.40542906522750854, 0.40385520458221436, 0.3386964201927185, 0.3162359297275543, 0.3174704313278198, 0.2994628846645355, 0.31555554270744324, 0.29467999935150146, 0.33847370743751526, 0.3651933968067169, 0.4222211241722107, 0.6518768668174744, 0.8104949593544006, 0.9231330752372742, 0.9720202684402466, 0.979941189289093, 0.9827271103858948, 0.9623492360115051, 0.9733553528785706, 0.962984025478363, 0.9316075444221497, 0.9336385726928711, 0.9043137431144714, 0.9202242493629456, 0.8923919200897217, 0.8783496618270874, 0.9541491270065308, 0.9649885892868042, 0.9857655763626099, 0.988461971282959, 0.9650126695632935, 0.9711479544639587, 0.961085855960846, 0.902938187122345, 0.8815451264381409, 0.8915153741836548, 0.867794394493103, 0.9589200019836426, 0.972090482711792, 0.9665794968605042, 0.9791914820671082, 0.9544004201889038, 0.9652278423309326, 0.9475774765014648, 0.8908705115318298, 0.8132067322731018, 0.7828447818756104, 0.8673164248466492, 0.9465125799179077, 0.9751171469688416, 0.985869288444519, 0.9871063232421875, 0.9874526858329773, 0.981147050857544, 0.9797859787940979, 0.9741155505180359, 0.9634208083152771, 0.9679197072982788, 0.8939864039421082, 0.9217003583908081, 0.8873468637466431, 0.9422335028648376, 0.9744216799736023, 0.9833105802536011, 0.9827516674995422, 0.9449757933616638, 0.9572578072547913, 0.9517872929573059, 0.945343017578125, 0.9189321398735046, 0.8513001799583435, 0.7120230197906494, 0.683588981628418, 0.9030085206031799, 0.9417439699172974, 0.954758882522583, 0.9614033102989197, 0.981878936290741, 0.9661928415298462, 0.9772137403488159, 0.9823452830314636, 0.9897038340568542, 0.9871904253959656, 0.9868554472923279, 0.9834923148155212, 0.982012927532196, 0.9815783500671387, 0.976733922958374, 0.9765952825546265, 0.9556712508201599, 0.9450042247772217, 0.9173257350921631, 0.9604183435440063, 0.9726066589355469, 0.9817456007003784, 0.9811348915100098, 0.9847933053970337, 0.9891281723976135, 0.9837679266929626, 0.9807921051979065, 0.9794419407844543, 0.9751639366149902, 0.9818578958511353, 0.9575890302658081, 0.9542904496192932, 0.9745383858680725, 0.9796496629714966, 0.9791041612625122, 0.9826846718788147, 0.9736207127571106, 0.9718464016914368, 0.9620027542114258, 0.9653453826904297, 0.9649872183799744, 0.9704560041427612, 0.9745473265647888, 0.9741292595863342, 0.9712803959846497, 0.972149133682251, 0.9703741669654846, 0.9706548452377319, 0.9721274971961975, 0.9746415019035339, 0.9750351905822754, 0.9743875861167908, 0.974513590335846, 0.97264164686203, 0.9732493162155151, 0.9656878709793091, 0.9559805393218994, 0.9405809640884399, 0.8689736127853394, 0.8107065558433533, 0.752322793006897, 0.7157682776451111, 0.7187077403068542, 0.6579494476318359, 0.6205576062202454, 0.5484887957572937, 0.5208185315132141, 0.49717870354652405, 0.5020574927330017, 0.4883727729320526, 0.47168296575546265, 0.440326988697052, 0.4165874123573303, 0.419736385345459, 0.4197153151035309, 0.4025854170322418, 0.39954641461372375, 0.40380993485450745, 0.416507363319397, 0.3904968798160553, 0.8165537714958191, 0.8478125333786011, 0.9232754111289978, 0.9570276737213135, 0.9233292937278748, 0.9084837436676025, 0.8560179471969604, 0.7998550534248352, 0.7685621380805969, 0.7710633873939514, 0.7292231917381287, 0.9294908046722412, 0.9196001291275024, 0.9615587592124939, 0.9789626002311707, 0.9631757736206055, 0.9654574990272522, 0.9690120220184326, 0.9717644453048706, 0.969978392124176, 0.9762982130050659, 0.9767867922782898, 0.9807395935058594, 0.9771293997764587, 0.9755941033363342, 0.9715452194213867, 0.9726875424385071, 0.9524410367012024, 0.9620274305343628, 0.9524422287940979, 0.9386889338493347, 0.921347975730896, 0.9195610284805298, 0.8895316123962402, 0.8743607401847839, 0.8372955322265625, 0.7911867499351501, 0.8077003955841064, 0.8137556314468384, 0.7802161574363708, 0.8487710356712341, 0.910834014415741, 0.9295873045921326, 0.9567962884902954, 0.9674583673477173, 0.9462165236473083, 0.9496059417724609, 0.9512524008750916, 0.9568655490875244, 0.9625552892684937, 0.9818775653839111, 0.9866182208061218, 0.9825409054756165, 0.9751945734024048, 0.9551237225532532, 0.9549460411071777, 0.9623160362243652, 0.9553215503692627, 0.9405341148376465, 0.9473423957824707, 0.942558228969574, 0.9085822105407715, 0.9246655106544495, 0.8945214152336121, 0.8268392086029053, 0.8050501942634583, 0.8216559886932373, 0.8648381233215332, 0.9457864165306091, 0.9847263693809509, 0.9877855777740479, 0.9885293841362, 0.9854318499565125, 0.9813781976699829, 0.9836981296539307, 0.985080897808075, 0.9803099036216736, 0.9765126705169678, 0.9792542457580566, 0.9733161330223083, 0.9706294536590576, 0.6648507714271545, 0.6592761278152466, 0.7244044542312622, 0.7203340530395508, 0.7516839504241943, 0.7285681366920471, 0.8298481702804565, 0.915993869304657, 0.9598581194877625, 0.9611147046089172, 0.9589844346046448, 0.9680719971656799, 0.9721459150314331, 0.9595434665679932, 0.9526958465576172, 0.9577984809875488, 0.9570990800857544, 0.9655317068099976, 0.9586706757545471, 0.9521898627281189, 0.9304940104484558, 0.9199660420417786, 0.9074868559837341, 0.8788831233978271, 0.8644260168075562, 0.9558330774307251, 0.9589148163795471, 0.9655898213386536, 0.9736241102218628, 0.9726818799972534, 0.9754600524902344, 0.9701703786849976, 0.9655485153198242, 0.9615176916122437, 0.9379848837852478, 0.8614794611930847, 0.8067842125892639, 0.7310707569122314, 0.6199994087219238, 0.5997992753982544, 0.6105345487594604, 0.546142578125, 0.5044264197349548, 0.5096054673194885, 0.45400846004486084, 0.4419284462928772, 0.4207126498222351, 0.43653953075408936, 0.36741700768470764, 0.3802265226840973, 0.39380866289138794, 0.38843852281570435, 0.3524465560913086, 0.5315241813659668, 0.6046318411827087, 0.7821096777915955, 0.8647176623344421, 0.9143548011779785, 0.930306077003479, 0.9458413124084473, 0.9475984573364258, 0.9244115948677063, 0.9587003588676453, 0.9586514830589294, 0.8628623485565186, 0.8129808306694031, 0.884538471698761, 0.9221017956733704, 0.8908369541168213, 0.8780956864356995, 0.9635571241378784, 0.9745341539382935, 0.9836492538452148, 0.9688693284988403, 0.9737623333930969, 0.9613674879074097, 0.9654874801635742, 0.9527843594551086, 0.9461551904678345, 0.9274216890335083, 0.9335674047470093, 0.9579134583473206, 0.9741708040237427, 0.9822516441345215, 0.9816407561302185, 0.9794955849647522, 0.9796096682548523, 0.9783158898353577, 0.9777513742446899, 0.9773926138877869, 0.9728090167045593, 0.9627601504325867, 0.9407639503479004, 0.9733110666275024, 0.9753702878952026, 0.9734526872634888, 0.9657219052314758, 0.9728239178657532, 0.9758027195930481, 0.980387270450592, 0.9607051014900208, 0.9223342537879944, 0.9508378505706787, 0.9252031445503235, 0.9508153200149536, 0.970060408115387, 0.9729869365692139, 0.908656895160675, 0.9252480864524841, 0.9146966338157654, 0.8790549635887146, 0.905273973941803, 0.8991640210151672, 0.8174188137054443, 0.791660726070404, 0.76542729139328, 0.8703635931015015, 0.9294608235359192, 0.9622548818588257, 0.9709638357162476, 0.9803378582000732, 0.9801304340362549, 0.9704450964927673, 0.9485501050949097, 0.9519245028495789, 0.9434482455253601, 0.9186373949050903, 0.9447261691093445, 0.9311696887016296, 0.897223949432373, 0.9353144764900208, 0.9445376396179199, 0.9678842425346375, 0.9241800904273987, 0.8913542628288269, 0.9144954085350037, 0.9006198048591614, 0.921698272228241, 0.9494302272796631, 0.9638233780860901, 0.953683614730835, 0.9553409218788147, 0.9618688225746155, 0.9575957655906677, 0.9525513052940369, 0.9438641667366028, 0.9272187352180481, 0.9219503998756409, 0.9163036942481995, 0.9237087965011597, 0.8771845698356628, 0.8461354374885559, 0.8493492603302002, 0.8098078370094299, 0.7428613901138306, 0.6895901560783386, 0.6381773948669434, 0.5735471844673157, 0.4756903052330017, 0.4471265971660614, 0.40992113947868347, 0.3747453987598419, 0.3621343970298767, 0.3406643569469452, 0.3219118118286133, 0.3239901065826416, 0.30553585290908813, 0.28713440895080566, 0.26260077953338623, 0.2495788335800171, 0.36926400661468506, 0.66435706615448, 0.7471330165863037, 0.8131622076034546, 0.8821017146110535, 0.9080814123153687, 0.9568327069282532, 0.9334079027175903, 0.948250412940979, 0.9661505818367004, 0.9676405191421509, 0.9428774118423462, 0.9419678449630737, 0.9474524855613708, 0.9422173500061035, 0.94012850522995, 0.9522485733032227, 0.9668121933937073, 0.9637511968612671, 0.9637921452522278, 0.9617382287979126, 0.9501404166221619, 0.9587835073471069, 0.9496629238128662, 0.9316422939300537, 0.9161521792411804, 0.8908224701881409, 0.8310384154319763, 0.8503373861312866, 0.8648136854171753, 0.8253213167190552, 0.8199498653411865, 0.7699125409126282, 0.7141855955123901, 0.6930603384971619, 0.6557822823524475, 0.6337140202522278, 0.7633876204490662, 0.7854065895080566, 0.9276068806648254, 0.9610463976860046, 0.9711002707481384, 0.966808557510376, 0.9693686366081238, 0.9598360657691956, 0.9564975500106812, 0.9554885029792786, 0.9598326086997986, 0.9637487530708313, 0.961932897567749, 0.9574767351150513, 0.9514248371124268, 0.9512600898742676, 0.9504267573356628, 0.9507203698158264, 0.9425586462020874, 0.9402831196784973, 0.9430176615715027, 0.9613277912139893, 0.9585182070732117, 0.9530366063117981, 0.9400612115859985, 0.9507986903190613, 0.9530974626541138, 0.9499576687812805, 0.9251615405082703, 0.8158518075942993, 0.7456303238868713, 0.7309276461601257, 0.7526067495346069, 0.7350371479988098, 0.7409915328025818, 0.8680034875869751, 0.8786419034004211, 0.931411623954773, 0.949889063835144, 0.9557179808616638, 0.9561349749565125, 0.9474422335624695, 0.9516090750694275, 0.9541062712669373, 0.9437803030014038, 0.9484035968780518, 0.9383028745651245, 0.9362013936042786, 0.9516807198524475, 0.9487447142601013, 0.9353539943695068, 0.9337794780731201, 0.936615526676178, 0.9403994083404541, 0.8972999453544617, 0.8342923521995544, 0.7808993458747864, 0.8050814867019653, 0.8436107635498047, 0.9031067490577698, 0.9570356607437134, 0.9298087358474731, 0.8970509767532349, 0.9390662908554077, 0.9207864999771118, 0.9038916230201721, 0.9375726580619812, 0.9354654550552368, 0.962529718875885, 0.9705693125724792, 0.9669255614280701, 0.9722554087638855, 0.970442533493042, 0.9685345888137817, 0.9667460322380066, 0.9664993286132812, 0.9663022756576538, 0.9401913285255432, 0.9451084733009338, 0.9178813099861145, 0.8656411170959473, 0.7702390551567078, 0.6890288591384888, 0.6523339748382568, 0.581917405128479, 0.4841526448726654, 0.4312840700149536, 0.40748631954193115, 0.5948838591575623, 0.6720612049102783, 0.6805526614189148, 0.7178061604499817, 0.7629356384277344, 0.8098915219306946, 0.8431330323219299, 0.7615322470664978, 0.7291755676269531, 0.6701950430870056, 0.679032564163208, 0.8156265616416931, 0.9290739893913269, 0.9858623147010803, 0.986814022064209, 0.9749429225921631, 0.9687748551368713, 0.9639660716056824, 0.969515323638916, 0.9752041697502136, 0.9775274991989136, 0.9674063920974731, 0.9639527797698975, 0.9660309553146362, 0.9659349322319031, 0.9692496061325073, 0.9557057619094849, 0.9518802165985107, 0.942139208316803, 0.9412243962287903, 0.9274985790252686, 0.922083854675293, 0.9009718894958496, 0.8731514811515808, 0.8263649344444275, 0.7729319930076599, 0.8096332550048828, 0.8348467946052551, 0.8067536354064941, 0.8123453855514526, 0.7982338666915894, 0.7529315948486328, 0.641084611415863, 0.5806806683540344, 0.5051531791687012, 0.41880789399147034, 0.3852623999118805, 0.3792881667613983, 0.3644458055496216, 0.3770821988582611, 0.3899378776550293, 0.3490631878376007, 0.36238816380500793, 0.33365869522094727, 0.36897820234298706, 0.3360327184200287, 0.3203619122505188, 0.32916781306266785, 0.32114410400390625, 0.3281060457229614, 0.3585468530654907, 0.3814878463745117, 0.4041312336921692, 0.4178156554698944, 0.39220625162124634, 0.3326468765735626, 0.41287973523139954, 0.5920257568359375, 0.7664608955383301, 0.878863513469696, 0.9563500285148621, 0.9738453030586243, 0.9802879095077515, 0.9832388162612915, 0.9830867052078247, 0.9616643786430359, 0.9633840918540955, 0.9727665781974792, 0.9726579785346985, 0.980541467666626, 0.9835432767868042, 0.9750708937644958, 0.9490838646888733, 0.9381515979766846, 0.9570898413658142, 0.9482967257499695, 0.9155230522155762, 0.9230781197547913, 0.8646079897880554, 0.8295158743858337, 0.8344897627830505, 0.7891899943351746, 0.9501407146453857, 0.9748153686523438, 0.9774437546730042, 0.9501933455467224, 0.9387313723564148, 0.8767077326774597, 0.8902820944786072, 0.8950128555297852, 0.9743836522102356, 0.9885324239730835, 0.9901190996170044, 0.9898243546485901, 0.9851030111312866, 0.9751997590065002, 0.9771501421928406, 0.9563408493995667, 0.8985996842384338, 0.8658072352409363, 0.8592076897621155, 0.8519335389137268, 0.8920494914054871, 0.8606423735618591, 0.961201012134552, 0.9720712304115295, 0.9726864099502563, 0.9790465831756592, 0.9754706025123596, 0.9807217121124268, 0.9584640860557556, 0.946379542350769, 0.9291703104972839, 0.9237011075019836, 0.8975062966346741, 0.8339776992797852, 0.7792274355888367, 0.6798990368843079, 0.6309837698936462, 0.6586589813232422, 0.5640984177589417, 0.5263160467147827, 0.6680205464363098, 0.8195554614067078, 0.9127960205078125, 0.9568942785263062, 0.9671033620834351, 0.9777352809906006, 0.9763681888580322, 0.976425051689148, 0.9708117842674255, 0.9439218640327454, 0.9373526573181152, 0.9490092396736145, 0.9512718319892883, 0.9656789898872375, 0.9308155179023743, 0.8767697811126709, 0.8480103015899658, 0.8052466511726379, 0.7390987277030945, 0.8388835191726685, 0.8789787888526917, 0.9546384811401367, 0.9600632190704346, 0.9689100384712219, 0.9703843593597412, 0.9698735475540161, 0.971527636051178, 0.9711363911628723, 0.9774157404899597, 0.9731462001800537, 0.9717611074447632, 0.9611141085624695, 0.9268242120742798, 0.9256731867790222, 0.8845226168632507, 0.9147124886512756, 0.9143177270889282, 0.9152815937995911, 0.8917317390441895, 0.8465257287025452, 0.8252117037773132, 0.781197190284729, 0.724669337272644, 0.6635237336158752, 0.6229680180549622, 0.537986695766449, 0.44687533378601074, 0.4048595130443573, 0.3732193410396576, 0.39400872588157654, 0.3686250150203705, 0.328748881816864, 0.3234812915325165, 0.32393190264701843, 0.3141918480396271, 0.2631748616695404, 0.2740667164325714, 0.25286850333213806, 0.30158478021621704, 0.266790509223938, 0.2914336919784546, 0.26092493534088135, 0.2585377097129822, 0.24078857898712158, 0.27553844451904297, 0.24365973472595215, 0.23676009476184845, 0.2504320740699768, 0.2508661150932312, 0.2612222135066986, 0.26134809851646423, 0.7214850783348083, 0.7954569458961487, 0.8567533493041992, 0.9189543724060059, 0.9354320764541626, 0.9569189548492432, 0.9730129241943359, 0.9746481776237488, 0.9684818387031555, 0.962405264377594, 0.9550866484642029, 0.9532449245452881, 0.9559159874916077, 0.9568641781806946, 0.955157995223999, 0.9479323625564575, 0.9503206014633179, 0.947820782661438, 0.9495266079902649, 0.9562627077102661, 0.957944929599762, 0.9515560865402222, 0.9477932453155518, 0.9442406296730042, 0.9446254968643188, 0.9441259503364563, 0.9390265941619873, 0.8862606883049011, 0.7989927530288696, 0.8524237275123596, 0.8337173461914062, 0.745220959186554, 0.7635445594787598, 0.7230520248413086, 0.7851918339729309, 0.7929466366767883, 0.8827900290489197, 0.9409583806991577, 0.9695888161659241, 0.9798508286476135, 0.9770094752311707, 0.976477324962616, 0.9717038869857788, 0.951370120048523, 0.9085182547569275, 0.802880048751831, 0.8578030467033386, 0.8839621543884277, 0.842708170413971, 0.8121179342269897, 0.8630914688110352, 0.7980328798294067, 0.7709130644798279, 0.7767056822776794, 0.7503010034561157, 0.7257140278816223, 0.7197538614273071, 0.6881259679794312, 0.64284747838974, 0.6111069917678833, 0.6088583469390869, 0.5529464483261108, 0.533830463886261, 0.4909309446811676, 0.447075217962265, 0.42501458525657654, 0.4216526448726654, 0.40775594115257263, 0.39343422651290894, 0.34263792634010315, 0.33453983068466187, 0.31338027119636536, 0.31141456961631775, 0.3012884259223938, 0.26825952529907227, 0.2643945515155792, 0.2686876654624939, 0.2670772671699524, 0.24508452415466309, 0.3259686231613159, 0.2506600618362427, 0.2837313413619995, 0.33220863342285156, 0.2863152027130127, 0.23767036199569702, 0.23972585797309875, 0.22300714254379272, 0.2545608580112457, 0.254420667886734, 0.21668513119220734, 0.22034499049186707, 0.23892736434936523, 0.22095079720020294, 0.24497246742248535, 0.29737722873687744, 0.22263440489768982, 0.24000447988510132, 0.2165110558271408, 0.24611830711364746, 0.4434596598148346, 0.32118645310401917, 0.26326099038124084, 0.3177723288536072, 0.22768308222293854, 0.26283878087997437, 0.2803887128829956, 0.2654942274093628, 0.22933469712734222, 0.21837612986564636, 0.2044493556022644, 0.22867156565189362, 0.1968264877796173, 0.20264005661010742, 0.19783717393875122, 0.4410948157310486, 0.4053645730018616, 0.27696436643600464, 0.22341755032539368, 0.28032028675079346, 0.2187264859676361, 0.3401680886745453, 0.4831465482711792, 0.37549737095832825, 0.6871476769447327, 0.8149428963661194, 0.922805666923523, 0.9676086902618408, 0.9660021662712097, 0.9761768579483032, 0.9626352787017822, 0.9629302024841309, 0.9360528588294983, 0.8142060041427612, 0.7913415431976318, 0.8405918478965759, 0.7685621976852417, 0.8747352957725525, 0.9212195873260498, 0.9271408319473267, 0.8872449398040771, 0.8912235498428345, 0.8969898819923401, 0.9537410140037537, 0.9727126955986023, 0.9651150107383728, 0.9739949107170105, 0.9720847010612488, 0.9702333211898804, 0.9609987139701843, 0.9516850709915161, 0.9630250930786133, 0.960908830165863, 0.9708285331726074, 0.9730241894721985, 0.9716897010803223, 0.9750030040740967, 0.9387317299842834, 0.965476930141449, 0.9529119729995728, 0.9570401310920715, 0.9609758257865906, 0.967645525932312, 0.9602756500244141, 0.9290027022361755, 0.880181074142456, 0.8674073219299316, 0.908400297164917, 0.8986555337905884, 0.8698582649230957, 0.8317967057228088, 0.9693467020988464, 0.9842929244041443, 0.9857866764068604, 0.9851921796798706, 0.9741432666778564, 0.9807296395301819, 0.974787712097168, 0.9534935355186462, 0.9809457659721375, 0.9482606053352356, 0.9409705996513367, 0.938757061958313, 0.8857810497283936, 0.8303777575492859, 0.7623159885406494, 0.7004286646842957, 0.6142296195030212, 0.5368038415908813, 0.8841937780380249, 0.9581726789474487, 0.974294900894165, 0.9726197719573975, 0.9713501334190369, 0.9728485941886902, 0.9657554626464844, 0.9651772975921631, 0.9252489805221558, 0.9301113486289978, 0.9447241425514221, 0.9279177784919739, 0.9016997218132019, 0.8983352780342102, 0.8809592723846436, 0.8970319032669067, 0.9516566395759583, 0.968431293964386, 0.9842188954353333, 0.9776363968849182, 0.9775102734565735, 0.971100389957428, 0.9660816788673401, 0.9741097092628479, 0.9805284142494202, 0.9764071702957153, 0.9526178240776062, 0.958143413066864, 0.9374309182167053, 0.920763373374939, 0.8717261552810669, 0.8522964119911194, 0.8492449522018433, 0.809053361415863, 0.7733982801437378, 0.9272019863128662, 0.9378477334976196, 0.947329044342041, 0.9818869829177856, 0.9795291423797607, 0.986076295375824, 0.9777942299842834, 0.972396731376648, 0.9537582397460938, 0.9529072046279907, 0.8364185690879822, 0.7695951461791992, 0.7747868299484253, 0.7326703667640686, 0.7401015758514404, 0.6383869647979736, 0.5951937437057495, 0.5824648141860962, 0.5408241152763367, 0.4707247018814087, 0.4232078790664673, 0.3862702548503876, 0.3701564371585846, 0.3656787574291229, 0.37766900658607483, 0.4029025733470917, 0.43126100301742554, 0.4044428765773773, 0.704838216304779, 0.508928120136261, 0.8359355330467224, 0.849555253982544, 0.9420745968818665, 0.9639768004417419, 0.9590085744857788, 0.9728397130966187, 0.9856522083282471, 0.9877474308013916, 0.9852437973022461, 0.9837303161621094, 0.9566075205802917, 0.9544271230697632, 0.9645535945892334, 0.9731407165527344, 0.9512416124343872, 0.9519820809364319, 0.9752547740936279, 0.9836553335189819, 0.9869821667671204, 0.9878173470497131, 0.9822005033493042, 0.9707859754562378, 0.952094316482544, 0.9564095735549927, 0.9585437178611755, 0.9534828066825867, 0.9289135932922363, 0.8959243893623352, 0.8963378071784973, 0.8169806003570557, 0.7772862911224365, 0.7309890985488892, 0.6866658329963684, 0.6273927688598633, 0.8664839863777161, 0.9275774359703064, 0.9802353382110596, 0.9821348190307617, 0.9835471510887146, 0.9850804209709167, 0.9862150549888611, 0.984574019908905, 0.9837536215782166, 0.9816379547119141, 0.9831597208976746, 0.9804098010063171, 0.973334550857544, 0.9321109652519226, 0.8624599575996399, 0.8660039901733398, 0.8566469550132751, 0.8721725344657898, 0.8582370281219482, 0.9057832360267639, 0.9112524390220642, 0.9625996351242065, 0.9704756140708923, 0.9731695055961609, 0.9664431810379028, 0.9675651788711548, 0.9682259559631348, 0.9673032760620117, 0.9650393128395081, 0.9680815935134888, 0.9678042531013489, 0.9627382755279541, 0.9522377252578735, 0.902705192565918, 0.8592835664749146, 0.8159361481666565, 0.807440996170044, 0.814155638217926, 0.751592218875885, 0.6792372465133667, 0.6016720533370972, 0.5448048114776611, 0.5747032165527344, 0.5443814396858215, 0.5016879439353943, 0.4921482503414154, 0.42750850319862366, 0.40614891052246094, 0.39559829235076904, 0.37027934193611145, 0.4158032536506653, 0.37112361192703247, 0.37285640835762024, 0.35944199562072754, 0.3374050259590149, 0.3400120735168457, 0.3102942705154419, 0.27221882343292236, 0.3048272728919983, 0.2938934564590454, 0.2911960780620575, 0.2574363648891449, 0.28271427750587463, 0.2926904857158661, 0.2907300889492035, 0.2889770269393921, 0.30589842796325684, 0.5906850695610046, 0.6813890933990479, 0.8143640756607056, 0.9014596939086914, 0.9464881420135498, 0.947046160697937, 0.9232462644577026, 0.9115266799926758, 0.8838421702384949, 0.8380899429321289, 0.8222535252571106, 0.7547191381454468, 0.6895163059234619, 0.9188401103019714, 0.9683378338813782, 0.9861061573028564, 0.9917703866958618, 0.9885778427124023, 0.983307957649231, 0.9791897535324097, 0.9841392636299133, 0.9800077080726624, 0.9271294474601746, 0.9276115298271179, 0.9358231425285339, 0.8838834166526794, 0.8678010106086731, 0.8935697674751282, 0.9035952687263489, 0.9603519439697266, 0.9746977090835571, 0.9831016659736633, 0.9544111490249634, 0.9615272283554077, 0.9501550197601318, 0.9404358863830566, 0.9434657692909241, 0.9214474558830261, 0.9354188442230225, 0.8879818320274353, 0.9637526273727417, 0.9717724323272705, 0.9870882034301758, 0.9885347485542297, 0.9899460673332214, 0.9841392636299133, 0.974464476108551, 0.976469099521637, 0.9712762236595154, 0.9312928318977356, 0.958807110786438, 0.9565520882606506, 0.9352819323539734, 0.894624650478363, 0.7543086409568787, 0.7198749780654907, 0.6910592317581177, 0.681401789188385, 0.6167152523994446, 0.577948808670044, 0.5392956733703613, 0.5011746883392334, 0.4997584819793701, 0.46576637029647827, 0.434741348028183, 0.4153071343898773, 0.4218631982803345, 0.40894919633865356, 0.37290653586387634, 0.35429880023002625, 0.337309330701828, 0.3423137664794922, 0.3122822344303131, 0.3036866784095764, 0.3206649720668793, 0.2882578670978546, 0.2749808132648468, 0.29896092414855957, 0.2972623109817505, 0.2821738123893738, 0.28431621193885803, 0.25837188959121704, 0.28291386365890503, 0.2753778100013733, 0.272758424282074, 0.26376351714134216, 0.23964641988277435, 0.28426623344421387, 0.4342532753944397, 0.5834724307060242, 0.6238303184509277, 0.8071704506874084, 0.8285002112388611, 0.9092614650726318, 0.95701664686203, 0.9789976477622986, 0.9796978235244751, 0.983355700969696, 0.9778574705123901, 0.9663776159286499, 0.9643132090568542, 0.9734472632408142, 0.9765772223472595, 0.9747332334518433, 0.9628205895423889, 0.9640008211135864, 0.9671496152877808, 0.972252607345581, 0.9758526682853699, 0.9731517434120178, 0.9620950818061829, 0.9749693274497986, 0.9723446369171143, 0.9686697125434875, 0.946017861366272, 0.9466298818588257, 0.9766765236854553, 0.9830090403556824, 0.9817909002304077, 0.9740340113639832, 0.9655282497406006, 0.9661206007003784, 0.9736230969429016, 0.9654633402824402, 0.9619333148002625, 0.9589837789535522, 0.9563320279121399, 0.9536311626434326, 0.9471473097801208, 0.9132620692253113, 0.800035297870636, 0.800710916519165, 0.7743441462516785, 0.8213777542114258, 0.8078840970993042, 0.8413349390029907, 0.846535861492157, 0.8850516676902771, 0.8919388055801392, 0.8950411677360535, 0.8906539082527161, 0.8886547088623047, 0.875676155090332, 0.9250879883766174, 0.9519292712211609, 0.9242890477180481, 0.9364089369773865, 0.958101212978363, 0.9574153423309326, 0.9566835761070251, 0.9073790907859802, 0.9096469879150391, 0.8654682636260986, 0.7889764308929443, 0.7063426971435547, 0.6891340613365173, 0.6341444849967957, 0.5886331796646118, 0.5272114276885986, 0.44510984420776367, 0.4420534670352936, 0.4102003276348114, 0.8683489561080933, 0.9460129141807556, 0.944242537021637, 0.9600932002067566, 0.975648045539856, 0.9540386199951172, 0.9419835209846497, 0.9223236441612244, 0.9113845825195312, 0.8943091034889221, 0.8527902960777283, 0.7860000729560852, 0.7493616342544556, 0.7223779559135437, 0.6588051319122314, 0.550440788269043, 0.5047625303268433, 0.4651917815208435, 0.4254971444606781, 0.36056196689605713, 0.4519222676753998, 0.4470372796058655, 0.4977191388607025, 0.7088656425476074, 0.7284537553787231, 0.6368567943572998, 0.6592244505882263, 0.6703067421913147, 0.6665939688682556, 0.6834341287612915, 0.6677055358886719, 0.6138311624526978, 0.6087766289710999, 0.5766423940658569, 0.5253371596336365, 0.499644935131073, 0.459176242351532, 0.3997170627117157, 0.38358500599861145, 0.35156524181365967, 0.28898149728775024, 0.29517480731010437, 0.29152682423591614, 0.2637845277786255, 0.27852872014045715, 0.2483769655227661, 0.2318117320537567, 0.24124020338058472, 0.22054432332515717, 0.20866043865680695, 0.21006052196025848, 0.2019113451242447, 0.20770683884620667, 0.20369839668273926, 0.1935015469789505, 0.2004743218421936, 0.1817328929901123, 0.18502277135849, 0.1882632076740265, 0.18530821800231934, 0.19262617826461792, 0.18093429505825043, 0.18003658950328827, 0.18284910917282104, 0.17228396236896515, 0.1756897121667862, 0.1807820349931717, 0.19652071595191956, 0.1936233937740326, 0.20659485459327698, 0.20085981488227844, 0.18241503834724426, 0.19700123369693756, 0.18388192355632782, 0.18091510236263275, 0.20085962116718292, 0.1749596744775772, 0.2854946255683899, 0.4062231183052063, 0.45744240283966064, 0.4775230288505554, 0.4495212733745575, 0.4534164071083069, 0.41418561339378357, 0.37901440262794495, 0.3694520592689514, 0.36075347661972046, 0.3335374891757965, 0.3101147711277008, 0.31019535660743713, 0.28874218463897705, 0.34500423073768616, 0.2992706000804901, 0.29625600576400757, 0.31201714277267456, 0.28464964032173157, 0.2911951243877411, 0.3134045898914337, 0.285616934299469, 0.3085631728172302, 0.288631796836853, 0.29069983959198, 0.3113158643245697, 0.3232225775718689, 0.3120618760585785, 0.289497047662735, 0.2886534035205841, 0.30468258261680603, 0.28729724884033203, 0.2824070155620575, 0.26170840859413147, 0.2615751028060913, 0.2807716727256775, 0.25888824462890625, 0.2481217086315155, 0.2337927222251892, 0.3195692300796509, 0.2291799634695053, 0.2519465684890747, 0.2380359023809433, 0.21383172273635864, 0.2444087564945221, 0.2524086833000183, 0.21854393184185028, 0.21281428635120392, 0.23246783018112183, 0.20957939326763153, 0.20674221217632294, 0.21562781929969788, 0.2167467623949051, 0.2066829353570938, 0.20244692265987396, 0.20471830666065216, 0.20712600648403168, 0.20770300924777985, 0.20753438770771027, 0.19187016785144806, 0.20920206606388092, 0.20878292620182037, 0.1910688430070877, 0.2249932736158371, 0.24125976860523224, 0.1912861317396164, 0.22575697302818298, 0.1880142092704773, 0.20851440727710724, 0.20348533987998962, 0.19749507308006287, 0.20812512934207916, 0.19159157574176788, 0.1829102337360382, 0.18527868390083313, 0.18727745115756989, 0.19747960567474365, 0.1964724063873291, 0.18928980827331543, 0.18824495375156403, 0.18218128383159637, 0.18520772457122803, 0.1848960518836975, 0.20283769071102142, 0.1800927072763443, 0.18677258491516113, 0.1890747994184494, 0.1926821768283844, 0.36132150888442993, 0.6617596745491028, 0.7555304765701294, 0.9071340560913086, 0.9741666913032532, 0.9851512312889099, 0.9860104322433472, 0.9877912998199463, 0.9897733330726624, 0.9846851825714111, 0.9772458672523499, 0.9389025568962097, 0.9578419327735901, 0.92616868019104, 0.9118571281433105, 0.9458492994308472, 0.9395955801010132, 0.9839985370635986, 0.984558641910553, 0.9890781044960022, 0.984012246131897, 0.921596884727478, 0.9299415946006775, 0.9490970969200134, 0.9459429383277893, 0.9761364459991455, 0.9890563488006592, 0.9830954670906067, 0.9715518355369568, 0.9794676899909973, 0.9749501943588257, 0.9729570746421814, 0.9649297595024109, 0.9190958738327026, 0.8236211538314819, 0.8618544936180115, 0.9129891395568848, 0.879963755607605, 0.8964515328407288, 0.9278501868247986, 0.9791492819786072, 0.9844847917556763, 0.9850512742996216, 0.9601104855537415, 0.8377509713172913, 0.838976263999939, 0.9126794338226318, 0.9338228106498718, 0.9779173135757446, 0.9619337916374207, 0.9672325849533081, 0.9696993231773376, 0.969215989112854, 0.9662302136421204, 0.9612326622009277, 0.9591563940048218, 0.9350970387458801, 0.9138139486312866, 0.8254182934761047, 0.7922468781471252, 0.7332922220230103, 0.6897578835487366, 0.6595843434333801, 0.6357510089874268, 0.5709734559059143, 0.5260926485061646, 0.48765134811401367, 0.42991724610328674, 0.43216314911842346, 0.41332101821899414, 0.39908716082572937, 0.4089154005050659, 0.3803839087486267, 0.3790559470653534, 0.3869144916534424, 0.3787834644317627, 0.32864463329315186, 0.2892213761806488, 0.2662641406059265, 0.25502291321754456, 0.2230558544397354, 0.21873922646045685, 0.2012266367673874, 0.1951669603586197, 0.18360979855060577, 0.1856134831905365, 0.1818149983882904, 0.175020232796669, 0.18819858133792877, 0.17682752013206482, 0.19496667385101318, 0.19431407749652863, 0.19105255603790283, 0.20585349202156067, 0.18038664758205414, 0.15892212092876434, 0.15801787376403809, 0.18600480258464813, 0.16375291347503662, 0.16721877455711365, 0.18832437694072723, 0.26378554105758667, 0.27774959802627563, 0.2664008140563965, 0.25703805685043335, 0.23917315900325775, 0.21700477600097656, 0.201768696308136, 0.19070830941200256, 0.20024962723255157, 0.18731538951396942, 0.221390962600708, 0.19677041471004486, 0.18596036732196808, 0.185990571975708, 0.2066163420677185, 0.21082617342472076, 0.2183820903301239, 0.20468290150165558, 0.17997273802757263, 0.21265341341495514, 0.17747944593429565, 0.17405059933662415, 0.20220786333084106, 0.23872879147529602, 0.2203090935945511, 0.23968005180358887, 0.22701725363731384, 0.20491015911102295, 0.18493729829788208, 0.18677881360054016, 0.16304953396320343, 0.18546997010707855, 0.19894391298294067, 0.17619839310646057, 0.16904844343662262, 0.1789398193359375, 0.17455537617206573, 0.18725788593292236, 0.3044498562812805, 0.3843730390071869, 0.6881811022758484, 0.7624069452285767, 0.9015044569969177, 0.965838611125946, 0.9773274660110474, 0.9784532189369202, 0.9750648736953735, 0.9776723980903625, 0.9736093878746033, 0.9507076740264893, 0.9215500354766846, 0.9488147497177124, 0.9373583197593689, 0.8866245746612549, 0.8753513693809509, 0.8440921306610107, 0.8774397969245911, 0.9394524693489075, 0.9817055463790894, 0.9908407926559448, 0.9848233461380005, 0.9786943197250366, 0.9381905794143677, 0.9747987389564514, 0.9753978848457336, 0.972145676612854, 0.9776487946510315, 0.9753544330596924, 0.9746996164321899, 0.9743952751159668, 0.9728531241416931, 0.973010778427124, 0.9636825323104858, 0.9603598713874817, 0.9566046595573425, 0.9472607970237732, 0.9410886168479919, 0.9563134908676147, 0.881130039691925, 0.7769193649291992, 0.7433766722679138, 0.6759594678878784, 0.6078551411628723, 0.6392875909805298, 0.8591815829277039, 0.9477549195289612, 0.972206175327301, 0.9692753553390503, 0.966719925403595, 0.9858019948005676, 0.9673375487327576, 0.9587280750274658, 0.9603727459907532, 0.9693436622619629, 0.9518328905105591, 0.963399350643158, 0.970277726650238, 0.9694392085075378, 0.9331344366073608, 0.9503824710845947, 0.9211647510528564, 0.922248363494873, 0.9687680602073669, 0.9708076119422913, 0.9121578931808472, 0.8690866827964783, 0.8044062256813049, 0.73822021484375, 0.6416484713554382, 0.6301640868186951, 0.7629987597465515, 0.9147188067436218, 0.9825875163078308, 0.9891046285629272, 0.9918362498283386, 0.986438512802124, 0.9837716221809387, 0.984968900680542, 0.9690755605697632, 0.8553826212882996, 0.8649516105651855, 0.8609230518341064, 0.7671629786491394, 0.8322076201438904, 0.8889507055282593, 0.9124882221221924, 0.8962318301200867, 0.8908610939979553, 0.9218405485153198, 0.8836647868156433, 0.8569751977920532, 0.8685552477836609, 0.9582383036613464, 0.985215425491333, 0.9868647456169128, 0.987060546875, 0.9864813089370728, 0.9835545420646667, 0.959435760974884, 0.9034438729286194, 0.9054786562919617, 0.8664246201515198, 0.8084311485290527, 0.7371411323547363, 0.6572487354278564, 0.8141127228736877, 0.7599896192550659, 0.8126422166824341, 0.8535579442977905, 0.9362062215805054, 0.9736765623092651, 0.9792742133140564, 0.9887164831161499, 0.9886166453361511, 0.9875591397285461, 0.9798886179924011, 0.9741994142532349, 0.8978836536407471, 0.8988127112388611, 0.9256767630577087, 0.9198449850082397, 0.9676507711410522, 0.9818732738494873, 0.9845556616783142, 0.9823158383369446, 0.9825797080993652, 0.9754623174667358, 0.9736607074737549, 0.967795193195343, 0.9346528053283691, 0.9327009916305542, 0.8215488791465759, 0.751298189163208, 0.7434749007225037, 0.7785705327987671, 0.7172878384590149, 0.6505532264709473, 0.5980473756790161, 0.5769753456115723, 0.557792067527771, 0.5598916411399841, 0.560016930103302, 0.5458559393882751, 0.5510355830192566, 0.5135630965232849, 0.49369075894355774, 0.47213056683540344, 0.4593587815761566, 0.4120241701602936, 0.3535548448562622, 0.34331873059272766, 0.2963814437389374, 0.27979549765586853, 0.4498523771762848, 0.7073937654495239, 0.8622961044311523, 0.958376944065094, 0.977377712726593, 0.9640089273452759, 0.9479284882545471, 0.9767295122146606, 0.9791898727416992, 0.9695515036582947, 0.9542648196220398, 0.9325030446052551, 0.8614891171455383, 0.8187787532806396, 0.744773805141449, 0.7104255557060242, 0.6941980719566345, 0.6825876832008362, 0.6143574118614197, 0.860858678817749, 0.9391726851463318, 0.960252046585083, 0.9687045812606812, 0.9689019918441772, 0.9182224869728088, 0.8982653617858887, 0.8648737072944641, 0.8377068042755127, 0.7844932675361633, 0.7346802353858948, 0.655940055847168, 0.6112263798713684, 0.4872048795223236, 0.410190612077713, 0.36625659465789795, 0.4482797086238861, 0.43242305517196655, 0.34422338008880615, 0.31711217761039734, 0.3294646441936493, 0.3235763609409332, 0.26721668243408203, 0.23860876262187958, 0.2881908416748047, 0.24202078580856323, 0.25220754742622375, 0.23587371408939362, 0.2457229197025299, 0.23394064605236053, 0.28155583143234253, 0.22862261533737183, 0.23175296187400818, 0.23017051815986633, 0.22278322279453278, 0.22662319242954254, 0.24684759974479675, 0.2475064992904663, 0.253010094165802, 0.44888919591903687, 0.5987407565116882, 0.7010502815246582, 0.7258607149124146, 0.7367603778839111, 0.7627899050712585, 0.8017959594726562, 0.9385445713996887, 0.9686279892921448, 0.9844472408294678, 0.9841667413711548, 0.9913075566291809, 0.991511881351471, 0.9869712591171265, 0.9857017993927002, 0.9816812872886658, 0.9763243198394775, 0.9760580658912659, 0.9752585291862488, 0.9757323265075684, 0.9720690846443176, 0.9623642563819885, 0.956746518611908, 0.9546378254890442, 0.9144420027732849, 0.8193592429161072, 0.8426734805107117, 0.835382878780365, 0.8510352969169617, 0.8799707293510437, 0.9098752737045288, 0.9443946480751038, 0.957588791847229, 0.9661513566970825, 0.968643069267273, 0.9664379358291626, 0.974346399307251, 0.9756508469581604, 0.9710869193077087, 0.964931309223175, 0.9665907621383667, 0.9520571827888489, 0.9428350329399109, 0.9506629705429077, 0.9540398120880127, 0.9577012658119202, 0.9640864729881287, 0.9679462313652039, 0.9715772867202759, 0.960472047328949, 0.9581512212753296, 0.9557822942733765, 0.9557709097862244, 0.9349383115768433, 0.9255557656288147, 0.781880259513855, 0.7051262855529785, 0.6490282416343689, 0.6694252490997314, 0.6010769605636597, 0.5832383036613464, 0.5498681664466858, 0.6715907454490662, 0.6085667610168457, 0.8090695142745972, 0.86153644323349, 0.9200897216796875, 0.9293474555015564, 0.9225799441337585, 0.9279553890228271, 0.9443126916885376, 0.9617033004760742, 0.9702193737030029, 0.9705221056938171, 0.9656608700752258, 0.958279013633728, 0.9581626057624817, 0.9595626592636108, 0.9641536474227905, 0.9658156037330627, 0.9663981199264526, 0.9601858258247375, 0.9553248286247253, 0.9579699039459229, 0.9556856155395508, 0.9458245635032654, 0.9139459133148193, 0.8224059343338013, 0.7429352402687073, 0.7084910869598389, 0.768172562122345, 0.8053545355796814, 0.8728358745574951, 0.9493033289909363, 0.9711461663246155, 0.9781266450881958, 0.9715778231620789, 0.9657459259033203, 0.9708096385002136, 0.971796452999115, 0.9691368341445923, 0.9617801308631897, 0.961505651473999, 0.9617383480072021, 0.9630878567695618, 0.9664806127548218, 0.9618074893951416, 0.9665817022323608, 0.9602543115615845, 0.962063193321228, 0.9455409049987793, 0.9553248286247253, 0.9535303711891174, 0.9633800983428955, 0.9417682886123657, 0.9121871590614319, 0.925719678401947, 0.9182716608047485, 0.9134202599525452, 0.9339506030082703, 0.896822452545166, 0.9042292237281799, 0.8774875402450562, 0.8113236427307129, 0.7745661735534668, 0.7384748458862305, 0.6915143132209778, 0.6474124193191528, 0.6004697680473328, 0.5269311666488647, 0.48106205463409424, 0.4418434500694275, 0.4258626401424408, 0.3801303207874298, 0.38836026191711426, 0.6465109586715698, 0.7277121543884277, 0.8807100057601929, 0.9528923630714417, 0.9715237021446228, 0.9787884950637817, 0.9782994985580444, 0.9472317099571228, 0.9480807185173035, 0.9679168462753296, 0.9654800295829773, 0.9735906720161438, 0.9760162830352783, 0.9785032868385315, 0.9702360033988953, 0.9646973609924316, 0.9598219990730286, 0.9674885869026184, 0.958928644657135, 0.9511757493019104, 0.9535737037658691, 0.9575962424278259, 0.951694905757904, 0.953711211681366, 0.9623319506645203, 0.9667478203773499, 0.9681734442710876, 0.9671980142593384, 0.9654464721679688, 0.9638712406158447, 0.9613516330718994, 0.9558969140052795, 0.951849639415741, 0.9295833110809326, 0.9107601046562195, 0.8279885053634644, 0.7615189552307129, 0.7291696667671204, 0.7144089341163635, 0.6870356798171997, 0.6366664171218872, 0.6086785197257996, 0.5484097599983215, 0.6024599075317383, 0.5647953152656555, 0.6268708109855652, 0.6222558617591858, 0.6144164204597473, 0.5737174153327942, 0.5631071925163269, 0.5638323426246643, 0.5275530219078064, 0.5223597288131714, 0.49573197960853577, 0.48044922947883606, 0.5018662810325623, 0.6906263828277588, 0.8448692560195923, 0.9555901288986206, 0.9699164032936096, 0.9666415452957153, 0.9512583613395691, 0.9493741989135742, 0.9768072962760925, 0.9626669883728027, 0.8879222273826599, 0.8424073457717896, 0.8648953437805176, 0.8037249445915222, 0.9519319534301758, 0.9823611974716187, 0.980509877204895, 0.9699571132659912, 0.9430741667747498, 0.910995602607727, 0.9201655983924866, 0.8731873035430908, 0.8677708506584167, 0.9346540570259094, 0.955487072467804, 0.9786285161972046, 0.9618016481399536, 0.9703051447868347, 0.9652572870254517, 0.9725096821784973, 0.970896303653717, 0.9743515849113464, 0.9657506942749023, 0.9654076099395752, 0.8989865779876709, 0.8366906046867371, 0.8022079467773438, 0.7849540114402771, 0.7879737615585327, 0.8265215754508972, 0.8174794316291809, 0.8326340317726135, 0.8049479126930237, 0.8456969261169434, 0.8477194905281067, 0.8682271242141724, 0.8519377708435059, 0.893263041973114, 0.9313308596611023, 0.9479019641876221, 0.9538602828979492, 0.9471583366394043, 0.9324725866317749, 0.9404780268669128, 0.9553551077842712, 0.9651987552642822, 0.9703190326690674, 0.9621837735176086, 0.9308456778526306, 0.9351192712783813, 0.9679429531097412, 0.941135048866272, 0.9413736462593079, 0.9509373307228088, 0.9567047357559204, 0.9508969783782959, 0.9736602902412415, 0.9696687459945679, 0.9717634320259094, 0.9601696133613586, 0.9163809418678284, 0.8943618535995483, 0.8297036290168762, 0.7656586766242981, 0.7113914489746094, 0.6677206754684448, 0.649086594581604, 0.5233674049377441, 0.5507117509841919, 0.5050966739654541, 0.4521808624267578, 0.8177496194839478, 0.8631386756896973, 0.9498757123947144, 0.96186363697052, 0.9524709582328796, 0.9191524386405945, 0.9025450944900513, 0.8642423152923584, 0.8458425402641296, 0.8608609437942505, 0.8721507787704468, 0.8308330178260803, 0.8091669082641602, 0.7854751944541931, 0.6718984842300415, 0.663938045501709, 0.5849210023880005, 0.538189709186554, 0.4467821419239044, 0.42984071373939514, 0.8664225935935974, 0.8486839532852173, 0.8701136112213135, 0.8989191055297852, 0.8831714391708374, 0.8428999185562134, 0.7893679738044739, 0.79267817735672, 0.7748993635177612, 0.7468448877334595, 0.7282264232635498, 0.7171571850776672, 0.7300220727920532, 0.791730523109436, 0.8228363990783691, 0.8438977003097534, 0.8310835361480713, 0.8040673732757568, 0.7908160090446472, 0.7116125822067261, 0.703666627407074, 0.5884689092636108, 0.4782119691371918, 0.3710731863975525, 0.33014973998069763, 0.38310009241104126, 0.3241308033466339, 0.337555468082428, 0.3461582660675049, 0.34238892793655396, 0.33610621094703674, 0.32361018657684326, 0.3201064467430115, 0.34275394678115845, 0.29729360342025757, 0.3037031292915344, 0.2702466547489166, 0.27629080414772034, 0.2790328860282898, 0.2968750298023224, 0.29255011677742004, 0.26694533228874207, 0.2644471228122711, 0.26549115777015686, 0.26918289065361023, 0.27970069646835327, 0.29006749391555786, 0.7540911436080933, 0.8275115489959717, 0.9387770891189575, 0.947758674621582, 0.9326669573783875, 0.9246583580970764] \ No newline at end of file From cb37c3c8b56d7790ae1364b325bf34f19b265e23 Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Fri, 21 Aug 2026 23:19:34 -0700 Subject: [PATCH 09/32] feat(ten-vad): select the pitch source by config, defaulting to the device estimator The four tensor stages landed one at a time, each pinned against the host oracle but reachable only from its own tests. This wires them into a source and puts the choice in the config. `tensor/source.rs` composes them: * `TensorPitchConfig` / `TensorPitch` / `TensorPitchContext`, following the config -> init -> stateful-context shape the rest of the front end uses. * `forward_sequence` runs stages 1 and 3 across the whole run in one pass and threads a carry through 2 and 4; `forward` is the single-hop case of the same code, so there is one implementation to be wrong. * `with_anti_alias` / `reference` builders select the tier. `TenVadPitchSourceConfig` is the seam the level above sees -- a `Config` enum over `Zero`, `Host` and `Tensor(TensorPitchConfig)`, matched by `TenVadPitchSourceKind` dispatching `TenVadPitchSource` on itself. The associated-type contract cannot express one config yielding three source types, so the enum source is what makes the enum config possible. Call sites that stay monomorphic keep static dispatch; only config-driven ones pay the match. `TenVadContextConfig` gains `pitch`, defaulting to `Tensor` with the folded FIR anti-alias -- so `init_context` now returns a fully device-resident front end, and nothing in the pitch path synchronizes. Cross-testing, which is the point of keeping three implementations: * `test_forward_sequence_matches_the_host_estimator` drives the whole device path and the whole host path over the same audio. * `test_the_two_tiers_agree` puts `Recurrence` against `TruncatedFir`. * the reference probability golden now runs through the device path by default: mean 3.157e-5, worst 2.834e-4, decisions agree on every frame. Also, in `track.rs`, the Viterbi triple becomes a named `ViterbiCarry` rather than a four-tuple threaded through the loop by position. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../src/kits/speech/ten_vad/context/driver.rs | 71 ++- .../src/kits/speech/ten_vad/context/mod.rs | 49 +- .../speech/ten_vad/context/pitch/source.rs | 116 ++++ .../ten_vad/context/pitch/tensor/antialias.rs | 2 +- .../ten_vad/context/pitch/tensor/correlate.rs | 7 +- .../ten_vad/context/pitch/tensor/mod.rs | 3 + .../ten_vad/context/pitch/tensor/source.rs | 550 ++++++++++++++++++ .../ten_vad/context/pitch/tensor/track.rs | 76 ++- 8 files changed, 808 insertions(+), 66 deletions(-) create mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/source.rs diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs b/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs index bfd66e8d..13b9bee7 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs @@ -53,10 +53,10 @@ use crate::{ TenVadFeatureMeta, }, pitch::{ - HostPitch, - TenVadPitchEstimator, TenVadPitchSource, + TenVadPitchSourceConfig, TenVadPitchSourceInit, + TenVadPitchSourceKind, }, }, }, @@ -108,6 +108,14 @@ pub struct TenVadContextConfig { /// The feature front-end geometry. #[config(default = "TenVadFeatureConfig::new()")] pub features: TenVadFeatureConfig, + + /// How feature `40` is obtained. + /// + /// Defaults to the device-side estimator. See + /// [`TenVadPitchSourceConfig`] for the alternatives, and for how to select + /// the literal-transcription tier. + #[config(default = "TenVadPitchSourceConfig::default()")] + pub pitch: TenVadPitchSourceConfig, } impl TenVadContextMeta for TenVadContextConfig { @@ -172,7 +180,7 @@ impl TenVadContextConfig { /// /// Built by [`TenVad::init_context`]. Implements [`TenVadContextMeta`]. #[derive(Debug, Clone)] -pub struct TenVadContext = HostPitch> { +pub struct TenVadContext = TenVadPitchSourceKind> { /// The audio front-end streaming state. pub features: TenVadFeatureContext, @@ -270,18 +278,13 @@ impl> TenVadContext { } impl TenVad { - /// Builds a zeroed driving context with the reference pitch estimator. - /// - /// This is the faithful front end: all 41 features match the reference - /// implementation. Feature `40` is a host-side recurrence, so the driver - /// reads the raw hops and the bin powers back from the device to step it — - /// once per [`context_forward_sequence`](Self::context_forward_sequence) - /// call, not once per hop. + /// Builds a zeroed driving context, with the pitch source `cfg` selects. /// - /// To trade that fidelity for an entirely on-device sequence path, pass - /// [`ZeroPitch`](super::ZeroPitch) to - /// [`init_context_with`](Self::init_context_with); it pins feature `40` to - /// a constant and leaves the other 40 exact. + /// Defaults to the device-side estimator, so the whole front end stays + /// resident and no stage synchronizes. Set + /// [`TenVadContextConfig::pitch`] to choose otherwise — the host oracle, + /// the constant stub, or the device estimator's literal-transcription + /// tier. /// /// # Errors /// @@ -291,8 +294,8 @@ impl TenVad { &self, cfg: &TenVadContextConfig, device: &B::Device, - ) -> BunsenResult>> { - self.init_context_with(cfg, TenVadPitchEstimator::new(), device) + ) -> BunsenResult>> { + self.init_context_with(cfg, cfg.pitch.clone(), device) } /// Builds a zeroed driving context over a specific pitch source. @@ -569,6 +572,42 @@ mod tests { (vad, device) } + #[test] + fn test_default_pitch_source_is_the_device_estimator() { + // The driver's default is the device path, so the whole front end + // stays resident and no stage synchronizes. + let cfg = TenVadContextConfig::new(); + assert!( + matches!(cfg.pitch, TenVadPitchSourceConfig::Tensor(_)), + "expected the device estimator by default, got {:?}", + cfg.pitch, + ); + + let (vad, device) = model(); + let ctx = vad.init_context(&cfg, &device).unwrap(); + assert!(matches!( + ctx.features.pitch, + TenVadPitchSourceKind::Tensor(_) + )); + + // And the other variants are reachable through the same config. + let host = vad + .init_context_with(&cfg, TenVadPitchSourceConfig::Host, &device) + .unwrap(); + assert!(matches!( + host.features.pitch, + TenVadPitchSourceKind::Host(_) + )); + + let zero = vad + .init_context_with(&cfg, TenVadPitchSourceConfig::Zero, &device) + .unwrap(); + assert!(matches!( + zero.features.pitch, + TenVadPitchSourceKind::Zero(_) + )); + } + #[test] fn test_config_meta() { let cfg = TenVadContextConfig::new(); diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs index 804f758a..92f668af 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs @@ -61,24 +61,35 @@ //! //! ## Choosing a pitch source //! -//! Feature `40` is a serial host-side recurrence, so the driver reaches it -//! through the [`TenVadPitchSource`] seam: -//! -//! * [`TenVadPitchEstimator`] — the reference estimator, and what -//! [`TenVad::init_context`](crate::kits::speech::ten_vad::TenVad::init_context) -//! builds. It runs on the host, so the driver must read the raw hops and the -//! bin powers back from the device to step it: once per -//! `context_forward_sequence` call for the whole sequence, or once per hop on -//! the single-step path, which pays that cost anyway. -//! * [`ZeroPitch`] — pins feature `40` to a constant and never inspects its -//! arguments, so the sequence path skips the readback and stays entirely -//! on-device. The other 40 features are exact either way. Select it via -//! [`TenVad::init_context_with`](crate::kits::speech::ten_vad::TenVad::init_context_with). -//! -//! Both are reached through the same tensor-in, tensor-out -//! [`TenVadPitchSource`] seam; [`HostPitch`] is the adapter that carries a -//! host-side estimator across it, and is the only place the front end -//! synchronizes. +//! Feature `40` is reached through the [`TenVadPitchSource`] seam, selected by +//! [`TenVadPitchSourceConfig`] on [`TenVadContextConfig::pitch`]: +//! +//! | variant | what it is | +//! |---|---| +//! | `Tensor(..)` | the device estimator. **The default.** Keeps the whole front end resident; nothing synchronizes. | +//! | `Host` | the host scalar port — the reference oracle. Costs a device-to-host readback per call. | +//! | `Zero` | pins feature `40` to a constant and skips the branch. Features `0..40` are exact regardless. | +//! +//! The device variant has two tiers, differing only in how the anti-alias +//! filter before decimation is realized. The default folds the filter and its +//! decimation into one GEMM against a truncated impulse response; +//! [`TensorPitchConfig::reference`] instead selects a literal transcription of +//! the reference's IIR cascade. That tier is sample-sequential — five sections +//! stepping one sample at a time — so it is a correctness reference for short +//! inputs rather than a workload path. It is not a fidelity trade in the usual +//! direction: the truncated FIR is measurably *more* accurate than the +//! recurrence, being a better-conditioned realization of the same filter. +//! +//! All three implementations are cross-tested against each other, and the host +//! oracle is pinned to the C reference. +//! +//! ### Cost shape +//! +//! The device path is built for sequences. `forward_sequence` runs stages 1 +//! and 3 over the whole run in one pass, and threads a carry through 2 and 4; +//! a single-hop `forward` pays the same setup for one frame's worth of work. +//! Callers stepping hop by hop through the device path should expect that, +//! and the `Host` variant may well be cheaper for them. //! //! ## Known deviations from the reference driver //! @@ -102,6 +113,8 @@ //! * **The driver as a whole** — the kit's cross test pins it against the ONNX //! graph over real audio. //! +//! [`TenVadContextConfig::pitch`]: crate::kits::speech::ten_vad::TenVadContextConfig +//! [`TensorPitchConfig::reference`]: crate::kits::speech::ten_vad::context::pitch::tensor::TensorPitchConfig::reference //! [`TenVad::forward`]: crate::kits::speech::ten_vad::TenVad::forward //! [`TenVad::context_forward`]: crate::kits::speech::ten_vad::TenVad::context_forward //! [`SlidingStftContext`]: crate::ops::signal::SlidingStftContext diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/source.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/source.rs index aa3daa28..8d6417e5 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/source.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/source.rs @@ -24,6 +24,14 @@ use burn::prelude::*; +use super::{ + estimator::TenVadPitchEstimator, + host::HostPitch, + tensor::source::{ + TensorPitchConfig, + TensorPitchContext, + }, +}; use crate::{ errors::WithOkOrPanic, kits::speech::ten_vad::context::coeff::{ @@ -323,3 +331,111 @@ mod tests { assert_eq!(pitch.calls, 0); } } + +/// How the driver obtains feature `40`. +/// +/// A `Config` enum whose variants wrap each implementation's own config, +/// following [`StftWindowConfig`](crate::ops::signal::StftWindowConfig): the +/// contract is [`TenVadPitchSourceInit`], and this dispatches to it. +/// +/// [`Self::default`] selects [`Self::Tensor`], which is both the faithful +/// choice and the one that keeps the front end device-resident. +#[derive(Config, Debug)] +pub enum TenVadPitchSourceConfig { + /// Pin feature `40` to a constant and skip the branch entirely. + /// + /// Never inspects its input, so the sequence path stays on-device with no + /// synchronization at all. Features `0..40` are exact regardless. + Zero, + + /// The host scalar estimator, one instance per stream. + /// + /// The reference port, and the permanent oracle the device stages are + /// validated against. Carries no configuration of its own — the geometry + /// is fixed by the reference. Costs a device-to-host readback per call. + Host, + + /// The device-side estimator. The default. + /// + /// Selecting [`PitchAntiAliasConfig::Recurrence`] inside chooses the + /// literal-transcription tier instead of the optimized one; see + /// [`TensorPitchConfig::reference`]. + /// + /// [`PitchAntiAliasConfig::Recurrence`]: super::tensor::PitchAntiAliasConfig::Recurrence + Tensor(TensorPitchConfig), +} + +impl Default for TenVadPitchSourceConfig { + fn default() -> Self { + Self::Tensor(TensorPitchConfig::new()) + } +} + +impl TenVadPitchSourceInit for TenVadPitchSourceConfig { + type Source = TenVadPitchSourceKind; + + fn try_init_source( + &self, + batch_size: usize, + device: &B::Device, + ) -> BunsenResult { + Ok(match self { + Self::Zero => TenVadPitchSourceKind::Zero(ZeroPitch), + Self::Host => { + TenVadPitchSourceKind::Host(HostPitch::new(TenVadPitchEstimator::new(), batch_size)) + } + Self::Tensor(cfg) => { + TenVadPitchSourceKind::Tensor(cfg.try_init(device)?.init_state(batch_size, device)) + } + }) + } +} + +/// A pitch source selected by [`TenVadPitchSourceConfig`]. +/// +/// An enum rather than a boxed trait object: the contract returns a *stateful* +/// source, and an associated type must be one type across every variant. This +/// keeps dispatch static and the state concrete enough to inspect. +#[derive(Debug, Clone)] +pub enum TenVadPitchSourceKind { + /// See [`ZeroPitch`]. + Zero(ZeroPitch), + /// See [`HostPitch`]. + Host(HostPitch), + /// See [`TensorPitchContext`]. + Tensor(TensorPitchContext), +} + +impl TenVadPitchSource for TenVadPitchSourceKind { + fn forward( + &mut self, + raw: Tensor, + bin_power: Tensor, + ) -> Tensor { + match self { + Self::Zero(s) => s.forward(raw, bin_power), + Self::Host(s) => s.forward(raw, bin_power), + Self::Tensor(s) => s.forward(raw, bin_power), + } + } + + fn forward_sequence( + &mut self, + raw: Tensor, + bin_power: Tensor, + ) -> Tensor { + match self { + Self::Zero(s) => s.forward_sequence(raw, bin_power), + Self::Host(s) => s.forward_sequence(raw, bin_power), + Self::Tensor(s) => s.forward_sequence(raw, bin_power), + } + } + + fn reset(&mut self) { + match self { + Self::Zero(s) => TenVadPitchSource::::reset(s), + Self::Host(s) => TenVadPitchSource::::reset(s), + Self::Tensor(s) => TenVadPitchSource::::reset(s), + } + } +} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/antialias.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/antialias.rs index 1a692ae1..2c32db4b 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/antialias.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/antialias.rs @@ -64,7 +64,7 @@ //! The carry is `taps − 1`, **not** `in_len − hop`. Those differ by three, and //! using the latter would both drop the three oldest taps and desync the carry //! from the kernel offset. Both are derived from one constant here, and -//! [`tests::test_fir_matches_the_exact_cascade`] would catch a slip. +//! `test_fir_matches_the_exact_cascade` would catch a slip. use burn::{ config::Config, diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/correlate.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/correlate.rs index 79215ee7..8ceb40a2 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/correlate.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/correlate.rs @@ -499,9 +499,10 @@ mod tests { #[test] fn test_octave_suppression_reads_are_write_safe() { - // The whole vectorized form rests on this. Checked as a const, and - // re-derived here so a geometry change cannot quietly invalidate it. - assert!(SHARPEN_IS_WRITE_SAFE); + // The whole vectorized form rests on this. `SHARPEN_IS_WRITE_SAFE` + // proves it at compile time for the shipped geometry; re-derived here + // against the live config so a geometry change cannot slip past. + const _: () = assert!(SHARPEN_IS_WRITE_SAFE); let cfg = config(); let (a, b, c) = cfg.to_vec_sharpen_indices(); diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs index dfbd83a1..bf413371 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs @@ -57,6 +57,7 @@ pub mod antialias; pub mod correlate; pub mod excitation; pub mod prefilter; +pub mod source; pub mod tables; pub mod track; @@ -72,6 +73,8 @@ pub use excitation::*; #[doc(inline)] pub use prefilter::*; #[doc(inline)] +pub use source::*; +#[doc(inline)] pub use tables::*; #[doc(inline)] pub use track::*; diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/source.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/source.rs new file mode 100644 index 00000000..8d6fd3e8 --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/source.rs @@ -0,0 +1,550 @@ +//! # The device-side pitch source. +//! +//! Composes the four stages into a [`TenVadPitchSource`]: +//! +//! ```text +//! bin_power ──prefilter──▶ lpc ──┐ +//! ├──excitation──▶ exc ──correlate──▶ xcorr ──track──▶ Hz +//! raw ───────────────────────────┘ +//! ``` +//! +//! Only two stages carry state — the excitation branch's filters and FIFO, and +//! the tracker's Viterbi accumulator. The pre-filter design and the lag search +//! are pure functions of their inputs, which is why a whole sequence runs +//! through them in one pass while the other two thread a carry. +//! +//! ## Reference and optimized tiers +//! +//! Both are this type; they differ in one sub-step, the anti-alias filter +//! ([`PitchAntiAliasConfig`]). Selecting [`Recurrence`] gives a literal +//! transcription of the reference's IIR cascade — the correctness reference, +//! sample-sequential and viable only on short inputs. Selecting +//! [`TruncatedFir`] gives the default, which is both faster *and* more accurate +//! than the recurrence. +//! +//! [`Recurrence`]: PitchAntiAliasConfig::Recurrence +//! [`TruncatedFir`]: PitchAntiAliasConfig::TruncatedFir + +use burn::{ + config::Config, + prelude::*, +}; + +use super::{ + super::{ + coeff::LPC_ORDER, + source::TenVadPitchSource, + }, + antialias::PitchAntiAliasConfig, + correlate::{ + PitchCorrelate, + PitchCorrelateConfig, + }, + excitation::{ + PitchExcitation, + PitchExcitationConfig, + PitchExcitationState, + }, + prefilter::{ + PitchPrefilter, + PitchPrefilterConfig, + }, + track::{ + PitchTrack, + PitchTrackConfig, + PitchTrackState, + }, +}; +use crate::errors::{ + BunsenResult, + WithOkOrPanic, +}; + +/// Config for [`TensorPitch`]. +#[derive(Config, Debug)] +pub struct TensorPitchConfig { + /// Stage 1: the whitening filter design. + #[config(default = "PitchPrefilterConfig::new()")] + pub prefilter: PitchPrefilterConfig, + + /// Stage 2: whitening and decimation. + #[config(default = "PitchExcitationConfig::new()")] + pub excitation: PitchExcitationConfig, + + /// Stage 3: the normalized lag search. + #[config(default = "PitchCorrelateConfig::new()")] + pub correlate: PitchCorrelateConfig, + + /// Stage 4: period tracking. + #[config(default = "PitchTrackConfig::new()")] + pub track: PitchTrackConfig, +} + +impl TensorPitchConfig { + /// Selects how the anti-alias filter is realized. + /// + /// The only place the reference and optimized tiers differ, so this is the + /// knob that chooses between them. + pub fn with_anti_alias( + mut self, + anti_alias: PitchAntiAliasConfig, + ) -> Self { + self.excitation = self.excitation.with_anti_alias(anti_alias); + self + } + + /// A config selecting the literal-transcription tier. + /// + /// Sample-sequential, so this is a correctness reference for short inputs + /// rather than a workload path. + pub fn reference() -> Self { + Self::new().with_anti_alias(PitchAntiAliasConfig::Recurrence) + } + + /// The number of frequency bins this source expects. + pub fn n_bins(&self) -> usize { + self.prefilter.n_bins() + } + + /// The hop size, in samples at 16 kHz. + pub fn hop_size(&self) -> usize { + self.excitation.hop_size + } + + /// Validates every stage, and that they agree on geometry. + /// + /// # Errors + /// [`BunsenError::Invalid`](crate::errors::BunsenError::Invalid) if any + /// stage is invalid, or if the excitation history the excitation stage + /// emits is not the length the lag search reads. + pub fn validate(&self) -> BunsenResult<()> { + self.prefilter.validate()?; + self.excitation.validate()?; + self.correlate.validate()?; + self.track.validate()?; + + if self.excitation.exc_len() != self.correlate.exc_len() { + return Err(crate::errors::BunsenError::Invalid(format!( + "TensorPitch excitation emits {} samples but the lag search reads {}", + self.excitation.exc_len(), + self.correlate.exc_len(), + ))); + } + Ok(()) + } + + /// Builds the coefficients. + /// + /// # Errors + /// + /// See [`validate`](Self::validate). + pub fn try_init( + &self, + device: &B::Device, + ) -> BunsenResult> { + self.validate()?; + Ok(TensorPitch { + prefilter: self.prefilter.try_init(device)?, + excitation: self.excitation.try_init(device)?, + correlate: self.correlate.try_init(device)?, + track: self.track.try_init(device)?, + }) + } + + /// Builds the coefficients, panicking on error. + pub fn init( + &self, + device: &B::Device, + ) -> TensorPitch { + self.try_init(device).ok_or_panic() + } +} + +/// The device-side pitch estimator's fixed coefficients. +/// +/// Stateless, so one instance serves any number of streams; state lives in +/// [`TensorPitchContext`]. Built by [`TensorPitchConfig::try_init`]. +#[derive(Debug, Clone)] +pub struct TensorPitch { + /// Stage 1. + pub prefilter: PitchPrefilter, + /// Stage 2. + pub excitation: PitchExcitation, + /// Stage 3. + pub correlate: PitchCorrelate, + /// Stage 4. + pub track: PitchTrack, +} + +impl TensorPitch { + /// The number of frequency bins this source expects. + pub fn n_bins(&self) -> usize { + self.prefilter.n_bins() + } + + /// The hop size, in samples at 16 kHz. + pub fn hop_size(&self) -> usize { + self.excitation.hop_size() + } + + /// Binds a start-of-stream state over `batch_size` independent streams. + pub fn init_state( + &self, + batch_size: usize, + device: &B::Device, + ) -> TensorPitchContext { + TensorPitchContext { + excitation: self.excitation.init_state(batch_size, device), + track: self.track.init_state(batch_size, device), + batch_size, + coef: self.clone(), + } + } +} + +/// The device-side pitch estimator's streaming state. +/// +/// Implements [`TenVadPitchSource`]. Built by [`TensorPitch::init_state`]. +#[derive(Debug, Clone)] +pub struct TensorPitchContext { + /// The fixed coefficients. + pub coef: TensorPitch, + + /// Stage 2's carried buffers. + pub excitation: PitchExcitationState, + + /// Stage 4's carried accumulator and slot history. + pub track: PitchTrackState, + + batch_size: usize, +} + +impl TensorPitchContext { + /// The batch size; each row is an independent stream. + pub fn batch_size(&self) -> usize { + self.batch_size + } +} + +impl TenVadPitchSource for TensorPitchContext { + fn forward( + &mut self, + raw: Tensor, + bin_power: Tensor, + ) -> Tensor { + let [batch, hop] = raw.dims(); + let n_bins = bin_power.dims()[1]; + + // One hop is just a one-step sequence; the stages are written for the + // batched form and a `steps` of 1 costs nothing extra. + let out = self.forward_sequence( + raw.reshape([1, batch, hop]), + bin_power.reshape([1, batch, n_bins]), + ); + out.reshape([batch, 1]) + } + + fn forward_sequence( + &mut self, + raw: Tensor, + bin_power: Tensor, + ) -> Tensor { + let [steps, batch, _] = raw.dims(); + let n_bins = bin_power.dims()[2]; + assert_eq!(batch, self.batch_size, "TensorPitch batch mismatch"); + assert_eq!(n_bins, self.coef.n_bins(), "TensorPitch bin count mismatch"); + + let rows = steps * batch; + + // Stage 1 carries nothing, so the whole sequence designs in one pass. + let lpc = self + .coef + .prefilter + .forward(bin_power.reshape([rows, n_bins])) + .reshape([steps, batch, LPC_ORDER]); + + let (exc, excitation) = self + .coef + .excitation + .forward(raw, lpc, self.excitation.clone()); + self.excitation = excitation; + + // Stage 3 likewise: stateless given the history it is handed. + let exc_len = self.coef.correlate.exc_len(); + let (xcorr, energy) = self.coef.correlate.forward(exc.reshape([rows, exc_len])); + + let max_period = self.coef.correlate.max_period(); + let subs = super::correlate::SUBS_PER_HOP; + let (pitch, track) = self.coef.track.forward( + xcorr.reshape([steps, batch, subs, max_period]), + energy.reshape([steps, batch, subs]), + self.track.clone(), + ); + self.track = track; + + pitch + } + + fn reset(&mut self) { + let device = self.track.path_score.device(); + self.excitation = self.coef.excitation.init_state(self.batch_size, &device); + self.track = self.coef.track.init_state(self.batch_size, &device); + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::Tolerance; + + use super::{ + super::super::{ + TenVadPitchEstimator, + TenVadPitchScalarSource, + }, + *, + }; + use crate::{ + prelude::*, + support::testing::PerformanceBackend, + }; + + type B = PerformanceBackend; + + const HOP: usize = 256; + const N_BINS: usize = 513; + + fn pulse_hop( + f0: f32, + at: usize, + ) -> Vec { + let period = 16000.0 / f0; + (0..HOP) + .map(|i| { + let pos = (at + i) as f32 % period; + 8000.0 * (-pos / (period * 0.08)).exp() + }) + .collect() + } + + fn spectrum(step: usize) -> Vec { + (0..N_BINS) + .map(|k| { + let k = k as f32; + 1e7 * (-k / 70.0).exp() * (1.0 + 0.4 * (k * 0.05 + step as f32 * 0.3).sin()) + }) + .collect() + } + + /// Drives the host over `steps` hops, returning the inputs it saw and the + /// pitch it reported. + fn host_reference(steps: usize) -> (Vec, Vec, Vec) { + let mut est = TenVadPitchEstimator::new(); + let (mut raw, mut power, mut hz) = (Vec::new(), Vec::new(), Vec::new()); + for step in 0..steps { + let hop = pulse_hop(150.0, step * HOP); + let spec = spectrum(step); + hz.push(est.frame_pitch(&hop, &spec)); + raw.extend_from_slice(&hop); + power.extend_from_slice(&spec); + } + (raw, power, hz) + } + + fn run( + cfg: &TensorPitchConfig, + steps: usize, + raw: &[f32], + power: &[f32], + device: &::Device, + ) -> Vec { + let coef: TensorPitch = cfg.init(device); + let mut ctx = coef.init_state(1, device); + + let raw_t = Tensor::::from_floats(raw, device).reshape([steps, 1, HOP]); + let pow_t = Tensor::::from_floats(power, device).reshape([steps, 1, N_BINS]); + + ctx.forward_sequence(raw_t, pow_t) + .to_data_as::() + .to_vec_as::() + .unwrap() + } + + #[test] + fn test_config_meta() { + let cfg = TensorPitchConfig::new(); + assert_eq!(cfg.n_bins(), N_BINS); + assert_eq!(cfg.hop_size(), HOP); + assert!(cfg.validate().is_ok()); + assert!(TensorPitchConfig::reference().validate().is_ok()); + } + + #[test] + fn test_reference_tier_selects_the_recurrence() { + assert_eq!( + TensorPitchConfig::reference().excitation.anti_alias, + PitchAntiAliasConfig::Recurrence, + ); + assert_eq!( + TensorPitchConfig::new().excitation.anti_alias, + PitchAntiAliasConfig::default(), + ); + } + + #[test] + fn test_validate_rejects_mismatched_stage_geometry() { + let cfg = + TensorPitchConfig::new().with_correlate(PitchCorrelateConfig::new().with_hop_size(128)); + assert!(cfg.validate().is_err()); + } + + #[test] + fn test_forward_sequence_matches_the_host_estimator() { + // The whole device path against the whole host path, over the same + // inputs. This is what the stage-level differential tests add up to. + let device = Default::default(); + let steps = 16; + let (raw, power, want) = host_reference(steps); + + let got = run(&TensorPitchConfig::new(), steps, &raw, &power, &device); + + for (t, (g, w)) in got.iter().zip(want.iter()).enumerate() { + assert_eq!(*g > 0.0, *w > 0.0, "hop {t}: voicing disagrees, {g} vs {w}"); + if *w > 0.0 { + let rel = (g - w).abs() / w; + assert!(rel < 1e-3, "hop {t}: {g} Hz vs {w} Hz (rel {rel})"); + } + } + assert!( + want.iter().filter(|v| **v > 0.0).count() * 2 > steps, + "fixture should be mostly voiced", + ); + } + + #[test] + fn test_the_two_tiers_agree() { + // Short, because the reference tier is sample-sequential: five cascade + // sections stepping one sample at a time. + let device = Default::default(); + let steps = 3; + let (raw, power, _) = host_reference(steps); + + let fast = run(&TensorPitchConfig::new(), steps, &raw, &power, &device); + let exact = run( + &TensorPitchConfig::reference(), + steps, + &raw, + &power, + &device, + ); + + for (t, (f, e)) in fast.iter().zip(exact.iter()).enumerate() { + assert_eq!(*f > 0.0, *e > 0.0, "hop {t}: voicing disagrees, {f} vs {e}"); + if *e > 0.0 { + let rel = (f - e).abs() / e; + assert!(rel < 1e-3, "hop {t}: fast {f} Hz vs reference {e} Hz"); + } + } + } + + #[test] + fn test_forward_sequence_matches_stepwise() { + let device = Default::default(); + let steps = 8; + let (raw, power, _) = host_reference(steps); + let cfg = TensorPitchConfig::new(); + + let whole = run(&cfg, steps, &raw, &power, &device); + + let coef: TensorPitch = cfg.init(&device); + let mut ctx = coef.init_state(1, &device); + let mut stepwise = Vec::new(); + for step in 0..steps { + let r = Tensor::::from_floats(&raw[step * HOP..(step + 1) * HOP], &device) + .reshape([1, HOP]); + let p = + Tensor::::from_floats(&power[step * N_BINS..(step + 1) * N_BINS], &device) + .reshape([1, N_BINS]); + stepwise.extend( + ctx.forward(r, p) + .to_data_as::() + .to_vec_as::() + .unwrap(), + ); + } + + TensorData::from(whole.as_slice()).assert_approx_eq::( + &TensorData::from(stepwise.as_slice()), + Tolerance::relative(1e-4), + ); + } + + #[test] + fn test_reset_rewinds_the_stream() { + let device = Default::default(); + let steps = 4; + let (raw, power, _) = host_reference(steps); + let cfg = TensorPitchConfig::new(); + let coef: TensorPitch = cfg.init(&device); + let mut ctx = coef.init_state(1, &device); + + let raw_t = Tensor::::from_floats(raw.as_slice(), &device).reshape([steps, 1, HOP]); + let pow_t = + Tensor::::from_floats(power.as_slice(), &device).reshape([steps, 1, N_BINS]); + + let first: Vec = ctx + .forward_sequence(raw_t.clone(), pow_t.clone()) + .to_data_as::() + .to_vec_as::() + .unwrap(); + + ctx.reset(); + let again: Vec = ctx + .forward_sequence(raw_t, pow_t) + .to_data_as::() + .to_vec_as::() + .unwrap(); + + assert_eq!(first, again); + } + + #[test] + fn test_batch_rows_are_independent() { + let device = Default::default(); + let steps = 6; + let (raw, power, _) = host_reference(steps); + let cfg = TensorPitchConfig::new(); + let coef: TensorPitch = cfg.init(&device); + + // Row 1 is silence, which must stay unvoiced whatever row 0 does. + let mut raw_pair = Vec::new(); + let mut pow_pair = Vec::new(); + for step in 0..steps { + raw_pair.extend_from_slice(&raw[step * HOP..(step + 1) * HOP]); + raw_pair.extend(std::iter::repeat_n(0.0f32, HOP)); + pow_pair.extend_from_slice(&power[step * N_BINS..(step + 1) * N_BINS]); + pow_pair.extend(std::iter::repeat_n(0.0f32, N_BINS)); + } + + let mut ctx = coef.init_state(2, &device); + let got: Vec = ctx + .forward_sequence( + Tensor::::from_floats(raw_pair.as_slice(), &device).reshape([steps, 2, HOP]), + Tensor::::from_floats(pow_pair.as_slice(), &device) + .reshape([steps, 2, N_BINS]), + ) + .to_data_as::() + .to_vec_as::() + .unwrap(); + + let solo = run(&cfg, steps, &raw, &power, &device); + for step in 0..steps { + assert!( + (got[step * 2] - solo[step]).abs() < 1e-3, + "hop {step}: row 0 {} vs solo {}", + got[step * 2], + solo[step], + ); + assert_eq!(got[step * 2 + 1], 0.0, "hop {step}: silent row 1 is voiced"); + } + } +} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs index c8fd62a8..195baec8 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs @@ -139,9 +139,9 @@ impl PitchTrackConfig { /// The `[dif_period, dif_period]` transition penalty matrix, row-major. /// - /// Entry `[idx][cand]` is `PITCH_MAX_PATH_W · jdx²` where reachable, and - /// [`INVALID_PENALTY`] where not. The arithmetic order matches the - /// reference's `(W · |j|) · |j|`. + /// Entry `[idx][cand]` is `PITCH_MAX_PATH_W · jdx²` where reachable, and a + /// penalty large enough to lose every `max` where not. The order matches + /// the reference's `(W · |j|) · |j|`. pub fn to_vec_penalty(&self) -> Vec { let dif = self.dif_period(); let mut out = vec![INVALID_PENALTY; dif * dif]; @@ -225,6 +225,24 @@ pub struct PitchTrackState { pub slot_prev: Tensor, } +/// The three fields the Viterbi recursion threads from one half-hop to the +/// next. +/// +/// Split out of [`PitchTrackState`] because the forward pass steps these twice +/// per hop while the ring carries advance once, and because naming the triple +/// keeps [`PitchTrack::viterbi_step`]'s signature legible. +#[derive(Debug, Clone)] +struct ViterbiCarry { + /// `[batch, dif_period]` accumulator, renormalized to peak zero. + score: Tensor, + + /// `[batch, 1]` best score reached so far. + best: Tensor, + + /// `[batch, 1]` best period index reached so far. + period: Tensor, +} + impl PitchTrack { /// The width of the tracker's state space. pub fn dif_period(&self) -> usize { @@ -303,9 +321,11 @@ impl PitchTrack { let weights = Self::normalize_weights(energy_hist.clone(), slots, steps); // --- forward pass: two dependent steps per hop --- - let mut path_score = state.path_score; - let mut path_best = state.path_best; - let mut best_period = state.best_period; + let mut carry_state = ViterbiCarry { + score: state.path_score, + best: state.path_best, + period: state.best_period, + }; let mut new_prev = Vec::with_capacity(steps * SUBS_PER_HOP); let mut hop_best = Vec::with_capacity(steps); @@ -326,14 +346,11 @@ impl PitchTrack { ]) .reshape([batch, 1]); - let (score, best, arg, prev) = - self.viterbi_step(path_score, path_best, best_period, xc, w); - path_score = score; - path_best = best; - best_period = arg; + let (next, prev) = self.viterbi_step(carry_state, xc, w); + carry_state = next; new_prev.push(prev.unsqueeze_dim::<3>(1)); } - hop_best.push(best_period.clone()); + hop_best.push(carry_state.period.clone()); } let prev_hist = Tensor::cat(vec![state.slot_prev, Tensor::cat(new_prev, 1)], 1); @@ -360,9 +377,9 @@ impl PitchTrack { ( pitch.reshape([steps, batch, 1]), PitchTrackState { - path_score, - path_best, - best_period, + path_score: carry_state.score, + path_best: carry_state.best, + best_period: carry_state.period, slot_xcorr: xcorr_hist.slice_dim(1, keep as isize..), slot_energy: energy_hist.slice_dim(1, keep as isize..), slot_prev: prev_hist.slice_dim(1, keep as isize..), @@ -397,35 +414,38 @@ impl PitchTrack { } /// One Viterbi step: the dense transition max, then the renormalization. + /// + /// Returns the advanced carry and the `[batch, dif_period]` backpointer row + /// this half-hop emits. fn viterbi_step( &self, - path_score: Tensor, - path_best: Tensor, - best_period: Tensor, + carry: ViterbiCarry, xcorr: Tensor, weight: Tensor, - ) -> ( - Tensor, - Tensor, - Tensor, - Tensor, - ) { + ) -> (ViterbiCarry, Tensor) { // [batch, dif, dif]: score of arriving at `idx` from `cand`. let transitions = - path_score.unsqueeze_dim::<3>(1) - self.penalty.clone().unsqueeze_dim::<3>(0); + carry.score.unsqueeze_dim::<3>(1) - self.penalty.clone().unsqueeze_dim::<3>(0); let (best_in, arg_in) = transitions.max_dim_with_indices(2); // The reference seeds its search at `path_best - 1e10` and keeps the // previous best period when nothing beats it; invalid transitions sit // far below that floor, so they never win. - let floor = path_best.sub_scalar(1e10f32).unsqueeze_dim::<3>(2); + let floor = carry.best.sub_scalar(1e10f32).unsqueeze_dim::<3>(2); let stalled = best_in.clone().lower_equal(floor.clone()); - let prev = arg_in.mask_where(stalled, best_period.clone().unsqueeze_dim::<3>(2)); + let prev = arg_in.mask_where(stalled, carry.period.unsqueeze_dim::<3>(2)); let scored = best_in.max_pair(floor).squeeze_dim::<2>(2) + xcorr * weight; let (top, arg_top) = scored.clone().max_dim_with_indices(1); - (scored - top.clone(), top, arg_top, prev.squeeze_dim::<2>(2)) + ( + ViterbiCarry { + score: scored - top.clone(), + best: top, + period: arg_top, + }, + prev.squeeze_dim::<2>(2), + ) } /// Walks each hop's path back six slots and fits a period contour. From b70b00ea1ff5dc3d1655486c2446109a8b1c07c5 Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Sat, 22 Aug 2026 01:18:37 -0700 Subject: [PATCH 10/32] feat(ten-vad): reproduce the reference's periodic LSTM state reset The C driver zeroes both LSTM states every `resetFrameNum = 1875` model calls -- 30 s of audio -- and this driver did not. It was the last entry on the context module's "Known deviations from the reference driver" list, and that entry named the cost: byte-parity on clips longer than 30 s needs it. The shipped golden is 3750 hops, exactly two periods, so it has been encoding a reset this driver could not reproduce. `TenVadContextConfig::reset_frames: Option` carries the policy, defaulting to `RESET_FRAMES`; `None` disables it. `TenVadContext` carries the policy and its counter, and gains `reset_states()` -- the reference's reset as a callable primitive, useful on its own to callers segmenting a stream. Three details are the reference's, and each is pinned by a test: * Only the recurrence is zeroed (`src/aed.cc:155-158`). The feature stack, the STFT queue and the pre-emphasis carry keep running, so the frame after a reset still sees its predecessors. That is what separates `reset_states` from `reset`. * The counter increments after the model call and fires on `>=` (`src/aed.cc:476-481`), so call 1875 runs with the states it inherited and call 1876 runs from zero. * Both drivers tick through one helper, so `context_forward_sequence` stays exactly "iterating `context_forward`" -- a reset lands on the same hops whatever chunk boundaries the caller uses. The chunked test deliberately splits *on* a reset boundary, which is where a misplaced tick shows itself. The strongest of those tests needs no golden at all: a periodic reset over `2K` hops must equal driving `K` hops, `reset_states()`, `K` more, `reset_states()` again -- mirrored block for block, terminal boundary included, since a period of `K` over `2K` hops fires twice. The reference zeroes lazily, at the top of its next call, via a `clear_hidden` flag; this driver zeroes eagerly at the end of the current one. Those are observationally identical for any continuation. `test_reference_probability_golden` is split so the capped form stays in the suite and a new `#[ignore]`d `..._full` runs all 3750 hops. That full run is the only test that can show 1875 is the right period and that our phase matches the reference's -- a reset on the wrong hop diverges immediately after 1876 -- but it takes over a quarter of an hour, which is the deferred per-hop `context_forward_sequence` cost rather than anything this change adds. The reference marks the constant `// TODO` (`src/aed.cc:640`), so this is reproduced behavior, not an endorsement. It is on by default because parity with the pretrained weights is what this kit is for. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../kits/speech/silero_vad/blocks/module.rs | 6 +- .../src/kits/speech/ten_vad/context/coeff.rs | 14 + .../src/kits/speech/ten_vad/context/driver.rs | 342 +++++++++++++++++- .../src/kits/speech/ten_vad/context/mod.rs | 34 +- .../src/kits/speech/ten_vad/cross_test.rs | 47 ++- 5 files changed, 421 insertions(+), 22 deletions(-) diff --git a/crates/bunsen/src/kits/speech/silero_vad/blocks/module.rs b/crates/bunsen/src/kits/speech/silero_vad/blocks/module.rs index a2e3c76c..7de277d4 100644 --- a/crates/bunsen/src/kits/speech/silero_vad/blocks/module.rs +++ b/crates/bunsen/src/kits/speech/silero_vad/blocks/module.rs @@ -614,7 +614,8 @@ impl SileroVad { /// stream. /// /// # Returns - /// `(probabilities, context, state)`, with: + /// + /// `(probabilities, state)`, with: /// * `probabilities` : `[steps, batch]` /// * `state`: `[2, batch, d_hidden]` pub fn forward_sequence( @@ -705,7 +706,8 @@ impl SileroVad { /// [`init_state`](Self::init_state)). /// /// # Returns - /// `(probabilities, context, state)`, with: + /// + /// `(probabilities, state)`, with: /// * `probabilities` : `[batch]` /// * `state`: `[2, batch, d_hidden]` pub fn forward( diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/coeff.rs b/crates/bunsen/src/kits/speech/ten_vad/context/coeff.rs index cff362f0..af1dc15f 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/coeff.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/coeff.rs @@ -34,6 +34,20 @@ pub const HOP_SIZE: usize = 256; /// The sample rate, in Hz, the ten-vad front end is defined for. pub const SAMPLE_RATE: usize = 16000; +/// The reference driver's periodic LSTM reset period, in model calls. +/// +/// 1875 hops is 30 s at 16 kHz. The C driver zeroes both LSTM states this +/// often, leaving the feature stack intact (`ALGO_TRACE.md` §5, +/// `src/aed.cc:476-481`). The reference marks the value `// TODO` +/// (`src/aed.cc:640`); it is reproduced here for parity, not because it is +/// obviously right. +/// +/// See [`TenVadContextConfig::reset_frames`] to change or disable it. +/// +/// [`TenVadContextConfig::reset_frames`]: +/// crate::kits::speech::ten_vad::TenVadContextConfig +pub const RESET_FRAMES: usize = 1875; + /// The epsilon used both as the log floor and as the normalization guard. /// /// The reference applies it twice: `log(melPower + EPS)` and diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs b/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs index 13b9bee7..88196730 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs @@ -45,6 +45,7 @@ use crate::{ context::{ coeff::{ D_CTX, + RESET_FRAMES, SAMPLE_RATE, }, features::{ @@ -85,7 +86,7 @@ pub trait TenVadContextMeta { /// Config for [`TenVadContext`]. /// /// Defaults match the ten-vad reference driver: one 16 kHz stream, hop 256, -/// a 3-frame context stack. +/// a 3-frame context stack, and its 30 s periodic LSTM reset. /// /// Builds [`TenVadContext`] via [`TenVad::init_context`]. Implements /// [`TenVadContextMeta`]. @@ -116,6 +117,14 @@ pub struct TenVadContextConfig { /// the literal-transcription tier. #[config(default = "TenVadPitchSourceConfig::default()")] pub pitch: TenVadPitchSourceConfig, + + /// How often to zero the LSTM states, in model calls; `None` never does. + /// + /// Defaults to [`RESET_FRAMES`], the reference driver's 30 s period. See + /// [`TenVadContext::reset_states`] for exactly what a reset touches, and + /// the module docs for why the reference does this at all. + #[config(default = "Some(RESET_FRAMES)")] + pub reset_frames: Option, } impl TenVadContextMeta for TenVadContextConfig { @@ -161,6 +170,13 @@ impl TenVadContextConfig { "TenVadContext d_ctx must be non-zero".to_string(), )); } + if self.reset_frames == Some(0) { + return Err(BunsenError::Invalid( + "TenVadContext reset_frames must be non-zero; use None to disable the \ + periodic reset" + .to_string(), + )); + } if self.sample_rate != self.features.sample_rate() { return Err(BunsenError::Invalid(format!( "TenVadContext sample_rate ({}) != feature sample_rate ({})", @@ -195,6 +211,21 @@ pub struct TenVadContext = TenVadPitchSource /// The `[batch, d_hidden]` second-LSTM state. pub state2: ExtLstmState, + + /// How often to zero the LSTM states, in model calls; `None` never does. + /// + /// Copied from [`TenVadContextConfig::reset_frames`] at init, and honoured + /// identically by [`TenVad::context_forward`] and + /// [`TenVad::context_forward_sequence`]. + pub reset_frames: Option, + + /// Model calls since the last state reset. + /// + /// Advanced on every model call -- including while + /// [`reset_frames`](Self::reset_frames) is `None`, so the field always + /// means what its name says -- and rewound by + /// [`reset_states`](Self::reset_states). + pub frames_since_reset: usize, } impl> TenVadContextMeta for TenVadContext { @@ -233,12 +264,53 @@ impl> TenVadContext { /// Resets the whole context to the start-of-stream condition. /// /// Zeroes the feature stack, both LSTM states, the STFT queue, the - /// pre-emphasis carry, and every pitch source. + /// pre-emphasis carry, every pitch source, and the reset counter. + /// + /// Contrast [`reset_states`](Self::reset_states), which zeroes only the + /// recurrence and leaves the front end running. pub fn reset(&mut self) { self.features.reset(); self.stack = Tensor::zeros_like(&self.stack); - self.state1 = ExtLstmState::initial(self.state1.hidden.dims(), &self.stack.device()); - self.state2 = ExtLstmState::initial(self.state2.hidden.dims(), &self.stack.device()); + self.reset_states(); + } + + /// Zeroes both LSTM states, leaving the front end running. + /// + /// This is the reference's periodic reset (`ALGO_TRACE.md` §5) as a + /// callable primitive. The feature stack, the STFT queue and the + /// pre-emphasis carry are deliberately untouched, so the next frame still + /// sees its predecessors in the context stack -- only the recurrence + /// restarts from zero. Also rewinds + /// [`frames_since_reset`](Self::frames_since_reset). + /// + /// Useful on its own for callers segmenting a stream themselves; the + /// periodic form is [`reset_frames`](Self::reset_frames). + pub fn reset_states(&mut self) { + let device = self.stack.device(); + self.state1 = ExtLstmState::initial(self.state1.hidden.dims(), &device); + self.state2 = ExtLstmState::initial(self.state2.hidden.dims(), &device); + self.frames_since_reset = 0; + } + + /// Advances the reset counter, and fires at a period boundary. + /// + /// Called by both drivers immediately after a model call, which is where + /// the reference increments (`src/aed.cc:476-481`): the counter fires on + /// `>=`, so call `n` runs with the states it inherited and call `n + 1` + /// runs from zero. + /// + /// The reference defers the zeroing to the top of its next call, via a + /// `clear_hidden` flag. Zeroing here instead is observationally identical + /// for any continuation, and keeps the context self-consistent between + /// calls. + fn tick_state_reset(&mut self) { + self.frames_since_reset += 1; + if self + .reset_frames + .is_some_and(|period| self.frames_since_reset >= period) + { + self.reset_states(); + } } /// Rolls one feature frame into the context stack. @@ -339,6 +411,8 @@ impl TenVad { stack: Tensor::zeros([batch, cfg.d_ctx(), cfg.n_freq()], device), state1: ExtLstmState::initial(state_shape, device), state2: ExtLstmState::initial(state_shape, device), + reset_frames: cfg.reset_frames, + frames_since_reset: 0, }) } @@ -346,7 +420,8 @@ impl TenVad { /// /// Extracts the frame's features, rolls them into the context stack, and /// runs one [`forward`](Self::forward) with the carried LSTM states. The - /// context is advanced in place. + /// context is advanced in place, including its periodic + /// [`reset_frames`](TenVadContext::reset_frames) counter. /// /// # Arguments /// * `hop`: `[batch, hop_size]` mono audio in `[-1, 1]`, at this model's @@ -377,6 +452,7 @@ impl TenVad { self.forward(stack, Some(ctx.state1.clone()), Some(ctx.state2.clone())); ctx.state1 = state1; ctx.state2 = state2; + ctx.tick_state_reset(); // [batch, 1] -> [batch] probs.squeeze_dim(1) @@ -389,7 +465,9 @@ impl TenVad { /// runs batched across the sequence: one pre-emphasis pass, one `stft` /// call, one filterbank matmul, and every step's context stack /// materialized in a single concatenation. Only the recurrence itself is - /// stepped. + /// stepped -- and it is stepped identically, so a periodic + /// [`reset_frames`](TenVadContext::reset_frames) fires on exactly the same + /// hops either way. /// /// # Arguments /// * `hop_seq`: `[steps, batch, hop_size]` consecutive mono audio hops in @@ -440,6 +518,7 @@ impl TenVad { self.forward(stack, Some(ctx.state1.clone()), Some(ctx.state2.clone())); ctx.state1 = state1; ctx.state2 = state2; + ctx.tick_state_reset(); probs.push(prob); } @@ -618,6 +697,10 @@ mod tests { assert_eq!(cfg.d_ctx(), 3); assert_eq!(cfg.n_freq(), N_FREQ); + // The reference driver's 30 s period, on by default. + assert_eq!(cfg.reset_frames, Some(RESET_FRAMES)); + assert_eq!(RESET_FRAMES * cfg.hop_size() / cfg.sample_rate(), 30); + cfg.validate().unwrap(); } @@ -628,6 +711,8 @@ mod tests { TenVadContextConfig::new().with_d_ctx(0), // The declared rate must agree with the front end's. TenVadContextConfig::new().with_sample_rate(8000), + // `None` disables the reset; `Some(0)` is a mistake, not a way to. + TenVadContextConfig::new().with_reset_frames(Some(0)), ] { assert!( matches!(bad.validate(), Err(BunsenError::Invalid(_))), @@ -636,6 +721,14 @@ mod tests { } } + #[test] + fn test_validate_accepts_a_disabled_reset() { + TenVadContextConfig::new() + .with_reset_frames(None) + .validate() + .unwrap(); + } + #[test] fn test_init_context_shapes_and_zeroing() { let (vad, device) = model(); @@ -1001,6 +1094,243 @@ mod tests { .assert_approx_eq::(&first.to_data_as::(), Tolerance::permissive()); } + /// The largest absolute value in either LSTM state. + /// + /// Zero exactly when the recurrence has been reset. + fn state_peak>(ctx: &TenVadContext) -> f32 { + [ + &ctx.state1.hidden, + &ctx.state1.cell, + &ctx.state2.hidden, + &ctx.state2.cell, + ] + .into_iter() + .flat_map(|t| { + t.clone() + .to_data_as::() + .to_vec_as::() + .ok_or_panic() + .into_iter() + }) + .fold(0.0f32, |acc, v| acc.max(v.abs())) + } + + /// Asserts two contexts are the same continuation: same stack, same + /// recurrence, same front end. + fn assert_contexts_agree, Q: TenVadPitchSource>( + a: &TenVadContext, + b: &TenVadContext, + ) { + let tol = Tolerance::::permissive(); + for (x, y) in [ + (&a.state1.hidden, &b.state1.hidden), + (&a.state1.cell, &b.state1.cell), + (&a.state2.hidden, &b.state2.hidden), + (&a.state2.cell, &b.state2.cell), + (&a.features.stft.queue, &b.features.stft.queue), + ] { + x.clone() + .to_data_as::() + .assert_approx_eq::(&y.clone().to_data_as::(), tol); + } + a.stack + .clone() + .to_data_as::() + .assert_approx_eq::(&b.stack.clone().to_data_as::(), tol); + } + + #[test] + fn test_state_reset_fires_on_the_period_boundary() { + // The reference increments *after* the model call and fires on `>=`, + // so call `k` runs with the states it inherited and call `k + 1` runs + // from zero. Off by one in either direction fails here. + const K: usize = 3; + + let (vad, device) = model(); + let cfg = TenVadContextConfig::new().with_reset_frames(Some(K)); + let mut ctx = vad.init_context(&cfg, &device).unwrap(); + + for call in 1..=(2 * K) { + let hop = Tensor::::random([1, cfg.hop_size()], Distribution::Default, &device); + vad.context_forward(hop, &mut ctx); + + let on_boundary = call.is_multiple_of(K); + assert_eq!( + ctx.frames_since_reset, + if on_boundary { 0 } else { call % K }, + "counter wrong after call {call}", + ); + + let peak = state_peak(&ctx); + if on_boundary { + assert_eq!(peak, 0.0, "call {call} should have zeroed the states"); + } else { + assert!(peak > 0.0, "call {call} should not have zeroed the states"); + } + } + } + + #[test] + fn test_state_reset_splits_the_stream_exactly() { + // A periodic reset must be *exactly* a manual one at the same hop -- + // this is the whole semantic, and it needs no golden to check. + const K: usize = 3; + + let (vad, device) = model(); + let cfg = TenVadContextConfig::new(); + let hops = + Tensor::::random([2 * K, 1, cfg.hop_size()], Distribution::Default, &device); + + let periodic = cfg.clone().with_reset_frames(Some(K)); + let mut auto_ctx = vad.init_context(&periodic, &device).unwrap(); + let auto = vad.context_forward_sequence(hops.clone(), &mut auto_ctx); + + // Mirrored block for block, terminal boundary included: a period of + // `K` over `2K` hops fires at `K` *and* at `2K`. The second one lands + // after the last output, so it shows up in the carried state rather + // than in the trace -- which is exactly why the states are compared. + let manual = cfg.with_reset_frames(None); + let mut manual_ctx = vad.init_context(&manual, &device).unwrap(); + let mut blocks = Vec::with_capacity(2); + for block in 0..2 { + let lo = (block * K) as isize; + blocks.push(vad.context_forward_sequence( + hops.clone().slice_dim(0, lo..lo + K as isize), + &mut manual_ctx, + )); + manual_ctx.reset_states(); + } + + auto.to_data_as::().assert_approx_eq::( + &Tensor::cat(blocks, 0).to_data_as::(), + Tolerance::permissive(), + ); + assert_contexts_agree(&auto_ctx, &manual_ctx); + } + + #[test] + fn test_state_reset_changes_the_output() { + // Guard the guard: every assertion above also passes if the reset + // silently never fires, so pin that it is observable at all. + const K: usize = 3; + + let (vad, device) = model(); + let cfg = TenVadContextConfig::new(); + let hops = + Tensor::::random([2 * K, 1, cfg.hop_size()], Distribution::Default, &device); + + let mut on = vad + .init_context(&cfg.clone().with_reset_frames(Some(K)), &device) + .unwrap(); + let mut off = vad + .init_context(&cfg.with_reset_frames(None), &device) + .unwrap(); + + let with = vad.context_forward_sequence(hops.clone(), &mut on); + let without = vad.context_forward_sequence(hops, &mut off); + + let a = with.to_data_as::().to_vec_as::().ok_or_panic(); + let b = without.to_data_as::().to_vec_as::().ok_or_panic(); + let worst = a + .iter() + .zip(b.iter()) + .fold(0.0f32, |acc, (x, y)| acc.max((x - y).abs())); + + // The first `K` hops are identical by construction; the reset only + // shows up from hop `K + 1` on. + assert!( + worst > 1e-6, + "a periodic reset must change the trace, but the worst difference \ + over {} hops was {worst:.3e}", + 2 * K, + ); + } + + #[test] + fn test_reset_states_leaves_the_front_end_alone() { + // The reference resets the recurrence only: the frame after a reset + // still sees its predecessors in the context stack. + let (vad, device) = model(); + let cfg = TenVadContextConfig::new().with_reset_frames(None); + let mut ctx = vad.init_context(&cfg, &device).unwrap(); + + let hops = Tensor::::random([4, 1, cfg.hop_size()], Distribution::Default, &device); + vad.context_forward_sequence(hops, &mut ctx); + + let stack = ctx.stack.clone(); + let queue = ctx.features.stft.queue.clone(); + assert!(state_peak(&ctx) > 0.0, "fixture should leave live states"); + + ctx.reset_states(); + + assert_eq!(state_peak(&ctx), 0.0); + assert_eq!(ctx.frames_since_reset, 0); + ctx.stack.to_data().assert_eq(&stack.to_data(), true); + ctx.features + .stft + .queue + .to_data() + .assert_eq(&queue.to_data(), true); + } + + #[test] + fn test_sequence_matches_stepwise_with_a_periodic_reset() { + // The invariant the whole design rests on: `context_forward_sequence` + // stays exactly "iterating `context_forward`", reset included. + const K: usize = 3; + + let (vad, device) = model(); + let cfg = TenVadContextConfig::new().with_reset_frames(Some(K)); + + let steps = 3 * K; + let hops = + Tensor::::random([steps, 1, cfg.hop_size()], Distribution::Default, &device); + + let mut seq_ctx = vad.init_context(&cfg, &device).unwrap(); + let seq_probs = vad.context_forward_sequence(hops.clone(), &mut seq_ctx); + + let mut step_ctx = vad.init_context(&cfg, &device).unwrap(); + let mut step_probs = Vec::with_capacity(steps); + for step in 0..steps { + step_probs + .push(vad.context_forward(hops.clone().select_dim::<2>(0, step), &mut step_ctx)); + } + let step_probs: Tensor = Tensor::stack(step_probs, 0); + + seq_probs + .to_data_as::() + .assert_approx_eq::(&step_probs.to_data_as::(), Tolerance::permissive()); + assert_contexts_agree(&seq_ctx, &step_ctx); + assert_eq!(seq_ctx.frames_since_reset, step_ctx.frames_since_reset); + } + + #[test] + fn test_sequence_resumes_across_chunks_with_a_periodic_reset() { + // The chunk boundary lands *on* the reset boundary, which is where a + // counter that ticks in the wrong place gives itself away. + const K: usize = 3; + + let (vad, device) = model(); + let cfg = TenVadContextConfig::new().with_reset_frames(Some(K)); + + let hops = Tensor::::random([8, 1, cfg.hop_size()], Distribution::Default, &device); + + let mut whole_ctx = vad.init_context(&cfg, &device).unwrap(); + let whole = vad.context_forward_sequence(hops.clone(), &mut whole_ctx); + + let mut split_ctx = vad.init_context(&cfg, &device).unwrap(); + let a = + vad.context_forward_sequence(hops.clone().slice_dim(0, ..K as isize), &mut split_ctx); + let b = vad.context_forward_sequence(hops.slice_dim(0, K as isize..), &mut split_ctx); + + whole.to_data_as::().assert_approx_eq::( + &Tensor::cat(vec![a, b], 0).to_data_as::(), + Tolerance::permissive(), + ); + assert_contexts_agree(&whole_ctx, &split_ctx); + assert_eq!(whole_ctx.frames_since_reset, split_ctx.frames_since_reset); + } + #[test] #[should_panic(expected = "hop_seq must be non-empty")] fn test_sequence_rejects_empty_input() { diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs index 92f668af..ec32b426 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs @@ -91,13 +91,36 @@ //! Callers stepping hop by hop through the device path should expect that, //! and the `Host` variant may well be cheaper for them. //! +//! ## The periodic state reset +//! +//! The reference driver zeroes both LSTM states every `resetFrameNum = 1875` +//! model calls — 30 s of audio — and this driver reproduces that, on +//! [`TenVadContextConfig::reset_frames`]. `None` disables it. +//! +//! Three details matter, and all three are the reference's +//! (`src/aed.cc:476-481`, `ALGO_TRACE.md` §5): +//! +//! * **Only the recurrence is zeroed.** The feature stack, the STFT queue and +//! the pre-emphasis carry keep running, so the frame after a reset still sees +//! its two predecessors in the context stack. That is what separates +//! [`TenVadContext::reset_states`] from [`TenVadContext::reset`]. +//! * **The counter fires after the model call**, on `>=`. Call 1875 runs with +//! the states it inherited; call 1876 runs from zero. +//! * **Both drivers tick identically**, so `context_forward_sequence` stays +//! exactly "iterating `context_forward`" — a reset lands on the same hops +//! whether or not the caller batches, and whatever chunk boundaries it uses. +//! +//! The reference zeroes lazily, at the top of its next call, via a +//! `clear_hidden` flag; this driver zeroes eagerly at the end of the current +//! one. Those are observationally identical for any continuation. +//! +//! The reference marks the constant `// TODO` (`src/aed.cc:640`), so treat it +//! as reproduced behavior rather than a recommendation — but it is on by +//! default, because parity with the pretrained weights is what this kit is +//! for, and the shipped golden covers 3750 hops, exactly two periods. +//! //! ## Known deviations from the reference driver //! -//! * **No periodic state reset.** The C driver zeroes both LSTM states every -//! `resetFrameNum = 1875` model calls — 30 s of audio — while leaving the -//! feature stack intact (`ALGO_TRACE.md` §5). This driver does not, so -//! `context_forward_sequence` stays exactly "iterating `context_forward`". -//! Byte-parity with the C driver on clips longer than 30 s needs it. //! * **Batch size 1.** The stock ONNX graph pins its LSTM batch to 1; see //! [`TenVad::forward`] for what the leading axis actually means. //! @@ -114,6 +137,7 @@ //! graph over real audio. //! //! [`TenVadContextConfig::pitch`]: crate::kits::speech::ten_vad::TenVadContextConfig +//! [`TenVadContextConfig::reset_frames`]: crate::kits::speech::ten_vad::TenVadContextConfig //! [`TensorPitchConfig::reference`]: crate::kits::speech::ten_vad::context::pitch::tensor::TensorPitchConfig::reference //! [`TenVad::forward`]: crate::kits::speech::ten_vad::TenVad::forward //! [`TenVad::context_forward`]: crate::kits::speech::ten_vad::TenVad::context_forward diff --git a/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs b/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs index fee67633..14fa248b 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs @@ -353,9 +353,46 @@ mod tests { /// front end and its own inference engine, against bunsen's front end and /// its burn port of the graph. Nothing is shared between the two arms /// except the audio. + /// + /// This form is capped short of the periodic-reset boundary; see + /// [`test_reference_probability_golden_full`] for the whole fixture. #[test] #[serial_test::serial] fn test_reference_probability_golden() -> Result<(), Box> { + // Capped like the neighbouring cross test: this arm runs the model once + // per hop on the device, since the LSTM state threads through the loop. + // Raising the cap costs patience, not correctness. + run_probability_golden(Some(400)) + } + + /// The probability golden over the *whole* 60 s fixture. + /// + /// Ignored by default because it runs for over a quarter of an hour -- the + /// per-hop cost of [`TenVad::context_forward_sequence`], not anything this + /// test does. Run it explicitly with: + /// + /// ```text + /// cargo test -p bunsen --lib --features wgpu -- \ + /// test_reference_probability_golden_full --ignored --exact + /// ``` + /// + /// It is worth the wait for one reason: the fixture is 3750 hops, which is + /// **exactly two [`RESET_FRAMES`] periods**, so this is the only test that + /// can show the periodic LSTM reset fires on the same hop as the + /// reference's. A reset at the wrong hop -- or a missing one -- shows up + /// as a divergence immediately after hop 1875. The capped test above + /// never reaches the boundary. + /// + /// [`RESET_FRAMES`]: crate::kits::speech::ten_vad::context::coeff::RESET_FRAMES + #[test] + #[ignore = "runs the full 3750-hop fixture; see the rustdoc"] + #[serial_test::serial] + fn test_reference_probability_golden_full() -> Result<(), Box> { + run_probability_golden(None) + } + + /// Drives the probability golden, optionally capped to `steps_cap` hops. + fn run_probability_golden(steps_cap: Option) -> Result<(), Box> { type B = PerformanceBackend; type F = ::FloatElem; @@ -372,15 +409,7 @@ mod tests { ) .map_err(BunsenError::external)?; - // The golden covers the whole 60 s fixture, but this arm runs the model - // once per hop on the device -- `context_forward_sequence` is a - // sequential loop, by necessity, since the LSTM state threads through - // it. So it is capped like the neighbouring cross test. Raising the cap - // costs patience, not correctness: the full 3750 hops runs for over a - // quarter of an hour, and the golden file holds all of them. - const STEPS: usize = 400; - - let steps = STEPS.min(expected.len()); + let steps = steps_cap.unwrap_or(usize::MAX).min(expected.len()); let samples = steps * cfg.hop_size(); assert!( wav_vec.len() >= samples, From 079308f535b4110b0a31b9fca1526778c729059f Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Sat, 22 Aug 2026 02:55:36 -0700 Subject: [PATCH 11/32] perf(ten-vad): run the model over whole sequences, not hop by hop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `context_forward_sequence` batched the entire front end and then stepped the model once per hop, because `TenVad::forward` was contracted to a single frame. The reference graph is not so limited: its leading axis is *time*, not stream-batch (`ALGO_TRACE.md` §8.1), and feeding T stacked context frames as one call is bit-identical to T sequential calls, final states included (§8.2). Almost all of that was already true here and unreachable behind two details: * `frame_features` reshaped `[1, -1, d_ctx, n_freq]`, putting the leading axis on the conv *channel* dimension. The reference uses `[-1, 1, d_ctx, n_freq]`; the two agree only at one step, and above it the stem would not typecheck against `cs1`'s single input channel. `TenVad::forward`'s own rustdoc named this as the blocker. * Both LSTMs are already `batch_first(false)`, so `lstm_step`'s `[-1, 1, 80]` reshape *is* `new_shape__177` -- burn walks the recurrence internally. What pinned it to one step was a shape contract reading `[1, 1, ..]` as `[a, 1, ..]` where the general shape is `[1, steps, ..]`; the two are indistinguishable at one step, and the contract guessed wrong. So `TenVad::forward_sequence` is mostly contract repair. `forward` becomes its single-step case, which is what keeps them honest. `output_head` loses two reshape round-trips that were no-ops. `context_forward_sequence` now materializes every step's widened context with one `unfold` over the history and hands whole runs to `forward_sequence`, chunked at reset boundaries -- which is exactly §8.2's usage note for reproducing the C driver's periodic reset, and is why `calls_until_state_reset` exists. Verified where it counts: `test_reference_probability_golden_full` now runs the whole 3750-hop fixture -- mean |diff| 5.649e-5, worst 6.446e-4 at hop 2973, **decisions agree on every frame**, across the reset boundary at 1875. `test_sequence_matches_stepwise` and its chunked and periodic-reset siblings pin the batched path against the iterated one. Also fixes the release build, which was broken independently of this: two uses of `unpack_shape_contract` in `pre_emphasis` are `cfg`-gated but the import was not, so a non-test optimized lib build tripped `-D warnings`. The new `test_where_the_time_goes` reports what is left, and the answer was not what I expected. Warm cost is linear and small -- 0.75 ms/hop for the model, 0.50 ms/hop for the device pitch estimator, flat from 400 to 1600 hops. Cold cost is not: cubecl keys kernel selection on shape, and the pitch estimator's cold cost grows roughly quadratically (4.75 s, 15.09 s, 66.61 s at 400, 800, 1600 hops, against warm times of 0.50 s, 1.00 s, 2.06 s). The model half tunes almost for free. So the golden's ten minutes is one-time shape-keyed tuning, not work -- the same 3750 hops cost about five seconds warm, and the fix is to stop handing the pitch estimator a new shape per run. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../src/kits/speech/ten_vad/blocks/module.rs | 298 +++++++++++------- .../src/kits/speech/ten_vad/context/driver.rs | 193 +++++++++++- .../speech/ten_vad/context/pre_emphasis.rs | 3 +- .../src/kits/speech/ten_vad/cross_test.rs | 9 +- 4 files changed, 362 insertions(+), 141 deletions(-) diff --git a/crates/bunsen/src/kits/speech/ten_vad/blocks/module.rs b/crates/bunsen/src/kits/speech/ten_vad/blocks/module.rs index 71632512..738a0587 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/blocks/module.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/blocks/module.rs @@ -258,73 +258,105 @@ impl TenVad { /// normalized context stack. See /// [`context_forward`](Self::context_forward) to drive raw audio. /// - /// # The `a` axis + /// # The leading axis is time, not stream-batch /// - /// `a` is **not** a stream-batch axis, and this implementation pins it to - /// `1`. In the reference ONNX graph the leading dimension of the feature - /// input lands on the LSTM's *sequence* axis, with the LSTM batch fixed at - /// 1 by a graph constant (`new_shape__177 = [-1, 1, 80]`); the reference - /// `ALGO_TRACE.md` §8 documents this and verifies it empirically. + /// In the reference ONNX graph the leading dimension of the feature input + /// lands on the LSTM's *sequence* axis, with the LSTM batch fixed at 1 by a + /// graph constant (`new_shape__177 = [-1, 1, 80]`); `ALGO_TRACE.md` §8 + /// documents this and verifies it empirically. This method pins it to `1` + /// and is the single-step form; + /// [`forward_sequence`](Self::forward_sequence) is the same computation + /// over a run of steps. /// - /// Two consequences, both deliberate and both currently out of scope: - /// - /// * **Sequence batching is available in the graph but not here.** Feeding - /// `T` stacked context frames as one call is bit-identical to `T` - /// sequential calls (§8.2), and far faster. Reaching it from bunsen needs - /// [`frame_features`](Self::frame_features) to reshape `[-1, 1, d_ctx, - /// n_freq]`, as the reference graph does, rather than the `[1, -1, d_ctx, - /// n_freq]` it currently uses — the two agree only at `a == 1`, which is - /// why the shape contract pins it there. - /// * **Multi-stream batching is structurally impossible** against the stock - /// graph: batched states fail shape validation outright. It requires - /// patching two reshape constants (§8.3), i.e. a different model file. + /// **Multi-stream batching is structurally impossible** against the stock + /// graph: batched states fail shape validation outright. It requires + /// patching two reshape constants (§8.3), i.e. a different model file. /// /// # Arguments - /// * `input`: `[a, d_ctx, n_freq]` the widened feature stack, `a == 1`. - /// * `state1`: `[a, d_hidden]` first-LSTM state, or `None` to start zeroed. - /// * `state2`: `[a, d_hidden]` second-LSTM state, or `None` to start + /// * `input`: `[1, d_ctx, n_freq]` the widened feature stack. + /// * `state1`: `[1, d_hidden]` first-LSTM state, or `None` to start zeroed. + /// * `state2`: `[1, d_hidden]` second-LSTM state, or `None` to start /// zeroed. /// /// # Returns /// `(probabilities, state1, state2)`, with: - /// * `probabilities`: `[a, 1]` speech probabilities in `[0, 1]` - /// * `state1` / `state2`: `[a, d_hidden]` next LSTM states + /// * `probabilities`: `[1, 1]` speech probability in `[0, 1]` + /// * `state1` / `state2`: `[1, d_hidden]` next LSTM states pub fn forward( &self, input: Tensor, state1: Option>, state2: Option>, ) -> (Tensor, ExtLstmState, ExtLstmState) { - // TODO: debug the 1, 1 dims; relationship to batch, seq. - assert_eq!(state1.is_some(), state2.is_some()); #[cfg(any(test, debug_assertions))] - { - use crate::contracts::assert_shape_contract; - let [a] = crate::contracts::unpack_shape_contract!( - ["a", "d_ctx", "n_freq"], - &input, - &["a"], - &[("a", 1), ("d_ctx", self.d_ctx()), ("n_freq", self.n_freq())] - ); - if let Some(state1) = &state1 { - assert_shape_contract!( - ["a", "d_hidden"], - state1.shape(), - &[("a", a), ("d_hidden", self.d_hidden())], - ); - } - if let Some(state2) = &state2 { - assert_shape_contract!( - ["a", "d_hidden"], - state2.shape(), - &[("a", a), ("d_hidden", self.d_hidden())], - ); - } - } + crate::contracts::assert_shape_contract!( + ["steps", "d_ctx", "n_freq"], + &input, + &[ + ("steps", 1), + ("d_ctx", self.d_ctx()), + ("n_freq", self.n_freq()) + ] + ); + + self.forward_sequence(input, state1, state2) + } + + /// Stateless forward pass over a run of consecutive feature stacks. + /// + /// Equivalent to `steps` calls of [`forward`](Self::forward), final states + /// included — the reference verified exactly that against its own graph + /// (`ALGO_TRACE.md` §8.2) — but it runs as **one** pass instead of `steps`. + /// + /// Only the recurrence is inherently sequential, and it is the one part + /// that does not become a Rust-side loop: + /// + /// | stage | over a run of `steps` | + /// |---|---| + /// | [`frame_features`](Self::frame_features) | one conv pass, `steps` images | + /// | `lstm_step` | one call; burn walks the recurrence internally | + /// | `output_head` | one pass, `steps` rows | + /// + /// So a `steps`-hop run costs a constant number of dispatches rather than + /// a number proportional to `steps`. The reference measured ~46x on CPU at + /// `steps = 1875` (1756 ms sequential vs 38 ms batched). + /// + /// # The periodic reset + /// + /// This threads one unbroken recurrence through the whole input, so a + /// caller reproducing the reference's periodic state reset must chunk at + /// reset boundaries and zero the states between chunks — §8.2's own usage + /// note. [`context_forward_sequence`](Self::context_forward_sequence) does + /// that for you. + /// + /// # Arguments + /// * `input`: `[steps, d_ctx, n_freq]` consecutive widened feature stacks, + /// with `steps` non-zero. + /// * `state1`: `[1, d_hidden]` first-LSTM state, or `None` to start zeroed. + /// * `state2`: `[1, d_hidden]` second-LSTM state, or `None` to start + /// zeroed. + /// + /// # Returns + /// `(probabilities, state1, state2)`, with: + /// * `probabilities`: `[steps, 1]` speech probabilities in `[0, 1]`, in + /// order + /// * `state1` / `state2`: `[1, d_hidden]` states after the final step + pub fn forward_sequence( + &self, + input: Tensor, + state1: Option>, + state2: Option>, + ) -> (Tensor, ExtLstmState, ExtLstmState) { + assert_eq!(state1.is_some(), state2.is_some()); + assert_ne!(input.dims()[0], 0, "TenVad input must be non-empty"); + + // [steps, f_ctx, d_features] let x = self.frame_features(input); + // [1, steps, 2 * d_hidden] let (x, state1, state2) = self.lstm_step(x, state1, state2); + // [steps, 1] let x = self.output_head(x); (x, state1, state2) @@ -332,42 +364,52 @@ impl TenVad { /// Runs the conv stem over a feature stack. /// + /// Purely per-step: each stack is convolved on its own, so this runs once + /// over a whole sequence rather than once per step. That is the other half + /// of what makes [`forward_sequence`](Self::forward_sequence) worth having. + /// /// # Arguments - /// * `x`: `[a, d_ctx, n_freq]` the widened feature stack. + /// * `x`: `[steps, d_ctx, n_freq]` the widened feature stacks. /// /// # Returns - /// `[a, f_ctx, d_features]` embeddings. + /// `[steps, f_ctx, d_features]` embeddings. /// - /// Note the `[1, -1, d_ctx, n_freq]` reshape below: the reference graph - /// uses `[-1, 1, d_ctx, n_freq]`. The two agree at `a == 1`, which is all - /// this model is contracted for; see [`forward`](Self::forward). + /// # The `[-1, 1, d_ctx, n_freq]` reshape + /// + /// This matches the reference graph, which reshapes `input_1` per item so + /// that the leading dimension is a true conv batch (`ALGO_TRACE.md` §8.1). + /// An earlier form here used `[1, -1, d_ctx, n_freq]`, which puts the + /// leading axis on the *channel* dimension instead — the two agree only at + /// `steps == 1`, and above it the stem would not even typecheck against + /// `cs1`'s single input channel. pub fn frame_features( &self, x: Tensor, ) -> Tensor { - // TODO: debug the 1, 1 dims; relationship to batch, seq. #[cfg(any(test, debug_assertions))] - let [a] = crate::contracts::unpack_shape_contract!( - ["a", "d_ctx", "n_freq"], + let [steps] = crate::contracts::unpack_shape_contract!( + ["steps", "d_ctx", "n_freq"], &x, - &["a"], + &["steps"], &[("d_ctx", self.d_ctx()), ("n_freq", self.n_freq())] ); - // this *appears* batch-able. - let x = x.reshape([1, -1, 3, self.n_freq() as isize]); + // [steps, 1, d_ctx, n_freq]: one single-channel image per step. + let x = x.reshape([-1, 1, self.d_ctx() as isize, self.n_freq() as isize]); let x = self.cs1.forward(x); let x = self.pool.forward(x); let x = self.cs2.forward(x); + + // The stem collapses the context axis to 1. let x = x.squeeze_dim(2); let x = x.permute([0, 2, 1]); #[cfg(any(test, debug_assertions))] crate::contracts::assert_shape_contract!( - ["a", "f_ctx", "d_features"], + ["steps", "f_ctx", "d_features"], &x, &[ - ("a", a), + ("steps", steps), ("f_ctx", self.f_ctx()), ("d_features", self.d_features()) ] @@ -375,52 +417,69 @@ impl TenVad { x } + /// Runs both LSTMs over a whole sequence, threading the states through. + /// + /// This is the model's only recurrence, and it is the reason the reference + /// graph's leading axis behaves the way it does: `new_shape__177 = + /// [-1, 1, 80]` lands it on the LSTM's *sequence* axis with the LSTM batch + /// pinned to 1 (`ALGO_TRACE.md` §8.1). Both [`Lstm`]s here are configured + /// `batch_first(false)`, so `[steps, 1, ..]` is that same layout, and burn + /// walks the recurrence inside one call — `steps` sequential steps of the + /// cell, not `steps` dispatches from Rust. + /// + /// The reference verified this equals `steps` separate batch-1 calls, + /// final states included (`ALGO_TRACE.md` §8.2). + /// + /// # Arguments + /// * `x`: `[steps, f_ctx, d_features]` per-step embeddings. + /// * `state1` / `state2`: `[1, d_hidden]` states, or `None` to start + /// zeroed. These stay batch-1 whatever `steps` is — they are the state of + /// *one* stream, before and after the run. + /// + /// # Returns + /// `([1, steps, 2 * d_hidden]` concatenated outputs, next `state1`, next + /// `state2)`. fn lstm_step( &self, x: Tensor, state1: Option>, state2: Option>, ) -> (Tensor, ExtLstmState, ExtLstmState) { - // TODO: debug the 1, 1 dims; relationship to batch, seq. assert_eq!(state1.is_some(), state2.is_some()); #[cfg(any(test, debug_assertions))] - let a = { + let steps = { use crate::contracts::assert_shape_contract; - let [a] = crate::contracts::unpack_shape_contract!( - ["a", "f_ctx", "d_features"], + let [steps] = crate::contracts::unpack_shape_contract!( + ["steps", "f_ctx", "d_features"], &x, - &["a"], + &["steps"], &[("f_ctx", self.f_ctx()), ("d_features", self.d_features())] ); - if let Some(state1) = &state1 { + for state in [&state1, &state2].into_iter().flatten() { assert_shape_contract!( - ["a", "d_hidden"], - state1.shape(), - &[("a", a), ("d_hidden", self.d_hidden())], + ["batch", "d_hidden"], + state.shape(), + &[("batch", 1), ("d_hidden", self.d_hidden())], ); } - if let Some(state2) = &state2 { - assert_shape_contract!( - ["a", "d_hidden"], - state2.shape(), - &[("a", a), ("d_hidden", self.d_hidden())], - ); - } - a + steps }; - // converting this to batch seems odd. - // The existing re-shape seems to feed [batch=-1, seq=1, features=80]; - // which is a weird way to run this ...? + // [steps, 1, f_ctx * d_features]: the reference's `new_shape__177`, + // which is `[seq, batch, input]` for a `batch_first(false)` LSTM. let x = x.reshape([-1, 1, (self.f_ctx() * self.d_features()) as isize]); let (x, state1) = self.lstm1.forward(x, state1.map(Into::into)); + + // [1, steps, d_hidden]: `new_shape__176`. let y = x.reshape([1, -1, self.d_hidden() as isize]); + // [steps, 1, d_hidden]: the graph's `[1, 0, 2]` transpose, putting the + // second LSTM back on the same sequence-major layout. let x = y.clone().swap_dims(0, 1); - let (x, state2) = self.lstm2.forward(x, state2.map(Into::into)); let x = x.swap_dims(0, 1); + // [1, steps, 2 * d_hidden] let x = Tensor::cat([x, y].into(), 2); let state1: ExtLstmState = state1.into(); let state2: ExtLstmState = state2.into(); @@ -428,63 +487,58 @@ impl TenVad { { use crate::contracts::assert_shape_contract; assert_shape_contract!( - ["a", 1, 2 * "d_hidden"], + [1, "steps", 2 * "d_hidden"], &x, - &[("a", a), ("d_hidden", self.d_hidden())] - ); - assert_shape_contract!( - ["a", "d_hidden"], - &state1.shape(), - &[("a", 1), ("d_hidden", self.d_hidden())], - ); - assert_shape_contract!( - ["a", "d_hidden"], - &state2.shape(), - &[("a", 1), ("d_hidden", self.d_hidden())], + &[("steps", steps), ("d_hidden", self.d_hidden())] ); + for state in [&state1, &state2] { + assert_shape_contract!( + ["batch", "d_hidden"], + &state.shape(), + &[("batch", 1), ("d_hidden", self.d_hidden())], + ); + } } (x, state1, state2) } + /// Projects the recurrent output to a probability per step. + /// + /// Purely per-step: every row of `x` goes through the same two linears, so + /// this runs once over a whole sequence rather than once per step. That is + /// half of what makes [`forward_sequence`](Self::forward_sequence) worth + /// having. + /// + /// # Arguments + /// * `x`: `[1, steps, 2 * d_hidden]` the concatenated LSTM outputs. + /// + /// # Returns + /// `[steps, 1]` speech probabilities in `[0, 1]`. fn output_head( &self, x: Tensor, ) -> Tensor { - // TODO: debug the 1, 1 dims; relationship to batch, seq. #[cfg(any(test, debug_assertions))] - let [a] = crate::contracts::unpack_shape_contract!( - ["a", 1, 2 * "d_hidden"], + let [steps] = crate::contracts::unpack_shape_contract!( + [1, "steps", 2 * "d_hidden"], &x, - &["a"], + &["steps"], &[("d_hidden", self.d_hidden())] ); - let half_hidden = self.d_hidden() / 2; - let twice_hidden = self.d_hidden() * 2; - - // this *appears* batch-able. - let mut shape1: [usize; 3] = x.dims(); - shape1[2] = self.d_hidden() / 2; - // [a * 1, 2 * d_hidden] - let x = x.reshape([-1, twice_hidden as isize]); - let x = self.linear1.forward(x); - let x = relu(x); - // [a * 1, d_hidden / 2] - let x = x.reshape(shape1); - - let mut shape2: [usize; 3] = x.dims(); - shape2[2] = 1; - // [a * 1, d_hidden / 2] - let x = x.reshape([-1, half_hidden as isize]); - let x = self.linear2.forward(x); - let x = sigmoid(x); - let x = x.reshape(shape2); - // [a, 1] - let x = x.squeeze_dim(2); + // The leading axis is a pinned batch of 1, so folding it away leaves + // one row per step, in order. + // [steps, 2 * d_hidden] + let x = x.reshape([-1, (self.d_hidden() * 2) as isize]); + + // [steps, d_hidden / 2] + let x = relu(self.linear1.forward(x)); + + // [steps, 1] + let x = sigmoid(self.linear2.forward(x)); #[cfg(any(test, debug_assertions))] - // TODO: really? - crate::contracts::assert_shape_contract!(["a", 1], &x, &[("a", a)]); + crate::contracts::assert_shape_contract!(["steps", 1], &x, &[("steps", steps)]); x } } diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs b/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs index 88196730..e18bd10c 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs @@ -304,7 +304,41 @@ impl> TenVadContext { /// for any continuation, and keeps the context self-consistent between /// calls. fn tick_state_reset(&mut self) { - self.frames_since_reset += 1; + self.advance_state_reset(1); + } + + /// How many model calls may run before the next reset has to fire. + /// + /// [`usize::MAX`] when the reset is disabled, and never zero: a caller + /// that lowered `reset_frames` below the live count still gets to run one + /// call, after which [`advance_state_reset`](Self::advance_state_reset) + /// fires immediately -- the same thing a per-hop tick would have done. + /// + /// This is what lets [`TenVad::context_forward_sequence`] hand whole runs + /// to [`TenVad::forward_sequence`]: the recurrence may be threaded + /// uninterrupted for exactly this many steps. + fn calls_until_state_reset(&self) -> usize { + match self.reset_frames { + None => usize::MAX, + Some(period) => period.saturating_sub(self.frames_since_reset).max(1), + } + } + + /// Advances the reset counter by `calls`, and fires at a period boundary. + /// + /// `calls` must not exceed + /// [`calls_until_state_reset`](Self::calls_until_state_reset), or it would + /// step over a boundary the reference stops at. + fn advance_state_reset( + &mut self, + calls: usize, + ) { + debug_assert!( + calls <= self.calls_until_state_reset(), + "advancing {calls} calls would skip a reset boundary", + ); + + self.frames_since_reset += calls; if self .reset_frames .is_some_and(|period| self.frames_since_reset >= period) @@ -492,6 +526,11 @@ impl TenVad { let steps = hop_seq.dims()[0]; assert_ne!(steps, 0, "TenVad hop_seq must be non-empty"); + assert_eq!( + ctx.batch_size(), + 1, + "TenVad pins its LSTM batch to 1; see `TenVad::forward`", + ); let d_ctx = ctx.d_ctx(); @@ -506,25 +545,47 @@ impl TenVad { // The carry-out stack is the final `d_ctx` frames. ctx.stack = history.clone().slice_dim(1, steps as isize..); - let mut probs = Vec::with_capacity(steps); - for step in 0..steps { - // Step `s` sees frames `s + 1 ..= s + d_ctx` of the history. - // [batch, d_ctx, n_freq] - let stack = history - .clone() - .slice_dim(1, (step + 1) as isize..(step + 1 + d_ctx) as isize); - - let (prob, state1, state2) = - self.forward(stack, Some(ctx.state1.clone()), Some(ctx.state2.clone())); + // Every step's widened context, materialized once: step `s` sees + // frames `s + 1 ..= s + d_ctx`, so the windows are the history's own + // sliding view with the carried stack dropped. The batch axis goes + // away because the model pins it to 1 and reads its leading axis as + // time; `assert_batch_one` above is what makes that sound. + // [steps, d_ctx, n_freq] + // `unfold` appends the window axis last, so this lands as + // `[steps + 1, n_freq, d_ctx]` and has to be transposed back. + let stacks = history + .squeeze_dim::<2>(0) + .unfold::<3, _>(0, d_ctx, 1) + .slice_dim(0, 1..) + .swap_dims(1, 2); + + // One pass per reset period rather than one per hop. `forward_sequence` + // walks the recurrence internally, so the only thing that has to + // interrupt it is a state reset -- which is exactly the chunking rule + // the reference gives for batched calls (`ALGO_TRACE.md` §8.2). + let mut probs = Vec::new(); + let mut done = 0; + while done < steps { + let take = ctx.calls_until_state_reset().min(steps - done); + + // [take, 1] + let (chunk, state1, state2) = self.forward_sequence( + stacks + .clone() + .slice_dim(0, done as isize..(done + take) as isize), + Some(ctx.state1.clone()), + Some(ctx.state2.clone()), + ); ctx.state1 = state1; ctx.state2 = state2; - ctx.tick_state_reset(); + ctx.advance_state_reset(take); - probs.push(prob); + probs.push(chunk); + done += take; } - // [steps, batch, 1] -> [steps, batch] - Tensor::stack::<3>(probs, 0).squeeze_dim(2) + // [steps, 1] is [steps, batch], the batch being the pinned 1. + Tensor::cat(probs, 0) } /// Uploads host audio and drives it through the model. @@ -1331,6 +1392,108 @@ mod tests { assert_eq!(whole_ctx.frames_since_reset, split_ctx.frames_since_reset); } + /// Reports cold and warm cost of [`TenVad::context_forward_sequence`]. + /// + /// A measurement, not an assertion -- it prints and asserts nothing, so it + /// is ignored by default. Run it against an optimized build: + /// + /// ```text + /// cargo test --release -p bunsen --lib --features wgpu -- \ + /// test_where_the_time_goes --ignored --exact --nocapture + /// ``` + /// + /// **Cold is the first call at a given `steps`; warm is a later one.** The + /// gap is kernel selection, which cubecl keys on shape -- so a caller that + /// hands in a different `steps` every time pays it every time, and one + /// that reuses a fixed chunk size pays it once. + /// + /// That distinction is the whole point of this test. Measured on wgpu, one + /// stream, `Zero` against the default device pitch source: + /// + /// | pitch | hops | cold | warm | + /// |---|---|---|---| + /// | zero | 1600 | 1.26 s | 1.19 s | + /// | tensor | 400 | 4.75 s | 0.50 s | + /// | tensor | 800 | 15.09 s | 1.00 s | + /// | tensor | 1600 | 66.61 s | 2.06 s | + /// + /// Warm cost is linear and small -- about 0.75 ms/hop for the model and + /// 0.50 ms/hop for the device pitch estimator, flat across the sweep. Cold + /// cost is neither: the model half tunes almost for free (`zero` cold is + /// warm plus a little), while the pitch estimator's cold cost grows + /// roughly quadratically. At 3750 hops that is the difference between + /// about five seconds of work and the ten minutes + /// [`test_reference_probability_golden_full`] actually takes. + /// + /// [`test_reference_probability_golden_full`]: + /// crate::kits::speech::ten_vad::cross_test + #[test] + #[ignore = "measurement, not an assertion"] + #[serial_test::serial] + fn test_where_the_time_goes() { + use std::time::{ + Duration, + Instant, + }; + + /// Runs per measurement; the minimum is reported. + const REPS: usize = 3; + + let (vad, device) = model(); + + /// Wall time for a single run of `run`, which must synchronize. + fn best_of_1(mut run: impl FnMut()) -> Duration { + let start = Instant::now(); + run(); + start.elapsed() + } + + /// Best-of-`REPS` wall time for `run`, which must synchronize. + fn best_of(mut run: impl FnMut()) -> Duration { + (0..REPS) + .map(|_| { + let start = Instant::now(); + run(); + start.elapsed() + }) + .min() + .unwrap() + } + + eprintln!("{:>6} {:>6} {:>12} {:>12}", "pitch", "hops", "cold", "warm"); + + for (name, pitch) in [ + ("zero", TenVadPitchSourceConfig::Zero), + ("tensor", TenVadPitchSourceConfig::default()), + ] { + let cfg = TenVadContextConfig::new().with_pitch(pitch); + + for steps in [400usize, 800, 1600] { + let hops = Tensor::::random( + [steps, 1, cfg.hop_size()], + Distribution::Default, + &device, + ); + + // The *first* run at this shape, kernel selection included. + let cold = best_of_1(|| { + let mut ctx = vad.init_context(&cfg, &device).unwrap(); + vad.context_forward_sequence(hops.clone(), &mut ctx) + .to_data(); + }); + + // And a subsequent one, with the same shapes already tuned. + let warm = best_of(|| { + let mut ctx = vad.init_context(&cfg, &device).unwrap(); + vad.context_forward_sequence(hops.clone(), &mut ctx) + .to_data(); + }); + + eprintln!("{name:>6} {steps:>6} {cold:>12.2?} {warm:>12.2?}"); + } + } + } + #[test] #[should_panic(expected = "hop_seq must be non-empty")] fn test_sequence_rejects_empty_input() { diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pre_emphasis.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pre_emphasis.rs index 0b70792c..df3d0243 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pre_emphasis.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pre_emphasis.rs @@ -29,8 +29,9 @@ use burn::{ prelude::*, }; +#[cfg(any(test, debug_assertions))] +use crate::contracts::unpack_shape_contract; use crate::{ - contracts::unpack_shape_contract, kits::speech::ten_vad::context::coeff::PRE_EMPHASIS_COEFF, prelude::TensorOpExt, }; diff --git a/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs b/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs index 14fa248b..0876b959 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs @@ -367,9 +367,12 @@ mod tests { /// The probability golden over the *whole* 60 s fixture. /// - /// Ignored by default because it runs for over a quarter of an hour -- the - /// per-hop cost of [`TenVad::context_forward_sequence`], not anything this - /// test does. Run it explicitly with: + /// Ignored by default because it runs for about ten minutes in release + /// (much longer in debug). That is almost entirely one-time, shape-keyed + /// kernel selection in the device pitch estimator rather than the work + /// itself -- the same 3750 hops cost roughly five seconds once the shapes + /// are tuned. `driver::tests::test_where_the_time_goes` measures the + /// split. Run this one explicitly with: /// /// ```text /// cargo test -p bunsen --lib --features wgpu -- \ From ecfe9d8ff948dd2d3b6c919e2fb751e960c740e7 Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Sat, 22 Aug 2026 08:04:14 -0700 Subject: [PATCH 12/32] perf(ten-vad): run the device pitch estimator in fixed-size passes `TenVad::forward_sequence` made the model half cheap and left the device pitch estimator as the whole cost, so `test_where_the_time_goes` went looking for why. The answer was not the arithmetic. Warm cost was already linear and small -- about 0.50 ms/hop, flat from 400 to 1600 hops. Cold cost was not: 4.75 s, 15.09 s, 66.61 s at 400, 800 and 1600 hops, roughly quadratic. cubecl selects kernels per shape, and a run of `steps` hops derives a shape from `steps` in every stage, so a caller that hands in a different length each time re-tunes the entire estimator each time. The 3750-hop golden was ten minutes of kernel selection wrapped around five seconds of work. So `TensorPitchConfig::chunk_steps` pins the pass size, defaulting to 512 hops -- also roughly where the anti-alias GEMM's materialized input stops being cheap, which is why the number was already written down. `forward_sequence` splits into fixed passes and `forward_chunk` is the old body; `None` restores the single-pass form. This cannot change the answer, and that is the point: the state carry it leans on is the same one that already lets a caller split a stream across calls. Two tests pin it -- chunked against unchunked, at a length that leaves a remainder and at an exact multiple. Measured, wgpu, one stream: | pitch | hops | cold, unchunked | cold, chunked | warm | |---|---|---|---|---| | tensor | 400 | 4.75 s | 4.77 s | 0.51 s | | tensor | 800 | 15.09 s | 15.92 s | 1.03 s | | tensor | 1600 | 66.61 s | 12.74 s | 2.09 s | The sweep shows the mechanism rather than just the improvement: chunked, 1600 hops costs *less* cold than 800, because by then the 512-hop shape is tuned and only the 64-hop remainder is new. 400 is unchanged -- it fits in one pass. End to end, `test_reference_probability_golden_full` went from 612 s to 19.6 s in release, and from over an hour to 126 s in debug, with byte-identical results: mean |diff| 5.649e-5, worst 6.446e-4 at hop 2973, decisions agreeing on every one of 3750 frames. It stays `#[ignore]`d. 126 s is still more than the suite should spend on one case when the capped form covers the same path. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../src/kits/speech/ten_vad/context/driver.rs | 42 +++-- .../src/kits/speech/ten_vad/context/mod.rs | 6 + .../ten_vad/context/pitch/tensor/source.rs | 166 +++++++++++++++++- .../src/kits/speech/ten_vad/cross_test.rs | 9 +- 4 files changed, 192 insertions(+), 31 deletions(-) diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs b/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs index e18bd10c..53f601cc 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs @@ -1402,28 +1402,32 @@ mod tests { /// test_where_the_time_goes --ignored --exact --nocapture /// ``` /// - /// **Cold is the first call at a given `steps`; warm is a later one.** The - /// gap is kernel selection, which cubecl keys on shape -- so a caller that - /// hands in a different `steps` every time pays it every time, and one - /// that reuses a fixed chunk size pays it once. + /// **Cold is the first call at a given `steps`; warm is a later one.** + /// The gap is kernel selection, which cubecl keys on shape. /// - /// That distinction is the whole point of this test. Measured on wgpu, one - /// stream, `Zero` against the default device pitch source: + /// Warm cost is linear and small -- about 0.75 ms/hop for the model and + /// 0.50 ms/hop for the device pitch estimator, flat across the sweep. + /// Cold cost is the interesting half, and it is what + /// [`TensorPitchConfig::chunk_steps`] exists to control. Measured on wgpu, + /// one stream, before and after fixing the estimator's pass size: /// - /// | pitch | hops | cold | warm | - /// |---|---|---|---| - /// | zero | 1600 | 1.26 s | 1.19 s | - /// | tensor | 400 | 4.75 s | 0.50 s | - /// | tensor | 800 | 15.09 s | 1.00 s | - /// | tensor | 1600 | 66.61 s | 2.06 s | + /// | pitch | hops | cold, unchunked | cold, chunked | warm | + /// |---|---|---|---|---| + /// | zero | 1600 | 1.26 s | 1.30 s | 1.22 s | + /// | tensor | 400 | 4.75 s | 4.77 s | 0.51 s | + /// | tensor | 800 | 15.09 s | 15.92 s | 1.03 s | + /// | tensor | 1600 | 66.61 s | 12.74 s | 2.09 s | /// - /// Warm cost is linear and small -- about 0.75 ms/hop for the model and - /// 0.50 ms/hop for the device pitch estimator, flat across the sweep. Cold - /// cost is neither: the model half tunes almost for free (`zero` cold is - /// warm plus a little), while the pitch estimator's cold cost grows - /// roughly quadratically. At 3750 hops that is the difference between - /// about five seconds of work and the ten minutes - /// [`test_reference_probability_golden_full`] actually takes. + /// Unchunked, cold grows roughly quadratically. Chunked, the sweep shows + /// the mechanism directly: **1600 hops costs less cold than 800 does**, + /// because by then the 512-hop shape is already tuned and only the 64-hop + /// remainder is new. 400 is unchanged either way -- it fits in one chunk. + /// + /// The model half barely tunes at all (`zero` cold is warm plus a little), + /// which is what [`TenVad::forward_sequence`] bought. + /// + /// End to end, [`test_reference_probability_golden_full`] went from 612 s + /// to 19.6 s in release, and from over an hour to 126 s in debug. /// /// [`test_reference_probability_golden_full`]: /// crate::kits::speech::ten_vad::cross_test diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs index ec32b426..f1e66cc4 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs @@ -91,6 +91,11 @@ //! Callers stepping hop by hop through the device path should expect that, //! and the `Host` variant may well be cheaper for them. //! +//! Internally it works in fixed-size passes +//! ([`TensorPitchConfig::chunk_steps`]), which does not change the answer but +//! matters a great deal for cost: cubecl selects kernels per shape, so a +//! pinned pass size is tuned once instead of once per distinct input length. +//! //! ## The periodic state reset //! //! The reference driver zeroes both LSTM states every `resetFrameNum = 1875` @@ -139,6 +144,7 @@ //! [`TenVadContextConfig::pitch`]: crate::kits::speech::ten_vad::TenVadContextConfig //! [`TenVadContextConfig::reset_frames`]: crate::kits::speech::ten_vad::TenVadContextConfig //! [`TensorPitchConfig::reference`]: crate::kits::speech::ten_vad::context::pitch::tensor::TensorPitchConfig::reference +//! [`TensorPitchConfig::chunk_steps`]: crate::kits::speech::ten_vad::context::pitch::tensor::TensorPitchConfig //! [`TenVad::forward`]: crate::kits::speech::ten_vad::TenVad::forward //! [`TenVad::context_forward`]: crate::kits::speech::ten_vad::TenVad::context_forward //! [`SlidingStftContext`]: crate::ops::signal::SlidingStftContext diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/source.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/source.rs index 8d6fd3e8..30fd512e 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/source.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/source.rs @@ -78,8 +78,39 @@ pub struct TensorPitchConfig { /// Stage 4: period tracking. #[config(default = "PitchTrackConfig::new()")] pub track: PitchTrackConfig, + + /// How many hops to process per internal pass; `None` runs the whole + /// input in one. + /// + /// This does not change the result -- the stages already carry their state + /// across calls, and chunking only exercises that carry more often. What it + /// changes is *cost*, and by a lot. + /// + /// cubecl selects kernels per shape, so a run of `steps` hops tunes every + /// op in this estimator at a shape derived from `steps`. Hand it a + /// different `steps` each time and it re-tunes every time; tuning here is + /// superlinear in `steps`, so one large pass is far worse than several + /// small ones. Pinning the pass size means the tuning happens once and + /// every later run reuses it. + /// + /// Defaults to [`DEFAULT_CHUNK_STEPS`], which is also about where the + /// anti-alias GEMM's materialized input stops being cheap. + /// + /// Measured on wgpu, one stream: a cold 1600-hop run costs 66.6 s + /// unchunked and 12.7 s chunked, against a warm cost of 2.1 s either way. + /// End to end, the 3750-hop reference golden went from 612 s to 19.6 s. + /// See `driver::tests::test_where_the_time_goes`. + #[config(default = "Some(DEFAULT_CHUNK_STEPS)")] + pub chunk_steps: Option, } +/// The default hops per internal pass; see +/// [`TensorPitchConfig::chunk_steps`]. +/// +/// 512 hops is 8.2 s of audio, a 4.7 MB transient through the anti-alias GEMM, +/// and a tuning cost paid once rather than per call. +pub const DEFAULT_CHUNK_STEPS: usize = 512; + impl TensorPitchConfig { /// Selects how the anti-alias filter is realized. /// @@ -123,6 +154,12 @@ impl TensorPitchConfig { self.correlate.validate()?; self.track.validate()?; + if self.chunk_steps == Some(0) { + return Err(crate::errors::BunsenError::Invalid( + "TensorPitch chunk_steps must be non-zero; use None to disable chunking" + .to_string(), + )); + } if self.excitation.exc_len() != self.correlate.exc_len() { return Err(crate::errors::BunsenError::Invalid(format!( "TensorPitch excitation emits {} samples but the lag search reads {}", @@ -148,6 +185,7 @@ impl TensorPitchConfig { excitation: self.excitation.try_init(device)?, correlate: self.correlate.try_init(device)?, track: self.track.try_init(device)?, + chunk_steps: self.chunk_steps, }) } @@ -174,6 +212,9 @@ pub struct TensorPitch { pub correlate: PitchCorrelate, /// Stage 4. pub track: PitchTrack, + + /// Hops per internal pass; see [`TensorPitchConfig::chunk_steps`]. + pub chunk_steps: Option, } impl TensorPitch { @@ -254,6 +295,49 @@ impl TenVadPitchSource for TensorPitchContext { assert_eq!(batch, self.batch_size, "TensorPitch batch mismatch"); assert_eq!(n_bins, self.coef.n_bins(), "TensorPitch bin count mismatch"); + let chunk = self.coef.chunk_steps.unwrap_or(steps).max(1); + if steps <= chunk { + return self.forward_chunk(raw, bin_power); + } + + // Fixed-size passes, so every op sees the same shapes however long the + // input is. The state carry is what makes this free: it is the same + // carry that already lets a caller split a stream across calls, so + // chunking here cannot change the answer -- only what it costs. + let mut out = Vec::with_capacity(steps.div_ceil(chunk)); + let mut done = 0; + while done < steps { + let take = chunk.min(steps - done); + let lo = done as isize; + let hi = (done + take) as isize; + out.push(self.forward_chunk( + raw.clone().slice_dim(0, lo..hi), + bin_power.clone().slice_dim(0, lo..hi), + )); + done += take; + } + Tensor::cat(out, 0) + } + + fn reset(&mut self) { + let device = self.track.path_score.device(); + self.excitation = self.coef.excitation.init_state(self.batch_size, &device); + self.track = self.coef.track.init_state(self.batch_size, &device); + } +} + +impl TensorPitchContext { + /// One pass of all four stages over `steps` hops. + /// + /// The whole estimator, and the unit + /// [`forward_sequence`](TenVadPitchSource::forward_sequence) chunks into. + fn forward_chunk( + &mut self, + raw: Tensor, + bin_power: Tensor, + ) -> Tensor { + let [steps, batch, _] = raw.dims(); + let n_bins = bin_power.dims()[2]; let rows = steps * batch; // Stage 1 carries nothing, so the whole sequence designs in one pass. @@ -284,12 +368,6 @@ impl TenVadPitchSource for TensorPitchContext { pitch } - - fn reset(&mut self) { - let device = self.track.path_score.device(); - self.excitation = self.coef.excitation.init_state(self.batch_size, &device); - self.track = self.coef.track.init_state(self.batch_size, &device); - } } #[cfg(test)] @@ -372,6 +450,7 @@ mod tests { #[test] fn test_config_meta() { let cfg = TensorPitchConfig::new(); + assert_eq!(cfg.chunk_steps, Some(DEFAULT_CHUNK_STEPS)); assert_eq!(cfg.n_bins(), N_BINS); assert_eq!(cfg.hop_size(), HOP); assert!(cfg.validate().is_ok()); @@ -478,6 +557,81 @@ mod tests { ); } + #[test] + fn test_chunking_does_not_change_the_answer() { + // The whole justification for `chunk_steps`: it is a cost knob, not a + // numeric one. Run a stream long enough to span several chunks both + // ways and require the same answer. + let device = Default::default(); + let steps = 11; + let (raw, power, _) = host_reference(steps); + + let chunked = run( + &TensorPitchConfig::new().with_chunk_steps(Some(3)), + steps, + &raw, + &power, + &device, + ); + let whole = run( + &TensorPitchConfig::new().with_chunk_steps(None), + steps, + &raw, + &power, + &device, + ); + + assert_eq!(chunked.len(), steps); + TensorData::from(chunked.as_slice()).assert_approx_eq::( + &TensorData::from(whole.as_slice()), + Tolerance::relative(1e-4), + ); + } + + #[test] + fn test_chunking_handles_an_exact_multiple() { + // The remainder-free case takes a different path through the loop's + // bound, so pin it too. + let device = Default::default(); + let steps = 9; + let (raw, power, _) = host_reference(steps); + + let chunked = run( + &TensorPitchConfig::new().with_chunk_steps(Some(3)), + steps, + &raw, + &power, + &device, + ); + let whole = run( + &TensorPitchConfig::new().with_chunk_steps(None), + steps, + &raw, + &power, + &device, + ); + + TensorData::from(chunked.as_slice()).assert_approx_eq::( + &TensorData::from(whole.as_slice()), + Tolerance::relative(1e-4), + ); + } + + #[test] + fn test_validate_rejects_a_zero_chunk() { + // `None` disables chunking; `Some(0)` is a mistake, not a way to. + assert!( + TensorPitchConfig::new() + .with_chunk_steps(Some(0)) + .validate() + .is_err(), + ); + TensorPitchConfig::new() + .with_chunk_steps(None) + .validate() + .unwrap(); + } + #[test] fn test_reset_rewinds_the_stream() { let device = Default::default(); diff --git a/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs b/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs index 0876b959..a92372c4 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs @@ -367,12 +367,9 @@ mod tests { /// The probability golden over the *whole* 60 s fixture. /// - /// Ignored by default because it runs for about ten minutes in release - /// (much longer in debug). That is almost entirely one-time, shape-keyed - /// kernel selection in the device pitch estimator rather than the work - /// itself -- the same 3750 hops cost roughly five seconds once the shapes - /// are tuned. `driver::tests::test_where_the_time_goes` measures the - /// split. Run this one explicitly with: + /// Ignored by default: about 20 s in release, 126 s in debug, which is + /// still more than the suite should spend on one case when the capped form + /// above covers the same path. Run it explicitly with: /// /// ```text /// cargo test -p bunsen --lib --features wgpu -- \ From 294ab13244e21b7deb7920b041e2e7a832889581 Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Sat, 22 Aug 2026 08:19:15 -0700 Subject: [PATCH 13/32] test(ten-vad): run the probability golden over the whole fixture The golden was capped at 400 hops because the full 3750 took over an hour, and was then split into a capped test plus an `#[ignore]`d full one. Neither is needed now: `TenVad::forward_sequence` and `TensorPitchConfig::chunk_steps` brought the full run to 20 s in release and 126 s in debug. So the two collapse back into one test at full length. The capped form's only stated justification was the cost of the full one, and keeping a strict subset of another test in the same suite run buys nothing. Length is the point. 3750 hops is exactly two `RESET_FRAMES` periods, so this is the only test that can show the periodic LSTM reset fires on the same hop as the reference's -- a reset on the wrong hop, or a missing one, diverges immediately after 1875. Every other end-to-end check stops short of it. In the suite: mean |diff| 5.649e-5, worst 6.446e-4 at hop 2973, decisions agreeing on all 3750 frames. The kit's suite goes from 475 s to 578 s. `test_where_the_time_goes` stays ignored; it is a measurement and asserts nothing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../src/kits/speech/ten_vad/context/driver.rs | 9 ++-- .../src/kits/speech/ten_vad/cross_test.rs | 51 ++++++------------- 2 files changed, 20 insertions(+), 40 deletions(-) diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs b/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs index 53f601cc..44860bbe 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs @@ -1426,11 +1426,10 @@ mod tests { /// The model half barely tunes at all (`zero` cold is warm plus a little), /// which is what [`TenVad::forward_sequence`] bought. /// - /// End to end, [`test_reference_probability_golden_full`] went from 612 s - /// to 19.6 s in release, and from over an hour to 126 s in debug. - /// - /// [`test_reference_probability_golden_full`]: - /// crate::kits::speech::ten_vad::cross_test + /// End to end, the 3750-hop reference probability golden went from 612 s + /// to 19.6 s in release, and from over an hour to 126 s in debug -- which + /// is why it now runs in the suite instead of being capped at 400 hops. + #[test] #[ignore = "measurement, not an assertion"] #[serial_test::serial] diff --git a/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs b/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs index a92372c4..281cee0d 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs @@ -354,45 +354,26 @@ mod tests { /// its burn port of the graph. Nothing is shared between the two arms /// except the audio. /// - /// This form is capped short of the periodic-reset boundary; see - /// [`test_reference_probability_golden_full`] for the whole fixture. - #[test] - #[serial_test::serial] - fn test_reference_probability_golden() -> Result<(), Box> { - // Capped like the neighbouring cross test: this arm runs the model once - // per hop on the device, since the LSTM state threads through the loop. - // Raising the cap costs patience, not correctness. - run_probability_golden(Some(400)) - } - - /// The probability golden over the *whole* 60 s fixture. - /// - /// Ignored by default: about 20 s in release, 126 s in debug, which is - /// still more than the suite should spend on one case when the capped form - /// above covers the same path. Run it explicitly with: + /// It runs the **whole** fixture, and the length is the point: 3750 hops is + /// exactly two [`RESET_FRAMES`] periods, so this is the only test that can + /// show the periodic LSTM reset fires on the same hop as the reference's. A + /// reset on the wrong hop -- or a missing one -- diverges immediately after + /// hop 1875. /// - /// ```text - /// cargo test -p bunsen --lib --features wgpu -- \ - /// test_reference_probability_golden_full --ignored --exact - /// ``` - /// - /// It is worth the wait for one reason: the fixture is 3750 hops, which is - /// **exactly two [`RESET_FRAMES`] periods**, so this is the only test that - /// can show the periodic LSTM reset fires on the same hop as the - /// reference's. A reset at the wrong hop -- or a missing one -- shows up - /// as a divergence immediately after hop 1875. The capped test above - /// never reaches the boundary. + /// About 20 s in release and 126 s in debug. It was capped at 400 hops + /// while `context_forward_sequence` stepped the model per hop and the + /// device pitch estimator re-tuned per input length; with + /// [`TenVad::forward_sequence`] and [`TensorPitchConfig::chunk_steps`] it + /// no longer needs to be. /// /// [`RESET_FRAMES`]: crate::kits::speech::ten_vad::context::coeff::RESET_FRAMES + /// [`TenVad::forward_sequence`]: + /// crate::kits::speech::ten_vad::TenVad::forward_sequence + /// [`TensorPitchConfig::chunk_steps`]: + /// crate::kits::speech::ten_vad::context::pitch::tensor::TensorPitchConfig #[test] - #[ignore = "runs the full 3750-hop fixture; see the rustdoc"] #[serial_test::serial] - fn test_reference_probability_golden_full() -> Result<(), Box> { - run_probability_golden(None) - } - - /// Drives the probability golden, optionally capped to `steps_cap` hops. - fn run_probability_golden(steps_cap: Option) -> Result<(), Box> { + fn test_reference_probability_golden() -> Result<(), Box> { type B = PerformanceBackend; type F = ::FloatElem; @@ -409,7 +390,7 @@ mod tests { ) .map_err(BunsenError::external)?; - let steps = steps_cap.unwrap_or(usize::MAX).min(expected.len()); + let steps = expected.len(); let samples = steps * cfg.hop_size(); assert!( wav_vec.len() >= samples, From 7173001198cf83567536d7f0c3f2369d94a71eab Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Sat, 22 Aug 2026 13:08:55 -0700 Subject: [PATCH 14/32] docs(ten-vad): add a fidelity ledger for the reference-parity decisions The kit's numerical decisions were documented where they were made -- module docs, test names, commit bodies -- which is right for the person editing that file and useless for anyone asking "what are we still matching, and what does it cost us?". This collects all thirteen in one place, with a block diagram of the front end and the pitch estimator's four stages. The organizing claim is that "matching the reference" is three unrelated things, and conflating them is why the question is hard to answer: * the **trained function** -- filterbank, normalization tables, weights. Not a numerics decision; a retraining project. * the reference's **algorithm** -- the Viterbi window, the Levinson early exit, the 30 s reset. Changing these changes output; whether it changes it for the worse is a twenty-second experiment nobody has run. * the reference's **arithmetic realization** -- how a filter is factored, how a sliding sum accumulates. The function is unchanged and only rounding differs. Every departure here so far has improved speed and accuracy at once. Two findings the ledger makes concrete. The lag-energy sliding sum (D5) clamps inside its recurrence against a quantity that, being a window sum of squares minus one of its own members, can only go negative through f32 cancellation -- so the clamp guards rounding, and reproducing it costs a 126-step dependency chain where a direct windowed reduction is depth one and strictly more accurate. And the Viterbi candidate window (D4) is unbounded below, almost certainly a `min` that should be a `max`, forcing a dense 56x56 transition matrix where the intended one is a 56x9 band. Figures measured by the kit's own tests are cited as such; figures reasoned from the code are marked estimated, including both of the above. Also published at: https://claude.ai/code/artifact/1e40807e-4d10-4373-b58c-38f8c47c0382 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../src/kits/speech/ten_vad/NUMERICS.html | 1379 +++++++++++++++++ 1 file changed, 1379 insertions(+) create mode 100644 crates/bunsen/src/kits/speech/ten_vad/NUMERICS.html diff --git a/crates/bunsen/src/kits/speech/ten_vad/NUMERICS.html b/crates/bunsen/src/kits/speech/ten_vad/NUMERICS.html new file mode 100644 index 00000000..cef76f08 --- /dev/null +++ b/crates/bunsen/src/kits/speech/ten_vad/NUMERICS.html @@ -0,0 +1,1379 @@ +ten-vad Fidelity Ledger + + + + +

+ +
+

bunsen · kits::speech::ten_vad

+

The ten-vad Fidelity Ledger

+

+ Every place bunsen's port reproduces the reference implementation's arithmetic + instead of doing the standard thing — what it costs, and what it would take to + stop. +

+
+ Commit 294ab13 + Date 2026-08-22 + Reference TEN-framework/ten-vad, src/ + ALGO_TRACE.md +
+
+ +
+

Fidelity is three different things

+

And only one of them is free to change

+ +

+ The working hypothesis behind this document is that the ten-vad authors got some + of the math wrong, and that matching them bit for bit is holding back both + unification with the rest of bunsen and raw speed. That is partly right, + and the useful move is to separate three things that all look like "matching the + reference" but behave completely differently. +

+ +
+ The distinction that does the work +

+ Class 1 — the trained function. The filterbank, the + normalization tables, the model weights. The network was trained on + these features. Changing them is not a numerics decision; it is a + retraining project. +

+

+ Class 2 — the reference's algorithm. The Viterbi transition + window, the Levinson early exit, the 30-second state reset. Changing these + changes the output. Whether that output is worse is an empirical + question the golden can answer in twenty seconds. +

+

+ Class 3 — the reference's arithmetic realization. How a filter + is factored, how a sliding sum is accumulated, what precision an inner product + uses. The function is unchanged; only the rounding differs. Departing here has + so far improved speed and accuracy every single time. +

+
+ +

+ Class 3 is where the free money is, and it is also where most of the remaining + cost sits. Class 2 is a set of cheap experiments nobody has run. Class 1 is + locked until someone retrains. +

+
+ +
+

What the pipeline actually does

+

Front end, then a small recurrent network

+ +
+
+ + + FRONT END — context/ + + + audio hop + 256 @ 16 kHz + + + + ×32768 + int16 scale + + + + pre-emph + 0.97 + + + + sliding STFT + 768 → 1024 + + + + |X|² + 513 bins + + + + + mel bank ×40 + D1 · non-standard + + + + ln(x + 1e-20) + + + + + + pitch estimator + feature 40 · see below + + + + + + standardize + 41 features + + + + stack ×3 + [1, 3, 41] + + + MODEL — blocks/ + + + + conv stem + + + + LSTM 80→64 + + + + LSTM 64→64 + + + + head → sigmoid + + + + P(speech) + +
+
+ Solid wires carry data; the dashed wire is the un-normalized bin power, which + the pitch branch reads before the 1/32768² division. Two orderings + here are load-bearing and easy to reverse: pitch runs on the raw + un-pre-emphasized hop, and the power normalization happens before the + filterbank matmul, not after the log. +
+
+ +
+
+ + PITCH ESTIMATOR — context/pitch/ + + + 1 · prefilter design + 513 bins → 16 LPC taps + stateless + + + + 2 · excitation + whiten, ÷4 decimate + D6 · anti-alias filter + + + + 3 · lag search + 64 lags × 2 half-hops + D5 · sliding energy + + + + 4 · Viterbi track + 56 states, 2 steps/hop + D4 · candidate window + + + + pitch, Hz + + + CARRIED ACROSS HOPS + + + FIFO · smoother + filter state · exc_buf + + + + correlation ring + slot energies + + + + path score · backpointers + +
+
+ Only stage 1 is free of carried state. Stages 2 and 3 carry sliding windows, + which fold into the ordinary prepend-and-reslice idiom. Stage 4 is a genuine + Viterbi recurrence: two dependent steps per hop over 56 states, and the only + part of the whole front end that cannot be batched across time. +
+
+
+ +
+

The ledger

+

Thirteen places the port defers to the reference

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDWhatClassCost of matchingSwitching
D1Mel filterbank edges, slopes, normalization1 · trainedCannot share bunsen's mel pathRetrain
D2Non-standard DCT normalization1 · trainedNone at runtimeNot worth it
D3Feature mean/std tables1 · trainedNoneRetrain
D4Viterbi window asymmetric, unbounded below2 · algorithmDense 56×56 instead of banded 56×9Test it
D5Lag energy as a clamped running sum3 · realization~192 sequential kernels per callDo it
D6Anti-alias IIR realization3 · realizationAlready done
D7Levinson–Durbin early exit2 · algorithmNone — the freeze costs what the fix wouldNeutral
D8Autocorrelation precision3 · realizationAlready done
D9int16 scale round-trip2 · algorithmLooks removable; is notLoad-bearing
D10LSTM state reset every 1875 frames2 · algorithmNone — already a config knobTest it
D11PITCH_PI is one ULP low3 · realizationNone — folded at build timeNeutral
D12dc0_bias integer-divides first3 · realizationNone at the shipped geometryLatent
D13Batch pinned to 1 by two graph constants1 · trainedNo multi-stream batchingNeeds new weights file
+
+
+ +
+

Class 3 — the reference's arithmetic

+

Same function, different rounding. Change freely.

+ +
+

Best single change available

+
+ D5 +

Lag energy as a clamped running sum

+ Do it +
+
+
What the reference does
+
+

+ Normalizes each of the 64 candidate lags by the energy under the lagged + window, maintained as a sliding sum with a clamp inside the recurrence: +

+
lagged = max(lagged - exc_sq[base + lag - 1], 0.0);
+lagged += exc_sq[base + lag + half - 1];
+
+ +
Why that clamp cannot be there
+
+

+ Every term is a square, so the quantity being clamped is a window sum of + non-negative values minus one of its own members. In exact arithmetic it is + never negative and the clamp never fires. It exists purely to + catch f32 cancellation once the running sum has drifted below + the value being subtracted — which happens in near-silence, where the sum + is small and 64 chained subtractions have eaten it. +

+
+ +
What it costs to reproduce
+
+

+ The clamp makes the recurrence non-associative, so no prefix-sum + reformulation reproduces it — correlate.rs says exactly this, + and it is correct. What it leaves is a chain of 63 dependent steps + per half-hop, each a subtract, a clamp, an add and a slice-assign, on + tensors of a few kilobytes: 126 sequenced steps and roughly 500 + operations per call. Fusion can merge some of those operations; it + cannot shorten the dependency chain, and the chain is what costs. +

+

+ The standard alternative is not a prefix sum, which inherits the same + instability. It is to compute all 64 window sums directly: + 64 lags × 32 terms = 2048 multiply-accumulates, expressible as a windowed + reduction or one banded contraction. That is two operations at + dependency depth one, fully parallel, accumulating no error across + lags at all. It trades 96 adds for 2048 — arithmetic that size is free on a + device, and the serialization is not. +

+
+ +
Impact of switching
+
+

+ The direct form is more accurate, not less — the running sum's + error accumulates monotonically over 64 subtractions and never washes out. + Estimated divergence is bounded by that accumulated error, on the + order of 64 · 2⁻²⁴ ≈ 4e-6 relative in the worst case and far + less typically, further damped by the (lagged + 1 + reference) + denominator. +

+

+ That sits inside the perturbation the golden already demonstrably survives: + bunsen and the C reference reach their bin powers through entirely + different FFTs, differ by ~1e-6 relative, and still agree on the voicing + decision for all 3750 frames. This is the change with the best + ratio of upside to risk in the whole ledger. +

+
+
+
+ +
+
+ D6 +

The anti-alias filter realization

+ Already done +
+
+
What the reference does
+
+

+ Guards the 4 kHz Nyquist with a five-section Direct-Form-II IIR cascade, + stepped sample by sample — 1280 sequential steps per hop. +

+
+
What bunsen does instead
+
+

+ Realizes the same LTI system as a 2048-tap truncated impulse response with + the ×4 decimation folded into the kernel, evaluated as a single GEMM. The + impulse response is generated by running a unit impulse through the exact + host cascade, so truncation is the only difference between the two. +

+
+
Impact — measured
+
+

+ Against an f64 ground truth, the truncated FIR is + 25–50% more accurate than the reference's own + f32 DF-II cascade (7.3e-7 against 1.5e-6 relative on full-scale + input). Truncation at 2048 taps contributes ~8e-11 — three orders of + magnitude below the noise floor of the thing it replaces. +

+

+ This is the precedent for the entire argument. Departing from the + reference's realization while preserving its system was + faster and more accurate at the same time. The literal transcription is + still in the tree, selectable, as the thing that proves the translation was + correct in the first place. +

+
+
+
+ +
+
+ D8 +

Autocorrelation precision and folding

+ Already done +
+
+
What the reference does
+
+

+ Recovers autocorrelation lags from the band envelope by FFT, whose error + grows with log N rather than N. +

+
+
What bunsen does instead
+
+

+ Sums the 511-term series directly, accumulating in f64 — a flat + f32 sum there would be materially worse than the FFT it + replaces, while f64 is at least as good. Then it folds + envelope interpolation, Nyquist zeroing, and the autocorrelation into one + constant [18, 17] matrix, because all three are linear. +

+
+
Impact
+
+

+ The device contracts 18 terms where the host contracts 513, and the + precision trap is closed by construction rather than by a test. Strictly + better on both axes. +

+
+
+
+ +
+
+ D11 +

PITCH_PI = 3.1415926

+ Neutral +
+
+
What it is
+
+

+ The reference writes π as a truncated decimal literal, landing exactly one + ULP below f32::consts::PI. A unit test pins that relationship + so the constant cannot be "corrected" by accident. +

+
+
Impact of switching
+
+

+ ~1e-7 relative on the cosine tables it seeds. Those tables are folded into + constant matrices at build time, so the choice has zero runtime + cost either way — there is nothing to gain by changing it, and the + perturbation lands upstream of an argmax. Keep it. +

+
+
+
+ +
+
+ D12 +

dc0_bias divides as an integer first

+ Latent +
+
+
What it is
+
+

+ The noise floor added to lag zero is windowSz / 12 / 38.0f, + where the first division is integer. At the shipped 768-sample window + 768 / 12 = 64 exactly, so the truncation has no effect today. +

+
+
Impact
+
+

+ Nothing now; a trap for anyone who changes the analysis window. Worth + keeping as-is precisely because it is inert — reproducing it costs + nothing and documents the reference's intent. +

+
+
+
+
+ +
+

Class 2 — the reference's algorithm

+

Changing these changes the output. Run the golden and find out.

+ +
+

Very likely a bug in the reference

+
+ D4 +

The Viterbi candidate window is unbounded below

+ Test it +
+
+
What the reference does
+
+

+ Computes the transition window's lower offset as + SIDXT = min(0, 4 - idx). That min against zero is + almost certainly meant to be a max: as written the window never + extends below index 4, so it widens as the period index grows. +

+
cand ∈ [ min(idx, 4), min(idx + 4, 55) ]
+
+idx =  4  →  5 candidates   (as intended)
+idx = 55  → 52 candidates   (jdx reaches 51, penalty 52.0)
+
+ +
What it costs to reproduce
+
+

+ A dense [56, 56] transition matrix — 3136 entries — where the + intended ±4 window is a [56, 9] band of 504. Invalid + transitions are handled by assigning a penalty large enough to lose every + max, which is the only way to express a ragged window as a + batched tensor op. That is 6.2× the transition arithmetic + per Viterbi step, and there are two steps per hop. +

+
+ +
Impact of switching — estimated
+
+

+ Smaller than the window width suggests, because the quadratic penalty is + already doing the job the bound was supposed to do. The per-step score gain + is xcorr · weight, and xcorr is + bounded by 1 — by Cauchy–Schwarz the numerator satisfies + 2·inst ≤ E_ref + E_lag, against a denominator of + E_ref + E_lag + 1. The weight is normalized to average 1 + across the tracker's window, so a typical step contributes well under 1, + against: +

+
    +
  • |jdx| = 5 → penalty 0.5, already half a typical step's + entire gain.
  • +
  • |jdx| = 10 → penalty 2.0, needing several steps of + accumulated advantage to overcome.
  • +
  • |jdx| = 51 → penalty 52.0, which nothing plausible reaches.
  • +
+

+ So the far half of the erroneous window is dead weight, and the live + difference zone is roughly |jdx| ≤ 7 — reachable only at sharp + pitch discontinuities such as voicing onsets. That bounds a single step, not + the accumulated path score, whose spread across states can grow, which is + exactly why this wants the golden rather than an argument. A twenty-second + experiment behind a config flag, and it would cut the transition matrix 6×. +

+
+
+
+ +
+
+ D10 +

The LSTM states are zeroed every 1875 frames

+ Test it +
+
+
What the reference does
+
+

+ Counts model calls and zeroes both LSTM states every 1875 of them — 30 + seconds of audio — leaving the feature context stack intact. The constant + carries a bare // TODO in the C, which is not the marking of a + settled design decision. +

+
+
Why it might be wrong
+
+

+ Periodic amnesia in a streaming VAD is a strange thing to want. It is + plausibly a workaround for state drift in a model that was never trained on + sequences that long, in which case the right fix is upstream. It is equally + plausibly load-bearing for long-clip stability. +

+
+
Impact of switching
+
+

+ Changes the output for any stream longer than 30 s, and nothing shorter. + Already exposed as TenVadContextConfig::reset_frames, so + None disables it without touching code. The 3750-hop golden + spans exactly two reset periods, which makes it the natural instrument — + though note that it can only tell you which arm matches the reference, + not which arm is better. Judging that needs labelled audio. +

+
+
+
+ +
+
+ D7 +

Levinson–Durbin exits early

+ Neutral +
+
+
What the reference does
+
+

+ Breaks out of the recursion once the residual drops 30 dB below lag zero, + leaving the remaining coefficients at their zero initializer — not, as is + easy to assume, at whatever the recursion had reached. +

+
+
What it costs to reproduce
+
+

+ A batched tensor version cannot branch per row, so it runs all 16 steps and + freezes each row under a mask once its condition trips. Two details make + that faithful: the reference checks after completing an iteration, + so the freeze is applied after the update; and a frozen row's error stays + below the threshold, so the mask is already monotone without sticky + bookkeeping. +

+
+
Impact of switching
+
+

+ Essentially nil in either direction. The masked freeze already costs exactly + what running to full order would cost, so dropping the early exit buys no + speed — it only removes the mask. Meanwhile it would change the output + wherever the break fires. Nothing to gain. +

+
+
+
+ +
+
+ D9 +

The int16 scale round-trip is not removable

+ Load-bearing +
+
+
What it looks like
+
+

+ bunsen takes [-1, 1] audio, multiplies by 32768 on entry, and + divides the bin power by 32768² before the filterbank. Pre-emphasis and the + STFT are linear and power is quadratic, so for features 0–39 the round-trip + is algebraically the identity. It looks like pure legacy + baggage from a fixed-point original. +

+
+
Why it has to stay
+
+

+ The pitch branch reads the raw scaled hop and the un-normalized bin + power, and it is full of hard-coded absolute magnitudes calibrated for + int16-scale signals: +

+
denom   = lagged + (1.0 + reference)   // the "1 +" floors silence
+denom   = max(denom, 1e-12)
+ac[0]  += ac[0] * 1e-4 + dc0_bias      // dc0_bias ≈ 1.684
+

+ Drop the scaling and every one of those thresholds moves by nine orders of + magnitude relative to the signal. The correlation denominator's + 1 + would stop flooring silence and start dominating speech. +

+
+
Impact of switching
+
+

+ Feature 40 breaks completely. Worth writing down precisely because the + identity is easy to spot and the dependency is not — this is the trap in + the ledger most likely to catch someone tidying up. +

+
+
+
+
+ +
+

Class 1 — the trained function

+

Locked until someone retrains

+ +
+

The real unification blocker

+
+ D1 +

The mel filterbank is not a mel filterbank

+ Retrain +
+
+
What the reference does
+
+

Three deviations from every standard builder, all load-bearing:

+
mel = 2595 · log10(1 + hz / 700)        // HTK, fine
+bin = (usize)((fft_size + 1) · hz / sr) // +1, and truncates
+
    +
  • Edge mapping uses fft_size + 1 and a + truncating cast, not fft_size and a round.
  • +
  • Slopes are integer — the triangles follow where the + truncation landed, not the exact edge frequencies.
  • +
  • No area normalization — every filter peaks at exactly + 1.0 regardless of width.
  • +
+

+ The edge arithmetic is also deliberately done in f32; computing + it in f64 can round an edge across an integer boundary and + silently produce a different filterbank. +

+
+ +
What it costs
+
+

+ This is the reason ten_vad cannot share a filterbank with + anything else in bunsen, and it is the single largest obstacle to + unification. No librosa- or Slaney-style builder reproduces it. +

+
+ +
Impact of switching — estimated
+
+

+ Total. At a 1024-point FFT over 16 kHz one bin is 15.6 Hz, and the combined + +1 and truncation move edges by up to a bin. The lowest mel + bands are only a few bins wide, so that is a large fractional change to + their shape — and dropping area normalization on top changes every band's + gain. +

+

+ All 40 log-mel features shift, which invalidates the reference mean/std + tables (D3) and the trained weights simultaneously. This is not a + numerics decision that a tolerance can absorb; it is a different model + input. The honest answer is that it stays until someone retrains, and that + retraining is the only thing that unlocks it. +

+
+
+
+ +
+
+ D2 +

The cepstral DCT uses no standard normalization

+ Not worth it +
+
+
What the reference does
+
+

+ Builds cos((i + 0.5)·j·π / 18), scales column 0 by + √0.5, and applies √(2/18) in both directions — so + forward and inverse differ only in which index of one table they walk. +

+
+
Impact of switching
+
+

+ The forward/inverse pair is self-consistent, and bunsen folds the whole + thing into a constant matrix, so matching it costs nothing at + runtime. Switching to an orthonormal DCT-II would rescale the + cepstrum, which then feeds BAND_LPC_COMP and changes the LPC + envelope. Real risk, no reward. +

+
+
+
+ +
+
+ D3 +

Feature mean / std tables

+ Retrain +
+
+
What it is
+
+

+ 41 mean and 41 std constants transcribed verbatim from the reference's + coeff.h, applied as + (v - mean) / (std + 1e-20). These are reference data, not + tunables — they are the statistics of the training corpus under D1's + filterbank. +

+
+
Impact of switching
+
+

+ Moves in lockstep with D1 and the weights. No independent decision to make + here; it is listed so the coupling is explicit. +

+
+
+
+ +
+
+ D13 +

Batch is pinned to 1 by two graph constants

+ Needs new weights file +
+
+
What it is
+
+

+ Not a numerics issue, but the other unification blocker. The exported graph + reshapes by constant new_shape__177 = [-1, 1, 80] immediately + before the first LSTM, which lands the leading axis on the LSTM's + sequence dimension with its batch fixed at 1. +

+
+
Consequences
+
+

+ Sequence batching is free and bunsen now uses it: feeding + T stacked context frames as one call is bit-identical to + T sequential calls, final states included. +

+

+ Multi-stream batching is structurally impossible against + the stock graph — batched states fail shape validation outright. It needs + two reshape constants patched, which means a different model file, not a + different call. +

+
+
+
+
+ +
+

What departing has already bought

+

Four changes, all Class 3, all strictly better

+ +

+ The thesis that bit-identical reproduction is holding things back has a track + record behind it. Every departure so far has been from the reference's + realization rather than its function, and each one improved at + least one axis without costing the other. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ChangeBeforeAfterAccuracy
Truncated FIR anti-alias1280 seq. steps/hop1 GEMM25–50% better
f64 autocorrelation, folded tables513-term contraction18-termBetter
Sequence-batched model1 call/hop1 call/chunkUnchanged
Fixed-size pitch passes612 s golden19.6 s goldenByte-identical
+
+ +

+ The last row is the one to notice: a 31× speedup with byte-identical output, + because the cost was never in the arithmetic at all — it was one-time, + shape-keyed kernel selection. Before assuming a numerical constraint is what + makes something slow, measure cold against warm. +

+
+ +
+

What to do next

+

In order of expected value

+ +
    +
  1. + Replace the sliding lag energy with a direct banded contraction (D5). + Removes a 64-step scan and ~192 dispatches per call, and is more accurate than + what it replaces. Class 3 — the function does not change. Verify with the + 3750-hop golden. +
  2. +
  3. + Put the Viterbi window correction behind a flag and run the golden (D4). + If the decision agreement holds, the transition matrix drops from 3136 entries + to 504 and a probable reference bug goes away. If it does not hold, you have + learned something worth writing down either way. +
  4. +
  5. + Measure the 1875-frame reset against labelled audio, not the golden (D10). + The golden can only say which arm matches the reference. Deciding whether the + reset helps needs ground truth on clips longer than 30 s. +
  6. +
  7. + Leave D1 alone until there is a retraining budget. It is the + real unification blocker, and no amount of numerical cleverness gets around it. +
  8. +
+ +
+ The short answer to the hypothesis +

+ Yes, the reference has at least one clear bug (D4) and one decision its own + authors flagged as unfinished (D10). But bit-identical reproduction is not what + is costing speed — D5 is the only place where matching the reference's + arithmetic forces a genuinely slower structure, and the two largest speedups so + far came from changes that preserved the output exactly. The real blocker to + unification is D1, and it is a training problem, not a numerics one. +

+
+
+ +
+

+ Measured figures come from the kit's own tests — cross_test.rs for + the golden, driver::tests::test_where_the_time_goes for the timing + breakdown, and the per-stage parity tests under + context/pitch/tensor/. Figures marked estimated are + reasoned from the code and have not been measured. +

+
+ +
From 3be565e4fe1837e4033a37515b3c560026e58018 Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Mon, 24 Aug 2026 11:12:40 -0700 Subject: [PATCH 15/32] refactor(signal): lift the biquad cascade and burn-behavior pins out of ten_vad First step of extracting the reusable machinery from `ten_vad` ahead of dropping that layer. These two pieces have nothing to do with voice activity detection; they were only ever there because that is where they were needed. `ops::signal::BiquadCascade` is the Direct-Form-II cascade, moved unchanged -- its own doc had already predicted the move. Its tests did not survive unchanged, and that is the point: they used to assert agreement with a transcribed coefficient table, so they tested "does this match that vendor's filter" rather than "is this a biquad". They are now closed-form: * a one-pole section's impulse response is exactly `r^n` * `H(1)` computed analytically equals the summed impulse response, which catches a sign-convention slip on `a` immediately * a two-pole section is checked against its own difference equation in f64 * block-splitting is unobservable, `reset` rewinds, sections apply in series That is a better anchor than the table was, and it depends on nothing external. Two additions while it was in hand: `dc_gain`, which is a cheap sanity check on any coefficient table, and `to_vec_impulse_response`, which the decimating-FIR work already needed and open-coded. `burner::tensor::burn_behavior` is a new test-only module holding pins on upstream burn behavior that bunsen works around -- currently `unfold` deriving its batch-row stride from the covered span rather than the true row length, and `gather` ignoring strides on a non-contiguous index tensor. Both were found the hard way earlier in this work, and both were pinned inside the pitch stages where they were hit. They are written to fail *when the bug is fixed*, and each says which workaround has become redundant, so the upgrade that fixes them also tells you what to simplify. A workaround with no test is indistinguishable from an accident; the next reader cannot tell load-bearing from leftover, and deletes it. `test_batch_row_offset_regression` stays with the anti-alias filter, since it guards that consumer rather than the upstream behavior. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../bunsen/src/burner/tensor/burn_behavior.rs | 162 ++++++++ crates/bunsen/src/burner/tensor/mod.rs | 2 + .../speech/ten_vad/context/pitch/biquad.rs | 257 ------------- .../speech/ten_vad/context/pitch/estimator.rs | 2 +- .../kits/speech/ten_vad/context/pitch/mod.rs | 4 - .../ten_vad/context/pitch/tensor/antialias.rs | 94 +---- .../ten_vad/context/pitch/tensor/track.rs | 53 +-- crates/bunsen/src/ops/signal/biquad.rs | 364 ++++++++++++++++++ crates/bunsen/src/ops/signal/mod.rs | 3 + 9 files changed, 552 insertions(+), 389 deletions(-) create mode 100644 crates/bunsen/src/burner/tensor/burn_behavior.rs delete mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/pitch/biquad.rs create mode 100644 crates/bunsen/src/ops/signal/biquad.rs diff --git a/crates/bunsen/src/burner/tensor/burn_behavior.rs b/crates/bunsen/src/burner/tensor/burn_behavior.rs new file mode 100644 index 00000000..1b735caf --- /dev/null +++ b/crates/bunsen/src/burner/tensor/burn_behavior.rs @@ -0,0 +1,162 @@ +//! # Pinned assumptions about burn's tensor behavior. +//! +//! Test-only. Each test here documents a behavior of an upstream burn op that +//! bunsen code works around, and fails when that behavior changes. +//! +//! These are not tests of bunsen. They exist because a workaround with no test +//! is indistinguishable from an accident: the next person to read the call site +//! cannot tell whether the awkward formulation is load-bearing or leftover, and +//! deletes it. Every test below is written to fail *when the bug is fixed*, and +//! says in its message which workaround has become redundant — so the upgrade +//! that fixes it also tells you what to go simplify. +//! +//! When one starts failing: +//! +//! 1. Confirm against the upstream change that the behavior really is fixed. +//! 2. Simplify the call sites the test names. +//! 3. Delete the test. +//! +//! Everything here is backend-visible behavior, so it runs on +//! `PerformanceBackend` like the rest of the device tests. + +#[cfg(test)] +mod tests { + use burn::{ + prelude::*, + tensor::backend::BackendTypes, + }; + + use crate::{ + prelude::*, + support::testing::PerformanceBackend, + }; + + type B = PerformanceBackend; + + /// `unfold` derives its batch-row stride from the span its windows cover + /// rather than from the row's true length. + /// + /// A row with a leftover tail therefore places every *subsequent* row + /// early, by exactly the tail length. Row 0 is always correct, which is + /// what makes this so easy to miss: a batch-1 test passes, and the bug only + /// appears once a second stream is added. + /// + /// **Workaround:** trim the input to the covered span before unfolding. + /// Used by `ops::signal` and by the ten-vad pitch stages. + #[test] + fn test_unfold_derives_row_stride_from_the_covered_span() { + let device = ::Device::default(); + let (win, step, steps, tail) = (12usize, 4usize, 3usize, 3usize); + let covered = (steps - 1) * step + win; + let len = covered + tail; + + // Row 1 carries a marker at its very first element. + let mut flat = vec![0.0f32; 2 * len]; + flat[len] = 1.0; + let t = Tensor::::from_data(TensorData::new(flat, [2, len]), &device); + + let rows: Vec = t + .unfold::<3, _>(1, win, step) + .reshape([2 * steps, win]) + .to_data_as::() + .to_vec_as::() + .unwrap(); + + // Window `steps` is (batch 1, step 0), so the marker should sit at 0. + let found = rows[steps * win..(steps + 1) * win] + .iter() + .position(|v| *v != 0.0); + assert_eq!( + found, + Some(tail), + "expected the row-1 marker displaced by the leftover tail ({tail}). \ + Some(0) means `unfold` now uses the true row stride, and every \ + trim-before-unfold in the tree is redundant.", + ); + } + + /// With no leftover tail, `unfold`'s row stride is correct. + /// + /// The control for + /// [`test_unfold_derives_row_stride_from_the_covered_span`]: it establishes + /// that trimming to the covered span is a *sufficient* workaround, not just + /// a different way to be wrong. + #[test] + fn test_unfold_row_stride_is_correct_without_a_tail() { + let device = ::Device::default(); + let (win, step, steps) = (12usize, 4usize, 3usize); + let covered = (steps - 1) * step + win; + + let mut flat = vec![0.0f32; 2 * covered]; + flat[covered] = 1.0; + let t = Tensor::::from_data(TensorData::new(flat, [2, covered]), &device); + + let rows: Vec = t + .unfold::<3, _>(1, win, step) + .reshape([2 * steps, win]) + .to_data_as::() + .to_vec_as::() + .unwrap(); + + assert_eq!( + rows[steps * win..(steps + 1) * win] + .iter() + .position(|v| *v != 0.0), + Some(0), + ); + } + + /// `gather` ignores strides on a non-contiguous *index* tensor. + /// + /// Given a transposed index view it reads element 0 of each row rather than + /// the indexed element. The data tensor's strides are honoured; only the + /// index's are not. + /// + /// **Workaround:** build index tensors with `stack`, which is contiguous, + /// rather than `cat` + `swap_dims`, which is a view. Used by the ten-vad + /// Viterbi backtrace. + #[test] + fn test_gather_ignores_strides_on_a_non_contiguous_index() { + let device = ::Device::default(); + let (steps, wide) = (3usize, 56usize); + + let mut v = vec![0i32; steps * wide]; + for t in 0..steps { + for j in 0..wide { + v[t * wide + j] = (t as i32) * 1000 + j as i32; + } + } + let data = Tensor::::from_data(TensorData::new(v, [steps, 1, wide]), &device); + + // The same logical index, built two ways. + let contiguous = Tensor::::full([steps, 1, 1], 37, &device); + let rows: Vec> = (0..steps) + .map(|_| Tensor::full([1, 1], 37, &device)) + .collect(); + let transposed = Tensor::cat(rows, 1).swap_dims(0, 1).unsqueeze_dim::<3>(2); + + let good: Vec = data + .clone() + .gather(2, contiguous) + .to_data_as::() + .to_vec_as::() + .unwrap(); + let bad: Vec = data + .gather(2, transposed) + .to_data_as::() + .to_vec_as::() + .unwrap(); + + assert_eq!( + good, + vec![37, 1037, 2037], + "a contiguous index should read element 37 of each row", + ); + assert_ne!( + good, bad, + "`gather` now honours strides on the index tensor; every \ + `stack`-instead-of-`cat`+`swap_dims` workaround in the tree is \ + no longer load-bearing.", + ); + } +} diff --git a/crates/bunsen/src/burner/tensor/mod.rs b/crates/bunsen/src/burner/tensor/mod.rs index 24848abb..d21a2af5 100644 --- a/crates/bunsen/src/burner/tensor/mod.rs +++ b/crates/bunsen/src/burner/tensor/mod.rs @@ -49,6 +49,8 @@ //! [`burn::tensor::TensorData`] to provide multi-dimensional element access //! via `view[&[i, j]]` indexing. +#[cfg(test)] +mod burn_behavior; mod data_view; mod tensor_op_ext; diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/biquad.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/biquad.rs deleted file mode 100644 index 97a2efd0..00000000 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/biquad.rs +++ /dev/null @@ -1,257 +0,0 @@ -//! # Cascaded biquad IIR filter. -//! -//! The anti-alias filter the reference pitch estimator runs before decimating -//! 16 kHz down to 4 kHz (`src/biquad.cc`). -//! -//! Each section is Direct Form II: -//! -//! ```text -//! w0 = x - a1*w1 - a2*w2 -//! y = g * (b0*w0 + b1*w1 + b2*w2) -//! w2 = w1; w1 = w0 -//! ``` -//! -//! and sections are applied in series, each carrying its own two-sample -//! state across calls. Sections are run one at a time over the whole block, -//! matching the reference; because each section's state depends only on its -//! own input sequence, that is equivalent to running every section per -//! sample, and both are equivalent to filtering the unsegmented stream. -//! -//! Like the rest of the pitch branch this is host-side scalar code. It is a -//! plain DSP primitive with nothing ten-vad-specific in it but the -//! coefficients in [`super::coeff`], so it would move to -//! [`crate::ops::signal`] unchanged if a tensor-side caller ever wanted it. - -/// One second-order section of a [`BiquadCascade`]. -/// -/// `a[0]` is assumed to be `1`, as it is in every tabulated ten-vad section; -/// the difference equation ignores it. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct BiquadSection { - /// Numerator coefficients `[b0, b1, b2]`. - pub b: [f32; 3], - - /// Denominator coefficients `[_, a1, a2]`; `a[0]` is unused. - pub a: [f32; 3], - - /// The section's output gain. - pub g: f32, -} - -/// A series of [`BiquadSection`]s with per-section state. -/// -/// Built by [`BiquadCascade::new`]; reset by [`BiquadCascade::reset`]. -#[derive(Debug, Clone, PartialEq)] -pub struct BiquadCascade { - sections: [BiquadSection; NSECT], - - /// Per-section `[w1, w2]` delay state. - state: [[f32; 2]; NSECT], -} - -impl BiquadCascade { - /// Builds a cascade with zeroed state. - pub fn new(sections: [BiquadSection; NSECT]) -> Self { - Self { - sections, - state: [[0.0; 2]; NSECT], - } - } - - /// Builds a cascade from the reference's parallel coefficient tables. - /// - /// # Arguments - /// * `b`: per-section numerator coefficients. - /// * `a`: per-section denominator coefficients. - /// * `g`: per-section gains. - pub fn from_tables( - b: &[[f32; 3]; NSECT], - a: &[[f32; 3]; NSECT], - g: &[f32; NSECT], - ) -> Self { - Self::new(core::array::from_fn(|i| BiquadSection { - b: b[i], - a: a[i], - g: g[i], - })) - } - - /// The number of sections. - pub fn len(&self) -> usize { - NSECT - } - - /// Whether the cascade has no sections, in which case it is a pass-through. - pub fn is_empty(&self) -> bool { - NSECT == 0 - } - - /// Zeroes every section's delay state. - pub fn reset(&mut self) { - self.state = [[0.0; 2]; NSECT]; - } - - /// Filters `buf` in place, carrying state across calls. - pub fn process_in_place( - &mut self, - buf: &mut [f32], - ) { - for (section, state) in self.sections.iter().zip(self.state.iter_mut()) { - let [b0, b1, b2] = section.b; - let a1 = section.a[1]; - let a2 = section.a[2]; - let g = section.g; - - let [mut w1, mut w2] = *state; - for y in buf.iter_mut() { - let w0 = *y - a1 * w1 - a2 * w2; - *y = g * (b0 * w0 + b1 * w1 + b2 * w2); - w2 = w1; - w1 = w0; - } - *state = [w1, w2]; - } - } -} - -#[cfg(test)] -mod tests { - use super::{ - super::coeff::{ - ANTI_ALIAS_A_4KHZ, - ANTI_ALIAS_B_4KHZ, - ANTI_ALIAS_G_4KHZ, - ANTI_ALIAS_SECTIONS, - }, - *, - }; - - fn anti_alias() -> BiquadCascade { - BiquadCascade::from_tables(&ANTI_ALIAS_B_4KHZ, &ANTI_ALIAS_A_4KHZ, &ANTI_ALIAS_G_4KHZ) - } - - /// A single section reproducing the difference equation by hand. - fn reference_section( - section: &BiquadSection, - input: &[f32], - ) -> Vec { - let (mut w1, mut w2) = (0.0f32, 0.0f32); - input - .iter() - .map(|&x| { - let w0 = x - section.a[1] * w1 - section.a[2] * w2; - let y = section.g * (section.b[0] * w0 + section.b[1] * w1 + section.b[2] * w2); - w2 = w1; - w1 = w0; - y - }) - .collect() - } - - #[test] - fn test_single_section_matches_the_difference_equation() { - let section = BiquadSection { - b: ANTI_ALIAS_B_4KHZ[0], - a: ANTI_ALIAS_A_4KHZ[0], - g: ANTI_ALIAS_G_4KHZ[0], - }; - let input: Vec = (0..64).map(|i| (i as f32 * 0.3).sin()).collect(); - - let mut cascade = BiquadCascade::new([section]); - let mut got = input.clone(); - cascade.process_in_place(&mut got); - - assert_eq!(got, reference_section(§ion, &input)); - } - - #[test] - fn test_block_split_matches_whole_stream() { - // The state carry is the whole point: a stream chopped into hops must - // filter identically to the same stream filtered at once. - let input: Vec = (0..512).map(|i| (i as f32 * 0.11).sin() * 1000.0).collect(); - - let mut whole = input.clone(); - anti_alias().process_in_place(&mut whole); - - let mut split = input.clone(); - let mut cascade = anti_alias(); - for chunk in split.chunks_mut(64) { - cascade.process_in_place(chunk); - } - - assert_eq!(whole, split); - } - - #[test] - fn test_reset_restores_start_of_stream() { - let input: Vec = (0..128).map(|i| (i as f32 * 0.7).cos()).collect(); - let mut cascade = anti_alias(); - - let mut first = input.clone(); - cascade.process_in_place(&mut first); - - // Without a reset the tail state colors the next block. - let mut carried = input.clone(); - cascade.process_in_place(&mut carried); - assert_ne!(first, carried); - - cascade.reset(); - let mut after_reset = input.clone(); - cascade.process_in_place(&mut after_reset); - assert_eq!(first, after_reset); - } - - #[test] - fn test_sections_apply_in_series() { - let input: Vec = (0..96).map(|i| (i as f32 * 0.23).sin()).collect(); - - let mut got = input.clone(); - anti_alias().process_in_place(&mut got); - - let mut expected = input; - for i in 0..ANTI_ALIAS_SECTIONS { - expected = reference_section( - &BiquadSection { - b: ANTI_ALIAS_B_4KHZ[i], - a: ANTI_ALIAS_A_4KHZ[i], - g: ANTI_ALIAS_G_4KHZ[i], - }, - &expected, - ); - } - - assert_eq!(got, expected); - } - - #[test] - fn test_anti_alias_attenuates_above_2khz() { - // Decimating 16 kHz by 4 puts the new Nyquist at 2 kHz; the cascade - // exists to keep everything above it out of the correlation branch. - let response = |freq_hz: f32| { - let input: Vec = (0..4096) - .map(|i| (core::f32::consts::TAU * freq_hz * i as f32 / 16000.0).sin()) - .collect(); - let mut out = input; - anti_alias().process_in_place(&mut out); - // Peak amplitude over the settled tail. - out[2048..].iter().fold(0.0f32, |m, v| m.max(v.abs())) - }; - - let passband = response(300.0); - let stopband = response(4000.0); - - assert!(passband > 0.5, "300 Hz should pass: {passband}"); - assert!(stopband < 0.02, "4 kHz should be rejected: {stopband}"); - } - - #[test] - fn test_empty_cascade_is_pass_through() { - let mut cascade: BiquadCascade<0> = BiquadCascade::new([]); - assert!(cascade.is_empty()); - assert_eq!(cascade.len(), 0); - - let mut buf = [1.0, -2.0, 3.0]; - cascade.process_in_place(&mut buf); - assert_eq!(buf, [1.0, -2.0, 3.0]); - } -} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/estimator.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/estimator.rs index 3d82f452..831203fa 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/estimator.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/estimator.rs @@ -49,7 +49,6 @@ use burn::prelude::Backend; use super::{ - biquad::BiquadCascade, coeff::{ ANTI_ALIAS_A_4KHZ, ANTI_ALIAS_B_4KHZ, @@ -86,6 +85,7 @@ use crate::{ HOP_SIZE, SAMPLE_RATE, }, + ops::signal::BiquadCascade, prelude::BunsenResult, }; diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/mod.rs index edc91e87..3aa320a8 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/mod.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/mod.rs @@ -16,7 +16,6 @@ //! * [`HostPitch`] — adapts a host-side [`TenVadPitchScalarSource`] to the //! device seam. **The only place in the front end that synchronizes.** //! * [`ZeroPitch`] — a constant stub that skips the branch entirely. -//! * [`BiquadCascade`] — the anti-alias filter before decimation. //! * [`coeff`](self) — the reference constants. //! * [`tensor`] — the device-side port, built stage by stage against the host //! estimator as its oracle. @@ -44,7 +43,6 @@ //! tracker carrying its accumulator across hops. The last is a genuine //! recurrence over 56 states with two steps per hop. -mod biquad; mod coeff; mod estimator; mod host; @@ -53,8 +51,6 @@ mod source; pub mod tensor; -#[doc(inline)] -pub use biquad::*; #[doc(inline)] pub use coeff::*; #[doc(inline)] diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/antialias.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/antialias.rs index 2c32db4b..d59cf531 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/antialias.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/antialias.rs @@ -71,23 +71,23 @@ use burn::{ prelude::*, }; -use super::super::{ - biquad::{ +use super::super::coeff::{ + ANTI_ALIAS_A_4KHZ, + ANTI_ALIAS_B_4KHZ, + ANTI_ALIAS_G_4KHZ, + ANTI_ALIAS_SECTIONS, + PROC_RESAMPLE_RATE, +}; +use crate::{ + errors::{ + BunsenError, + BunsenResult, + WithOkOrPanic, + }, + ops::signal::{ BiquadCascade, BiquadSection, }, - coeff::{ - ANTI_ALIAS_A_4KHZ, - ANTI_ALIAS_B_4KHZ, - ANTI_ALIAS_G_4KHZ, - ANTI_ALIAS_SECTIONS, - PROC_RESAMPLE_RATE, - }, -}; -use crate::errors::{ - BunsenError, - BunsenResult, - WithOkOrPanic, }; /// The default impulse-response length of @@ -458,9 +458,9 @@ impl PitchAntiAlias { // span first makes the two agree. The trimmed tail is not lost: it is // still part of the carry below. // - // `tests::test_unfold_row_stride_assumption` pins this; if a future - // burn fixes the stride, that test fails loudly rather than leaving - // dead defensive code. + // `burner::tensor::burn_behavior` pins the upstream behaviour; if a + // future burn fixes the stride, that test fails loudly rather than + // leaving dead defensive code here. let covered = (steps - 1) * hop_size + window; // [batch, steps, window] @@ -711,67 +711,11 @@ mod tests { fir.forward(Tensor::::zeros([1, HOP], &device), wrong); } - #[test] - fn test_unfold_row_stride_assumption() { - // `unfold` derives its batch-row stride from the span the windows cover - // rather than from the row's real length, so a row with a leftover tail - // places every subsequent row early. `run_fir` trims to the covered - // span to avoid it; this pins the behaviour that makes the trim - // necessary. - // - // If this starts failing, `unfold` has been fixed and the trim in - // `run_fir` is merely redundant rather than load-bearing. - let device = Default::default(); - let (win, step, steps, tail) = (12usize, 4usize, 3usize, 3usize); - let covered = (steps - 1) * step + win; - let len = covered + tail; - - // Row 1 carries a marker at its very first element. - let mut flat = vec![0.0f32; 2 * len]; - flat[len] = 1.0; - let t = Tensor::::from_data(TensorData::new(flat, [2, len]), &device); - - let rows: Vec = t - .clone() - .unfold::<3, _>(1, win, step) - .reshape([2 * steps, win]) - .to_data_as::() - .to_vec_as::() - .unwrap(); - - // Window `steps` is (batch 1, step 0); the marker should sit at 0. - let found = rows[steps * win..(steps + 1) * win] - .iter() - .position(|v| *v != 0.0); - assert_eq!( - found, - Some(tail), - "expected the row-1 marker displaced by the leftover tail ({tail}); \ - Some(0) would mean unfold now uses the true row stride", - ); - - // And with no tail, the stride is correct. - let mut exact = vec![0.0f32; 2 * covered]; - exact[covered] = 1.0; - let t = Tensor::::from_data(TensorData::new(exact, [2, covered]), &device); - let rows: Vec = t - .unfold::<3, _>(1, win, step) - .reshape([2 * steps, win]) - .to_data_as::() - .to_vec_as::() - .unwrap(); - assert_eq!( - rows[steps * win..(steps + 1) * win] - .iter() - .position(|v| *v != 0.0), - Some(0), - ); - } - #[test] fn test_batch_row_offset_regression() { // The bug the trim fixes, at the real geometry: before it, row 1's - // output was garbage while row 0 was bit-exact. + // output was garbage while row 0 was bit-exact. The upstream behaviour + // itself is pinned by `burner::tensor::burn_behavior`. let device = Default::default(); let cfg = PitchAntiAliasConfig::default(); let filter = cfg.init::(HOP, &device); diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs index 195baec8..42ec8d4d 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs @@ -361,7 +361,7 @@ impl PitchTrack { // non-contiguous index tensor -- it reads element 0 of every row // instead of the indexed one. `stack` builds `[steps, batch, 1]` // contiguously; a transposed view silently corrupts every hop after - // the first. See `tests::test_gather_needs_a_contiguous_index`. + // the first. Pinned by `burner::tensor::burn_behavior`. let cursor: Tensor = Tensor::stack::<3>(hop_best.clone(), 0).squeeze_dim::<2>(2); let pitch = self.backtrace_and_fit( &xcorr_hist, @@ -606,57 +606,6 @@ mod tests { PitchTrackConfig::new() } - #[test] - fn test_gather_needs_a_contiguous_index() { - // `gather` ignores strides on a non-contiguous index tensor: given a - // transposed view it reads element 0 of each row rather than the - // indexed element. The backtrace's cursor is exactly such an index, so - // it is built with `stack` rather than `cat` + `swap_dims`. - // - // If this starts failing, burn has fixed the stride handling and the - // comment at the cursor construction is stale -- the `stack` is then - // merely tidy rather than load-bearing. - let device = Default::default(); - let (steps, wide) = (3usize, 56usize); - - let mut v = vec![0i32; steps * wide]; - for t in 0..steps { - for j in 0..wide { - v[t * wide + j] = (t as i32) * 1000 + j as i32; - } - } - let data = Tensor::::from_data(TensorData::new(v, [steps, 1, wide]), &device); - - let contiguous = Tensor::::full([steps, 1, 1], 37, &device); - let rows: Vec> = (0..steps) - .map(|_| Tensor::full([1, 1], 37, &device)) - .collect(); - let transposed = Tensor::cat(rows, 1).swap_dims(0, 1).unsqueeze_dim::<3>(2); - - let good: Vec = data - .clone() - .gather(2, contiguous) - .to_data_as::() - .to_vec_as::() - .unwrap(); - let bad: Vec = data - .gather(2, transposed) - .to_data_as::() - .to_vec_as::() - .unwrap(); - - assert_eq!( - good, - vec![37, 1037, 2037], - "contiguous index should be exact" - ); - assert_ne!( - good, bad, - "gather now honours strides on the index; the `stack` in \ - `backtrace_and_fit` is no longer load-bearing", - ); - } - #[test] fn test_config_meta() { let cfg = config(); diff --git a/crates/bunsen/src/ops/signal/biquad.rs b/crates/bunsen/src/ops/signal/biquad.rs new file mode 100644 index 00000000..8c56714b --- /dev/null +++ b/crates/bunsen/src/ops/signal/biquad.rs @@ -0,0 +1,364 @@ +//! # Cascaded biquad IIR filtering. +//! +//! A series of second-order sections, each in Direct Form II: +//! +//! ```text +//! w0 = x - a1*w1 - a2*w2 +//! y = g * (b0*w0 + b1*w1 + b2*w2) +//! w2 = w1; w1 = w0 +//! ``` +//! +//! Sections are applied in series, each carrying its own two-sample state +//! across calls, so a stream may be filtered in arbitrary blocks and get the +//! same answer as filtering it whole. +//! +//! Each section is run over the entire block before the next one starts. +//! Because a section's state depends only on its own input sequence, that is +//! equivalent to advancing every section per sample, and both are equivalent +//! to filtering the unsegmented stream. [`BiquadCascade::process_in_place`] +//! takes the first form because it keeps one section's coefficients in +//! registers for a whole block. +//! +//! ## Host-side, and deliberately so +//! +//! This is scalar `f32` code with no tensor in it. A cascade is a sample-rate +//! recurrence, so a device implementation would be sequential in the sample +//! axis and slower than the host for any realistic block. When a *decimating* +//! cascade is what you want on device, realize it as a truncated FIR instead +//! and fold the decimation into the kernel — see +//! [`DecimatingFirConfig`](super::DecimatingFirConfig), which generates its +//! impulse response by driving this type with a unit impulse. That form is +//! both parallel and better conditioned than the recurrence. +//! +//! `a[0]` is assumed to be `1`. Sections whose leading denominator coefficient +//! is not already normalized must be divided through before construction. + +/// One second-order section of a [`BiquadCascade`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BiquadSection { + /// Numerator coefficients `[b0, b1, b2]`. + pub b: [f32; 3], + + /// Denominator coefficients `[_, a1, a2]`; `a[0]` is assumed `1` and + /// ignored. + pub a: [f32; 3], + + /// The section's output gain. + pub g: f32, +} + +impl BiquadSection { + /// A section that passes its input through unchanged. + pub const IDENTITY: Self = Self { + b: [1.0, 0.0, 0.0], + a: [1.0, 0.0, 0.0], + g: 1.0, + }; + + /// The section's DC gain, `H(1)`. + /// + /// Useful as a cheap sanity check on a coefficient table: a lowpass + /// section's cascade gain should be near `1`, and a value far from the + /// intended one usually means a section was transcribed with the wrong + /// sign convention on `a`. + pub fn dc_gain(&self) -> f32 { + let num = self.b[0] + self.b[1] + self.b[2]; + let den = 1.0 + self.a[1] + self.a[2]; + self.g * num / den + } +} + +/// A series of [`BiquadSection`]s with per-section delay state. +/// +/// Built by [`BiquadCascade::new`] or +/// [`BiquadCascade::from_tables`]; rewound by [`BiquadCascade::reset`]. +#[derive(Debug, Clone, PartialEq)] +pub struct BiquadCascade { + sections: [BiquadSection; NSECT], + + /// Per-section `[w1, w2]` delay state. + state: [[f32; 2]; NSECT], +} + +impl BiquadCascade { + /// Builds a cascade with zeroed state. + pub fn new(sections: [BiquadSection; NSECT]) -> Self { + Self { + sections, + state: [[0.0; 2]; NSECT], + } + } + + /// Builds a cascade from parallel coefficient tables. + /// + /// The layout filter-design tools usually emit: one row per section in + /// each of three arrays, rather than an array of sections. + /// + /// # Arguments + /// * `b`: per-section numerator coefficients. + /// * `a`: per-section denominator coefficients. + /// * `g`: per-section gains. + pub fn from_tables( + b: &[[f32; 3]; NSECT], + a: &[[f32; 3]; NSECT], + g: &[f32; NSECT], + ) -> Self { + Self::new(core::array::from_fn(|i| BiquadSection { + b: b[i], + a: a[i], + g: g[i], + })) + } + + /// The sections, in application order. + pub fn sections(&self) -> &[BiquadSection; NSECT] { + &self.sections + } + + /// The number of sections. + pub fn len(&self) -> usize { + NSECT + } + + /// Whether the cascade has no sections, in which case it is a + /// pass-through. + pub fn is_empty(&self) -> bool { + NSECT == 0 + } + + /// The whole cascade's DC gain, `H(1)`. + pub fn dc_gain(&self) -> f32 { + self.sections.iter().map(BiquadSection::dc_gain).product() + } + + /// Zeroes every section's delay state. + pub fn reset(&mut self) { + self.state = [[0.0; 2]; NSECT]; + } + + /// The cascade's impulse response, `taps` long, from a zeroed state. + /// + /// Does not disturb this cascade's state; it runs against a clone. + pub fn to_vec_impulse_response( + &self, + taps: usize, + ) -> Vec { + let mut fresh = Self::new(self.sections); + let mut buf = vec![0.0f32; taps]; + if let Some(first) = buf.first_mut() { + *first = 1.0; + } + fresh.process_in_place(&mut buf); + buf + } + + /// Filters `buf` in place, carrying state across calls. + pub fn process_in_place( + &mut self, + buf: &mut [f32], + ) { + for (section, state) in self.sections.iter().zip(self.state.iter_mut()) { + let [b0, b1, b2] = section.b; + let a1 = section.a[1]; + let a2 = section.a[2]; + let g = section.g; + + let [mut w1, mut w2] = *state; + for y in buf.iter_mut() { + let w0 = *y - a1 * w1 - a2 * w2; + *y = g * (b0 * w0 + b1 * w1 + b2 * w2); + w2 = w1; + w1 = w0; + } + *state = [w1, w2]; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A one-pole section: `y[n] = x[n] + r*y[n-1]`. + /// + /// Its impulse response is exactly `r^n`, which makes it a closed-form + /// oracle rather than a fixture. + fn one_pole(r: f32) -> BiquadCascade<1> { + BiquadCascade::new([BiquadSection { + b: [1.0, 0.0, 0.0], + a: [1.0, -r, 0.0], + g: 1.0, + }]) + } + + /// A two-pole section with real poles at `0.7` and `0.5`. + fn two_pole() -> BiquadCascade<1> { + BiquadCascade::new([BiquadSection { + b: [1.0, 0.0, 0.0], + a: [1.0, -1.2, 0.35], + g: 1.0, + }]) + } + + #[test] + fn test_empty_cascade_is_pass_through() { + let mut cascade: BiquadCascade<0> = BiquadCascade::new([]); + assert!(cascade.is_empty()); + assert_eq!(cascade.len(), 0); + + let mut buf = [1.0, -2.0, 3.5, 0.0]; + cascade.process_in_place(&mut buf); + assert_eq!(buf, [1.0, -2.0, 3.5, 0.0]); + } + + #[test] + fn test_identity_section_is_pass_through() { + let mut cascade = BiquadCascade::new([BiquadSection::IDENTITY; 3]); + assert!(!cascade.is_empty()); + + let mut buf = [1.0, -2.0, 3.5, 0.0]; + cascade.process_in_place(&mut buf); + assert_eq!(buf, [1.0, -2.0, 3.5, 0.0]); + } + + #[test] + fn test_one_pole_impulse_response_is_geometric() { + // The point of a closed-form oracle: this checks the difference + // equation itself, not agreement with another implementation. + let r = 0.5f32; + let response = one_pole(r).to_vec_impulse_response(24); + + for (n, &got) in response.iter().enumerate() { + let want = r.powi(n as i32); + assert!( + (got - want).abs() <= 1e-6 * want.max(1e-6), + "tap {n}: got {got}, want {want}", + ); + } + } + + #[test] + fn test_dc_gain_matches_the_summed_impulse_response() { + // H(1) is by definition the sum of the impulse response, so these two + // routes to the same number must agree. Catches a sign-convention slip + // on `a` immediately. + for cascade in [one_pole(0.5), one_pole(0.9), two_pole()] { + let summed: f32 = cascade.to_vec_impulse_response(4096).iter().sum(); + let analytic = cascade.dc_gain(); + assert!( + (summed - analytic).abs() < 1e-3 * analytic.abs(), + "summed {summed} vs analytic {analytic}", + ); + } + } + + #[test] + fn test_two_pole_matches_its_difference_equation() { + let response = two_pole().to_vec_impulse_response(32); + + // y[n] = x[n] + 1.2*y[n-1] - 0.35*y[n-2] + let mut want = vec![0.0f64; 32]; + for n in 0..32 { + let x = if n == 0 { 1.0 } else { 0.0 }; + let y1 = if n >= 1 { want[n - 1] } else { 0.0 }; + let y2 = if n >= 2 { want[n - 2] } else { 0.0 }; + want[n] = x + 1.2 * y1 - 0.35 * y2; + } + + for (n, (&got, &w)) in response.iter().zip(want.iter()).enumerate() { + assert!( + (got as f64 - w).abs() <= 1e-5 * w.abs().max(1e-6), + "tap {n}: got {got}, want {w}", + ); + } + } + + #[test] + fn test_state_carries_across_calls() { + // The property that makes this usable on a stream: block boundaries + // must not be observable. + let signal: Vec = (0..64).map(|n| ((n as f32) * 0.37).sin()).collect(); + + let mut whole = signal.clone(); + one_pole(0.8).process_in_place(&mut whole); + + let mut split = signal.clone(); + let mut cascade = one_pole(0.8); + let (head, tail) = split.split_at_mut(19); + cascade.process_in_place(head); + cascade.process_in_place(tail); + + assert_eq!(whole, split); + } + + #[test] + fn test_reset_rewinds_the_state() { + let mut cascade = one_pole(0.8); + + let mut first = [1.0, 0.0, 0.0, 0.0]; + cascade.process_in_place(&mut first); + + let mut dirty = [1.0, 0.0, 0.0, 0.0]; + cascade.process_in_place(&mut dirty); + assert_ne!(first, dirty, "state should have carried"); + + cascade.reset(); + let mut again = [1.0, 0.0, 0.0, 0.0]; + cascade.process_in_place(&mut again); + assert_eq!(first, again); + } + + #[test] + fn test_sections_apply_in_series() { + let signal: Vec = (0..48).map(|n| ((n as f32) * 0.21).cos()).collect(); + + let mut chained = signal.clone(); + one_pole(0.6).process_in_place(&mut chained); + one_pole(0.3).process_in_place(&mut chained); + + let mut cascaded = signal; + BiquadCascade::new([one_pole(0.6).sections()[0], one_pole(0.3).sections()[0]]) + .process_in_place(&mut cascaded); + + assert_eq!(chained, cascaded); + } + + #[test] + fn test_impulse_response_does_not_disturb_state() { + let mut cascade = one_pole(0.8); + + let baseline = cascade.to_vec_impulse_response(8); + let again = cascade.to_vec_impulse_response(8); + assert_eq!( + baseline, again, + "generating a response must not carry state" + ); + + let mut buf = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + cascade.process_in_place(&mut buf); + assert_eq!(&buf[..], &baseline[..]); + } + + #[test] + fn test_from_tables_matches_explicit_sections() { + let b = [[1.0, 0.0, 0.0], [0.5, 0.5, 0.0]]; + let a = [[1.0, -0.5, 0.0], [1.0, -0.2, 0.1]]; + let g = [1.0, 2.0]; + + let tabled = BiquadCascade::<2>::from_tables(&b, &a, &g); + let explicit = BiquadCascade::new([ + BiquadSection { + b: b[0], + a: a[0], + g: g[0], + }, + BiquadSection { + b: b[1], + a: a[1], + g: g[1], + }, + ]); + + assert_eq!(tabled, explicit); + } +} diff --git a/crates/bunsen/src/ops/signal/mod.rs b/crates/bunsen/src/ops/signal/mod.rs index 3449983a..5d4877d3 100644 --- a/crates/bunsen/src/ops/signal/mod.rs +++ b/crates/bunsen/src/ops/signal/mod.rs @@ -1,10 +1,13 @@ //! Tensor signal operations. +mod biquad; mod cosine_window; mod sliding_stft; mod stft_window; mod window_builder; +#[doc(inline)] +pub use biquad::*; #[doc(inline)] pub use cosine_window::*; #[doc(inline)] From 8e97fe1ed4bf504b30f170838a1f29c6fd27b62e Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Mon, 24 Aug 2026 11:34:05 -0700 Subject: [PATCH 16/32] refactor(signal): lift autocorrelation and Levinson-Durbin out of ten_vad Second extraction step. Neither piece is voice-activity detection: an all-pole spectral fit is ordinary linear prediction, and both halves of it were sitting in the pitch branch because that is where they were first needed. `ops::signal::Autocorrelator` moves unchanged. Its two baked-in conventions are now stated rather than implied -- results are unnormalized by `N/2`, and the Nyquist bin is excluded -- because neither is the only reasonable choice and a caller outside an LPC fit needs to know. `scale()` and `n_bins()` were added so that caller does not have to rederive them. `ops::signal::levinson_durbin` generalizes `celt_lpc`: slices instead of fixed arrays, and the early bail-out becomes `Option`. `None` is the textbook recursion, which is what a new caller wants; `Some(ratio)` reproduces a reference that stops early, which is a strictly weirder requirement and now reads as one. `celt_lpc` stays in ten_vad as a thin wrapper pinning CELT's `0.001`. The tests are the actual work. The old ones compared against a realistic envelope and a direct DFT -- fine, but they establish agreement with one case rather than the transform's identity. The new ones are closed-form: * a spectral delta at bin `k` must autocorrelate to `cos(2*pi*k*l/N)` exactly, which with the linearity test that follows it covers every spectrum, since deltas are a complete basis * the DC bin produces a constant, carrying the documented `0.5` weight * Nyquist energy produces exactly zero, pinning the exclusion * an AR(1) process recovers `[-a, 0, ...]`, an AR(2) recovers its own coefficients through Yule-Walker in reverse * the bail-out leaves exact zeros rather than partial values, and truncates without otherwise changing the coefficients it did compute One doc claim did not survive contact: `celt_lpc` said scale invariance leaves the result "unchanged", and asserting that bit-exactly fails. The invariance is mathematical -- scaling the input rounds it, and the recursion then rounds differently. The claim is now stated with that qualification and tested to a peak-relative tolerance. Verified against the golden while it still exists, which is the point of doing extraction before deletion: mean |diff| 5.649e-5, worst 6.446e-4 at hop 2973, decisions agreeing on all 3750 frames -- identical to the pre-extraction run, so the delegation is byte-for-byte the old behavior. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../speech/ten_vad/context/pitch/estimator.rs | 6 +- .../kits/speech/ten_vad/context/pitch/lpc.rs | 220 +------- .../ten_vad/context/pitch/tensor/tables.rs | 10 +- crates/bunsen/src/ops/signal/lpc.rs | 468 ++++++++++++++++++ crates/bunsen/src/ops/signal/mod.rs | 3 + 5 files changed, 494 insertions(+), 213 deletions(-) create mode 100644 crates/bunsen/src/ops/signal/lpc.rs diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/estimator.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/estimator.rs index 831203fa..d8b5804f 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/estimator.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/estimator.rs @@ -70,7 +70,6 @@ use super::{ HostPitchInit, }, lpc::{ - Autocorrelator, DctTable, band_energy, lpc_from_cepstrum, @@ -85,7 +84,10 @@ use crate::{ HOP_SIZE, SAMPLE_RATE, }, - ops::signal::BiquadCascade, + ops::signal::{ + Autocorrelator, + BiquadCascade, + }, prelude::BunsenResult, }; diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/lpc.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/lpc.rs index 977654c9..50588049 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/lpc.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/lpc.rs @@ -53,6 +53,10 @@ use super::coeff::{ NB_BANDS, PITCH_PI, }; +use crate::ops::signal::{ + Autocorrelator, + levinson_durbin, +}; /// The noise floor added to lag 0 before the LPC solve. /// @@ -215,93 +219,12 @@ pub fn interp_band_gain( } } -/// Autocorrelation lags from a real, even power spectrum. -/// -/// Holds the cosine table the sum is evaluated against; see the module docs -/// for why this replaces the reference's inverse FFT and why the result is -/// scaled by `0.5` rather than `1/fft_size`. -#[derive(Debug, Clone, PartialEq)] -pub struct Autocorrelator { - fft_size: usize, - - /// `cos(2πt/fft_size)` for `t` in `0..fft_size`. - cos_table: Vec, -} - -impl Autocorrelator { - /// Builds an autocorrelator for a given FFT size. - /// - /// # Panics - /// If `fft_size` is not even. - pub fn new(fft_size: usize) -> Self { - assert_eq!(fft_size % 2, 0, "fft_size must be even"); - let cos_table = (0..fft_size) - .map(|t| (core::f64::consts::TAU * t as f64 / fft_size as f64).cos()) - .collect(); - Self { - fft_size, - cos_table, - } - } - - /// The FFT size this autocorrelator was built for. - pub fn fft_size(&self) -> usize { - self.fft_size - } - - /// Lags `0..n_lags` of the spectrum in `spectrum`. - /// - /// The Nyquist bin is ignored; the reference zeroes it before - /// transforming, and this reproduces that by construction. - /// - /// The sum is accumulated in `f64`. The reference accumulates through FFT - /// butterflies, whose error grows with `log(fft_size)` rather than - /// `fft_size`; a flat `f32` sum over 511 terms would be materially worse - /// than the thing being reproduced, while `f64` is at least as good. - /// - /// # Arguments - /// * `spectrum`: `[fft_size / 2 + 1]` non-negative bin values. - /// * `out`: the lags to fill; `out.len()` must not exceed `fft_size`. - /// - /// # Panics - /// If `spectrum` is not `fft_size / 2 + 1` long. - pub fn autocorrelate( - &self, - spectrum: &[f32], - out: &mut [f32], - ) { - let n_bins = self.fft_size / 2 + 1; - assert_eq!( - spectrum.len(), - n_bins, - "autocorrelate expects {n_bins} bins for a {}-point FFT", - self.fft_size, - ); - - for (lag, slot) in out.iter_mut().enumerate() { - let mut acc = 0.0f64; - for (k, &s) in spectrum.iter().enumerate().take(self.fft_size / 2).skip(1) { - acc += s as f64 * self.cos_table[(lag * k) % self.fft_size]; - } - *slot = (0.5 * spectrum[0] as f64 + acc) as f32; - } - } -} - -/// Solves for LPC coefficients by Levinson-Durbin recursion. +/// The reference's `AUP_PE_celt_lpc`: [`levinson_durbin`] with CELT's 30 dB +/// early bail-out and this port's fixed order. /// -/// The reference's `AUP_PE_celt_lpc`, itself CELT's. Returns the coefficients -/// of the whitening filter, and bails out early once the residual error has -/// dropped 30 dB below lag 0. -/// -/// Coefficients past the bail-out are left at **zero**, not at a partial -/// value: index `k` is only ever written by `lpc[i] = r` at step `k`, and the -/// symmetric update at step `i` touches only `0..i-1`. That matters for a -/// vectorized port, which cannot branch and must instead freeze each row under -/// a mask — the frozen tail is the zero initializer. -/// -/// Scale-invariant: multiplying `ac` through by a constant leaves the result -/// unchanged, including the bail-out point. +/// Kept as a named wrapper because the bail-out ratio is the reference's +/// choice, not a general default, and pinning it here keeps every call site +/// from repeating it. /// /// # Arguments /// * `ac`: autocorrelation lags `0..=LPC_ORDER`. @@ -310,38 +233,13 @@ impl Autocorrelator { /// The `LPC_ORDER` coefficients, all zero if `ac[0]` is zero. pub fn celt_lpc(ac: &[f32; LPC_ORDER + 1]) -> [f32; LPC_ORDER] { let mut lpc = [0.0f32; LPC_ORDER]; - if ac[0] == 0.0 { - return lpc; - } - - let mut error = ac[0]; - for i in 0..LPC_ORDER { - // The reference sums the products first and folds in `ac[i + 1]` - // last; the order is preserved because it is observable in f32. - let mut rr = 0.0f32; - for j in 0..i { - rr += lpc[j] * ac[i - j]; - } - rr += ac[i + 1]; - - let r = -rr / error; - lpc[i] = r; - for j in 0..((i + 1) >> 1) { - let head = lpc[j]; - let tail = lpc[i - 1 - j]; - lpc[j] = head + r * tail; - lpc[i - 1 - j] = tail + r * head; - } - - error -= r * r * error; - if error < 0.001 * ac[0] { - break; - } - } - + levinson_durbin(ac, &mut lpc, Some(CELT_LPC_BAIL_RATIO)); lpc } +/// CELT's early bail-out threshold: 30 dB below lag zero. +pub const CELT_LPC_BAIL_RATIO: f32 = 0.001; + /// Fits an all-pole filter to a set of band gains. /// /// Interpolates the gains across the bins, autocorrelates, applies the noise @@ -408,14 +306,6 @@ mod tests { const FFT: usize = 1024; const NBINS: usize = FFT / 2 + 1; - /// A smooth, non-negative, decaying spectrum — the shape a real band - /// envelope has. - fn envelope() -> Vec { - (0..NBINS) - .map(|k| 1e3 * (-(k as f32) / 90.0).exp() * (1.0 + 0.4 * (k as f32 * 0.07).sin())) - .collect() - } - #[test] fn test_dct_round_trips_through_idct() { let dct = DctTable::new(); @@ -517,90 +407,6 @@ mod tests { } } - #[test] - fn test_autocorrelate_matches_a_direct_dft() { - let spectrum = envelope(); - let ac = Autocorrelator::new(FFT); - let mut got = [0.0f32; LPC_ORDER + 1]; - ac.autocorrelate(&spectrum, &mut got); - - for (lag, &g) in got.iter().enumerate() { - // The closed form, evaluated independently in f64. - let mut want = 0.5 * spectrum[0] as f64; - for (k, &s) in spectrum.iter().enumerate().take(FFT / 2).skip(1) { - want += - s as f64 * (core::f64::consts::TAU * lag as f64 * k as f64 / FFT as f64).cos(); - } - let rel = (g as f64 - want).abs() / want.abs().max(1.0); - assert!(rel < 1e-6, "lag {lag}: {g} vs {want}"); - } - } - - #[test] - fn test_autocorrelate_ignores_the_nyquist_bin() { - let ac = Autocorrelator::new(FFT); - let mut spectrum = envelope(); - let mut with_nyquist = [0.0f32; LPC_ORDER + 1]; - let mut without = [0.0f32; LPC_ORDER + 1]; - - spectrum[NBINS - 1] = 0.0; - ac.autocorrelate(&spectrum, &mut without); - spectrum[NBINS - 1] = 1e6; - ac.autocorrelate(&spectrum, &mut with_nyquist); - - assert_eq!(with_nyquist, without); - } - - #[test] - fn test_autocorrelate_lag_zero_is_the_mean_power() { - // With the reference's 0.5 scale, lag 0 is half the DC bin plus the - // sum of every other bin. - let spectrum = envelope(); - let ac = Autocorrelator::new(FFT); - let mut got = [0.0f32; 1]; - ac.autocorrelate(&spectrum, &mut got); - - let want = - 0.5 * spectrum[0] as f64 + spectrum[1..FFT / 2].iter().map(|&s| s as f64).sum::(); - assert!((got[0] as f64 - want).abs() / want < 1e-6); - } - - #[test] - fn test_celt_lpc_is_scale_invariant() { - let mut ac = [0.0f32; LPC_ORDER + 1]; - for (i, slot) in ac.iter_mut().enumerate() { - *slot = 1000.0 * (-(i as f32) / 4.0).exp(); - } - let base = celt_lpc(&ac); - - for scale in [1e-3f32, 7.0, 512.0] { - let scaled: [f32; LPC_ORDER + 1] = core::array::from_fn(|i| ac[i] * scale); - let got = celt_lpc(&scaled); - for (g, b) in got.iter().zip(base.iter()) { - assert!((g - b).abs() < 1e-4, "scale {scale}: {g} vs {b}"); - } - } - } - - #[test] - fn test_celt_lpc_of_a_zero_signal_is_zero() { - assert_eq!(celt_lpc(&[0.0; LPC_ORDER + 1]), [0.0; LPC_ORDER]); - } - - #[test] - fn test_celt_lpc_whitens_a_known_ar_process() { - // An AR(1) process x[n] = a*x[n-1] + e has autocorrelation a^|k|, and - // the whitening filter should recover -a in the first coefficient. - let a = 0.8f32; - let ac: [f32; LPC_ORDER + 1] = core::array::from_fn(|i| a.powi(i as i32)); - let lpc = celt_lpc(&ac); - - assert!((lpc[0] + a).abs() < 1e-3, "lpc[0] = {}", lpc[0]); - for (i, c) in lpc.iter().enumerate().skip(1) { - assert!(c.abs() < 1e-3, "lpc[{i}] = {c} should be negligible"); - } - } - #[test] fn test_dc0_bias_uses_integer_division() { // 768/12 is exact, so this matches either reading; the smaller windows diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/tables.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/tables.rs index 6b9fad80..3cf02b2e 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/tables.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/tables.rs @@ -41,15 +41,17 @@ use super::super::{ NB_BANDS, }, lpc::{ - Autocorrelator, DctTable, band_energy, interp_band_gain, }, }; -use crate::errors::{ - BunsenError, - BunsenResult, +use crate::{ + errors::{ + BunsenError, + BunsenResult, + }, + ops::signal::Autocorrelator, }; /// Config for [`PitchTables`]. diff --git a/crates/bunsen/src/ops/signal/lpc.rs b/crates/bunsen/src/ops/signal/lpc.rs new file mode 100644 index 00000000..3599647c --- /dev/null +++ b/crates/bunsen/src/ops/signal/lpc.rs @@ -0,0 +1,468 @@ +//! # Linear prediction: autocorrelation and the Levinson-Durbin recursion. +//! +//! The two host-side pieces of an all-pole spectral fit: +//! +//! * [`Autocorrelator`] recovers autocorrelation lags from a power spectrum, +//! without a round trip through an inverse FFT. +//! * [`levinson_durbin`] solves the resulting Toeplitz system for the +//! prediction coefficients. +//! +//! Both are scalar `f32`, and deliberately so: an LPC fit is a short recursion +//! over a handful of lags, so the work is dominated by call overhead rather +//! than arithmetic. What benefits from a device is fitting *many* spectra at +//! once, which is a different shape — run the recursion with a masked freeze +//! instead of a branch, since a batched implementation cannot bail out per row. +//! +//! ## Why not an inverse FFT +//! +//! Autocorrelation is the inverse transform of the power spectrum, so an +//! inverse FFT is the obvious route. Evaluating the cosine sum directly wins +//! here for two reasons. It needs only the first few lags, where an FFT +//! computes all of them; and it can accumulate in `f64` at no meaningful cost, +//! which matters because a flat `f32` sum over several hundred bins has error +//! growing with the term count, while an FFT's grows with its logarithm. The +//! direct sum in `f32` would be materially *worse* than an FFT; in `f64` it is +//! at least as good. + +/// Autocorrelation lags from a real, even power spectrum. +/// +/// Holds the cosine table the sum is evaluated against, so building one is the +/// expensive part and evaluating it is cheap. +/// +/// # Scaling and the Nyquist bin +/// +/// For a spectrum `S` over an `N`-point FFT, lag `l` is +/// +/// ```text +/// r[l] = 0.5 * S[0] + sum(S[k] * cos(2*pi*k*l/N) for k in 1..N/2) +/// ``` +/// +/// Two conventions are baked in and worth stating plainly, because neither is +/// the only reasonable choice: +/// +/// * **The result is unnormalized**, larger than the true autocorrelation by +/// `N/2`. Nothing downstream in an LPC fit cares — [`levinson_durbin`] is +/// scale-invariant — but a caller using these lags for anything else should +/// divide. +/// * **The Nyquist bin is excluded**, since the sum stops at `N/2 - 1`. For a +/// spectrum that has been lowpassed well below Nyquist this is immaterial; +/// for one with real energy at Nyquist it is not. +#[derive(Debug, Clone, PartialEq)] +pub struct Autocorrelator { + fft_size: usize, + + /// `cos(2*pi*t/fft_size)` for `t` in `0..fft_size`. + cos_table: Vec, +} + +impl Autocorrelator { + /// Builds an autocorrelator for a given FFT size. + /// + /// # Panics + /// If `fft_size` is not even. + pub fn new(fft_size: usize) -> Self { + assert_eq!(fft_size % 2, 0, "fft_size must be even"); + let cos_table = (0..fft_size) + .map(|t| (core::f64::consts::TAU * t as f64 / fft_size as f64).cos()) + .collect(); + Self { + fft_size, + cos_table, + } + } + + /// The FFT size this autocorrelator was built for. + pub fn fft_size(&self) -> usize { + self.fft_size + } + + /// The number of spectrum bins [`autocorrelate`](Self::autocorrelate) + /// expects. + pub fn n_bins(&self) -> usize { + self.fft_size / 2 + 1 + } + + /// The factor by which results exceed the true autocorrelation. + /// + /// Divide by this to get normalized lags; see the type docs. + pub fn scale(&self) -> f32 { + (self.fft_size / 2) as f32 + } + + /// Fills `out` with lags `0..out.len()` of `spectrum`. + /// + /// Accumulated in `f64`; see the module docs. + /// + /// # Arguments + /// * `spectrum`: [`n_bins`](Self::n_bins) non-negative bin values. + /// * `out`: the lags to fill, overwritten in full. + /// + /// # Panics + /// If `spectrum` is not [`n_bins`](Self::n_bins) long. + pub fn autocorrelate( + &self, + spectrum: &[f32], + out: &mut [f32], + ) { + assert_eq!( + spectrum.len(), + self.n_bins(), + "autocorrelate expects {} bins for a {}-point FFT", + self.n_bins(), + self.fft_size, + ); + + for (lag, slot) in out.iter_mut().enumerate() { + let mut acc = 0.0f64; + for (k, &s) in spectrum.iter().enumerate().take(self.fft_size / 2).skip(1) { + acc += s as f64 * self.cos_table[(lag * k) % self.fft_size]; + } + *slot = (0.5 * spectrum[0] as f64 + acc) as f32; + } + } +} + +/// Solves for linear-prediction coefficients by Levinson-Durbin recursion. +/// +/// Returns the coefficients of the *whitening* filter, in the convention +/// +/// ```text +/// residual[n] = x[n] + sum(out[j] * x[n - 1 - j] for j in 0..out.len()) +/// ``` +/// +/// so a process `x[n] = a*x[n-1] + e[n]` yields `out[0] == -a`. Note the sign: +/// these are the negated prediction coefficients. +/// +/// Scale-invariant: multiplying `ac` through by a constant leaves the solution +/// unchanged, including where an early bail-out lands, since every comparison +/// is against `ac[0]`. That is exact arithmetic; in `f32` the two runs agree to +/// rounding rather than bit for bit, because scaling the input rounds it and +/// the recursion then rounds differently. +/// +/// # The early bail-out +/// +/// `bail_ratio` stops the recursion once the residual error falls below +/// `bail_ratio * ac[0]`, leaving the remaining coefficients at **zero** rather +/// than at a partial value: index `k` is only ever written by `out[i] = r` at +/// step `k`, and the symmetric update at step `i` touches only `0..i-1`. +/// +/// `None` runs to full order, which is the textbook algorithm and what most +/// callers want. The option exists because some reference implementations bail +/// (CELT's `_celt_lpc` uses `Some(0.001)`, i.e. 30 dB), and reproducing one of +/// those requires reproducing where it stopped. +/// +/// The check happens **after** an iteration commits, so iteration `i` always +/// completes and only `i+1..` are skipped. A batched port must freeze after the +/// update for the same reason. +/// +/// # Arguments +/// * `ac`: autocorrelation lags `0..=order`; at least 2 long. +/// * `out`: `ac.len() - 1` coefficients, overwritten in full. +/// * `bail_ratio`: early-exit threshold as a fraction of `ac[0]`. +/// +/// # Panics +/// If `out.len() + 1 != ac.len()`, or if `ac` is shorter than 2. +pub fn levinson_durbin( + ac: &[f32], + out: &mut [f32], + bail_ratio: Option, +) { + assert!(ac.len() >= 2, "levinson_durbin needs at least lags 0 and 1"); + assert_eq!( + out.len() + 1, + ac.len(), + "levinson_durbin writes {} coefficients for {} lags", + ac.len() - 1, + ac.len(), + ); + + out.fill(0.0); + if ac[0] == 0.0 { + return; + } + + let order = out.len(); + let mut error = ac[0]; + + for i in 0..order { + // Sum the products first and fold in `ac[i + 1]` last. The order is + // observable in f32, and this is the order CELT uses. + let mut rr = 0.0f32; + for j in 0..i { + rr += out[j] * ac[i - j]; + } + rr += ac[i + 1]; + + let r = -rr / error; + out[i] = r; + + // The symmetric update, walking inward from both ends. For odd `i` the + // final pair is the middle element against itself, written once as + // `l[m] + r * l[m]`. + for j in 0..((i + 1) >> 1) { + let head = out[j]; + let tail = out[i - 1 - j]; + out[j] = head + r * tail; + out[i - 1 - j] = tail + r * head; + } + + error -= r * r * error; + if bail_ratio.is_some_and(|ratio| error < ratio * ac[0]) { + break; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FFT: usize = 64; + + /// A spectrum that is zero everywhere but bin `k`. + fn spectral_delta( + k: usize, + amplitude: f32, + ) -> Vec { + let mut s = vec![0.0f32; FFT / 2 + 1]; + s[k] = amplitude; + s + } + + #[test] + fn test_meta() { + let ac = Autocorrelator::new(FFT); + assert_eq!(ac.fft_size(), FFT); + assert_eq!(ac.n_bins(), FFT / 2 + 1); + assert_eq!(ac.scale(), (FFT / 2) as f32); + } + + #[test] + #[should_panic(expected = "fft_size must be even")] + fn test_odd_fft_size_is_rejected() { + let _ = Autocorrelator::new(63); + } + + #[test] + fn test_spectral_delta_autocorrelates_to_a_cosine() { + // The closed form: a single bin `k` carries a single sinusoid, whose + // autocorrelation is a cosine at that same frequency. This checks the + // transform itself rather than agreement with another implementation. + let auto = Autocorrelator::new(FFT); + + for k in [1usize, 3, 7, 17, FFT / 2 - 1] { + let spectrum = spectral_delta(k, 2.0); + let mut lags = vec![0.0f32; 24]; + auto.autocorrelate(&spectrum, &mut lags); + + for (l, &got) in lags.iter().enumerate() { + let want = + 2.0 * (core::f64::consts::TAU * (k * l) as f64 / FFT as f64).cos() as f32; + assert!( + (got - want).abs() < 1e-5, + "bin {k}, lag {l}: got {got}, want {want}", + ); + } + } + } + + #[test] + fn test_dc_bin_autocorrelates_to_a_constant() { + // Bin 0 is a constant offset, so every lag sees the same value -- and + // it carries the documented `0.5` weight. + let auto = Autocorrelator::new(FFT); + let mut lags = vec![0.0f32; 8]; + auto.autocorrelate(&spectral_delta(0, 4.0), &mut lags); + + for (l, &got) in lags.iter().enumerate() { + assert!((got - 2.0).abs() < 1e-6, "lag {l}: got {got}, want 2.0"); + } + } + + #[test] + fn test_nyquist_bin_is_excluded() { + // Documented behavior, easy to regress: the sum stops below Nyquist. + let auto = Autocorrelator::new(FFT); + let mut lags = vec![1.0f32; 8]; + auto.autocorrelate(&spectral_delta(FFT / 2, 9.0), &mut lags); + + assert!( + lags.iter().all(|v| *v == 0.0), + "Nyquist energy leaked into the lags: {lags:?}", + ); + } + + #[test] + fn test_autocorrelation_is_linear_in_the_spectrum() { + let auto = Autocorrelator::new(FFT); + + let a = spectral_delta(3, 1.0); + let b = spectral_delta(11, 1.0); + let mixed: Vec = a + .iter() + .zip(b.iter()) + .map(|(x, y)| 2.0 * x + 5.0 * y) + .collect(); + + let mut la = vec![0.0f32; 12]; + let mut lb = vec![0.0f32; 12]; + let mut lm = vec![0.0f32; 12]; + auto.autocorrelate(&a, &mut la); + auto.autocorrelate(&b, &mut lb); + auto.autocorrelate(&mixed, &mut lm); + + for l in 0..12 { + let want = 2.0 * la[l] + 5.0 * lb[l]; + assert!((lm[l] - want).abs() < 1e-5, "lag {l}: {} vs {want}", lm[l]); + } + } + + #[test] + #[should_panic(expected = "expects 33 bins")] + fn test_wrong_bin_count_is_rejected() { + let auto = Autocorrelator::new(FFT); + let mut lags = [0.0f32; 4]; + auto.autocorrelate(&[0.0; 8], &mut lags); + } + + /// Autocorrelation of an AR(1) process, normalized to `r[0] == 1`. + fn ar1_lags( + a: f32, + order: usize, + ) -> Vec { + (0..=order).map(|k| a.powi(k as i32)).collect() + } + + #[test] + fn test_recovers_an_ar1_process() { + // `x[n] = a*x[n-1] + e[n]` has `r[k] = a^k`, and the whitening filter + // is `x[n] - a*x[n-1]`, so the coefficients are `[-a, 0, 0, ...]`. + for a in [0.3f32, -0.5, 0.9] { + let ac = ar1_lags(a, 8); + let mut lpc = [0.0f32; 8]; + levinson_durbin(&ac, &mut lpc, None); + + assert!( + (lpc[0] + a).abs() < 1e-5, + "a = {a}: expected lpc[0] = {}, got {}", + -a, + lpc[0], + ); + for (j, &c) in lpc.iter().enumerate().skip(1) { + assert!(c.abs() < 1e-5, "a = {a}: lpc[{j}] should vanish, got {c}"); + } + } + } + + #[test] + fn test_recovers_an_ar2_process() { + // Yule-Walker in reverse: build the lags an AR(2) process would have, + // then check the recursion inverts back to its coefficients. + let (a1, a2) = (0.6f64, -0.25f64); + let order = 6; + + let mut r = vec![0.0f64; order + 1]; + r[0] = 1.0; + r[1] = a1 / (1.0 - a2); + for k in 2..=order { + r[k] = a1 * r[k - 1] + a2 * r[k - 2]; + } + let ac: Vec = r.iter().map(|v| *v as f32).collect(); + + let mut lpc = [0.0f32; 6]; + levinson_durbin(&ac, &mut lpc, None); + + assert!((lpc[0] + a1 as f32).abs() < 1e-4, "lpc[0] = {}", lpc[0]); + assert!((lpc[1] + a2 as f32).abs() < 1e-4, "lpc[1] = {}", lpc[1]); + for (j, &c) in lpc.iter().enumerate().skip(2) { + assert!(c.abs() < 1e-4, "lpc[{j}] should vanish, got {c}"); + } + } + + #[test] + fn test_is_scale_invariant() { + // Mathematically exact, but not bit-exact in f32: scaling the input + // rounds it, and the recursion then rounds differently. The tolerance + // is against the solution's peak, since the trailing coefficients are + // legitimately near zero and have no meaningful relative error. + let ac = ar1_lags(0.7, 8); + let mut base = [0.0f32; 8]; + levinson_durbin(&ac, &mut base, Some(0.001)); + + let peak = base.iter().fold(0.0f32, |m, v| m.max(v.abs())); + for scale in [1e-4f32, 3.0, 5e5] { + let scaled: Vec = ac.iter().map(|v| v * scale).collect(); + let mut got = [0.0f32; 8]; + levinson_durbin(&scaled, &mut got, Some(0.001)); + + for (j, (&b, &g)) in base.iter().zip(got.iter()).enumerate() { + assert!( + (b - g).abs() < 1e-5 * peak, + "scale {scale}, lpc[{j}]: {b} vs {g}", + ); + } + } + } + + #[test] + fn test_zero_lag_zero_yields_zero_coefficients() { + let mut lpc = [1.0f32; 4]; + levinson_durbin(&[0.0, 0.0, 0.0, 0.0, 0.0], &mut lpc, None); + assert_eq!(lpc, [0.0; 4]); + } + + #[test] + fn test_bail_out_leaves_zeros_not_partial_values() { + // The property a batched port depends on: a frozen tail is the zero + // initializer, so masking is enough and no separate fill is needed. + // + // An AR(1) at a = 0.9 whitens completely at step 1, so a generous + // threshold trips immediately. + let ac = ar1_lags(0.9, 8); + let mut lpc = [0.0f32; 8]; + levinson_durbin(&ac, &mut lpc, Some(0.5)); + + assert!((lpc[0] + 0.9).abs() < 1e-5, "step 0 must still commit"); + for (j, &c) in lpc.iter().enumerate().skip(1) { + assert_eq!(c, 0.0, "lpc[{j}] should be exactly zero after the bail"); + } + } + + #[test] + fn test_bail_out_only_truncates() { + // Whatever the threshold, the coefficients that *were* computed match + // the un-bailed run. The bail changes where it stops, nothing else. + let ac = ar1_lags(0.45, 10); + + let mut full = [0.0f32; 10]; + levinson_durbin(&ac, &mut full, None); + + let mut bailed = [0.0f32; 10]; + levinson_durbin(&ac, &mut bailed, Some(0.001)); + + let live = bailed.iter().rposition(|v| *v != 0.0).map_or(0, |i| i + 1); + assert!(live >= 1, "expected at least one coefficient"); + for j in 0..live { + assert!( + (full[j] - bailed[j]).abs() < 1e-5, + "lpc[{j}]: full {} vs bailed {}", + full[j], + bailed[j], + ); + } + } + + #[test] + #[should_panic(expected = "writes 4 coefficients")] + fn test_mismatched_output_length_is_rejected() { + let mut lpc = [0.0f32; 3]; + levinson_durbin(&[1.0, 0.5, 0.2, 0.1, 0.05], &mut lpc, None); + } + + #[test] + #[should_panic(expected = "at least lags 0 and 1")] + fn test_degenerate_input_is_rejected() { + let mut lpc: [f32; 0] = []; + levinson_durbin(&[1.0], &mut lpc, None); + } +} diff --git a/crates/bunsen/src/ops/signal/mod.rs b/crates/bunsen/src/ops/signal/mod.rs index 5d4877d3..f94c40f9 100644 --- a/crates/bunsen/src/ops/signal/mod.rs +++ b/crates/bunsen/src/ops/signal/mod.rs @@ -2,6 +2,7 @@ mod biquad; mod cosine_window; +mod lpc; mod sliding_stft; mod stft_window; mod window_builder; @@ -11,6 +12,8 @@ pub use biquad::*; #[doc(inline)] pub use cosine_window::*; #[doc(inline)] +pub use lpc::*; +#[doc(inline)] pub use sliding_stft::*; #[doc(inline)] pub use stft_window::*; From f167aada1d80f57c4f6bb59dfddd2afbb5b74681 Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Mon, 24 Aug 2026 11:58:54 -0700 Subject: [PATCH 17/32] refactor(signal): lift the decimating FIR out of ten_vad Third extraction, and the one most worth having: `ops::signal::DecimatingFir` is a general answer to "run an IIR cascade and a downsample on a device". It filters and decimates in one contraction, folding the decimation into the kernel so the discarded samples are never computed. Nothing about it is voice-activity detection. The impulse response is an argument, not a constant -- a caller supplies one from `BiquadCascade::to_vec_impulse_response`, a windowed-sinc design, or a file -- and the decimation factor is config rather than a hardcoded 4. ten_vad's `PitchAntiAliasConfig::TruncatedFir` now holds one of these and delegates. The reasoning that made this worth doing generalizes too, so it moved with the code: a truncated FIR is a better-conditioned realization of the same LTI system rather than an approximation of it, which is why it measured 25-50% *more* accurate than the f32 Direct-Form-II cascade it replaced; and why neither `conv1d` nor a log-depth scan is the right shape here. Its tests no longer mention biquads at all. The anchor is now `test_matches_direct_convolution`: an arbitrary response against `y[m] = sum(h[k] * u[decim*m - k])` computed on the host. That tests the definition rather than agreement with one filter. Around it, a unit kernel must select exactly every Nth sample -- which catches a phase error immediately, and was previously only implied -- decimation of 1 must be plain convolution, and the carry must make block boundaries unobservable. `test_batch_rows_are_independent` doubles as the regression for burn's `unfold` row-stride bug, now at general geometry rather than only ten-vad's. ten_vad keeps its own version: that one guards the consumer at the real geometry, which is a different job from guarding the op. Golden unchanged again -- mean |diff| 5.649e-5, worst 6.446e-4 at hop 2973, decisions agreeing on all 3750 frames -- so the delegation preserved behavior exactly. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../ten_vad/context/pitch/tensor/antialias.rs | 116 +--- .../bunsen/src/ops/signal/decimating_fir.rs | 558 ++++++++++++++++++ crates/bunsen/src/ops/signal/mod.rs | 3 + 3 files changed, 581 insertions(+), 96 deletions(-) create mode 100644 crates/bunsen/src/ops/signal/decimating_fir.rs diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/antialias.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/antialias.rs index d59cf531..19790515 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/antialias.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/antialias.rs @@ -87,6 +87,8 @@ use crate::{ ops::signal::{ BiquadCascade, BiquadSection, + DecimatingFir, + DecimatingFirConfig, }, }; @@ -184,11 +186,7 @@ impl PitchAntiAliasConfig { &self, taps: usize, ) -> Vec { - let mut cascade = exact_cascade(); - let mut buf = vec![0.0f32; taps]; - buf[0] = 1.0; - cascade.process_in_place(&mut buf); - buf + exact_cascade().to_vec_impulse_response(taps) } /// The `[window_len, hop_size / 4]` decimating Toeplitz matrix, row-major. @@ -202,21 +200,9 @@ impl PitchAntiAliasConfig { let Self::TruncatedFir { taps } = self else { return Vec::new(); }; - let response = self.to_vec_impulse_response(*taps); - let carry = taps - 1; - let rows = self.window_len(hop_size); - let cols = hop_size / PROC_RESAMPLE_RATE; - - let mut out = vec![0.0f32; rows * cols]; - for m in 0..cols { - let head = PROC_RESAMPLE_RATE * m + carry; - for j in 0..rows { - if head >= j && head - j < *taps { - out[j * cols + m] = response[head - j]; - } - } - } - out + DecimatingFirConfig::new(hop_size, *taps) + .with_decimation(PROC_RESAMPLE_RATE) + .to_vec_toeplitz(&self.to_vec_impulse_response(*taps)) } /// Builds the filter. @@ -232,18 +218,12 @@ impl PitchAntiAliasConfig { self.validate(hop_size)?; Ok(match self { Self::Recurrence => PitchAntiAlias::Recurrence { hop_size }, - Self::TruncatedFir { taps } => { - let rows = self.window_len(hop_size); - let cols = hop_size / PROC_RESAMPLE_RATE; - PitchAntiAlias::TruncatedFir { - hop_size, - taps: *taps, - toeplitz: Tensor::from_data( - TensorData::new(self.to_vec_toeplitz(hop_size), [rows, cols]), - device, - ), - } - } + Self::TruncatedFir { taps } => PitchAntiAlias::TruncatedFir { + hop_size, + fir: DecimatingFirConfig::new(hop_size, *taps) + .with_decimation(PROC_RESAMPLE_RATE) + .try_init(&self.to_vec_impulse_response(*taps), device)?, + }, }) } @@ -271,14 +251,13 @@ pub enum PitchAntiAlias { hop_size: usize, }, - /// The truncated-FIR GEMM. + /// The truncated-FIR GEMM, as the general decimating filter. TruncatedFir { /// The hop size, in samples at 16 kHz. hop_size: usize, - /// The impulse-response length. - taps: usize, - /// `[window_len, hop_size / 4]` decimating kernel. - toeplitz: Tensor, + /// The filter itself; the reference cascade's impulse response, + /// decimated by four. + fir: DecimatingFir, }, } @@ -319,8 +298,8 @@ impl PitchAntiAlias { Self::Recurrence { .. } => PitchAntiAliasState::Recurrence { registers: Tensor::zeros([batch_size, ANTI_ALIAS_SECTIONS, 2], device), }, - Self::TruncatedFir { taps, .. } => PitchAntiAliasState::TruncatedFir { - history: Tensor::zeros([batch_size, taps - 1], device), + Self::TruncatedFir { fir, .. } => PitchAntiAliasState::TruncatedFir { + history: fir.init_history(batch_size, device), }, } } @@ -361,16 +340,8 @@ impl PitchAntiAlias { PitchAntiAliasState::Recurrence { registers }, ) } - ( - Self::TruncatedFir { - hop_size, - taps, - toeplitz, - }, - PitchAntiAliasState::TruncatedFir { history }, - ) => { - let (out, history) = - Self::run_fir(input, history, toeplitz.clone(), *hop_size, *taps); + (Self::TruncatedFir { fir, .. }, PitchAntiAliasState::TruncatedFir { history }) => { + let (out, history) = fir.forward(input, history); (out, PitchAntiAliasState::TruncatedFir { history }) } _ => panic!("PitchAntiAlias state does not match its formulation"), @@ -431,53 +402,6 @@ impl PitchAntiAlias { (signal, Tensor::cat(next_registers, 1)) } - - /// The truncated response, contracted against windowed input. - fn run_fir( - input: Tensor, - history: Tensor, - toeplitz: Tensor, - hop_size: usize, - taps: usize, - ) -> (Tensor, Tensor) { - let [batch, len] = input.dims(); - let steps = len / hop_size; - let carry = taps - 1; - let window = hop_size + taps - PROC_RESAMPLE_RATE; - let per_hop = hop_size / PROC_RESAMPLE_RATE; - - // The carried history in front, so window `s` starts `carry` samples - // before hop `s`. - let extended = Tensor::cat(vec![history, input], 1); - - // `unfold` derives its batch-row stride from the span the windows - // cover, `(steps - 1) * hop + window`, rather than from the row's real - // length. When a row is longer than that span — which it is here, by - // `carry - (window - hop)` samples — every row after the first is - // placed that many samples early, silently. Trimming to the covered - // span first makes the two agree. The trimmed tail is not lost: it is - // still part of the carry below. - // - // `burner::tensor::burn_behavior` pins the upstream behaviour; if a - // future burn fixes the stride, that test fails loudly rather than - // leaving dead defensive code here. - let covered = (steps - 1) * hop_size + window; - - // [batch, steps, window] - let windows = extended - .clone() - .slice_dim(1, 0..covered as isize) - .unfold::<3, _>(1, window, hop_size); - - let out = windows - .reshape([batch * steps, window]) - .matmul(toeplitz) - .reshape([batch, steps * per_hop]); - - let next_history = extended.slice_dim(1, -(carry as isize)..); - - (out, next_history) - } } #[cfg(test)] diff --git a/crates/bunsen/src/ops/signal/decimating_fir.rs b/crates/bunsen/src/ops/signal/decimating_fir.rs new file mode 100644 index 00000000..24debaf6 --- /dev/null +++ b/crates/bunsen/src/ops/signal/decimating_fir.rs @@ -0,0 +1,558 @@ +//! # Decimating FIR filtering, as one GEMM. +//! +//! Filters and downsamples in a single contraction: +//! +//! ```text +//! y[m] = sum(h[k] * u[decimation * m - k] for k in 0..taps) +//! ``` +//! +//! The decimation is folded into the kernel rather than applied afterwards, so +//! the discarded samples are never computed. Blocks are processed with a +//! carried history, so a stream may be filtered in hop-sized pieces and get the +//! same answer as filtering it whole. +//! +//! ## Why this shape, and not the obvious ones +//! +//! **Not an IIR recurrence.** When the filter you actually want is an IIR +//! cascade, realizing it as a truncated impulse response is usually the better +//! move on a device — see [`BiquadCascade`](super::BiquadCascade), whose +//! [`to_vec_impulse_response`](super::BiquadCascade::to_vec_impulse_response) +//! feeds straight into [`DecimatingFirConfig::try_init`]. A cascade is a +//! sample-rate recurrence, so it is sequential in the sample axis; the FIR form +//! is one parallel contraction. +//! +//! That is not a speed-for-accuracy trade, which is the surprising part. A +//! truncated FIR is a *better-conditioned realization of the same LTI system*, +//! not an approximation of it: measured against an `f64` ground truth, an FIR +//! at 2048 taps was 25-50% **more** accurate than the `f32` Direct-Form-II +//! cascade it replaced, while truncation contributed three orders of magnitude +//! less error than the thing it replaced. Pick `taps` from the impulse +//! response's L1 tail, not from its peak decay. +//! +//! **Not a long `conv1d`.** `burn-flex`'s depthwise convolution path fires when +//! `channels == groups == 1` and parallelizes over `batch * channels`, which is +//! then a single task — a long convolution would run single-threaded. On +//! cubecl, `groups != 1` disables every GEMM kernel outright. Reshaping the +//! windows and contracting against a constant matrix hits a threaded, +//! vectorized GEMM instead. +//! +//! ## Cost +//! +//! The Toeplitz matrix is `[window_len, hop_size / decimation]` and constant. +//! The transient is the materialized window stack, `batch * steps * window_len` +//! floats, which `matmul` makes contiguous — so chunk long inputs rather than +//! handing over an entire stream at once. Kernel selection is keyed on shape, +//! so a fixed chunk size is also tuned once instead of once per input length. + +use burn::{ + config::Config, + prelude::*, +}; + +use crate::errors::{ + BunsenError, + BunsenResult, + WithOkOrPanic, +}; + +/// Config for [`DecimatingFir`]. +/// +/// Describes the geometry only; the impulse response itself is supplied to +/// [`try_init`](Self::try_init), since it is data rather than configuration. +#[derive(Config, Debug, Copy)] +pub struct DecimatingFirConfig { + /// Samples of input consumed per call, per batch row. + pub hop_size: usize, + + /// Length of the impulse response. + pub taps: usize, + + /// Downsampling factor; `1` filters without decimating. + #[config(default = "1")] + pub decimation: usize, +} + +impl DecimatingFirConfig { + /// Validates the geometry. + /// + /// # Errors + /// [`BunsenError::Invalid`] if the hop does not decimate evenly, if the + /// decimation is zero, or if the response is too short to be meaningful + /// against it. + pub fn validate(&self) -> BunsenResult<()> { + if self.decimation == 0 { + return Err(BunsenError::Invalid( + "DecimatingFir decimation must be non-zero".to_string(), + )); + } + if self.hop_size == 0 || !self.hop_size.is_multiple_of(self.decimation) { + return Err(BunsenError::Invalid(format!( + "DecimatingFir hop_size ({}) must be a non-zero multiple of the \ + decimation ({})", + self.hop_size, self.decimation, + ))); + } + if self.taps <= self.decimation { + return Err(BunsenError::Invalid(format!( + "DecimatingFir taps ({}) must exceed the decimation ({})", + self.taps, self.decimation, + ))); + } + Ok(()) + } + + /// Outputs produced per hop. + pub fn out_per_hop(&self) -> usize { + self.hop_size / self.decimation + } + + /// Input samples carried between calls. + /// + /// This is `taps - 1`, **not** `window_len - hop_size`. Those differ by + /// `decimation - 1`, and using the latter would both drop the oldest taps + /// and desync the carry from the kernel's offset. Both quantities are + /// derived from `taps` here so they cannot drift apart. + pub fn carry_len(&self) -> usize { + self.taps - 1 + } + + /// The window length one hop's outputs are contracted from. + /// + /// Output `m` of a hop reads `u[decimation * m - k]` for `k < taps`, so the + /// window spans from `taps - 1` samples before the hop to + /// `decimation * (out_per_hop - 1) + 1` after its start. + pub fn window_len(&self) -> usize { + self.hop_size + self.taps - self.decimation + } + + /// The `[window_len, out_per_hop]` decimating Toeplitz matrix, row-major. + /// + /// Column `m` is the impulse response positioned so the contraction lands + /// output `m` at input phase `decimation * m`. + /// + /// # Arguments + /// * `response`: the impulse response, [`taps`](Self::taps) long. + /// + /// # Panics + /// If `response` is not `taps` long. + pub fn to_vec_toeplitz( + &self, + response: &[f32], + ) -> Vec { + assert_eq!( + response.len(), + self.taps, + "DecimatingFir expects a {}-tap response", + self.taps, + ); + + let carry = self.carry_len(); + let rows = self.window_len(); + let cols = self.out_per_hop(); + + let mut out = vec![0.0f32; rows * cols]; + for m in 0..cols { + let head = self.decimation * m + carry; + for j in 0..rows { + if head >= j && head - j < self.taps { + out[j * cols + m] = response[head - j]; + } + } + } + out + } + + /// Builds the filter around an impulse response. + /// + /// # Errors + /// See [`validate`](Self::validate); also + /// [`BunsenError::Invalid`] if `response` is not [`taps`](Self::taps) long. + pub fn try_init( + &self, + response: &[f32], + device: &B::Device, + ) -> BunsenResult> { + self.validate()?; + if response.len() != self.taps { + return Err(BunsenError::Invalid(format!( + "DecimatingFir response has {} taps, config declares {}", + response.len(), + self.taps, + ))); + } + + let toeplitz = Tensor::from_data( + TensorData::new( + self.to_vec_toeplitz(response), + [self.window_len(), self.out_per_hop()], + ), + device, + ); + + Ok(DecimatingFir { + cfg: *self, + toeplitz, + }) + } + + /// Builds the filter, panicking on error. + pub fn init( + &self, + response: &[f32], + device: &B::Device, + ) -> DecimatingFir { + self.try_init(response, device).ok_or_panic() + } +} + +/// A decimating FIR filter as a constant Toeplitz matrix. +/// +/// Stateless; the carried history is passed through +/// [`forward`](Self::forward). Built by [`DecimatingFirConfig::try_init`]. +#[derive(Debug, Clone)] +pub struct DecimatingFir { + cfg: DecimatingFirConfig, + + /// `[window_len, out_per_hop]` decimating kernel. + pub toeplitz: Tensor, +} + +impl DecimatingFir { + /// The geometry this filter was built for. + pub fn config(&self) -> &DecimatingFirConfig { + &self.cfg + } + + /// A zeroed `[batch, carry_len]` start-of-stream history. + pub fn init_history( + &self, + batch_size: usize, + device: &B::Device, + ) -> Tensor { + Tensor::zeros([batch_size, self.cfg.carry_len()], device) + } + + /// Filters and decimates a run of hops. + /// + /// # Arguments + /// * `input`: `[batch, steps * hop_size]` samples. + /// * `history`: `[batch, carry_len]` from the previous call, or + /// [`init_history`](Self::init_history). + /// + /// # Returns + /// `([batch, steps * out_per_hop]` outputs, the next history`)`. + pub fn forward( + &self, + input: Tensor, + history: Tensor, + ) -> (Tensor, Tensor) { + let [batch, len] = input.dims(); + let hop = self.cfg.hop_size; + + #[cfg(any(test, debug_assertions))] + { + assert_eq!( + len % hop, + 0, + "DecimatingFir input must be a whole number of hops", + ); + crate::contracts::assert_shape_contract!( + ["batch", "carry"], + &history, + &[("batch", batch), ("carry", self.cfg.carry_len())], + ); + } + + let steps = len / hop; + let window = self.cfg.window_len(); + + // The carried history in front, so window `s` starts `carry_len` + // samples before hop `s`. + let extended = Tensor::cat(vec![history, input], 1); + + // `unfold` derives its batch-row stride from the span the windows + // cover, `(steps - 1) * hop + window`, rather than from the row's real + // length. When a row is longer than that span -- which it is here, by + // `carry_len - (window - hop)` samples -- every row after the first is + // placed that many samples early, silently. Trimming to the covered + // span first makes the two agree; the trimmed tail is not lost, since + // it is still part of the carry below. + // + // `burner::tensor::burn_behavior` pins the upstream behavior; if a + // future burn fixes the stride, that test fails loudly rather than + // leaving dead defensive code here. + let covered = (steps - 1) * hop + window; + + // [batch, steps, window] + let windows = extended + .clone() + .slice_dim(1, 0..covered as isize) + .unfold::<3, _>(1, window, hop); + + let out = windows + .reshape([batch * steps, window]) + .matmul(self.toeplitz.clone()) + .reshape([batch, steps * self.cfg.out_per_hop()]); + + let next_history = extended.slice_dim(1, -(self.cfg.carry_len() as isize)..); + + (out, next_history) + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::Tolerance; + + use super::*; + use crate::{ + prelude::*, + support::testing::PerformanceBackend, + }; + + type B = PerformanceBackend; + + const HOP: usize = 32; + const TAPS: usize = 12; + const DECIM: usize = 4; + + fn cfg() -> DecimatingFirConfig { + DecimatingFirConfig::new(HOP, TAPS).with_decimation(DECIM) + } + + /// A deterministic, non-symmetric impulse response. + fn response(taps: usize) -> Vec { + (0..taps) + .map(|k| { + let t = k as f32; + (-(t) / 5.0).exp() * (0.7 + (t * 0.9).sin()) + }) + .collect() + } + + fn signal(len: usize) -> Vec { + (0..len) + .map(|n| { + let t = n as f32; + (t * 0.31).sin() + 0.4 * (t * 1.17).cos() + }) + .collect() + } + + /// `y[m] = sum(h[k] * u[decim * m - k])`, computed directly on the host. + /// + /// The definition the GEMM has to reproduce. Indices before the start of + /// the stream read zero, matching a zeroed initial history. + fn direct( + u: &[f32], + h: &[f32], + decimation: usize, + ) -> Vec { + let outs = u.len() / decimation; + (0..outs) + .map(|m| { + let head = decimation * m; + h.iter() + .enumerate() + .filter(|(k, _)| *k <= head) + .map(|(k, hk)| hk * u[head - k]) + .sum() + }) + .collect() + } + + fn to_vec(t: Tensor) -> Vec { + t.to_data_as::().to_vec_as::().ok_or_panic() + } + + #[test] + fn test_config_meta() { + let c = cfg(); + assert_eq!(c.out_per_hop(), HOP / DECIM); + assert_eq!(c.carry_len(), TAPS - 1); + assert_eq!(c.window_len(), HOP + TAPS - DECIM); + c.validate().unwrap(); + + // Decimation of 1 is a plain convolution, and still valid. + DecimatingFirConfig::new(HOP, TAPS).validate().unwrap(); + } + + #[test] + fn test_validate_rejects_bad_geometry() { + for bad in [ + cfg().with_decimation(0), + DecimatingFirConfig::new(0, TAPS).with_decimation(DECIM), + // 30 is not a multiple of 4. + DecimatingFirConfig::new(30, TAPS).with_decimation(DECIM), + // A response no longer than the decimation cannot span a phase. + DecimatingFirConfig::new(HOP, DECIM).with_decimation(DECIM), + ] { + assert!( + matches!(bad.validate(), Err(BunsenError::Invalid(_))), + "expected Invalid: {bad:?}", + ); + } + } + + #[test] + fn test_init_rejects_a_mismatched_response() { + let device = Default::default(); + assert!(matches!( + cfg().try_init::(&response(TAPS + 1), &device), + Err(BunsenError::Invalid(_)), + )); + } + + #[test] + fn test_matches_direct_convolution() { + // The anchor: the GEMM must equal the definition, for an arbitrary + // response. Nothing here refers to any particular filter. + let device = Default::default(); + let steps = 3; + let h = response(TAPS); + let u = signal(steps * HOP); + + let fir = cfg().init::(&h, &device); + let input = + Tensor::::from_data(TensorData::new(u.clone(), [1, steps * HOP]), &device); + let (out, _) = fir.forward(input, fir.init_history(1, &device)); + + let got = to_vec(out); + let want = direct(&u, &h, DECIM); + assert_eq!(got.len(), want.len()); + + let peak = want.iter().fold(0.0f32, |m, v| m.max(v.abs())); + for (m, (&g, &w)) in got.iter().zip(want.iter()).enumerate() { + assert!((g - w).abs() < 1e-5 * peak, "output {m}: got {g}, want {w}",); + } + } + + #[test] + fn test_decimation_of_one_is_plain_convolution() { + let device = Default::default(); + let h = response(TAPS); + let u = signal(2 * HOP); + + let c = DecimatingFirConfig::new(HOP, TAPS); + let fir = c.init::(&h, &device); + let input = Tensor::::from_data(TensorData::new(u.clone(), [1, 2 * HOP]), &device); + let (out, _) = fir.forward(input, fir.init_history(1, &device)); + + let got = to_vec(out); + let want = direct(&u, &h, 1); + assert_eq!(got.len(), 2 * HOP); + + let peak = want.iter().fold(0.0f32, |m, v| m.max(v.abs())); + for (m, (&g, &w)) in got.iter().zip(want.iter()).enumerate() { + assert!((g - w).abs() < 1e-5 * peak, "output {m}: {g} vs {w}"); + } + } + + #[test] + fn test_unit_kernel_selects_every_nth_sample() { + // With `h = [1, 0, 0, ...]` the filter is the identity, so the output + // is exactly the decimated input. A phase error shows up immediately. + let device = Default::default(); + let mut h = vec![0.0f32; TAPS]; + h[0] = 1.0; + + let u = signal(2 * HOP); + let fir = cfg().init::(&h, &device); + let input = Tensor::::from_data(TensorData::new(u.clone(), [1, 2 * HOP]), &device); + let (out, _) = fir.forward(input, fir.init_history(1, &device)); + + let got = to_vec(out); + let want: Vec = u.iter().step_by(DECIM).copied().collect(); + assert_eq!(got.len(), want.len()); + for (m, (&g, &w)) in got.iter().zip(want.iter()).enumerate() { + assert!((g - w).abs() < 1e-6, "output {m}: {g} vs {w}"); + } + } + + #[test] + fn test_streaming_matches_a_single_call() { + // The property that makes the carry correct: block boundaries must not + // be observable. + let device = Default::default(); + let steps = 4; + let h = response(TAPS); + let u = signal(steps * HOP); + let fir = cfg().init::(&h, &device); + + let whole = + Tensor::::from_data(TensorData::new(u.clone(), [1, steps * HOP]), &device); + let (out_whole, _) = fir.forward(whole, fir.init_history(1, &device)); + + let mut history = fir.init_history(1, &device); + let mut pieces = Vec::new(); + for chunk in u.chunks(HOP) { + let t = Tensor::::from_data(TensorData::new(chunk.to_vec(), [1, HOP]), &device); + let (y, next) = fir.forward(t, history); + history = next; + pieces.push(y); + } + let out_split = Tensor::cat(pieces, 1); + + out_whole + .to_data_as::() + .assert_approx_eq::(&out_split.to_data_as::(), Tolerance::permissive()); + } + + #[test] + fn test_batch_rows_are_independent() { + // Also the regression for burn's `unfold` row-stride bug: before the + // trim, row 0 was exact while every later row was displaced. The filter + // is linear, so a scaled row must give a scaled result. + let device = Default::default(); + let steps = 3; + let h = response(TAPS); + let a = signal(steps * HOP); + let b: Vec = a.iter().map(|v| -0.5 * v).collect(); + + let mut flat = a.clone(); + flat.extend_from_slice(&b); + let input = Tensor::::from_data(TensorData::new(flat, [2, steps * HOP]), &device); + + let fir = cfg().init::(&h, &device); + let (out, _) = fir.forward(input, fir.init_history(2, &device)); + let got = to_vec(out); + + let per = got.len() / 2; + let peak = got[..per].iter().fold(0.0f32, |m, v| m.max(v.abs())); + for i in 0..per { + let want = -0.5 * got[i]; + assert!( + (got[per + i] - want).abs() < 1e-5 * peak, + "row 1 sample {i}: {} vs {want}", + got[per + i], + ); + } + } + + #[test] + fn test_history_carries_the_last_taps_minus_one_samples() { + let device = Default::default(); + let h = response(TAPS); + let u = signal(HOP); + let fir = cfg().init::(&h, &device); + + let input = Tensor::::from_data(TensorData::new(u.clone(), [1, HOP]), &device); + let (_, history) = fir.forward(input, fir.init_history(1, &device)); + + assert_eq!(history.dims(), [1, TAPS - 1]); + let got = to_vec(history); + let want = &u[HOP - (TAPS - 1)..]; + for (i, (&g, &w)) in got.iter().zip(want.iter()).enumerate() { + assert!((g - w).abs() < 1e-6, "carry {i}: {g} vs {w}"); + } + } + + #[test] + #[should_panic(expected = "whole number of hops")] + fn test_partial_hop_is_rejected() { + let device = Default::default(); + let fir = cfg().init::(&response(TAPS), &device); + let input = Tensor::::zeros([1, HOP + 1], &device); + fir.forward(input, fir.init_history(1, &device)); + } +} diff --git a/crates/bunsen/src/ops/signal/mod.rs b/crates/bunsen/src/ops/signal/mod.rs index f94c40f9..5761c5d1 100644 --- a/crates/bunsen/src/ops/signal/mod.rs +++ b/crates/bunsen/src/ops/signal/mod.rs @@ -2,6 +2,7 @@ mod biquad; mod cosine_window; +mod decimating_fir; mod lpc; mod sliding_stft; mod stft_window; @@ -12,6 +13,8 @@ pub use biquad::*; #[doc(inline)] pub use cosine_window::*; #[doc(inline)] +pub use decimating_fir::*; +#[doc(inline)] pub use lpc::*; #[doc(inline)] pub use sliding_stft::*; From fcef659382f943381384db16a7d2bca3e78efc9c Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Mon, 24 Aug 2026 12:11:46 -0700 Subject: [PATCH 18/32] feat(seq): add a batched Viterbi decoder New module `ops::seq`, for algorithms that find or score a path through a sequence, as opposed to the per-sample filtering in `ops::signal`. Viterbi is the first occupant; CTC decode and DTW belong here too. Unlike the three preceding commits this is a **rewrite, not a lift**, and the distinction is worth stating. ten_vad's period tracker was the motivation and the source of the technique, but it is not a general Viterbi: its backtrace is fused with a weighted least-squares fit over recovered pitch periods, it steps two half-hop slots per hop, and it carries a fallback that reflects a quirk in its own reference rather than anything a decoder generally needs. Extracting that shape would have exported the quirks. So the general op is written from the recursion, and the pitch tracker keeps its own until the estimator is rebuilt reference-free -- at which point it can adopt this and drop them. What did carry across is the part worth having. The forward pass is genuinely sequential, but **the backtrace is not**: at lookback `k` every timestep reads the same relative position in the backpointer history, so one slice serves them all and the whole backtrace costs `depth` batched gathers instead of `steps * depth` sequential hops. Over a few thousand steps that is the difference between the backtrace dominating and the backtrace being free. Two design decisions are documented rather than implied. Scores are renormalized to peak zero every step, because path scores otherwise grow without bound and f32 loses the differences that decide the path. And forbidden transitions take a large finite negative (`FORBIDDEN`) rather than `-inf`, because `-inf` propagates and then produces NaN under exactly that renormalizing subtraction. Anchored against a scalar Viterbi written out from the recursion, on both deterministic and random emissions -- fixtures alone could flatter a shared misreading of the recursion; random data cannot. Around that: the peak really is zero, streaming matches a single call for scores *and* backpointers, batch rows are independent, forbidden edges are never taken, and the batched backtrace agrees with following one pointer at a time. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- crates/bunsen/src/ops/mod.rs | 1 + crates/bunsen/src/ops/seq/mod.rs | 13 + crates/bunsen/src/ops/seq/viterbi.rs | 647 +++++++++++++++++++++++++++ 3 files changed, 661 insertions(+) create mode 100644 crates/bunsen/src/ops/seq/mod.rs create mode 100644 crates/bunsen/src/ops/seq/viterbi.rs diff --git a/crates/bunsen/src/ops/mod.rs b/crates/bunsen/src/ops/mod.rs index b86b31e3..b942c0bd 100644 --- a/crates/bunsen/src/ops/mod.rs +++ b/crates/bunsen/src/ops/mod.rs @@ -72,4 +72,5 @@ pub mod embedding; pub mod noise; pub mod norm; pub mod repeat; +pub mod seq; pub mod signal; diff --git a/crates/bunsen/src/ops/seq/mod.rs b/crates/bunsen/src/ops/seq/mod.rs new file mode 100644 index 00000000..8225ddc6 --- /dev/null +++ b/crates/bunsen/src/ops/seq/mod.rs @@ -0,0 +1,13 @@ +//! # Sequence and trellis operations. +//! +//! Algorithms that find or score a path through a sequence, as opposed to the +//! per-sample filtering in [`signal`](super::signal). +//! +//! * [`Viterbi`] — batched maximum-scoring path through a trellis, streaming +//! across calls, with a backtrace that costs `depth` gathers rather than +//! `steps * depth`. + +mod viterbi; + +#[doc(inline)] +pub use viterbi::*; diff --git a/crates/bunsen/src/ops/seq/viterbi.rs b/crates/bunsen/src/ops/seq/viterbi.rs new file mode 100644 index 00000000..50f877ea --- /dev/null +++ b/crates/bunsen/src/ops/seq/viterbi.rs @@ -0,0 +1,647 @@ +//! # Batched Viterbi decoding on device. +//! +//! Finds the highest-scoring path through a trellis: +//! +//! ```text +//! score[t][s] = emission[t][s] + max(score[t-1][c] + transition[c][s] for c) +//! ``` +//! +//! Batched over independent sequences, streaming across calls, and with a +//! backtrace that does **not** walk step by step. +//! +//! ## The forward pass is sequential; the backtrace is not +//! +//! The recursion genuinely depends on its own previous step, so the forward +//! pass is one dependent iteration per timestep. That is irreducible. +//! +//! The backtrace is not. Walking back from every timestep independently looks +//! like `steps * depth` scalar hops, but at lookback `k` every timestep reads +//! the same *relative* position in the backpointer history — so one strided +//! slice serves all of them, and the whole backtrace is `depth` batched +//! gathers rather than `steps * depth` sequential ones. For a run of a few +//! thousand steps that is the difference between the backtrace dominating and +//! the backtrace being free. +//! +//! ## Renormalization +//! +//! Path scores grow without bound over a long stream, so +//! [`ViterbiState::score`] is kept with its peak at zero: each step subtracts +//! the running maximum. That changes nothing about which path wins — the same +//! constant is subtracted from every state — and it keeps `f32` from losing +//! the differences that matter to accumulated magnitude. +//! +//! ## Forbidden transitions +//! +//! Use a **large finite negative**, not `-inf`. A finite sentinel keeps the +//! `max` well-defined and lets a state with no reachable predecessor still +//! carry a comparable score, where `-inf` propagates and eventually produces +//! `NaN` under the renormalizing subtraction. [`FORBIDDEN`] is a suitable +//! value; anything that cannot be overcome by accumulated emissions works. + +use burn::{ + config::Config, + prelude::*, +}; + +use crate::{ + errors::{ + BunsenError, + BunsenResult, + WithOkOrPanic, + }, + prelude::TensorOpExt, +}; + +/// A transition score meaning "this transition may not be taken". +/// +/// Large enough that no plausible accumulation of emission scores overcomes +/// it, finite so that `max` and the renormalizing subtraction stay defined. +pub const FORBIDDEN: f32 = -1e30; + +/// Config for [`Viterbi`]. +#[derive(Config, Debug, Copy)] +pub struct ViterbiConfig { + /// The number of trellis states. + pub n_states: usize, +} + +impl ViterbiConfig { + /// Validates the geometry. + /// + /// # Errors + /// [`BunsenError::Invalid`] if the state count is zero. + pub fn validate(&self) -> BunsenResult<()> { + if self.n_states == 0 { + return Err(BunsenError::Invalid( + "Viterbi n_states must be non-zero".to_string(), + )); + } + Ok(()) + } + + /// Builds a decoder around a transition table. + /// + /// # Arguments + /// * `transition`: `[n_states * n_states]` row-major, indexed `[from][to]`. + /// Use [`FORBIDDEN`] for transitions that may not be taken. + /// + /// # Errors + /// See [`validate`](Self::validate); also [`BunsenError::Invalid`] if + /// `transition` is the wrong length. + pub fn try_init( + &self, + transition: &[f32], + device: &B::Device, + ) -> BunsenResult> { + self.validate()?; + + let want = self.n_states * self.n_states; + if transition.len() != want { + return Err(BunsenError::Invalid(format!( + "Viterbi transition has {} entries, expected {want}", + transition.len(), + ))); + } + + Ok(Viterbi { + n_states: self.n_states, + transition: Tensor::from_data( + TensorData::new(transition.to_vec(), [self.n_states, self.n_states]), + device, + ), + }) + } + + /// Builds a decoder, panicking on error. + pub fn init( + &self, + transition: &[f32], + device: &B::Device, + ) -> Viterbi { + self.try_init(transition, device).ok_or_panic() + } +} + +/// A batched Viterbi decoder over a fixed transition table. +/// +/// Built by [`ViterbiConfig::try_init`]. +#[derive(Debug, Clone)] +pub struct Viterbi { + n_states: usize, + + /// `[n_states, n_states]` transition scores, indexed `[from][to]`. + pub transition: Tensor, +} + +/// The accumulator [`Viterbi`] carries between steps. +#[derive(Debug, Clone)] +pub struct ViterbiState { + /// `[batch, n_states]` path scores, renormalized so the peak is zero. + pub score: Tensor, +} + +impl Viterbi { + /// The number of trellis states. + pub fn n_states(&self) -> usize { + self.n_states + } + + /// A zeroed start-of-stream accumulator. + /// + /// All states start equally likely. A caller with a prior over initial + /// states should build [`ViterbiState`] directly instead. + pub fn init_state( + &self, + batch_size: usize, + device: &B::Device, + ) -> ViterbiState { + ViterbiState { + score: Tensor::zeros([batch_size, self.n_states], device), + } + } + + /// Advances one timestep. + /// + /// # Arguments + /// * `state`: the accumulator. + /// * `emission`: `[batch, n_states]` per-state scores for this step. + /// + /// # Returns + /// `(next accumulator, `[batch, `n_states`]` backpointers)`, where entry + /// `s` is the predecessor state the best path into `s` came from. + pub fn step( + &self, + state: ViterbiState, + emission: Tensor, + ) -> (ViterbiState, Tensor) { + #[cfg(any(test, debug_assertions))] + crate::contracts::assert_shape_contract!( + ["batch", "n_states"], + &emission, + &[("n_states", self.n_states)], + ); + + // [batch, to, from]: the score of arriving at `to` out of `from`. + // `score` broadcasts over `to`; `transition` is [from, to], so it + // transposes into place and broadcasts over the batch. + let arrivals = state.score.unsqueeze_dim::<3>(1) + + self + .transition + .clone() + .swap_dims(0, 1) + .unsqueeze_dim::<3>(0); + + let (best_in, arg_in) = arrivals.max_dim_with_indices(2); + let scored = best_in.squeeze_dim::<2>(2) + emission; + + // Renormalize to peak zero; see the module docs. + let peak = scored.clone().max_dim(1); + + ( + ViterbiState { + score: scored - peak, + }, + arg_in.squeeze_dim::<2>(2), + ) + } + + /// Runs a sequence of timesteps. + /// + /// # Arguments + /// * `emissions`: `[steps, batch, n_states]` per-step scores. + /// * `state`: the accumulator. + /// + /// # Returns + /// `(`[steps, batch, `n_states`]` backpointers, next accumulator)`. + /// + /// # Panics + /// If `emissions` is empty. + pub fn forward( + &self, + emissions: Tensor, + state: ViterbiState, + ) -> (Tensor, ViterbiState) { + let steps = emissions.dims()[0]; + assert_ne!(steps, 0, "Viterbi emissions must be non-empty"); + + let mut state = state; + let mut prev = Vec::with_capacity(steps); + for step in 0..steps { + let (next, back) = self.step(state, emissions.clone().select_dim::<2>(0, step)); + state = next; + prev.push(back); + } + + (Tensor::stack::<3>(prev, 0), state) + } + + /// The best state per batch row, as `[batch, 1]`. + pub fn best_state( + &self, + state: &ViterbiState, + ) -> Tensor { + state.score.clone().argmax(1) + } + + /// Walks every timestep's path back `depth` states, all at once. + /// + /// At lookback `k` each timestep reads the same relative position in + /// `prev`, so one strided slice serves them all and the cost is `depth` + /// batched gathers rather than `steps * depth` sequential hops. + /// + /// # Arguments + /// * `prev`: `[batch, depth - 1 + steps, n_states]` backpointers in stream + /// order. The final `steps` entries are this run's; the leading `depth - + /// 1` are carried from earlier calls, so that early timesteps have + /// something to walk into. + /// * `cursor`: `[steps, batch]` the state each timestep's path ends on. + /// + /// # Returns + /// `[steps, batch, depth]`, where `[t][b][0]` is `cursor[t][b]` and + /// `[t][b][k]` is the state `k` steps earlier on that path. + /// + /// # Panics + /// If `prev` is not `depth - 1 + steps` long on its slot axis. + pub fn backtrace( + prev: Tensor, + cursor: Tensor, + depth: usize, + ) -> Tensor { + assert_ne!(depth, 0, "Viterbi backtrace depth must be non-zero"); + let [steps, _] = cursor.dims(); + let [_, history, _] = prev.dims(); + assert_eq!( + history, + depth - 1 + steps, + "Viterbi backtrace expects {} slots of history for {steps} steps at depth \ + {depth}", + depth - 1 + steps, + ); + + let carry = depth - 1; + let mut cursor = cursor; + let mut path = Vec::with_capacity(depth); + + for k in 0..depth { + path.push(cursor.clone().unsqueeze_dim::<3>(2)); + if k + 1 == depth { + break; + } + + // Timestep `t` looks back `k` steps, which is history slot + // `carry + t - k`; over all `t` that is one contiguous slice. + let lo = (carry - k) as isize; + let rows = prev + .clone() + .slice_dim(1, lo..lo + steps as isize) + .swap_dims(0, 1); + + // Built with `stack`, not `cat` + `swap_dims`: `gather` ignores + // strides on a non-contiguous index, pinned by + // `burner::tensor::burn_behavior`. + let index = cursor.unsqueeze_dim::<3>(2); + cursor = rows.gather(2, index).squeeze_dim::<2>(2); + } + + Tensor::cat(path, 2) + } +} + +#[cfg(test)] +mod tests { + use burn::tensor::{ + Distribution, + Tolerance, + }; + + use super::*; + use crate::{ + prelude::*, + support::testing::PerformanceBackend, + }; + + type B = PerformanceBackend; + + const S: usize = 5; + + /// A deterministic, tie-free transition table. + fn transition() -> Vec { + (0..S * S) + .map(|i| { + let (from, to) = (i / S, i % S); + let jump = (to as f32 - from as f32).abs(); + -0.3 * jump * jump + 0.017 * i as f32 + }) + .collect() + } + + /// A deterministic, tie-free emission block. + fn emissions( + steps: usize, + batch: usize, + ) -> Vec { + (0..steps * batch * S) + .map(|i| ((i as f32) * 0.734).sin() + 0.11 * ((i as f32) * 0.31).cos()) + .collect() + } + + /// A scalar Viterbi, written from the recursion, for one batch row. + /// + /// Returns the backpointers and the un-renormalized final scores. + fn scalar_viterbi( + emit: &[f32], + trans: &[f32], + steps: usize, + ) -> (Vec, Vec) { + let mut score = vec![0.0f64; S]; + let mut prev = vec![0usize; steps * S]; + + for t in 0..steps { + let mut next = vec![0.0f64; S]; + for to in 0..S { + let mut best = f64::NEG_INFINITY; + let mut arg = 0usize; + for from in 0..S { + let v = score[from] + trans[from * S + to] as f64; + if v > best { + best = v; + arg = from; + } + } + prev[t * S + to] = arg; + next[to] = best + emit[t * S + to] as f64; + } + score = next; + } + + (prev, score.iter().map(|v| *v as f32).collect()) + } + + #[test] + fn test_config_meta() { + let cfg = ViterbiConfig::new(S); + cfg.validate().unwrap(); + assert!(ViterbiConfig::new(0).validate().is_err()); + + let device = Default::default(); + let v: Viterbi = cfg.init(&transition(), &device); + assert_eq!(v.n_states(), S); + assert_eq!(v.transition.dims(), [S, S]); + } + + #[test] + fn test_init_rejects_a_mismatched_transition() { + let device = Default::default(); + assert!(matches!( + ViterbiConfig::new(S).try_init::(&[0.0; S * S - 1], &device), + Err(BunsenError::Invalid(_)), + )); + } + + #[test] + fn test_matches_a_scalar_viterbi() { + // The anchor: the device pass against the recursion written out by + // hand, backpointers and all. + let device = Default::default(); + let steps = 7; + let trans = transition(); + let emit = emissions(steps, 1); + + let v: Viterbi = ViterbiConfig::new(S).init(&trans, &device); + let e = Tensor::::from_data(TensorData::new(emit.clone(), [steps, 1, S]), &device); + let (prev, state) = v.forward(e, v.init_state(1, &device)); + + let (want_prev, want_score) = scalar_viterbi(&emit, &trans, steps); + + let got_prev: Vec = prev.to_data_as::().to_vec_as::().ok_or_panic(); + assert_eq!( + got_prev.iter().map(|v| *v as usize).collect::>(), + want_prev, + ); + + // Scores are renormalized to peak zero, so compare the differences. + let got: Vec = state + .score + .to_data_as::() + .to_vec_as::() + .ok_or_panic(); + let want_peak = want_score.iter().fold(f32::NEG_INFINITY, |m, v| m.max(*v)); + for s in 0..S { + let want = want_score[s] - want_peak; + assert!( + (got[s] - want).abs() < 1e-4, + "state {s}: got {}, want {want}", + got[s], + ); + } + } + + #[test] + fn test_score_peak_is_zero() { + let device = Default::default(); + let steps = 6; + let v: Viterbi = ViterbiConfig::new(S).init(&transition(), &device); + let e = + Tensor::::from_data(TensorData::new(emissions(steps, 3), [steps, 3, S]), &device); + let (_, state) = v.forward(e, v.init_state(3, &device)); + + let peak: Vec = state + .score + .max_dim(1) + .to_data_as::() + .to_vec_as::() + .ok_or_panic(); + for (row, p) in peak.iter().enumerate() { + assert!(p.abs() < 1e-5, "row {row} peak {p} should be zero"); + } + } + + #[test] + fn test_streaming_matches_a_single_call() { + let device = Default::default(); + let steps = 8; + let v: Viterbi = ViterbiConfig::new(S).init(&transition(), &device); + let emit = emissions(steps, 2); + let all = Tensor::::from_data(TensorData::new(emit, [steps, 2, S]), &device); + + let (whole_prev, whole) = v.forward(all.clone(), v.init_state(2, &device)); + + let (head_prev, mid) = v.forward(all.clone().slice_dim(0, ..3), v.init_state(2, &device)); + let (tail_prev, split) = v.forward(all.slice_dim(0, 3..), mid); + + let tol = Tolerance::::permissive(); + whole + .score + .to_data_as::() + .assert_approx_eq::(&split.score.to_data_as::(), tol); + whole_prev + .to_data() + .assert_eq(&Tensor::cat(vec![head_prev, tail_prev], 0).to_data(), true); + } + + #[test] + fn test_batch_rows_are_independent() { + let device = Default::default(); + let steps = 5; + let v: Viterbi = ViterbiConfig::new(S).init(&transition(), &device); + + let a = emissions(steps, 1); + // Row 1 gets a different, unrelated block. + let b: Vec = a.iter().rev().copied().collect(); + + let mut both = Vec::new(); + for t in 0..steps { + both.extend_from_slice(&a[t * S..(t + 1) * S]); + both.extend_from_slice(&b[t * S..(t + 1) * S]); + } + let paired = Tensor::::from_data(TensorData::new(both, [steps, 2, S]), &device); + let (_, joint) = v.forward(paired, v.init_state(2, &device)); + + let solo_a = Tensor::::from_data(TensorData::new(a, [steps, 1, S]), &device); + let (_, alone) = v.forward(solo_a, v.init_state(1, &device)); + + let joint_row: Vec = joint + .score + .slice_dim(0, 0..1) + .to_data_as::() + .to_vec_as::() + .ok_or_panic(); + let solo_row: Vec = alone + .score + .to_data_as::() + .to_vec_as::() + .ok_or_panic(); + for s in 0..S { + assert!( + (joint_row[s] - solo_row[s]).abs() < 1e-5, + "state {s}: batched {} vs alone {}", + joint_row[s], + solo_row[s], + ); + } + } + + #[test] + fn test_backtrace_walks_the_recorded_pointers() { + // Against a hand walk of the same history: the batched form must agree + // with following one pointer at a time. + let device = Default::default(); + let (steps, depth) = (5usize, 4usize); + let history = depth - 1 + steps; + + let raw: Vec = (0..history * S).map(|i| ((i * 7 + 3) % S) as i32).collect(); + let prev = + Tensor::::from_data(TensorData::new(raw.clone(), [1, history, S]), &device); + + let cursor_raw: Vec = (0..steps).map(|t| ((t * 3 + 1) % S) as i32).collect(); + let cursor = Tensor::::from_data( + TensorData::new(cursor_raw.clone(), [steps, 1]), + &device, + ); + + let path = Viterbi::::backtrace(prev, cursor, depth); + assert_eq!(path.dims(), [steps, 1, depth]); + let got: Vec = path.to_data_as::().to_vec_as::().ok_or_panic(); + + for t in 0..steps { + let mut c = cursor_raw[t] as usize; + for k in 0..depth { + assert_eq!(got[t * depth + k], c as i32, "step {t}, lookback {k}",); + if k + 1 < depth { + // History slot `carry + t - k`. + let slot = (depth - 1) + t - k; + c = raw[slot * S + c] as usize; + } + } + } + } + + #[test] + fn test_backtrace_depth_one_is_the_cursor() { + let device = Default::default(); + let steps = 4; + let prev = Tensor::::zeros([1, steps, S], &device); + let cursor = Tensor::::from_data( + TensorData::new(vec![2i32, 0, 4, 1], [steps, 1]), + &device, + ); + + let path = Viterbi::::backtrace(prev, cursor.clone(), 1); + assert_eq!(path.dims(), [steps, 1, 1]); + path.squeeze_dim::<2>(2) + .to_data() + .assert_eq(&cursor.to_data(), true); + } + + #[test] + #[should_panic(expected = "slots of history")] + fn test_backtrace_rejects_short_history() { + let device = Default::default(); + let prev = Tensor::::zeros([1, 4, S], &device); + let cursor = Tensor::::zeros([4, 1], &device); + Viterbi::::backtrace(prev, cursor, 3); + } + + #[test] + fn test_forbidden_transitions_are_never_taken() { + // Every route into state 0 is closed except from state 0 itself, so no + // backpointer into 0 may name anything else -- however attractive the + // emissions make it. + let device = Default::default(); + let steps = 6; + + let mut trans = transition(); + for from in 1..S { + trans[from * S] = FORBIDDEN; + } + + let v: Viterbi = ViterbiConfig::new(S).init(&trans, &device); + let e = + Tensor::::from_data(TensorData::new(emissions(steps, 1), [steps, 1, S]), &device); + let (prev, _) = v.forward(e, v.init_state(1, &device)); + + let got: Vec = prev.to_data_as::().to_vec_as::().ok_or_panic(); + for t in 0..steps { + assert_eq!( + got[t * S], + 0, + "step {t}: state 0 reached from a closed edge" + ); + } + } + + #[test] + #[should_panic(expected = "must be non-empty")] + fn test_empty_input_is_rejected() { + let device = Default::default(); + let v: Viterbi = ViterbiConfig::new(S).init(&transition(), &device); + let e = Tensor::::zeros([0, 1, S], &device); + v.forward(e, v.init_state(1, &device)); + } + + #[test] + fn test_random_sequences_match_the_scalar_reference() { + // The deterministic fixtures above could conceivably flatter a shared + // misreading of the recursion; random data cannot. + let device = Default::default(); + let (steps, batch) = (9usize, 1usize); + let trans = transition(); + let v: Viterbi = ViterbiConfig::new(S).init(&trans, &device); + + let e = Tensor::::random([steps, batch, S], Distribution::Normal(0.0, 1.0), &device); + let emit: Vec = e + .clone() + .to_data_as::() + .to_vec_as::() + .ok_or_panic(); + + let (prev, _) = v.forward(e, v.init_state(batch, &device)); + let (want_prev, _) = scalar_viterbi(&emit, &trans, steps); + + let got: Vec = prev.to_data_as::().to_vec_as::().ok_or_panic(); + assert_eq!( + got.iter().map(|v| *v as usize).collect::>(), + want_prev, + ); + } +} From 66f5d45acad434b7d6d266eb5fc12a106ef93e5d Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Mon, 24 Aug 2026 12:30:24 -0700 Subject: [PATCH 19/32] refactor(signal): lift the batched LPC solve out of ten_vad Completes `ops::signal::lpc` as one unit: the scalar recursion and the device form that fits many spectra at once. Any LPC-based speech model wants the second -- spectral envelopes, formant tracking, vocoder analysis -- and it is the half that is awkward to write, so it is the half worth having in a shared place. `levinson_durbin_batched` generalizes the ten_vad stage: order comes from the input rather than a constant, and the bail-out is the same `Option` the scalar form takes. The interesting part is why a batched port is not a transcription, and that is now documented on the function rather than lost in a commit: * **Commit, then freeze.** The scalar form checks after an iteration completes, so iteration `i` always commits and only `i+1..` are skipped. Move the mask before the update and the bail-out shifts by one step. * **No sticky bit.** A frozen row's error stays below the threshold, so re-deriving the mask each step is already monotone. * **The frozen tail is the zero initializer.** Coefficient `k` is only written at step `k`, so a row that stopped early leaves exact zeros -- which is what makes masking sufficient rather than merely convenient. Frozen rows divide by 1 rather than a stale error, so nothing ever produces an inf to be masked away afterwards. One generalization the ten_vad version did not need: it relied on its noise floor guaranteeing `ac[0] > 0`, which a general caller cannot promise, so non-positive rows are now frozen from the start and come back zeroed like the scalar form's guard. Tested differentially against the scalar solver -- which is itself pinned by the closed-form AR tests, so agreement reaches back to the recursion rather than to another implementation -- across all three bail settings, on lag sequences built by autocorrelating random spectra so they are sequences a real fit could actually produce. Plus closed-form AR(1) recovery per row, a dead row that must not poison its neighbours, and rows that demonstrably bail at different steps, which is the only thing that really exercises the mask. ten_vad's prefilter now delegates. Golden unchanged: mean |diff| 5.649e-5, worst 6.446e-4 at hop 2973, decisions agreeing on all 3750 frames. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../ten_vad/context/pitch/tensor/prefilter.rs | 91 +---- crates/bunsen/src/ops/signal/lpc.rs | 310 ++++++++++++++++++ 2 files changed, 325 insertions(+), 76 deletions(-) diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/prefilter.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/prefilter.rs index 5931d6a0..07f21292 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/prefilter.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/prefilter.rs @@ -56,20 +56,23 @@ use burn::{ use super::{ super::{ - coeff::{ - LPC_ORDER, - NB_BANDS, + coeff::NB_BANDS, + lpc::{ + CELT_LPC_BAIL_RATIO, + dc0_bias, }, - lpc::dc0_bias, }, tables::{ PitchTables, PitchTablesConfig, }, }; -use crate::errors::{ - BunsenResult, - WithOkOrPanic, +use crate::{ + errors::{ + BunsenResult, + WithOkOrPanic, + }, + ops::signal::levinson_durbin_batched, }; /// Config for [`PitchPrefilter`]. @@ -155,7 +158,7 @@ impl PitchPrefilter { &self, bin_power: Tensor, ) -> Tensor { - let [rows, n_bins] = bin_power.dims(); + let n_bins = bin_power.dims()[1]; assert_eq!( n_bins, self.n_bins(), @@ -180,7 +183,7 @@ impl PitchPrefilter { let ac = gain.matmul(self.tables.ac_from_bands.clone()); let ac = self.apply_noise_floor(ac); - levinson_durbin(ac, rows) + levinson_durbin_batched(ac, Some(CELT_LPC_BAIL_RATIO)) } /// The clamped log compression, as an unrolled 18-step scan. @@ -271,71 +274,6 @@ impl PitchPrefilter { /// /// # Returns /// `[rows, lpc_order]` coefficients. -fn levinson_durbin( - ac: Tensor, - rows: usize, -) -> Tensor { - let device = ac.device(); - - let ac0 = ac.clone().slice_dim(1, 0..1); - let threshold = ac0.clone().mul_scalar(0.001f32); - - let mut error = ac0; - let mut lpc: Tensor = Tensor::zeros([rows, LPC_ORDER], &device); - // Live at the start: `dc0_bias` guarantees `ac[0] > 0`, so no row can - // begin already converged. - let mut done = error.clone().lower(threshold.clone()); - - for i in 0..LPC_ORDER { - // rr = Σ_{j = Tensor::zeros([rows, 1], &device); - if i > 0 { - // `ac[i], ac[i-1], …, ac[1]`, so element `j` pairs with `lpc[j]`. - let reversed = ac.clone().slice_dim(1, 1..(i + 1) as isize).flip([1]); - let products = lpc.clone().slice_dim(1, 0..i as isize) * reversed; - for j in 0..i { - rr = rr + products.clone().slice_dim(1, j as isize..(j + 1) as isize); - } - } - rr = rr + ac.clone().slice_dim(1, (i + 1) as isize..(i + 2) as isize); - - // Frozen rows divide by 1 rather than by a stale error, so no inf or - // NaN is ever produced; the result is discarded either way. - let safe = error.clone().mask_fill(done.clone(), 1.0f32); - let r = rr.neg() / safe.clone(); - - // The reference's symmetric update, `lpc[j] += r·lpc[i-1-j]` over the - // first half, is exactly `head + flip(head)·r` over the whole prefix — - // including the middle element when `i` is odd, which the loop form - // writes twice with identical operands. - let mut parts = Vec::with_capacity(3); - if i > 0 { - let head = lpc.clone().slice_dim(1, 0..i as isize); - parts.push(head.clone() + head.flip([1]) * r.clone()); - } - parts.push(r.clone()); - if i + 1 < LPC_ORDER { - parts.push(Tensor::zeros([rows, LPC_ORDER - i - 1], &device)); - } - let next_lpc = Tensor::cat(parts, 1); - - let next_error = safe.clone() - (r.clone() * r) * safe; - - // Commit, then freeze: the reference checks after the iteration. - let wide = done.clone().expand([rows, LPC_ORDER]); - lpc = next_lpc.mask_where(wide, lpc); - error = next_error.mask_where(done, error); - done = error.clone().lower(threshold.clone()); - } - - lpc -} - #[cfg(test)] mod tests { use burn::tensor::Tolerance; @@ -344,6 +282,7 @@ mod tests { super::super::{ TenVadPitchEstimator, TenVadPitchScalarSource, + coeff::LPC_ORDER, lpc::celt_lpc, }, *, @@ -504,7 +443,7 @@ mod tests { ); let input = Tensor::::from_floats(ac.as_slice(), &device).reshape([1, LPC_ORDER + 1]); - let got: Vec = levinson_durbin::(input, 1) + let got: Vec = levinson_durbin_batched::(input, Some(CELT_LPC_BAIL_RATIO)) .to_data_as::() .to_vec_as::() .unwrap(); @@ -536,7 +475,7 @@ mod tests { } let input = Tensor::::from_floats(flat.as_slice(), &device) .reshape([cases.len(), LPC_ORDER + 1]); - let got: Vec = levinson_durbin::(input, cases.len()) + let got: Vec = levinson_durbin_batched::(input, Some(CELT_LPC_BAIL_RATIO)) .to_data_as::() .to_vec_as::() .unwrap(); diff --git a/crates/bunsen/src/ops/signal/lpc.rs b/crates/bunsen/src/ops/signal/lpc.rs index 3599647c..536d2c53 100644 --- a/crates/bunsen/src/ops/signal/lpc.rs +++ b/crates/bunsen/src/ops/signal/lpc.rs @@ -23,6 +23,16 @@ //! growing with the term count, while an FFT's grows with its logarithm. The //! direct sum in `f32` would be materially *worse* than an FFT; in `f64` it is //! at least as good. +//! +//! ## Fitting many spectra at once +//! +//! [`levinson_durbin_batched`] is the device form, and it is not a +//! transcription of the host one. A batched implementation cannot branch per +//! row, so the early exit becomes a masked freeze; see that function for why +//! the freeze is applied *after* the update and why it needs no sticky +//! bookkeeping. + +use burn::prelude::*; /// Autocorrelation lags from a real, even power spectrum. /// @@ -213,9 +223,131 @@ pub fn levinson_durbin( } } +/// [`levinson_durbin`] over many autocorrelation sequences at once. +/// +/// Same recursion, same convention, same `bail_ratio` semantics -- but a +/// batched implementation cannot take a different number of iterations per +/// row, so it always runs the full order and *freezes* rows that would have +/// stopped. +/// +/// # How the freeze works, and why it is exact +/// +/// Three details carry the equivalence, and all three are easy to get wrong: +/// +/// * **Commit, then freeze.** The scalar form checks its condition *after* an +/// iteration completes, so iteration `i` always commits and only `i+1..` are +/// skipped. The mask is therefore applied after the update, and recomputed +/// after that. +/// * **No sticky bit is needed.** Once a row's error is frozen below the +/// threshold it stays below it, so re-deriving the mask from the error each +/// step is already monotone. +/// * **The frozen tail is the zero initializer.** Coefficient `k` is only ever +/// written at step `k`, so a row that stopped early leaves exact zeros behind +/// rather than partial values -- which is what makes masking sufficient. +/// +/// Frozen rows divide by `1` rather than by a stale error, so no row ever +/// produces `inf` or `NaN` to be masked away afterwards. Rows whose `ac[0]` is +/// not positive are frozen from the start and come back as zeros, matching the +/// scalar form's guard. +/// +/// The inner product is accumulated one term at a time in increasing `j` +/// rather than by a tree reduce. That is deliberate: summation order is +/// observable in `f32`, and this order is the scalar function's. The cost is +/// `order` products and `order * (order - 1) / 2` adds on `[rows, 1]` tensors, +/// which is constant in the row count. +/// +/// # Arguments +/// * `ac`: `[rows, order + 1]` autocorrelation lags. +/// * `bail_ratio`: as [`levinson_durbin`]; `None` runs to full order. +/// +/// # Returns +/// `[rows, order]` coefficients. +/// +/// # Panics +/// If `ac` has fewer than two lags. +pub fn levinson_durbin_batched( + ac: Tensor, + bail_ratio: Option, +) -> Tensor { + let [rows, lags] = ac.dims(); + assert!( + lags >= 2, + "levinson_durbin_batched needs at least lags 0 and 1", + ); + let order = lags - 1; + let device = ac.device(); + + let ac0 = ac.clone().slice_dim(1, 0..1); + + // Rows that cannot be solved at all: the scalar form returns zeros for + // these, and freezing them from the start does the same. + let dead = ac0.clone().lower_equal_elem(0.0f32); + + let threshold = bail_ratio.map(|ratio| ac0.clone().mul_scalar(ratio)); + + let mut error = ac0; + let mut lpc: Tensor = Tensor::zeros([rows, order], &device); + let mut done = dead.clone(); + + for i in 0..order { + // rr = sum(lpc[j] * ac[i - j] for j < i), then += ac[i + 1]. + let mut rr: Tensor = Tensor::zeros([rows, 1], &device); + if i > 0 { + // `ac[i], ac[i-1], ..., ac[1]`, so element `j` pairs with `lpc[j]`. + let reversed = ac.clone().slice_dim(1, 1..(i + 1) as isize).flip([1]); + let products = lpc.clone().slice_dim(1, 0..i as isize) * reversed; + for j in 0..i { + rr = rr + products.clone().slice_dim(1, j as isize..(j + 1) as isize); + } + } + rr = rr + ac.clone().slice_dim(1, (i + 1) as isize..(i + 2) as isize); + + let safe = error.clone().mask_fill(done.clone(), 1.0f32); + let r = rr.neg() / safe.clone(); + + // The symmetric update `lpc[j] += r * lpc[i-1-j]` over the first half + // is exactly `head + flip(head) * r` over the whole prefix, including + // the middle element when `i` is odd -- which the scalar loop writes + // twice with identical operands. + let mut parts = Vec::with_capacity(3); + if i > 0 { + let head = lpc.clone().slice_dim(1, 0..i as isize); + parts.push(head.clone() + head.flip([1]) * r.clone()); + } + parts.push(r.clone()); + if i + 1 < order { + parts.push(Tensor::zeros([rows, order - i - 1], &device)); + } + let next_lpc = Tensor::cat(parts, 1); + + let next_error = safe.clone() - (r.clone() * r) * safe; + + // Commit, then freeze. + let wide = done.clone().expand([rows, order]); + lpc = next_lpc.mask_where(wide, lpc); + error = next_error.mask_where(done, error); + + done = match &threshold { + Some(t) => dead.clone().bool_or(error.clone().lower(t.clone())), + None => dead.clone(), + }; + } + + lpc +} + #[cfg(test)] mod tests { + use burn::tensor::Distribution; + use super::*; + use crate::{ + errors::WithOkOrPanic, + prelude::*, + support::testing::PerformanceBackend, + }; + + type Dev = PerformanceBackend; const FFT: usize = 64; @@ -459,6 +591,184 @@ mod tests { levinson_durbin(&[1.0, 0.5, 0.2, 0.1, 0.05], &mut lpc, None); } + /// A batch of genuinely positive-definite lag sequences. + /// + /// Built by autocorrelating random non-negative spectra, which guarantees + /// the sequences are ones a real fit could produce -- rather than + /// arbitrary numbers that might exercise paths no caller reaches. + fn lag_batch( + rows: usize, + order: usize, + device: &::Device, + ) -> Vec { + let auto = Autocorrelator::new(FFT); + let spectra: Vec = Tensor::::random( + [rows, auto.n_bins()], + Distribution::Uniform(0.0, 4.0), + device, + ) + .to_data_as::() + .to_vec_as::() + .ok_or_panic(); + + let mut out = Vec::with_capacity(rows * (order + 1)); + for r in 0..rows { + let mut lags = vec![0.0f32; order + 1]; + auto.autocorrelate( + &spectra[r * auto.n_bins()..(r + 1) * auto.n_bins()], + &mut lags, + ); + out.extend_from_slice(&lags); + } + out + } + + fn host_rows( + flat: &[f32], + rows: usize, + order: usize, + bail: Option, + ) -> Vec { + let mut out = vec![0.0f32; rows * order]; + for r in 0..rows { + levinson_durbin( + &flat[r * (order + 1)..(r + 1) * (order + 1)], + &mut out[r * order..(r + 1) * order], + bail, + ); + } + out + } + + #[test] + fn test_batched_matches_the_scalar_solver() { + // The differential anchor. The scalar side is itself pinned by the + // closed-form AR tests above, so agreement here reaches all the way + // back to the recursion rather than to another implementation. + let device = Default::default(); + let (rows, order) = (12usize, 16usize); + + for bail in [None, Some(0.001f32), Some(0.2f32)] { + let flat = lag_batch(rows, order, &device); + let want = host_rows(&flat, rows, order, bail); + + let ac = Tensor::::from_data(TensorData::new(flat, [rows, order + 1]), &device); + let got: Vec = levinson_durbin_batched(ac, bail) + .to_data_as::() + .to_vec_as::() + .ok_or_panic(); + + let peak = want.iter().fold(0.0f32, |m, v| m.max(v.abs())); + for (i, (&g, &w)) in got.iter().zip(want.iter()).enumerate() { + assert!( + (g - w).abs() < 1e-4 * peak.max(1e-3), + "bail {bail:?}, row {}, coeff {}: {g} vs {w}", + i / order, + i % order, + ); + } + } + } + + #[test] + fn test_batched_recovers_an_ar1_process() { + // Closed form, on the device side too: every row is a different pole. + let device = Default::default(); + let order = 8; + let poles = [0.2f32, -0.55, 0.8]; + + let mut flat = Vec::new(); + for a in poles { + flat.extend(ar1_lags(a, order)); + } + let ac = + Tensor::::from_data(TensorData::new(flat, [poles.len(), order + 1]), &device); + + let got: Vec = levinson_durbin_batched(ac, None) + .to_data_as::() + .to_vec_as::() + .ok_or_panic(); + + for (r, a) in poles.iter().enumerate() { + assert!( + (got[r * order] + a).abs() < 1e-4, + "row {r}: expected {}, got {}", + -a, + got[r * order], + ); + for j in 1..order { + assert!( + got[r * order + j].abs() < 1e-4, + "row {r}, coeff {j} should vanish, got {}", + got[r * order + j], + ); + } + } + } + + #[test] + fn test_batched_freezes_rows_independently() { + // The whole point of the mask: rows that bail at different steps must + // not disturb each other. A near-white row runs to full order; a + // strongly correlated one stops almost immediately. + let device = Default::default(); + let order = 10; + + let mut flat = ar1_lags(0.95, order); + flat.extend(ar1_lags(0.05, order)); + let want = host_rows(&flat, 2, order, Some(0.001)); + + let ac = Tensor::::from_data(TensorData::new(flat, [2, order + 1]), &device); + let got: Vec = levinson_durbin_batched(ac, Some(0.001)) + .to_data_as::() + .to_vec_as::() + .ok_or_panic(); + + // They really do stop at different points, or this proves nothing. + let live = |row: usize| { + want[row * order..(row + 1) * order] + .iter() + .rposition(|v| *v != 0.0) + .map_or(0, |i| i + 1) + }; + assert_ne!(live(0), live(1), "rows should bail at different steps"); + + for (i, (&g, &w)) in got.iter().zip(want.iter()).enumerate() { + assert!( + (g - w).abs() < 1e-4, + "row {}, coeff {}: {g} vs {w}", + i / order, + i % order, + ); + } + } + + #[test] + fn test_batched_zero_lag_zero_row_is_zero() { + // A degenerate row must come back zeroed rather than as NaN, and must + // not poison its neighbours. + let device = Default::default(); + let order = 6; + + let mut flat = vec![0.0f32; order + 1]; + flat.extend(ar1_lags(0.6, order)); + let ac = Tensor::::from_data(TensorData::new(flat, [2, order + 1]), &device); + + let got: Vec = levinson_durbin_batched(ac, Some(0.001)) + .to_data_as::() + .to_vec_as::() + .ok_or_panic(); + + for (j, &c) in got.iter().take(order).enumerate() { + assert_eq!(c, 0.0, "dead row coeff {j} should be exactly zero"); + } + assert!( + (got[order] + 0.6).abs() < 1e-4, + "the live row should be unaffected, got {}", + got[order], + ); + } + #[test] #[should_panic(expected = "at least lags 0 and 1")] fn test_degenerate_input_is_rejected() { From 1b81dab29a1f1ecbc292627bc5749d3189b5c84b Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Mon, 24 Aug 2026 13:12:05 -0700 Subject: [PATCH 20/32] feat(signal): add a triangular filterbank and mel scales The piece the next speech model needs regardless of what happens to ten_vad. Until now bunsen had exactly one filterbank, welded to ten-vad's geometry; `ops::signal::TriangularBank` is the construction every mel, bark and ERB bank shares. The split is the design: `TriangularBankConfig` builds weights from edges you supply and knows nothing about frequency, while `mel_bin_edges` and `MelScale` produce mel-spaced edges to hand it. A bank on any warped scale is the same triangles over different edges, so only the edge computation has to change -- including, as it turns out, ten-vad's. Edges are fractional bin coordinates rather than integers, so a boundary falling between bins gets slopes that reflect where it landed. Integer edges still work and reproduce an integer-rounded bank exactly; that is a property of the input rather than a limit of the construction, and it is what lets ten_vad delegate. `MelScale` carries both warpings, and they are not interchangeable: HTK is log-warped everywhere, Slaney is linear below 1 kHz, and they disagree by tens of mel across the speech band. A model trained against one will not accept the other's filterbank, so both are spelled out with that stated rather than a single "mel" that silently picks a side. `BankNorm` likewise makes peak-versus- area an explicit choice instead of an omission. The load-bearing test is closed-form: where two adjacent bands overlap their weights must sum to exactly one, which pins both slopes and their alignment together. Around it, both scales round-trip, HTK is calibrated so 1 kHz lands near 1000 mel, Slaney really is linear below its breakpoint -- and the two scales are asserted to *disagree*, without which every other scale test would pass against a single implementation. ten_vad's mel bank now delegates its triangles and keeps only its edges, which sharpens what its deviation actually is. Its module docs said three details were load-bearing; one of them, integer slopes, turns out to be a consequence of the integer edges rather than a separate quirk, and the docs now say so. All ten of its mel tests pass unchanged, including the exact-coefficient one, and the golden is unchanged: mean |diff| 5.649e-5, worst 6.446e-4 at hop 2973, decisions agreeing on all 3750 frames. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../src/kits/speech/ten_vad/context/mel.rs | 55 +- crates/bunsen/src/ops/signal/filterbank.rs | 531 ++++++++++++++++++ crates/bunsen/src/ops/signal/mod.rs | 3 + 3 files changed, 557 insertions(+), 32 deletions(-) create mode 100644 crates/bunsen/src/ops/signal/filterbank.rs diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/mel.rs b/crates/bunsen/src/kits/speech/ten_vad/context/mel.rs index 15834c69..659d9917 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/mel.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/mel.rs @@ -3,8 +3,10 @@ //! The 40-band triangular filterbank the ten-vad front end folds its 513 bin //! powers through (`ALGO_TRACE.md` §3.6). //! -//! This is **not** a standard mel filterbank, and a librosa / Slaney-style -//! builder will not reproduce it. Three details are load-bearing. +//! The triangles are the ordinary construction, shared with +//! [`TriangularBankConfig`]. What is **not** ordinary is where the band edges +//! land, and that difference is enough that a librosa- or Slaney-style builder +//! will not reproduce this bank. //! //! **Edge mapping.** Band edges are equally spaced on the HTK mel scale, then //! mapped to FFT bins by a truncating integer cast: @@ -14,19 +16,24 @@ //! bin = (usize) ((fft_size + 1) * hz / sample_rate) //! ``` //! -//! Note both the `fft_size + 1` and that the cast truncates, not rounds. +//! Note both the `fft_size + 1` — the usual factor is `fft_size` — and that the +//! cast truncates rather than rounding. Together they move edges by up to a +//! bin, which at 1024 points over 16 kHz is 15.6 Hz: a large fraction of a low +//! band's width. //! -//! **Integer slopes.** The triangles are built from integer bin differences, -//! so their slopes follow where the truncation landed rather than the exact -//! edge frequencies. -//! -//! **No area normalization.** Each filter rises from zero to one and falls -//! back to zero, peaking at exactly `1.0`. +//! Because the edges are integers, the slopes follow where the truncation +//! landed rather than the exact edge frequencies. That is a consequence of the +//! edges, not a separate deviation — handing integer edges to the shared +//! builder reproduces it exactly. //! //! The edge arithmetic is deliberately done in `f32`, matching the reference; //! computing it in `f64` can round an edge across an integer boundary and //! silently produce a different filterbank. //! +//! **No area normalization.** Each filter peaks at exactly `1.0` regardless of +//! width (`BankNorm::Peak`), so wide bands accumulate more energy than narrow +//! ones. This is HTK's convention rather than Slaney's. +//! //! The pieces are: //! * [`TenVadMelConfig`] — the geometry. //! * [`TenVadMelBank`] — the materialized `[n_mels, n_bins]` filter matrix; @@ -47,6 +54,7 @@ use crate::{ N_MELS, SAMPLE_RATE, }, + ops::signal::TriangularBankConfig, }; /// Common meta for [`TenVadMelConfig`] and [`TenVadMelBank`]. @@ -198,29 +206,12 @@ impl TenVadMelConfig { /// Exposed so callers (and tests) can inspect the coefficients without a /// device round trip. pub fn to_vec_weights(&self) -> Vec { - let n_bins = self.n_bins(); - let edges = self.bin_edges(); - let mut weights = vec![0.0f32; self.n_mels * n_bins]; - - for i in 0..self.n_mels { - let (lo, mid, hi) = (edges[i], edges[i + 1], edges[i + 2]); - let row = i * n_bins; - - // Rising slope: 0 -> 1 across [lo, mid). - for j in lo..mid { - if j < n_bins { - weights[row + j] = (j - lo) as f32 / (mid - lo) as f32; - } - } - // Falling slope: 1 -> 0 across [mid, hi). The peak of exactly 1.0 - // lands at `mid`, from this loop's first iteration. - for j in mid..hi { - if j < n_bins { - weights[row + j] = (hi - j) as f32 / (hi - mid) as f32; - } - } - } - weights + // The triangles themselves are the ordinary construction, so they come + // from the shared builder. What is *not* ordinary is where the edges + // land -- see [`bin_edges`](Self::bin_edges) and the module docs -- and + // that is the only ten-vad-specific part left here. + let edges: Vec = self.bin_edges().iter().map(|e| *e as f32).collect(); + TriangularBankConfig::new(self.n_bins()).to_vec_weights(&edges) } /// Initializes a [`TenVadMelBank`] on `device`. diff --git a/crates/bunsen/src/ops/signal/filterbank.rs b/crates/bunsen/src/ops/signal/filterbank.rs new file mode 100644 index 00000000..6bd34a1e --- /dev/null +++ b/crates/bunsen/src/ops/signal/filterbank.rs @@ -0,0 +1,531 @@ +//! # Triangular filterbanks and frequency scales. +//! +//! The construction every mel, bark, and ERB filterbank shares: a set of band +//! edges in bin coordinates, and a triangle per band rising from one edge to +//! the next and falling to the one after. +//! +//! ```text +//! edges: e[0] e[1] e[2] e[3] +//! \ /\ /\ / +//! band 0: \______/ \ / \ / +//! band 1: \____/ \____/ +//! ``` +//! +//! Two pieces, deliberately separate: +//! +//! * [`TriangularBankConfig`] builds the weights from edges you supply. It +//! knows nothing about frequency. +//! * [`mel_bin_edges`] and [`MelScale`] produce mel-spaced edges to hand it. +//! +//! Keeping them apart is what makes the bank reusable. A filterbank on any +//! warped scale — bark, ERB, a learned spacing, or one transcribed from a +//! reference implementation with its own rounding — is the same triangles over +//! different edges, and only the edge computation has to change. +//! +//! ## Edges are fractional +//! +//! Edges are `f32` bin coordinates, not integers, so a band boundary can fall +//! between bins and the slopes reflect where it actually landed. Passing +//! integer-valued edges is fine and reproduces an integer-rounded bank exactly; +//! that is a property of the input, not a limitation of the construction. + +use burn::{ + config::Config, + prelude::*, +}; + +use crate::errors::{ + BunsenError, + BunsenResult, + WithOkOrPanic, +}; + +/// A perceptual frequency warping. +#[derive(Config, Debug, Copy, PartialEq, Eq)] +pub enum MelScale { + /// `2595 * log10(1 + hz / 700)`, the HTK / Slaney-"htk" formula. + /// + /// Log-warped everywhere, which makes it smooth but gives it no linear + /// region at low frequency. Calibrated so 1000 Hz is very nearly 1000 mel. + Htk, + + /// Linear below 1 kHz, logarithmic above, as in Slaney's Auditory Toolbox. + /// + /// librosa's default. The two are not interchangeable: they disagree by + /// tens of mel across the speech band, so a model trained against one will + /// not accept the other's filterbank. + Slaney, +} + +/// The breakpoint of [`MelScale::Slaney`], in Hz. +const SLANEY_BREAK_HZ: f32 = 1000.0; + +/// Mel per Hz below the Slaney breakpoint. +const SLANEY_LINEAR_SLOPE: f32 = 3.0 / 200.0; + +/// The logarithmic step above the Slaney breakpoint. +const SLANEY_LOG_STEP: f32 = 0.068_751_777; + +impl MelScale { + /// Maps a frequency in Hz to its mel value. + pub fn hz_to_mel( + &self, + hz: f32, + ) -> f32 { + match self { + Self::Htk => 2595.0 * (1.0 + hz / 700.0).log10(), + Self::Slaney => { + let linear = hz * SLANEY_LINEAR_SLOPE; + if hz < SLANEY_BREAK_HZ { + linear + } else { + SLANEY_BREAK_HZ * SLANEY_LINEAR_SLOPE + + (hz / SLANEY_BREAK_HZ).ln() / SLANEY_LOG_STEP + } + } + } + } + + /// Maps a mel value back to Hz; the inverse of + /// [`hz_to_mel`](Self::hz_to_mel). + pub fn mel_to_hz( + &self, + mel: f32, + ) -> f32 { + match self { + Self::Htk => 700.0 * (10.0f32.powf(mel / 2595.0) - 1.0), + Self::Slaney => { + let breakpoint = SLANEY_BREAK_HZ * SLANEY_LINEAR_SLOPE; + if mel < breakpoint { + mel / SLANEY_LINEAR_SLOPE + } else { + SLANEY_BREAK_HZ * ((mel - breakpoint) * SLANEY_LOG_STEP).exp() + } + } + } + } +} + +/// How each triangle is scaled. +#[derive(Config, Debug, Copy, PartialEq, Eq)] +pub enum BankNorm { + /// Every triangle peaks at exactly `1.0`, whatever its width. + /// + /// Wide bands therefore accumulate more energy than narrow ones. This is + /// what HTK does, and what most reference C implementations do. + Peak, + + /// Every triangle is scaled by `2 / (upper - lower)`, so its area is + /// constant. + /// + /// Slaney's normalization, and librosa's `norm="slaney"` default. Keeps a + /// flat spectrum flat across bands rather than tilting it by bandwidth. + Area, +} + +/// Config for [`TriangularBank`]. +#[derive(Config, Debug, Copy)] +pub struct TriangularBankConfig { + /// The number of spectrum bins the bank consumes. + pub n_bins: usize, + + /// How each triangle is scaled. + #[config(default = "BankNorm::Peak")] + pub norm: BankNorm, +} + +impl TriangularBankConfig { + /// The number of bands `edges` describes. + /// + /// Each band needs a lower, centre and upper edge, and consecutive bands + /// share two of them, so `n + 2` edges give `n` bands. + pub fn n_bands(edges: &[f32]) -> usize { + edges.len().saturating_sub(2) + } + + /// Validates the geometry against a set of edges. + /// + /// # Errors + /// [`BunsenError::Invalid`] if there are fewer than three edges, if the + /// edges are not strictly increasing, or if the bin count is zero. + pub fn validate( + &self, + edges: &[f32], + ) -> BunsenResult<()> { + if self.n_bins == 0 { + return Err(BunsenError::Invalid( + "TriangularBank n_bins must be non-zero".to_string(), + )); + } + if edges.len() < 3 { + return Err(BunsenError::Invalid(format!( + "TriangularBank needs at least 3 edges to describe one band, got {}", + edges.len(), + ))); + } + if let Some(i) = edges.windows(2).position(|w| w[1] <= w[0]) { + return Err(BunsenError::Invalid(format!( + "TriangularBank edges must strictly increase; edge {i} is {} and edge \ + {} is {}", + edges[i], + i + 1, + edges[i + 1], + ))); + } + Ok(()) + } + + /// The `[n_bands, n_bins]` filter matrix, row-major. + /// + /// Band `i` rises from `edges[i]` to `edges[i + 1]` and falls to + /// `edges[i + 2]`. Bins outside `0..n_bins` are dropped, so edges may run + /// past the spectrum without special-casing. + /// + /// # Panics + /// If the geometry is invalid; see [`validate`](Self::validate). + pub fn to_vec_weights( + &self, + edges: &[f32], + ) -> Vec { + self.validate(edges).ok_or_panic(); + + let n_bands = Self::n_bands(edges); + let mut weights = vec![0.0f32; n_bands * self.n_bins]; + + for band in 0..n_bands { + let (lo, mid, hi) = (edges[band], edges[band + 1], edges[band + 2]); + let row = band * self.n_bins; + + let scale = match self.norm { + BankNorm::Peak => 1.0, + BankNorm::Area => 2.0 / (hi - lo), + }; + + // Rising, over the bins in [lo, mid). + let start = lo.ceil().max(0.0) as usize; + let centre = mid.ceil().max(0.0) as usize; + for j in start..centre.min(self.n_bins) { + weights[row + j] = scale * (j as f32 - lo) / (mid - lo); + } + + // Falling, over the bins in [mid, hi). A bin landing exactly on + // `mid` gets the peak from this branch, not the rising one. + let end = hi.ceil().max(0.0) as usize; + for j in centre..end.min(self.n_bins) { + weights[row + j] = scale * (hi - j as f32) / (hi - mid); + } + } + + weights + } + + /// Builds the bank, uploading a matmul-ready `[n_bins, n_bands]` matrix. + /// + /// # Errors + /// See [`validate`](Self::validate). + pub fn try_init( + &self, + edges: &[f32], + device: &B::Device, + ) -> BunsenResult> { + self.validate(edges)?; + let n_bands = Self::n_bands(edges); + + // Stored transposed: callers contract `[rows, n_bins] @ [n_bins, + // n_bands]`, so the transpose is paid once here rather than per call. + let weights: Tensor = Tensor::from_data( + TensorData::new(self.to_vec_weights(edges), [n_bands, self.n_bins]), + device, + ); + + Ok(TriangularBank { + n_bins: self.n_bins, + n_bands, + weights: weights.transpose(), + }) + } + + /// Builds the bank, panicking on error. + pub fn init( + &self, + edges: &[f32], + device: &B::Device, + ) -> TriangularBank { + self.try_init(edges, device).ok_or_panic() + } +} + +/// A materialized triangular filterbank. +/// +/// Built by [`TriangularBankConfig::try_init`]. Deliberately not a burn +/// `Module`: nothing here is learnable, and the weights are derived from the +/// geometry rather than trained. +#[derive(Debug, Clone)] +pub struct TriangularBank { + n_bins: usize, + n_bands: usize, + + /// `[n_bins, n_bands]`, matmul-ready. + pub weights: Tensor, +} + +impl TriangularBank { + /// The number of spectrum bins consumed. + pub fn n_bins(&self) -> usize { + self.n_bins + } + + /// The number of bands produced. + pub fn n_bands(&self) -> usize { + self.n_bands + } + + /// Folds `[rows, n_bins]` spectra into `[rows, n_bands]` band energies. + pub fn forward( + &self, + spectra: Tensor, + ) -> Tensor { + #[cfg(any(test, debug_assertions))] + crate::contracts::assert_shape_contract!( + ["rows", "n_bins"], + &spectra, + &[("n_bins", self.n_bins)], + ); + + spectra.matmul(self.weights.clone()) + } +} + +/// Mel-spaced band edges, in fractional bin coordinates. +/// +/// Returns `n_bands + 2` edges, equally spaced on the mel scale between `fmin` +/// and `fmax`, mapped back to Hz and then to bins. +/// +/// # Arguments +/// * `n_bands`: how many bands the edges should describe. +/// * `fft_size`: the FFT the bins come from. +/// * `sample_rate`: in Hz. +/// * `fmin` / `fmax`: the band range, in Hz. +/// * `scale`: which mel warping to use — see [`MelScale`], and note the two are +/// not interchangeable. +/// +/// # Panics +/// If `n_bands` is zero or `fmax` does not exceed `fmin`. +pub fn mel_bin_edges( + n_bands: usize, + fft_size: usize, + sample_rate: usize, + fmin: f32, + fmax: f32, + scale: MelScale, +) -> Vec { + assert_ne!(n_bands, 0, "mel_bin_edges needs at least one band"); + assert!( + fmax > fmin, + "mel_bin_edges needs fmax ({fmax}) > fmin ({fmin})" + ); + + let (lo, hi) = (scale.hz_to_mel(fmin), scale.hz_to_mel(fmax)); + let step = (hi - lo) / (n_bands + 1) as f32; + let per_hz = fft_size as f32 / sample_rate as f32; + + (0..n_bands + 2) + .map(|i| scale.mel_to_hz(lo + step * i as f32) * per_hz) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + prelude::*, + support::testing::PerformanceBackend, + }; + + type B = PerformanceBackend; + + const FFT: usize = 512; + const BINS: usize = FFT / 2 + 1; + const SR: usize = 16000; + + #[test] + fn test_htk_mel_round_trips() { + for hz in [0.0f32, 100.0, 700.0, 1000.0, 4000.0, 8000.0] { + let back = MelScale::Htk.mel_to_hz(MelScale::Htk.hz_to_mel(hz)); + assert!((back - hz).abs() < 1e-2, "{hz} -> {back}"); + } + } + + #[test] + fn test_slaney_mel_round_trips() { + // Both sides of the 1 kHz breakpoint, and the breakpoint itself. + for hz in [0.0f32, 250.0, 999.0, 1000.0, 1001.0, 4000.0, 8000.0] { + let back = MelScale::Slaney.mel_to_hz(MelScale::Slaney.hz_to_mel(hz)); + assert!((back - hz).abs() < 1e-2, "{hz} -> {back}"); + } + } + + #[test] + fn test_htk_mel_is_calibrated_at_1khz() { + // The formula's constants are chosen so 1000 Hz lands on ~1000 mel. + let mel = MelScale::Htk.hz_to_mel(1000.0); + assert!((mel - 1000.0).abs() < 1.0, "1000 Hz -> {mel} mel"); + } + + #[test] + fn test_slaney_is_linear_below_the_breakpoint() { + // The defining property of the Slaney warping, and what separates it + // from HTK where it matters most -- the low end of the speech band. + let a = MelScale::Slaney.hz_to_mel(200.0); + let b = MelScale::Slaney.hz_to_mel(400.0); + let c = MelScale::Slaney.hz_to_mel(600.0); + assert!( + (b - a - (c - b)).abs() < 1e-4, + "{a} {b} {c} not equally spaced" + ); + } + + #[test] + fn test_the_two_scales_actually_differ() { + // Guard the guard: if these ever agreed, every test above would pass + // against a single implementation. + let htk = MelScale::Htk.hz_to_mel(300.0); + let slaney = MelScale::Slaney.hz_to_mel(300.0); + assert!( + (htk - slaney).abs() > 1.0, + "HTK {htk} and Slaney {slaney} should disagree", + ); + } + + #[test] + fn test_mel_edges_are_increasing_and_bounded() { + let edges = mel_bin_edges(20, FFT, SR, 0.0, 8000.0, MelScale::Htk); + assert_eq!(edges.len(), 22); + assert!(edges.windows(2).all(|w| w[1] > w[0]), "{edges:?}"); + assert!(edges[0] >= 0.0); + assert!(*edges.last().unwrap() <= BINS as f32); + } + + #[test] + fn test_validate_rejects_bad_geometry() { + let cfg = TriangularBankConfig::new(BINS); + assert!(cfg.validate(&[1.0, 2.0]).is_err(), "too few edges"); + assert!(cfg.validate(&[1.0, 1.0, 2.0]).is_err(), "not increasing"); + assert!(cfg.validate(&[3.0, 2.0, 1.0]).is_err(), "decreasing"); + assert!( + TriangularBankConfig::new(0) + .validate(&[1.0, 2.0, 3.0]) + .is_err(), + "zero bins", + ); + cfg.validate(&[1.0, 2.0, 3.0]).unwrap(); + } + + #[test] + fn test_peak_normalization_reaches_exactly_one() { + // With integer edges the centre bin sits exactly on the peak. + let edges = [2.0f32, 6.0, 11.0]; + let w = TriangularBankConfig::new(BINS).to_vec_weights(&edges); + assert_eq!(w[6], 1.0, "peak should be exactly 1 at the centre edge"); + assert_eq!(w[2], 0.0, "the lower edge itself carries no weight"); + } + + #[test] + fn test_triangles_partition_unity_on_shared_spans() { + // The closed-form property of a triangular bank: where two adjacent + // bands overlap, their weights sum to exactly one. It pins both slopes + // and their alignment at once. + let edges = mel_bin_edges(16, FFT, SR, 0.0, 8000.0, MelScale::Htk); + let cfg = TriangularBankConfig::new(BINS); + let w = cfg.to_vec_weights(&edges); + let n_bands = TriangularBankConfig::n_bands(&edges); + + // Bins strictly inside the second..last spans are covered by exactly + // two triangles. + let lo = edges[1].ceil() as usize; + let hi = edges[n_bands].floor() as usize; + assert!(hi > lo, "need an interior region to test"); + + for j in lo..hi { + let total: f32 = (0..n_bands).map(|b| w[b * BINS + j]).sum(); + assert!( + (total - 1.0).abs() < 1e-4, + "bin {j} sums to {total}, expected 1", + ); + } + } + + #[test] + fn test_area_normalization_equalizes_bands() { + // A flat spectrum should come out flat, which is the whole point of + // the Slaney scaling -- and visibly does not under peak scaling. + let edges = mel_bin_edges(16, FFT, SR, 0.0, 8000.0, MelScale::Htk); + let n_bands = TriangularBankConfig::n_bands(&edges); + + let area = TriangularBankConfig::new(BINS) + .with_norm(BankNorm::Area) + .to_vec_weights(&edges); + let peak = TriangularBankConfig::new(BINS).to_vec_weights(&edges); + + let sums = |w: &[f32]| -> Vec { + (0..n_bands) + .map(|b| w[b * BINS..(b + 1) * BINS].iter().sum()) + .collect() + }; + + // Skip the narrowest low bands, where bin quantization dominates. + let a = sums(&area); + let p = sums(&peak); + let spread = |v: &[f32]| { + let tail = &v[n_bands / 2..]; + let max = tail.iter().fold(0.0f32, |m, x| m.max(*x)); + let min = tail.iter().fold(f32::MAX, |m, x| m.min(*x)); + max / min + }; + + assert!( + spread(&a) < spread(&p), + "area normalization should even the bands out: area {} vs peak {}", + spread(&a), + spread(&p), + ); + } + + #[test] + fn test_forward_folds_a_spectrum() { + let device = Default::default(); + let edges = mel_bin_edges(12, FFT, SR, 0.0, 8000.0, MelScale::Htk); + let bank: TriangularBank = TriangularBankConfig::new(BINS).init(&edges, &device); + + assert_eq!(bank.n_bins(), BINS); + assert_eq!(bank.n_bands(), 12); + assert_eq!(bank.weights.dims(), [BINS, 12]); + + let spectra = Tensor::::ones([3, BINS], &device); + let out = bank.forward(spectra); + assert_eq!(out.dims(), [3, 12]); + + // A flat spectrum folds to each band's weight sum. + let got: Vec = out.to_data_as::().to_vec_as::().ok_or_panic(); + let w = TriangularBankConfig::new(BINS).to_vec_weights(&edges); + for band in 0..12 { + let want: f32 = w[band * BINS..(band + 1) * BINS].iter().sum(); + assert!( + (got[band] - want).abs() < 1e-3 * want.max(1e-3), + "band {band}: {} vs {want}", + got[band], + ); + } + } + + #[test] + fn test_edges_past_the_spectrum_are_dropped() { + // Edges may run past Nyquist; the bank simply stops rather than + // needing the caller to clamp. + let cfg = TriangularBankConfig::new(8); + let w = cfg.to_vec_weights(&[2.0, 5.0, 40.0]); + assert_eq!(w.len(), 8); + assert!(w.iter().all(|v| v.is_finite())); + assert!(w[7] > 0.0, "the band should still cover the last bin"); + } +} diff --git a/crates/bunsen/src/ops/signal/mod.rs b/crates/bunsen/src/ops/signal/mod.rs index 5761c5d1..95adf2a2 100644 --- a/crates/bunsen/src/ops/signal/mod.rs +++ b/crates/bunsen/src/ops/signal/mod.rs @@ -3,6 +3,7 @@ mod biquad; mod cosine_window; mod decimating_fir; +mod filterbank; mod lpc; mod sliding_stft; mod stft_window; @@ -15,6 +16,8 @@ pub use cosine_window::*; #[doc(inline)] pub use decimating_fir::*; #[doc(inline)] +pub use filterbank::*; +#[doc(inline)] pub use lpc::*; #[doc(inline)] pub use sliding_stft::*; From 884b892ec0f01f3aaf62f5929c5e56284783c99e Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Mon, 24 Aug 2026 13:32:33 -0700 Subject: [PATCH 21/32] docs(ten-vad): drop the fidelity ledger It documented which of ten-vad's numerical decisions bunsen reproduces and what switching would cost. That question is being answered by deleting the layer, so the document has outlived the decision it was written to inform. It was also going stale as the extraction proceeded: D1's deviation turned out to be narrower than described -- integer slopes are a consequence of the integer edges, not a separate quirk -- and D4 and D5 are about to be resolved rather than reproduced. The reasoning worth keeping has moved to where it applies. `ops::signal` carries the truncated-FIR-versus-recurrence argument, the batched-freeze recipe for porting an early-exit recursion, and the mel-scale and normalization choices; `ops::seq` carries the renormalization and forbidden-transition rationale; `burner::tensor::burn_behavior` carries the upstream bugs. Those are attached to code that will outlive ten_vad. A published copy remains at https://claude.ai/code/artifact/1e40807e-4d10-4373-b58c-38f8c47c0382 if the history is wanted later. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../src/kits/speech/ten_vad/NUMERICS.html | 1379 ----------------- 1 file changed, 1379 deletions(-) delete mode 100644 crates/bunsen/src/kits/speech/ten_vad/NUMERICS.html diff --git a/crates/bunsen/src/kits/speech/ten_vad/NUMERICS.html b/crates/bunsen/src/kits/speech/ten_vad/NUMERICS.html deleted file mode 100644 index cef76f08..00000000 --- a/crates/bunsen/src/kits/speech/ten_vad/NUMERICS.html +++ /dev/null @@ -1,1379 +0,0 @@ -ten-vad Fidelity Ledger - - - - -
- -
-

bunsen · kits::speech::ten_vad

-

The ten-vad Fidelity Ledger

-

- Every place bunsen's port reproduces the reference implementation's arithmetic - instead of doing the standard thing — what it costs, and what it would take to - stop. -

-
- Commit 294ab13 - Date 2026-08-22 - Reference TEN-framework/ten-vad, src/ + ALGO_TRACE.md -
-
- -
-

Fidelity is three different things

-

And only one of them is free to change

- -

- The working hypothesis behind this document is that the ten-vad authors got some - of the math wrong, and that matching them bit for bit is holding back both - unification with the rest of bunsen and raw speed. That is partly right, - and the useful move is to separate three things that all look like "matching the - reference" but behave completely differently. -

- -
- The distinction that does the work -

- Class 1 — the trained function. The filterbank, the - normalization tables, the model weights. The network was trained on - these features. Changing them is not a numerics decision; it is a - retraining project. -

-

- Class 2 — the reference's algorithm. The Viterbi transition - window, the Levinson early exit, the 30-second state reset. Changing these - changes the output. Whether that output is worse is an empirical - question the golden can answer in twenty seconds. -

-

- Class 3 — the reference's arithmetic realization. How a filter - is factored, how a sliding sum is accumulated, what precision an inner product - uses. The function is unchanged; only the rounding differs. Departing here has - so far improved speed and accuracy every single time. -

-
- -

- Class 3 is where the free money is, and it is also where most of the remaining - cost sits. Class 2 is a set of cheap experiments nobody has run. Class 1 is - locked until someone retrains. -

-
- -
-

What the pipeline actually does

-

Front end, then a small recurrent network

- -
-
- - - FRONT END — context/ - - - audio hop - 256 @ 16 kHz - - - - ×32768 - int16 scale - - - - pre-emph - 0.97 - - - - sliding STFT - 768 → 1024 - - - - |X|² - 513 bins - - - - - mel bank ×40 - D1 · non-standard - - - - ln(x + 1e-20) - - - - - - pitch estimator - feature 40 · see below - - - - - - standardize - 41 features - - - - stack ×3 - [1, 3, 41] - - - MODEL — blocks/ - - - - conv stem - - - - LSTM 80→64 - - - - LSTM 64→64 - - - - head → sigmoid - - - - P(speech) - -
-
- Solid wires carry data; the dashed wire is the un-normalized bin power, which - the pitch branch reads before the 1/32768² division. Two orderings - here are load-bearing and easy to reverse: pitch runs on the raw - un-pre-emphasized hop, and the power normalization happens before the - filterbank matmul, not after the log. -
-
- -
-
- - PITCH ESTIMATOR — context/pitch/ - - - 1 · prefilter design - 513 bins → 16 LPC taps - stateless - - - - 2 · excitation - whiten, ÷4 decimate - D6 · anti-alias filter - - - - 3 · lag search - 64 lags × 2 half-hops - D5 · sliding energy - - - - 4 · Viterbi track - 56 states, 2 steps/hop - D4 · candidate window - - - - pitch, Hz - - - CARRIED ACROSS HOPS - - - FIFO · smoother - filter state · exc_buf - - - - correlation ring - slot energies - - - - path score · backpointers - -
-
- Only stage 1 is free of carried state. Stages 2 and 3 carry sliding windows, - which fold into the ordinary prepend-and-reslice idiom. Stage 4 is a genuine - Viterbi recurrence: two dependent steps per hop over 56 states, and the only - part of the whole front end that cannot be batched across time. -
-
-
- -
-

The ledger

-

Thirteen places the port defers to the reference

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
IDWhatClassCost of matchingSwitching
D1Mel filterbank edges, slopes, normalization1 · trainedCannot share bunsen's mel pathRetrain
D2Non-standard DCT normalization1 · trainedNone at runtimeNot worth it
D3Feature mean/std tables1 · trainedNoneRetrain
D4Viterbi window asymmetric, unbounded below2 · algorithmDense 56×56 instead of banded 56×9Test it
D5Lag energy as a clamped running sum3 · realization~192 sequential kernels per callDo it
D6Anti-alias IIR realization3 · realizationAlready done
D7Levinson–Durbin early exit2 · algorithmNone — the freeze costs what the fix wouldNeutral
D8Autocorrelation precision3 · realizationAlready done
D9int16 scale round-trip2 · algorithmLooks removable; is notLoad-bearing
D10LSTM state reset every 1875 frames2 · algorithmNone — already a config knobTest it
D11PITCH_PI is one ULP low3 · realizationNone — folded at build timeNeutral
D12dc0_bias integer-divides first3 · realizationNone at the shipped geometryLatent
D13Batch pinned to 1 by two graph constants1 · trainedNo multi-stream batchingNeeds new weights file
-
-
- -
-

Class 3 — the reference's arithmetic

-

Same function, different rounding. Change freely.

- -
-

Best single change available

-
- D5 -

Lag energy as a clamped running sum

- Do it -
-
-
What the reference does
-
-

- Normalizes each of the 64 candidate lags by the energy under the lagged - window, maintained as a sliding sum with a clamp inside the recurrence: -

-
lagged = max(lagged - exc_sq[base + lag - 1], 0.0);
-lagged += exc_sq[base + lag + half - 1];
-
- -
Why that clamp cannot be there
-
-

- Every term is a square, so the quantity being clamped is a window sum of - non-negative values minus one of its own members. In exact arithmetic it is - never negative and the clamp never fires. It exists purely to - catch f32 cancellation once the running sum has drifted below - the value being subtracted — which happens in near-silence, where the sum - is small and 64 chained subtractions have eaten it. -

-
- -
What it costs to reproduce
-
-

- The clamp makes the recurrence non-associative, so no prefix-sum - reformulation reproduces it — correlate.rs says exactly this, - and it is correct. What it leaves is a chain of 63 dependent steps - per half-hop, each a subtract, a clamp, an add and a slice-assign, on - tensors of a few kilobytes: 126 sequenced steps and roughly 500 - operations per call. Fusion can merge some of those operations; it - cannot shorten the dependency chain, and the chain is what costs. -

-

- The standard alternative is not a prefix sum, which inherits the same - instability. It is to compute all 64 window sums directly: - 64 lags × 32 terms = 2048 multiply-accumulates, expressible as a windowed - reduction or one banded contraction. That is two operations at - dependency depth one, fully parallel, accumulating no error across - lags at all. It trades 96 adds for 2048 — arithmetic that size is free on a - device, and the serialization is not. -

-
- -
Impact of switching
-
-

- The direct form is more accurate, not less — the running sum's - error accumulates monotonically over 64 subtractions and never washes out. - Estimated divergence is bounded by that accumulated error, on the - order of 64 · 2⁻²⁴ ≈ 4e-6 relative in the worst case and far - less typically, further damped by the (lagged + 1 + reference) - denominator. -

-

- That sits inside the perturbation the golden already demonstrably survives: - bunsen and the C reference reach their bin powers through entirely - different FFTs, differ by ~1e-6 relative, and still agree on the voicing - decision for all 3750 frames. This is the change with the best - ratio of upside to risk in the whole ledger. -

-
-
-
- -
-
- D6 -

The anti-alias filter realization

- Already done -
-
-
What the reference does
-
-

- Guards the 4 kHz Nyquist with a five-section Direct-Form-II IIR cascade, - stepped sample by sample — 1280 sequential steps per hop. -

-
-
What bunsen does instead
-
-

- Realizes the same LTI system as a 2048-tap truncated impulse response with - the ×4 decimation folded into the kernel, evaluated as a single GEMM. The - impulse response is generated by running a unit impulse through the exact - host cascade, so truncation is the only difference between the two. -

-
-
Impact — measured
-
-

- Against an f64 ground truth, the truncated FIR is - 25–50% more accurate than the reference's own - f32 DF-II cascade (7.3e-7 against 1.5e-6 relative on full-scale - input). Truncation at 2048 taps contributes ~8e-11 — three orders of - magnitude below the noise floor of the thing it replaces. -

-

- This is the precedent for the entire argument. Departing from the - reference's realization while preserving its system was - faster and more accurate at the same time. The literal transcription is - still in the tree, selectable, as the thing that proves the translation was - correct in the first place. -

-
-
-
- -
-
- D8 -

Autocorrelation precision and folding

- Already done -
-
-
What the reference does
-
-

- Recovers autocorrelation lags from the band envelope by FFT, whose error - grows with log N rather than N. -

-
-
What bunsen does instead
-
-

- Sums the 511-term series directly, accumulating in f64 — a flat - f32 sum there would be materially worse than the FFT it - replaces, while f64 is at least as good. Then it folds - envelope interpolation, Nyquist zeroing, and the autocorrelation into one - constant [18, 17] matrix, because all three are linear. -

-
-
Impact
-
-

- The device contracts 18 terms where the host contracts 513, and the - precision trap is closed by construction rather than by a test. Strictly - better on both axes. -

-
-
-
- -
-
- D11 -

PITCH_PI = 3.1415926

- Neutral -
-
-
What it is
-
-

- The reference writes π as a truncated decimal literal, landing exactly one - ULP below f32::consts::PI. A unit test pins that relationship - so the constant cannot be "corrected" by accident. -

-
-
Impact of switching
-
-

- ~1e-7 relative on the cosine tables it seeds. Those tables are folded into - constant matrices at build time, so the choice has zero runtime - cost either way — there is nothing to gain by changing it, and the - perturbation lands upstream of an argmax. Keep it. -

-
-
-
- -
-
- D12 -

dc0_bias divides as an integer first

- Latent -
-
-
What it is
-
-

- The noise floor added to lag zero is windowSz / 12 / 38.0f, - where the first division is integer. At the shipped 768-sample window - 768 / 12 = 64 exactly, so the truncation has no effect today. -

-
-
Impact
-
-

- Nothing now; a trap for anyone who changes the analysis window. Worth - keeping as-is precisely because it is inert — reproducing it costs - nothing and documents the reference's intent. -

-
-
-
-
- -
-

Class 2 — the reference's algorithm

-

Changing these changes the output. Run the golden and find out.

- -
-

Very likely a bug in the reference

-
- D4 -

The Viterbi candidate window is unbounded below

- Test it -
-
-
What the reference does
-
-

- Computes the transition window's lower offset as - SIDXT = min(0, 4 - idx). That min against zero is - almost certainly meant to be a max: as written the window never - extends below index 4, so it widens as the period index grows. -

-
cand ∈ [ min(idx, 4), min(idx + 4, 55) ]
-
-idx =  4  →  5 candidates   (as intended)
-idx = 55  → 52 candidates   (jdx reaches 51, penalty 52.0)
-
- -
What it costs to reproduce
-
-

- A dense [56, 56] transition matrix — 3136 entries — where the - intended ±4 window is a [56, 9] band of 504. Invalid - transitions are handled by assigning a penalty large enough to lose every - max, which is the only way to express a ragged window as a - batched tensor op. That is 6.2× the transition arithmetic - per Viterbi step, and there are two steps per hop. -

-
- -
Impact of switching — estimated
-
-

- Smaller than the window width suggests, because the quadratic penalty is - already doing the job the bound was supposed to do. The per-step score gain - is xcorr · weight, and xcorr is - bounded by 1 — by Cauchy–Schwarz the numerator satisfies - 2·inst ≤ E_ref + E_lag, against a denominator of - E_ref + E_lag + 1. The weight is normalized to average 1 - across the tracker's window, so a typical step contributes well under 1, - against: -

-
    -
  • |jdx| = 5 → penalty 0.5, already half a typical step's - entire gain.
  • -
  • |jdx| = 10 → penalty 2.0, needing several steps of - accumulated advantage to overcome.
  • -
  • |jdx| = 51 → penalty 52.0, which nothing plausible reaches.
  • -
-

- So the far half of the erroneous window is dead weight, and the live - difference zone is roughly |jdx| ≤ 7 — reachable only at sharp - pitch discontinuities such as voicing onsets. That bounds a single step, not - the accumulated path score, whose spread across states can grow, which is - exactly why this wants the golden rather than an argument. A twenty-second - experiment behind a config flag, and it would cut the transition matrix 6×. -

-
-
-
- -
-
- D10 -

The LSTM states are zeroed every 1875 frames

- Test it -
-
-
What the reference does
-
-

- Counts model calls and zeroes both LSTM states every 1875 of them — 30 - seconds of audio — leaving the feature context stack intact. The constant - carries a bare // TODO in the C, which is not the marking of a - settled design decision. -

-
-
Why it might be wrong
-
-

- Periodic amnesia in a streaming VAD is a strange thing to want. It is - plausibly a workaround for state drift in a model that was never trained on - sequences that long, in which case the right fix is upstream. It is equally - plausibly load-bearing for long-clip stability. -

-
-
Impact of switching
-
-

- Changes the output for any stream longer than 30 s, and nothing shorter. - Already exposed as TenVadContextConfig::reset_frames, so - None disables it without touching code. The 3750-hop golden - spans exactly two reset periods, which makes it the natural instrument — - though note that it can only tell you which arm matches the reference, - not which arm is better. Judging that needs labelled audio. -

-
-
-
- -
-
- D7 -

Levinson–Durbin exits early

- Neutral -
-
-
What the reference does
-
-

- Breaks out of the recursion once the residual drops 30 dB below lag zero, - leaving the remaining coefficients at their zero initializer — not, as is - easy to assume, at whatever the recursion had reached. -

-
-
What it costs to reproduce
-
-

- A batched tensor version cannot branch per row, so it runs all 16 steps and - freezes each row under a mask once its condition trips. Two details make - that faithful: the reference checks after completing an iteration, - so the freeze is applied after the update; and a frozen row's error stays - below the threshold, so the mask is already monotone without sticky - bookkeeping. -

-
-
Impact of switching
-
-

- Essentially nil in either direction. The masked freeze already costs exactly - what running to full order would cost, so dropping the early exit buys no - speed — it only removes the mask. Meanwhile it would change the output - wherever the break fires. Nothing to gain. -

-
-
-
- -
-
- D9 -

The int16 scale round-trip is not removable

- Load-bearing -
-
-
What it looks like
-
-

- bunsen takes [-1, 1] audio, multiplies by 32768 on entry, and - divides the bin power by 32768² before the filterbank. Pre-emphasis and the - STFT are linear and power is quadratic, so for features 0–39 the round-trip - is algebraically the identity. It looks like pure legacy - baggage from a fixed-point original. -

-
-
Why it has to stay
-
-

- The pitch branch reads the raw scaled hop and the un-normalized bin - power, and it is full of hard-coded absolute magnitudes calibrated for - int16-scale signals: -

-
denom   = lagged + (1.0 + reference)   // the "1 +" floors silence
-denom   = max(denom, 1e-12)
-ac[0]  += ac[0] * 1e-4 + dc0_bias      // dc0_bias ≈ 1.684
-

- Drop the scaling and every one of those thresholds moves by nine orders of - magnitude relative to the signal. The correlation denominator's - 1 + would stop flooring silence and start dominating speech. -

-
-
Impact of switching
-
-

- Feature 40 breaks completely. Worth writing down precisely because the - identity is easy to spot and the dependency is not — this is the trap in - the ledger most likely to catch someone tidying up. -

-
-
-
-
- -
-

Class 1 — the trained function

-

Locked until someone retrains

- -
-

The real unification blocker

-
- D1 -

The mel filterbank is not a mel filterbank

- Retrain -
-
-
What the reference does
-
-

Three deviations from every standard builder, all load-bearing:

-
mel = 2595 · log10(1 + hz / 700)        // HTK, fine
-bin = (usize)((fft_size + 1) · hz / sr) // +1, and truncates
-
    -
  • Edge mapping uses fft_size + 1 and a - truncating cast, not fft_size and a round.
  • -
  • Slopes are integer — the triangles follow where the - truncation landed, not the exact edge frequencies.
  • -
  • No area normalization — every filter peaks at exactly - 1.0 regardless of width.
  • -
-

- The edge arithmetic is also deliberately done in f32; computing - it in f64 can round an edge across an integer boundary and - silently produce a different filterbank. -

-
- -
What it costs
-
-

- This is the reason ten_vad cannot share a filterbank with - anything else in bunsen, and it is the single largest obstacle to - unification. No librosa- or Slaney-style builder reproduces it. -

-
- -
Impact of switching — estimated
-
-

- Total. At a 1024-point FFT over 16 kHz one bin is 15.6 Hz, and the combined - +1 and truncation move edges by up to a bin. The lowest mel - bands are only a few bins wide, so that is a large fractional change to - their shape — and dropping area normalization on top changes every band's - gain. -

-

- All 40 log-mel features shift, which invalidates the reference mean/std - tables (D3) and the trained weights simultaneously. This is not a - numerics decision that a tolerance can absorb; it is a different model - input. The honest answer is that it stays until someone retrains, and that - retraining is the only thing that unlocks it. -

-
-
-
- -
-
- D2 -

The cepstral DCT uses no standard normalization

- Not worth it -
-
-
What the reference does
-
-

- Builds cos((i + 0.5)·j·π / 18), scales column 0 by - √0.5, and applies √(2/18) in both directions — so - forward and inverse differ only in which index of one table they walk. -

-
-
Impact of switching
-
-

- The forward/inverse pair is self-consistent, and bunsen folds the whole - thing into a constant matrix, so matching it costs nothing at - runtime. Switching to an orthonormal DCT-II would rescale the - cepstrum, which then feeds BAND_LPC_COMP and changes the LPC - envelope. Real risk, no reward. -

-
-
-
- -
-
- D3 -

Feature mean / std tables

- Retrain -
-
-
What it is
-
-

- 41 mean and 41 std constants transcribed verbatim from the reference's - coeff.h, applied as - (v - mean) / (std + 1e-20). These are reference data, not - tunables — they are the statistics of the training corpus under D1's - filterbank. -

-
-
Impact of switching
-
-

- Moves in lockstep with D1 and the weights. No independent decision to make - here; it is listed so the coupling is explicit. -

-
-
-
- -
-
- D13 -

Batch is pinned to 1 by two graph constants

- Needs new weights file -
-
-
What it is
-
-

- Not a numerics issue, but the other unification blocker. The exported graph - reshapes by constant new_shape__177 = [-1, 1, 80] immediately - before the first LSTM, which lands the leading axis on the LSTM's - sequence dimension with its batch fixed at 1. -

-
-
Consequences
-
-

- Sequence batching is free and bunsen now uses it: feeding - T stacked context frames as one call is bit-identical to - T sequential calls, final states included. -

-

- Multi-stream batching is structurally impossible against - the stock graph — batched states fail shape validation outright. It needs - two reshape constants patched, which means a different model file, not a - different call. -

-
-
-
-
- -
-

What departing has already bought

-

Four changes, all Class 3, all strictly better

- -

- The thesis that bit-identical reproduction is holding things back has a track - record behind it. Every departure so far has been from the reference's - realization rather than its function, and each one improved at - least one axis without costing the other. -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ChangeBeforeAfterAccuracy
Truncated FIR anti-alias1280 seq. steps/hop1 GEMM25–50% better
f64 autocorrelation, folded tables513-term contraction18-termBetter
Sequence-batched model1 call/hop1 call/chunkUnchanged
Fixed-size pitch passes612 s golden19.6 s goldenByte-identical
-
- -

- The last row is the one to notice: a 31× speedup with byte-identical output, - because the cost was never in the arithmetic at all — it was one-time, - shape-keyed kernel selection. Before assuming a numerical constraint is what - makes something slow, measure cold against warm. -

-
- -
-

What to do next

-

In order of expected value

- -
    -
  1. - Replace the sliding lag energy with a direct banded contraction (D5). - Removes a 64-step scan and ~192 dispatches per call, and is more accurate than - what it replaces. Class 3 — the function does not change. Verify with the - 3750-hop golden. -
  2. -
  3. - Put the Viterbi window correction behind a flag and run the golden (D4). - If the decision agreement holds, the transition matrix drops from 3136 entries - to 504 and a probable reference bug goes away. If it does not hold, you have - learned something worth writing down either way. -
  4. -
  5. - Measure the 1875-frame reset against labelled audio, not the golden (D10). - The golden can only say which arm matches the reference. Deciding whether the - reset helps needs ground truth on clips longer than 30 s. -
  6. -
  7. - Leave D1 alone until there is a retraining budget. It is the - real unification blocker, and no amount of numerical cleverness gets around it. -
  8. -
- -
- The short answer to the hypothesis -

- Yes, the reference has at least one clear bug (D4) and one decision its own - authors flagged as unfinished (D10). But bit-identical reproduction is not what - is costing speed — D5 is the only place where matching the reference's - arithmetic forces a genuinely slower structure, and the two largest speedups so - far came from changes that preserved the output exactly. The real blocker to - unification is D1, and it is a training problem, not a numerics one. -

-
-
- -
-

- Measured figures come from the kit's own tests — cross_test.rs for - the golden, driver::tests::test_where_the_time_goes for the timing - breakdown, and the per-stage parity tests under - context/pitch/tensor/. Figures marked estimated are - reasoned from the code and have not been measured. -

-
- -
From 9abdb560ec9229464fa11f480afa3c8ca4d9b6a0 Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Mon, 24 Aug 2026 13:45:33 -0700 Subject: [PATCH 22/32] refactor(signal): lift the LPC analysis filter out of ten_vad Completes `ops::signal::lpc` as a chain that stands on its own: autocorrelate a spectrum, solve for coefficients, then filter with them. `lpc_residual_batched` is the third link, and it uses the same convention `levinson_durbin` emits, so its output feeds straight in. The generalization that matters is per-row taps. Speech coefficients are refitted every frame, so a run of frames is a run of *different* filters -- which is exactly what `conv1d` cannot express, since its kernel is shared across the batch. That constraint, not performance, is why this is written as shift-and-accumulate; the performance argument (an `order`-wide window stack would materialize `rows * out_len * order`) is the secondary one, and both are now on the function. The accumulation order is documented rather than incidental: base sample first, then lag 0 upward. Summation order is observable in f32, so pinning it is what lets a host implementation agree exactly rather than approximately. Also documented is what this deliberately is not. Synthesis -- `x[n] = y[n] - sum(taps[j] * x[n-1-j])` -- is a recurrence in its own output and needs a sequential scan, so a caller reaching for the inverse does not find a function here that quietly computes the wrong thing. The anchor is closed-form: drive an AR(1) process with a known excitation, whiten it with the matching tap, and the excitation comes back out. Around it, zero taps are the identity, and a scalar difference equation with a different tap set per row -- the case `conv1d` cannot do at all -- matches term for term. ten_vad's whitening stage now delegates. Golden unchanged: mean |diff| 5.649e-5, worst 6.446e-4 at hop 2973, decisions agreeing on all 3750 frames. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../context/pitch/tensor/excitation.rs | 14 +- crates/bunsen/src/ops/signal/lpc.rs | 197 ++++++++++++++++++ 2 files changed, 199 insertions(+), 12 deletions(-) diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/excitation.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/excitation.rs index 13ca39c4..a0086840 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/excitation.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/excitation.rs @@ -74,6 +74,7 @@ use crate::{ WithOkOrPanic, }, kits::speech::ten_vad::context::coeff::HOP_SIZE, + ops::signal::lpc_residual_batched, }; /// The 2-tap smoother's feedback coefficient. @@ -314,18 +315,7 @@ impl PitchExcitation { .swap_dims(0, 1) .reshape([rows, window]); - let taps = lpc.reshape([rows, LPC_ORDER]); - - let mut acc = aligned - .clone() - .slice_dim(1, LPC_ORDER as isize..(LPC_ORDER + hop) as isize); - for j in 0..LPC_ORDER { - let tap = taps.clone().slice_dim(1, j as isize..(j + 1) as isize); - let lo = (LPC_ORDER - 1 - j) as isize; - let lagged = aligned.clone().slice_dim(1, lo..lo + hop as isize); - acc = acc + lagged * tap; - } - acc + lpc_residual_batched(aligned, lpc.reshape([rows, LPC_ORDER])) } /// The 2-tap smoother, `y[n] = w[n] + 0.7·w[n-1]`, carrying `w[-1]`. diff --git a/crates/bunsen/src/ops/signal/lpc.rs b/crates/bunsen/src/ops/signal/lpc.rs index 536d2c53..9f66c42c 100644 --- a/crates/bunsen/src/ops/signal/lpc.rs +++ b/crates/bunsen/src/ops/signal/lpc.rs @@ -336,6 +336,81 @@ pub fn levinson_durbin_batched( lpc } +/// Applies the LPC analysis filter, with a different tap set per row. +/// +/// The filter [`levinson_durbin`] solves for, in the same convention: +/// +/// ```text +/// y[n] = x[m] + sum(taps[j] * x[m - 1 - j] for j in 0..order), m = order + n +/// ``` +/// +/// so passing coefficients straight from [`levinson_durbin_batched`] whitens +/// the signal they were fitted to. Zero taps leave the signal unchanged. +/// +/// Rows are independent and carry **their own taps**, which is the point: +/// speech coefficients are refitted every frame, so a run of frames is a run of +/// different filters. That is what rules out `conv1d`, whose kernel is shared +/// across the batch. +/// +/// # Why shift-and-accumulate +/// +/// Written as `[out_len, order + 1]` windows contracted against a per-row tap +/// vector, this would materialize a `rows * out_len * order` intermediate. As +/// `order` broadcast multiply-adds over `[rows, out_len]` slices it is far +/// smaller, and it accumulates in a fixed, documented order: the base sample +/// first, then lag `0` upward. Summation order is observable in `f32`, and +/// pinning it is what lets a host implementation and this one agree exactly +/// rather than approximately. +/// +/// # Analysis only +/// +/// This is the feed-forward direction. Synthesis -- +/// `x[n] = y[n] - sum(taps[j] * x[n - 1 - j])` -- is a recurrence in its own +/// output and cannot be written this way; it needs a sequential scan. +/// +/// # Arguments +/// * `windowed`: `[rows, order + out_len]`. The leading `order` samples are the +/// history the filter reaches back into, and produce no output of their own. +/// * `taps`: `[rows, order]` coefficients, one set per row. +/// +/// # Returns +/// `[rows, out_len]` residual. +/// +/// # Panics +/// If the row counts disagree, or if `windowed` is not longer than `order`. +pub fn lpc_residual_batched( + windowed: Tensor, + taps: Tensor, +) -> Tensor { + let [rows, span] = windowed.dims(); + let [tap_rows, order] = taps.dims(); + + assert_eq!( + rows, tap_rows, + "lpc_residual_batched: {rows} signal rows against {tap_rows} tap rows", + ); + assert!( + span > order, + "lpc_residual_batched needs more than {order} samples of window, got {span}", + ); + + let out_len = span - order; + + // The base sample, then each lag in increasing `j`. + let mut acc = windowed + .clone() + .slice_dim(1, order as isize..(order + out_len) as isize); + + for j in 0..order { + let tap = taps.clone().slice_dim(1, j as isize..(j + 1) as isize); + let lo = (order - 1 - j) as isize; + let lagged = windowed.clone().slice_dim(1, lo..lo + out_len as isize); + acc = acc + lagged * tap; + } + + acc +} + #[cfg(test)] mod tests { use burn::tensor::Distribution; @@ -769,6 +844,128 @@ mod tests { ); } + #[test] + fn test_residual_of_zero_taps_is_the_signal() { + let device = Default::default(); + let (rows, order, out) = (2usize, 4usize, 6usize); + + let flat: Vec = (0..rows * (order + out)).map(|i| i as f32 * 0.5).collect(); + let windowed = Tensor::::from_data( + TensorData::new(flat.clone(), [rows, order + out]), + &device, + ); + let taps = Tensor::::zeros([rows, order], &device); + + let got: Vec = lpc_residual_batched(windowed, taps) + .to_data_as::() + .to_vec_as::() + .ok_or_panic(); + + for r in 0..rows { + for n in 0..out { + let want = flat[r * (order + out) + order + n]; + assert!( + (got[r * out + n] - want).abs() < 1e-6, + "row {r}, sample {n}: {} vs {want}", + got[r * out + n], + ); + } + } + } + + #[test] + fn test_residual_recovers_the_excitation_of_an_ar_process() { + // Closed form, and the whole reason the filter exists: drive an AR(1) + // process with a known excitation, whiten it with the matching tap, + // and the excitation comes back out. + let device = Default::default(); + let (a, order, out) = (0.8f32, 1usize, 12usize); + + let excitation: Vec = (0..out + order).map(|n| ((n as f32) * 1.7).sin()).collect(); + let mut x = vec![0.0f32; out + order]; + for n in 0..x.len() { + let prev = if n == 0 { 0.0 } else { x[n - 1] }; + x[n] = a * prev + excitation[n]; + } + + let windowed = Tensor::::from_data(TensorData::new(x, [1, out + order]), &device); + // `levinson_durbin`'s convention: the AR(1) whitener is `-a`. + let taps = Tensor::::from_data(TensorData::new(vec![-a], [1, 1]), &device); + + let got: Vec = lpc_residual_batched(windowed, taps) + .to_data_as::() + .to_vec_as::() + .ok_or_panic(); + + for n in 0..out { + let want = excitation[order + n]; + assert!( + (got[n] - want).abs() < 1e-5, + "sample {n}: {} vs {want}", + got[n], + ); + } + } + + #[test] + fn test_residual_matches_a_scalar_filter() { + // Against the difference equation written out, with a different tap + // set per row -- which is the case `conv1d` cannot express at all. + let device = Default::default(); + let (rows, order, out) = (3usize, 5usize, 9usize); + + let sig: Vec = (0..rows * (order + out)) + .map(|i| ((i as f32) * 0.41).sin() + 0.3 * ((i as f32) * 1.13).cos()) + .collect(); + let tap: Vec = (0..rows * order) + .map(|i| 0.4 * ((i as f32) * 0.77).cos()) + .collect(); + + let got: Vec = lpc_residual_batched( + Tensor::::from_data(TensorData::new(sig.clone(), [rows, order + out]), &device), + Tensor::::from_data(TensorData::new(tap.clone(), [rows, order]), &device), + ) + .to_data_as::() + .to_vec_as::() + .ok_or_panic(); + + for r in 0..rows { + let row = &sig[r * (order + out)..(r + 1) * (order + out)]; + for n in 0..out { + let m = order + n; + let mut want = row[m]; + for j in 0..order { + want += tap[r * order + j] * row[m - 1 - j]; + } + assert!( + (got[r * out + n] - want).abs() < 1e-5, + "row {r}, sample {n}: {} vs {want}", + got[r * out + n], + ); + } + } + } + + #[test] + #[should_panic(expected = "tap rows")] + fn test_residual_rejects_mismatched_rows() { + let device = Default::default(); + lpc_residual_batched( + Tensor::::zeros([2, 8], &device), + Tensor::::zeros([3, 4], &device), + ); + } + + #[test] + #[should_panic(expected = "samples of window")] + fn test_residual_rejects_a_short_window() { + let device = Default::default(); + lpc_residual_batched( + Tensor::::zeros([1, 4], &device), + Tensor::::zeros([1, 4], &device), + ); + } + #[test] #[should_panic(expected = "at least lags 0 and 1")] fn test_degenerate_input_is_rejected() { From 71aa4b26b7461095c93335a9689799da189f7fcc Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Mon, 24 Aug 2026 14:30:46 -0700 Subject: [PATCH 23/32] feat(signal): add a normalized lag search, and pin a burn mat-vec failure `ops::signal::LagSearch` scores a reference window against every lag behind it -- the basis of time-domain pitch detection, and equally of delay estimation, echo alignment and onset matching. Like the Viterbi this is a rewrite rather than a lift: ten_vad's stage fuses the search with pitch-specific octave suppression, and its energy floor is an absolute constant calibrated to int16-scale signals. Extracting the shape would have carried both. The general op takes the floor as config and says plainly that it is absolute, so a caller working in [-1, 1] knows the number means nothing until they rescale it. Two things are documented rather than assumed. The normalization `2/(|x|^2 + |y|^2)` is bounded above by 1 and reaches it exactly on a repeat, by AM-GM -- so a score reads directly as "how close to a repeat is this" and a threshold means the same thing at any amplitude. And it is *not* the Pearson form: the geometric mean is indifferent to unequal energies, this penalizes them, which for periodicity is the behavior you want. The substantive change is that lag energies are computed directly rather than slid. A sliding sum is O(max_lag + window) against O(max_lag * window) and is still the wrong choice: it serializes the whole lag range into a dependency chain where the direct form is one parallel reduction, and it accumulates rounding monotonically with no way to recover -- which in near-silence drives it negative, which is why implementations that slide invariably end up clamping. `test_direct_energy_beats_a_sliding_sum` measures both against an f64 truth on a loud-then-quiet signal rather than asserting the claim. Anchored closed-form: an exact repeat scores exactly 1, no score exceeds 1 on structureless data, the peak lands a whole number of periods from the reference, and a direct host computation matches term for term. Along the way this turned up a third burn behavior worth pinning. `matmul` fails outright -- every autotune candidate returns `InvalidSamples` and the tuner panics rather than falling back -- when its left operand is an `unfold` view and its right is a column. Narrowed by elimination: the same shapes with a contiguous left operand succeed on both random and constant data, so the trigger is the non-contiguous operand, not the mat-vec shape. Worth stating because the autotune key reports `matrix_layout_lhs: Contiguous` for exactly the view that fails. Pinned with a control test, since without one the failure reads as "mat-vec is broken", which is both wrong and more alarming. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../bunsen/src/burner/tensor/burn_behavior.rs | 66 +++ crates/bunsen/src/ops/signal/lag_search.rs | 464 ++++++++++++++++++ crates/bunsen/src/ops/signal/mod.rs | 3 + 3 files changed, 533 insertions(+) create mode 100644 crates/bunsen/src/ops/signal/lag_search.rs diff --git a/crates/bunsen/src/burner/tensor/burn_behavior.rs b/crates/bunsen/src/burner/tensor/burn_behavior.rs index 1b735caf..da885c3c 100644 --- a/crates/bunsen/src/burner/tensor/burn_behavior.rs +++ b/crates/bunsen/src/burner/tensor/burn_behavior.rs @@ -106,6 +106,72 @@ mod tests { ); } + /// `matmul` fails outright when its left operand is an `unfold` view and + /// the right operand is a column. + /// + /// Every autotune candidate returns `InvalidSamples` and the tuner panics + /// rather than falling back, so this is a hard failure and not a slow path. + /// The panic happens on a worker thread; the caller sees a `CallError`. + /// + /// The trigger is the **non-contiguous left operand**, which is worth + /// stating because the autotune key claims otherwise -- it reports + /// `matrix_layout_lhs: Contiguous` for exactly the view that fails. + /// Narrowed by elimination: the same shapes with a contiguous left operand + /// succeed on both random and constant data, and only the `unfold`-derived + /// operand fails. Same family as the `unfold` row-stride bug above. + /// + /// **Workaround:** broadcast and reduce -- + /// `(lhs * rhs.squeeze_dim(2).unsqueeze_dim(1)).sum_dim(2)` -- which is the + /// same arithmetic, avoids the mat-vec entirely, and fuses with any + /// neighbouring reduction over the same operand. Used by + /// `ops::signal::LagSearch`. + /// + /// Marked `#[should_panic]` because the failure *is* the behavior being + /// pinned: when burn fixes this, the test fails by passing, and the + /// workaround it names can go. + #[test] + #[should_panic(expected = "CallError")] + fn test_matmul_rejects_an_unfold_view_against_a_column() { + let device = ::Device::default(); + let (rows, m, k) = (2usize, 32usize, 16usize); + + let buf = Tensor::::random( + [rows, m + k - 1], + burn::tensor::Distribution::Normal(0.0, 1.0), + &device, + ); + let lhs = buf.clone().unfold::<3, _>(1, k, 1); + let rhs = buf.slice_dim(1, 0..k as isize).reshape([rows, k, 1]); + + // Force execution: the failure happens on the device, not at build. + let _ = lhs.matmul(rhs).into_data(); + } + + /// A contiguous left operand at the same shape is fine. + /// + /// The control for + /// [`test_matmul_rejects_an_unfold_view_against_a_column`]: without it the + /// failure above reads as "mat-vec is broken", which would be both wrong + /// and much more alarming. + #[test] + fn test_matmul_accepts_a_contiguous_column() { + let device = ::Device::default(); + let (rows, m, k) = (2usize, 32usize, 16usize); + + let lhs = Tensor::::random( + [rows, m, k], + burn::tensor::Distribution::Normal(0.0, 1.0), + &device, + ); + let rhs = Tensor::::random( + [rows, k, 1], + burn::tensor::Distribution::Normal(0.0, 1.0), + &device, + ); + + assert_eq!(lhs.matmul(rhs).dims(), [rows, m, 1]); + } + /// `gather` ignores strides on a non-contiguous *index* tensor. /// /// Given a transposed index view it reads element 0 of each row rather than diff --git a/crates/bunsen/src/ops/signal/lag_search.rs b/crates/bunsen/src/ops/signal/lag_search.rs new file mode 100644 index 00000000..bfad6cf4 --- /dev/null +++ b/crates/bunsen/src/ops/signal/lag_search.rs @@ -0,0 +1,464 @@ +//! # Normalized cross-correlation over a lag range. +//! +//! Scores how well a reference window matches the signal at each of a range of +//! lags behind it. The basis of time-domain pitch detection, and equally of +//! delay estimation, echo alignment, and onset matching. +//! +//! ```text +//! buf: [-------- max_lag --------][--- window ---] +//! ^lag 0 ^the reference +//! ^lag 1 +//! ``` +//! +//! For each lag, the window starting there is scored against the reference: +//! +//! ```text +//! score[lag] = 2 * dot(ref, w[lag]) / (energy(w[lag]) + energy(ref) + floor) +//! ``` +//! +//! ## Why this normalization +//! +//! `2·⟨x,y⟩ / (‖x‖² + ‖y‖²)` is **bounded above by 1**, and reaches it exactly +//! when `x == y`. That follows from AM-GM: `2⟨x,y⟩ ≤ 2‖x‖‖y‖ ≤ ‖x‖² + ‖y‖²`. +//! So a score is directly readable as "how close to a repeat is this", with no +//! per-signal calibration, and a threshold means the same thing at any +//! amplitude. +//! +//! It differs from the Pearson-style `⟨x,y⟩ / (‖x‖‖y‖)` in what it does with +//! unequal energies: the geometric mean is indifferent to them, this form +//! penalizes them. For periodicity that is usually what you want — a quiet +//! window that merely correlates in shape is not a repeat of a loud one. +//! +//! ## The energy floor is absolute +//! +//! [`LagSearchConfig::energy_floor`] is added to the denominator to keep +//! silence from scoring as a perfect match against silence. It is an *absolute* +//! quantity, so it is only meaningful relative to the amplitude scale of the +//! input — a floor tuned for int16-scale signals means nothing applied to +//! `[-1, 1]` audio. Set it in the same units as the signal, or leave it zero +//! and threshold on the reference energy instead. +//! +//! ## Lag energies are computed directly, not slid +//! +//! The obvious optimization is a sliding sum: subtract the sample leaving the +//! window, add the one entering. It is `O(max_lag + window)` instead of +//! `O(max_lag · window)`, and it is the wrong choice here. +//! +//! It serializes. Each lag depends on the previous, so the whole range becomes +//! a dependency chain of `max_lag` steps where the direct form is one parallel +//! reduction — and on a device the arithmetic is free while the serialization +//! is not. +//! +//! It is also *less accurate*. A running sum of squares accumulates rounding +//! monotonically across the whole range and never recovers, and in near-silence +//! the accumulated error can drive it negative — which is why implementations +//! that slide invariably end up clamping it at zero. The direct reduction has +//! no chain to accumulate along and needs no clamp. + +use burn::{ + config::Config, + prelude::*, +}; + +use crate::errors::{ + BunsenError, + BunsenResult, + WithOkOrPanic, +}; + +/// Config for [`LagSearch`]. +#[derive(Config, Debug, Copy)] +pub struct LagSearchConfig { + /// The length of the correlation window. + pub window: usize, + + /// How many lags to score, counting back from the reference. + pub max_lag: usize, + + /// Added to the denominator; see the module docs on scale. + #[config(default = "0.0")] + pub energy_floor: f32, +} + +impl LagSearchConfig { + /// The buffer length [`LagSearch::forward`] expects. + /// + /// Enough to hold every lagged window plus the reference behind them. + pub fn buf_len(&self) -> usize { + self.max_lag + self.window + } + + /// Validates the geometry. + /// + /// # Errors + /// [`BunsenError::Invalid`] if the window or lag count is zero, or if the + /// floor is negative. + pub fn validate(&self) -> BunsenResult<()> { + if self.window == 0 { + return Err(BunsenError::Invalid( + "LagSearch window must be non-zero".to_string(), + )); + } + if self.max_lag == 0 { + return Err(BunsenError::Invalid( + "LagSearch max_lag must be non-zero".to_string(), + )); + } + if self.energy_floor < 0.0 { + return Err(BunsenError::Invalid(format!( + "LagSearch energy_floor ({}) must not be negative", + self.energy_floor, + ))); + } + Ok(()) + } + + /// Builds the search. + /// + /// # Errors + /// See [`validate`](Self::validate). + pub fn try_init(&self) -> BunsenResult { + self.validate()?; + Ok(LagSearch { cfg: *self }) + } + + /// Builds the search, panicking on error. + pub fn init(&self) -> LagSearch { + self.try_init().ok_or_panic() + } +} + +/// A normalized lag search. +/// +/// Carries no tensors — the geometry is all it needs — so one instance serves +/// any device and any batch. Built by [`LagSearchConfig::try_init`]. +#[derive(Debug, Clone, Copy)] +pub struct LagSearch { + cfg: LagSearchConfig, +} + +impl LagSearch { + /// The geometry this search was built for. + pub fn config(&self) -> &LagSearchConfig { + &self.cfg + } + + /// The buffer length [`forward`](Self::forward) expects. + pub fn buf_len(&self) -> usize { + self.cfg.buf_len() + } + + /// Scores every lag against the reference window. + /// + /// # Arguments + /// * `buf`: `[rows, buf_len]`. The trailing + /// [`window`](LagSearchConfig::window) samples are the reference; lag `l` + /// scores the window starting at `l`. Lags therefore run *backwards* in + /// time — lag `0` is furthest from the reference — which a caller mapping + /// lags to periods must account for. + /// + /// # Returns + /// `(`[rows, `max_lag`]` scores, `[rows, 1]` reference energy)`. The energy + /// is returned because callers almost always need it to decide whether a + /// score is meaningful at all, and recomputing it would be wasteful. + /// + /// # Panics + /// If `buf`'s trailing axis is not [`buf_len`](Self::buf_len). + pub fn forward( + &self, + buf: Tensor, + ) -> (Tensor, Tensor) { + let (window, max_lag) = (self.cfg.window, self.cfg.max_lag); + + #[cfg(any(test, debug_assertions))] + crate::contracts::assert_shape_contract!( + ["rows", "buf_len"], + &buf, + &[("buf_len", self.buf_len())], + ); + + // [rows, window]: the reference sits at the end of the buffer. + let reference = buf.clone().slice_dim(1, max_lag as isize..); + let ref_energy = reference.clone().powi_scalar(2).sum_dim(1); + + // The lagged windows cover `max_lag - 1 + window` samples, one short of + // the buffer. `unfold` derives its row stride from the covered span + // rather than the true row length, so the buffer is trimmed to that + // span first or every row after the first is displaced by the leftover + // sample. Pinned by `burner::tensor::burn_behavior`. + let covered = max_lag - 1 + window; + + // [rows, max_lag, window] + let windows = buf + .slice_dim(1, 0..covered as isize) + .unfold::<3, _>(1, window, 1); + + // Direct reduction rather than a sliding sum; see the module docs. + // [rows, max_lag] + let lag_energy = windows.clone().powi_scalar(2).sum_dim(2).squeeze_dim(2); + + // Broadcast-and-reduce rather than a matvec. `matmul` against a + // `[rows, window, 1]` operand is a batched matvec, and on wgpu every + // autotune candidate for `n == 1` fails outright -- pinned by + // `burner::tensor::burn_behavior`. This form is the same arithmetic + // and reduces over the same view the energy does, so the two fuse. + // [rows, max_lag] + let dot = (windows * reference.unsqueeze_dim::<3>(1)) + .sum_dim(2) + .squeeze_dim(2); + + let denominator = lag_energy + ref_energy.clone() + self.cfg.energy_floor; + let score = dot.mul_scalar(2.0f32) / denominator; + + (score, ref_energy) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + prelude::*, + support::testing::PerformanceBackend, + }; + + type B = PerformanceBackend; + + const WINDOW: usize = 16; + const MAX_LAG: usize = 24; + + fn cfg() -> LagSearchConfig { + LagSearchConfig::new(WINDOW, MAX_LAG) + } + + fn to_vec(t: Tensor) -> Vec { + t.to_data_as::().to_vec_as::().ok_or_panic() + } + + fn upload( + rows: &[Vec], + device: &::Device, + ) -> Tensor { + let len = rows[0].len(); + let flat: Vec = rows.iter().flatten().copied().collect(); + Tensor::from_data(TensorData::new(flat, [rows.len(), len]), device) + } + + #[test] + fn test_config_meta() { + let c = cfg(); + assert_eq!(c.buf_len(), MAX_LAG + WINDOW); + c.validate().unwrap(); + assert_eq!(c.init().buf_len(), MAX_LAG + WINDOW); + } + + #[test] + fn test_validate_rejects_bad_geometry() { + for bad in [ + LagSearchConfig::new(0, MAX_LAG), + LagSearchConfig::new(WINDOW, 0), + cfg().with_energy_floor(-1.0), + ] { + assert!( + matches!(bad.validate(), Err(BunsenError::Invalid(_))), + "expected Invalid: {bad:?}", + ); + } + } + + #[test] + fn test_an_exact_repeat_scores_exactly_one() { + // The closed form the normalization is chosen for: when the lagged + // window equals the reference, `2ab/(a^2+b^2)` is exactly 1. + let device = Default::default(); + let period = 8usize; + let search = cfg().init(); + + let buf: Vec = (0..search.buf_len()) + .map(|n| ((n % period) as f32 * 0.9).sin() + 0.3) + .collect(); + let (score, _) = search.forward(upload(&[buf], &device)); + let got = to_vec(score); + + // The reference starts at `max_lag`, so any lag congruent to it mod + // the period is an exact repeat. + for (lag, &v) in got.iter().enumerate() { + if lag % period == MAX_LAG % period { + assert!( + (v - 1.0).abs() < 1e-4, + "lag {lag} is an exact repeat, scored {v}" + ); + } + } + } + + #[test] + fn test_scores_never_exceed_one() { + // AM-GM, on data with no structure at all. + let device = Default::default(); + let search = cfg().init(); + let buf = Tensor::::random( + [6, search.buf_len()], + burn::tensor::Distribution::Normal(0.0, 1.0), + &device, + ); + + let (score, _) = search.forward(buf); + for (i, v) in to_vec(score).iter().enumerate() { + assert!(*v <= 1.0 + 1e-5, "score {i} is {v}, above the bound"); + } + } + + #[test] + fn test_the_peak_lands_on_the_period() { + let device = Default::default(); + let period = 7usize; + let search = cfg().init(); + + let buf: Vec = (0..search.buf_len()) + .map(|n| ((n % period) as f32 * 1.3).sin()) + .collect(); + let (score, _) = search.forward(upload(&[buf], &device)); + let got = to_vec(score); + + let peak = got + .iter() + .enumerate() + .max_by(|a, b| a.1.total_cmp(b.1)) + .unwrap() + .0; + assert_eq!( + peak % period, + MAX_LAG % period, + "peak at lag {peak} is not a whole number of periods from the reference", + ); + } + + #[test] + fn test_matches_a_direct_host_computation() { + let device = Default::default(); + let search = cfg().with_energy_floor(0.5).init(); + + let buf: Vec = (0..search.buf_len()) + .map(|n| ((n as f32) * 0.37).sin() + 0.2 * ((n as f32) * 1.9).cos()) + .collect(); + let (score, energy) = search.forward(upload(std::slice::from_ref(&buf), &device)); + + let reference = &buf[MAX_LAG..]; + let e_ref: f32 = reference.iter().map(|v| v * v).sum(); + assert!( + (to_vec(energy)[0] - e_ref).abs() < 1e-4 * e_ref, + "reference energy mismatch", + ); + + let got = to_vec(score); + for (lag, &v) in got.iter().enumerate() { + let w = &buf[lag..lag + WINDOW]; + let dot: f32 = w.iter().zip(reference.iter()).map(|(a, b)| a * b).sum(); + let e_lag: f32 = w.iter().map(|v| v * v).sum(); + let want = 2.0 * dot / (e_lag + e_ref + 0.5); + assert!((v - want).abs() < 1e-4, "lag {lag}: {v} vs {want}"); + } + } + + #[test] + fn test_rows_are_independent() { + let device = Default::default(); + let search = cfg().init(); + + let a: Vec = (0..search.buf_len()) + .map(|n| ((n % 5) as f32).sin()) + .collect(); + let b: Vec = (0..search.buf_len()) + .map(|n| ((n % 9) as f32).cos()) + .collect(); + + let (joint, _) = search.forward(upload(&[a.clone(), b], &device)); + let (solo, _) = search.forward(upload(std::slice::from_ref(&a), &device)); + + let j = to_vec(joint); + let s = to_vec(solo); + for (lag, (&a, &b)) in j.iter().zip(s.iter()).enumerate() { + assert!((a - b).abs() < 1e-5, "lag {lag}: batched {a} vs alone {b}"); + } + } + + #[test] + fn test_the_energy_floor_damps_silence() { + // Silence correlates perfectly with silence, which is exactly the + // false positive the floor exists to suppress. + let device = Default::default(); + let quiet: Vec = (0..cfg().buf_len()) + .map(|n| 1e-4 * (n as f32).sin()) + .collect(); + + let (bare, _) = cfg() + .init() + .forward(upload(std::slice::from_ref(&quiet), &device)); + let (floored, _) = cfg() + .with_energy_floor(1.0) + .init() + .forward(upload(&[quiet], &device)); + + let b = to_vec(bare).iter().fold(0.0f32, |m, v| m.max(*v)); + let f = to_vec(floored).iter().fold(0.0f32, |m, v| m.max(*v)); + assert!(b > 0.5, "unfloored silence should score high, got {b}"); + assert!(f < 1e-3, "floored silence should score near zero, got {f}"); + } + + #[test] + fn test_direct_energy_beats_a_sliding_sum() { + // The justification for not sliding, made concrete. A sliding sum of + // squares accumulates rounding monotonically across the lag range and + // never recovers; the direct reduction has no chain to accumulate + // along. Both are compared against an f64 ground truth. + let long = LagSearchConfig::new(64, 512); + let n = long.buf_len(); + + // A loud prefix followed by a quiet tail: the regime where a running + // sum's absolute error swamps the values it is still tracking. + let buf: Vec = (0..n) + .map(|i| { + let amp = if i < n / 2 { 3.0e3 } else { 1.0e-2 }; + amp * ((i as f32) * 0.7).sin() + }) + .collect(); + + let truth = |lag: usize| -> f64 { + buf[lag..lag + long.window] + .iter() + .map(|v| (*v as f64) * (*v as f64)) + .sum() + }; + + // The sliding form, in f32, with the clamp such implementations need. + let mut running: f32 = buf[..long.window].iter().map(|v| v * v).sum(); + let mut slide_err = 0.0f64; + let mut direct_err = 0.0f64; + for lag in 0..long.max_lag { + if lag > 0 { + running = (running - buf[lag - 1] * buf[lag - 1]).max(0.0) + + buf[lag + long.window - 1] * buf[lag + long.window - 1]; + } + let direct: f32 = buf[lag..lag + long.window].iter().map(|v| v * v).sum(); + + let t = truth(lag); + let scale = t.max(1e-12); + slide_err = slide_err.max(((running as f64) - t).abs() / scale); + direct_err = direct_err.max(((direct as f64) - t).abs() / scale); + } + + assert!( + direct_err < slide_err, + "direct {direct_err:.3e} should beat sliding {slide_err:.3e}", + ); + } + + #[test] + #[should_panic(expected = "buf_len")] + fn test_wrong_buffer_length_is_rejected() { + let device = Default::default(); + let search = cfg().init(); + search.forward(Tensor::::zeros([1, search.buf_len() + 3], &device)); + } +} diff --git a/crates/bunsen/src/ops/signal/mod.rs b/crates/bunsen/src/ops/signal/mod.rs index 95adf2a2..edf69b46 100644 --- a/crates/bunsen/src/ops/signal/mod.rs +++ b/crates/bunsen/src/ops/signal/mod.rs @@ -4,6 +4,7 @@ mod biquad; mod cosine_window; mod decimating_fir; mod filterbank; +mod lag_search; mod lpc; mod sliding_stft; mod stft_window; @@ -18,6 +19,8 @@ pub use decimating_fir::*; #[doc(inline)] pub use filterbank::*; #[doc(inline)] +pub use lag_search::*; +#[doc(inline)] pub use lpc::*; #[doc(inline)] pub use sliding_stft::*; From eba5789f64d3b8a5de3767cbea78081dd902869d Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Mon, 24 Aug 2026 14:52:52 -0700 Subject: [PATCH 24/32] fix(burner): correct the diagnosis of the CubeCL unfold bug The pin was right that something was wrong and wrong about what. It said `unfold` "derives its batch-row stride from the covered span", which blamed the wrong code: `burn-cubecl`'s `unfold` sets `strides[dim] = step * old`, appends `old`, and leaves every other stride alone -- exactly PyTorch's view semantics, and correct. The fault is in the read. When `size` and `step` share a factor of two the access vectorizes, and the outer stride is truncated to a multiple of the line width -- `(len / v) * v` rather than `len` -- so every row after the first starts `len % v` elements early. Row 0 is always correct, which is why a batch-1 test passes and the corruption only appears once a second row exists. Established by elimination rather than inspection, since the two candidate rules ("uses the covered span" and "rounds the row down to a line") predict the same offset in almost every configuration. They differ at size=2 step=4 len=9, which predicts row 1 starting at flat 6 or 8 respectively; it starts at 8. Swept 315 configurations: 42 wrong, all with `size` and `step` both even, failing exactly when `tail % v != 0`. Zero wrong when either is odd, i.e. when the scalar path runs. `Flex` is correct throughout, so this is CubeCL-specific. The tests are now minimal and use `arange`, so a displaced row reads as an off-by-one run rather than as arbitrary values, with two controls that isolate the cause: an odd `step` (v = 1, same tail, correct) and no tail (same vectorization, correct). The second control also shows why trimming to the covered span is a *sufficient* workaround and not merely a different shape -- the line width divides both `size` and `step`, so it divides the covered span and the truncation becomes a no-op. `DecimatingFir` and `LagSearch` keep the same trim; only the comments explaining it change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../bunsen/src/burner/tensor/burn_behavior.rs | 117 +++++++++--------- .../bunsen/src/ops/signal/decimating_fir.rs | 17 +-- crates/bunsen/src/ops/signal/lag_search.rs | 10 +- 3 files changed, 73 insertions(+), 71 deletions(-) diff --git a/crates/bunsen/src/burner/tensor/burn_behavior.rs b/crates/bunsen/src/burner/tensor/burn_behavior.rs index da885c3c..63b87ea5 100644 --- a/crates/bunsen/src/burner/tensor/burn_behavior.rs +++ b/crates/bunsen/src/burner/tensor/burn_behavior.rs @@ -33,77 +33,78 @@ mod tests { type B = PerformanceBackend; - /// `unfold` derives its batch-row stride from the span its windows cover - /// rather than from the row's true length. + /// On `CubeCL`, `unfold` reads every row after the first too early when the + /// unfolded axis is not a whole number of vectorization lines. /// - /// A row with a leftover tail therefore places every *subsequent* row - /// early, by exactly the tail length. Row 0 is always correct, which is - /// what makes this so easy to miss: a batch-1 test passes, and the bug only - /// appears once a second stream is added. + /// `unfold` itself is correct: `burn-cubecl`'s implementation sets + /// `strides[dim] = step * old` and appends `old`, leaving every other + /// stride alone, which is exactly `PyTorch`'s view semantics. The fault is in + /// the *read*. When `size` and `step` share a factor of two the access + /// vectorizes, and the outer stride is truncated to a multiple of the line + /// width `v` -- `(len / v) * v` rather than `len` -- so each subsequent row + /// starts `len % v` elements early. /// - /// **Workaround:** trim the input to the covered span before unfolding. - /// Used by `ops::signal` and by the ten-vad pitch stages. + /// Row 0 is always correct, which is what makes this easy to miss: a + /// batch-1 test passes and the corruption appears only once a second row + /// exists. + /// + /// Measured across 315 configurations: 42 wrong, all with `size` and `step` + /// both even, and failing exactly when `tail % v != 0`. `Flex` is correct + /// throughout, so this is `CubeCL`-specific. + /// + /// **Workaround:** trim the input to the span the windows actually cover, + /// `(num - 1) * step + size`, before unfolding. That is sound because `v` + /// divides both `size` and `step` and therefore divides the covered span, + /// so the truncation becomes a no-op. Used by `ops::signal::DecimatingFir` + /// and `ops::signal::LagSearch`. #[test] - fn test_unfold_derives_row_stride_from_the_covered_span() { + fn test_unfold_truncates_the_outer_stride_to_the_line_width() { let device = ::Device::default(); - let (win, step, steps, tail) = (12usize, 4usize, 3usize, 3usize); - let covered = (steps - 1) * step + win; - let len = covered + tail; - - // Row 1 carries a marker at its very first element. - let mut flat = vec![0.0f32; 2 * len]; - flat[len] = 1.0; - let t = Tensor::::from_data(TensorData::new(flat, [2, len]), &device); - - let rows: Vec = t - .unfold::<3, _>(1, win, step) - .reshape([2 * steps, win]) - .to_data_as::() - .to_vec_as::() - .unwrap(); - // Window `steps` is (batch 1, step 0), so the marker should sit at 0. - let found = rows[steps * win..(steps + 1) * win] - .iter() - .position(|v| *v != 0.0); + // [[0,1,2,3,4], unfold(dim=1, size=2, step=2) -> + // [5,6,7,8,9]] [[[0,1],[2,3]], [[5,6],[7,8]]] + // + // len = 5, v = 2, so 5 % 2 = 1 and row 1 is read one element early. + // `arange` so a displaced row reads as an off-by-one run rather than + // as arbitrary values. + let input = Tensor::::arange(0..10, &device).reshape([2, 5]); + let got: Vec = input.unfold::<3, _>(1, 2, 2).to_data().to_vec().unwrap(); + assert_eq!( - found, - Some(tail), - "expected the row-1 marker displaced by the leftover tail ({tail}). \ - Some(0) means `unfold` now uses the true row stride, and every \ - trim-before-unfold in the tree is redundant.", + got, + vec![0, 1, 2, 3, 4, 5, 6, 7], + "expected the known-bad reading; [0,1,2,3,5,6,7,8] means CubeCL now honours the outer stride, and every trim-before-unfold in the tree is redundant", ); } - /// With no leftover tail, `unfold`'s row stride is correct. + /// An odd `step` disables vectorization, and the same geometry is correct. /// - /// The control for - /// [`test_unfold_derives_row_stride_from_the_covered_span`]: it establishes - /// that trimming to the covered span is a *sufficient* workaround, not just - /// a different way to be wrong. + /// The control that isolates the fault to the vectorized read: same input, + /// same leftover tail, `v == 1`, right answer. #[test] - fn test_unfold_row_stride_is_correct_without_a_tail() { + fn test_unfold_is_correct_on_the_scalar_path() { let device = ::Device::default(); - let (win, step, steps) = (12usize, 4usize, 3usize); - let covered = (steps - 1) * step + win; - - let mut flat = vec![0.0f32; 2 * covered]; - flat[covered] = 1.0; - let t = Tensor::::from_data(TensorData::new(flat, [2, covered]), &device); - - let rows: Vec = t - .unfold::<3, _>(1, win, step) - .reshape([2 * steps, win]) - .to_data_as::() - .to_vec_as::() - .unwrap(); - assert_eq!( - rows[steps * win..(steps + 1) * win] - .iter() - .position(|v| *v != 0.0), - Some(0), - ); + let input = Tensor::::arange(0..10, &device).reshape([2, 5]); + let got: Vec = input.unfold::<3, _>(1, 2, 3).to_data().to_vec().unwrap(); + + // Row 0: [0,1] [3,4] Row 1: [5,6] [8,9] + assert_eq!(got, vec![0, 1, 3, 4, 5, 6, 8, 9]); + } + + /// With no leftover tail there is nothing to truncate, and the vectorized + /// path is correct. + /// + /// The second control, and the one that shows *why* trimming to the covered + /// span is a sufficient workaround rather than merely a different shape. + #[test] + fn test_unfold_is_correct_without_a_tail() { + let device = ::Device::default(); + + let input = Tensor::::arange(0..8, &device).reshape([2, 4]); + let got: Vec = input.unfold::<3, _>(1, 2, 2).to_data().to_vec().unwrap(); + + assert_eq!(got, vec![0, 1, 2, 3, 4, 5, 6, 7]); } /// `matmul` fails outright when its left operand is an `unfold` view and diff --git a/crates/bunsen/src/ops/signal/decimating_fir.rs b/crates/bunsen/src/ops/signal/decimating_fir.rs index 24debaf6..172b604b 100644 --- a/crates/bunsen/src/ops/signal/decimating_fir.rs +++ b/crates/bunsen/src/ops/signal/decimating_fir.rs @@ -270,16 +270,17 @@ impl DecimatingFir { // samples before hop `s`. let extended = Tensor::cat(vec![history, input], 1); - // `unfold` derives its batch-row stride from the span the windows - // cover, `(steps - 1) * hop + window`, rather than from the row's real - // length. When a row is longer than that span -- which it is here, by - // `carry_len - (window - hop)` samples -- every row after the first is - // placed that many samples early, silently. Trimming to the covered - // span first makes the two agree; the trimmed tail is not lost, since - // it is still part of the carry below. + // On CubeCL, a vectorized `unfold` truncates its outer stride to a + // multiple of the line width, so every row after the first is read + // early whenever the unfolded axis is not a whole number of lines -- + // which it is not here, by `carry_len - (window - hop)` samples. + // Trimming to the span the windows actually cover fixes it, because + // the line width divides both `window` and `hop` and therefore divides + // that span. The trimmed tail is not lost: it is still part of the + // carry below. // // `burner::tensor::burn_behavior` pins the upstream behavior; if a - // future burn fixes the stride, that test fails loudly rather than + // future burn honours the stride, that test fails loudly rather than // leaving dead defensive code here. let covered = (steps - 1) * hop + window; diff --git a/crates/bunsen/src/ops/signal/lag_search.rs b/crates/bunsen/src/ops/signal/lag_search.rs index bfad6cf4..5f6bc251 100644 --- a/crates/bunsen/src/ops/signal/lag_search.rs +++ b/crates/bunsen/src/ops/signal/lag_search.rs @@ -181,11 +181,11 @@ impl LagSearch { let reference = buf.clone().slice_dim(1, max_lag as isize..); let ref_energy = reference.clone().powi_scalar(2).sum_dim(1); - // The lagged windows cover `max_lag - 1 + window` samples, one short of - // the buffer. `unfold` derives its row stride from the covered span - // rather than the true row length, so the buffer is trimmed to that - // span first or every row after the first is displaced by the leftover - // sample. Pinned by `burner::tensor::burn_behavior`. + // The lagged windows cover `max_lag - 1 + window` samples, one short + // of the buffer. On CubeCL a vectorized `unfold` truncates its outer + // stride to a multiple of the line width, so that leftover sample + // would displace every row after the first; trimming to the covered + // span first avoids it. Pinned by `burner::tensor::burn_behavior`. let covered = max_lag - 1 + window; // [rows, max_lag, window] From 671b4945bdeb3dc90ead4667a7c4ad72bb8e41b7 Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Mon, 24 Aug 2026 15:04:20 -0700 Subject: [PATCH 25/32] feat(burner): add `burner::repro` with the CubeCL unfold reproduction Moves the standalone reproduction into the crate as `bunsen::burner::repro`, a home for backend-generic harnesses for defects in `burn` itself. Each is written to be lifted out and taken upstream unchanged, so they use only burn's public tensor surface. `repro` and `tensor::burn_behavior` assert deliberately opposite things: | | asserts | fails when | |---|---|---| | `repro` | the correct semantics | the bug is present | | `burn_behavior` | the current behavior | the bug is fixed | The pins keep bunsen's suite green while guaranteeing a fix announces itself and names the workarounds it makes redundant. The reproductions are the bug report: run one against a candidate fix and a pass means it works. So these fail on affected backends by design, which is why the tests driving them are `#[ignore]`d rather than wired into the suite. `repro::unfold` carries the minimal `arange` case, the two controls that isolate the cause, a discriminator between the two candidate rules, and a sweep that returns the failing set rather than printing it, so it can be run against a fix and watched shrink to empty. One test is *not* ignored: `test_cpu_backend_is_correct` runs the whole reproduction against `Flex`, where it passes. That documents the defect as backend-specific rather than universal, and would catch a regression that made the CPU path match the broken one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- crates/bunsen/src/burner/mod.rs | 3 + crates/bunsen/src/burner/repro/mod.rs | 31 ++ crates/bunsen/src/burner/repro/unfold.rs | 309 ++++++++++++++++++ .../bunsen/src/burner/tensor/burn_behavior.rs | 10 +- 4 files changed, 348 insertions(+), 5 deletions(-) create mode 100644 crates/bunsen/src/burner/repro/mod.rs create mode 100644 crates/bunsen/src/burner/repro/unfold.rs diff --git a/crates/bunsen/src/burner/mod.rs b/crates/bunsen/src/burner/mod.rs index b635fb8f..fab6e710 100644 --- a/crates/bunsen/src/burner/mod.rs +++ b/crates/bunsen/src/burner/mod.rs @@ -42,6 +42,8 @@ //! Headlined by the `GroupOptimizerAdaptor{N}` family and the //! `OptimizerGroup` / `LrSelector` building blocks. //! - [`record`] — helpers for working with `burn::record` types. +//! - [`repro`] — standalone, backend-generic reproductions of bugs in +//! `burn` itself, written to be lifted out and taken upstream unchanged. //! - [`tensor`] — tensor helpers that don't fit neatly in [`crate::ops`]: //! `Tensor` extension traits and `TensorData` index views. //! - [`distribution`] — distribution-related utilities. @@ -76,6 +78,7 @@ pub mod descriptors; pub mod distribution; pub mod module; pub mod record; +pub mod repro; #[cfg(feature = "train")] pub mod optim; diff --git a/crates/bunsen/src/burner/repro/mod.rs b/crates/bunsen/src/burner/repro/mod.rs new file mode 100644 index 00000000..3090ae0b --- /dev/null +++ b/crates/bunsen/src/burner/repro/mod.rs @@ -0,0 +1,31 @@ +//! # Standalone reproductions of upstream `burn` bugs. +//! +//! Backend-generic, dependency-free harnesses for defects in `burn` itself, +//! written so they can be lifted out of this crate and taken upstream +//! unchanged. Each one uses only `burn`'s public tensor surface. +//! +//! ## How this differs from the pins +//! +//! These two modules assert *opposite* things, deliberately: +//! +//! | | asserts | fails when | +//! |---|---|---| +//! | `burner::repro` | the **correct** semantics | the bug is present | +//! | `burner::tensor::burn_behavior` | the **current** behavior | the bug is fixed | +//! +//! The pins keep bunsen's own suite green while guaranteeing that a fix +//! announces itself and names the workarounds it makes redundant. The +//! reproductions here are the bug report: run one against a candidate fix and +//! a pass means the fix works. +//! +//! So the functions here **fail on affected backends today**. That is their +//! job, and it is why they are not wired into the default test suite — the +//! tests that drive them are `#[ignore]`d. +//! +//! ## Running them +//! +//! ```text +//! cargo test -p bunsen --lib --features wgpu -- burner::repro --ignored --nocapture +//! ``` + +pub mod unfold; diff --git a/crates/bunsen/src/burner/repro/unfold.rs b/crates/bunsen/src/burner/repro/unfold.rs new file mode 100644 index 00000000..cffb0caf --- /dev/null +++ b/crates/bunsen/src/burner/repro/unfold.rs @@ -0,0 +1,309 @@ +//! # `unfold` truncates its outer stride to the vectorization line width. +//! +//! Affects `CubeCL` backends; `burn::backend::Flex` is correct. +//! +//! ## Expected semantics +//! +//! `Tensor::unfold(dim, size, step)` should follow `PyTorch`'s +//! `Tensor.unfold`: a pure view that replaces `shape[dim]` with +//! `num = (shape[dim] - size) / step + 1`, appends a trailing axis of length +//! `size`, and sets +//! +//! ```text +//! strides[dim] = step * old_strides[dim] +//! strides.push( old_strides[dim]) +//! // every other stride is left ALONE +//! ``` +//! +//! so element `[.., i, .., j]` reads `input[.., i * step + j, ..]`. The +//! load-bearing part is that the *other* dimensions' strides are never +//! recomputed — that is what keeps the view correct when the unfolded axis is +//! longer than the windows happen to cover. +//! +//! `burn-cubecl`'s `unfold` (`ops/base.rs`) computes exactly this, correctly. +//! The fault is downstream, in how the resulting view is read. +//! +//! ## The defect +//! +//! When `size` and `step` share a factor of two the access vectorizes, and the +//! **outer stride is truncated to a multiple of the line width `v`** — +//! `(len / v) * v` rather than `len`. Every row after the first is then read +//! `len % v` elements early. +//! +//! Row 0 is always correct, which is what makes it easy to miss: a batch-1 +//! test passes, and the corruption appears only once a second row exists. +//! +//! ## Scope +//! +//! From [`sweep`] over 315 configurations (`size` 2..=8, `step` 1..=5, +//! `num` 2..=4, tails 0..=6): +//! +//! * 42 wrong, **all** with `size` and `step` both even. +//! * Zero wrong when either is odd — that is `v == 1`, the scalar path. +//! * Wrong exactly when `tail % v != 0`, for a tail of +//! `len - ((num - 1) * step + size)`. +//! +//! ## Why the two rules needed separating +//! +//! "Uses the covered span `(num - 1) * step + size`" and "rounds the row down +//! to a whole line" predict the same offset in almost every configuration, +//! because `v` divides both `size` and `step` and therefore divides the +//! covered span. They differ at `size = 2, step = 4, len = 9`, which predicts +//! row 1 starting at flat `6` or `8` respectively. It starts at `8`, so the +//! truncation rule is the operative one — see +//! [`discriminate_truncation_rule`]. +//! +//! That distinction matters for anyone fixing it: the outer stride is being +//! rounded, not substituted. + +use burn::{ + prelude::*, + tensor::Int, +}; + +/// The line width inferred for a `(size, step)` pair. +/// +/// The largest power of two dividing both. Inferred from observed failures +/// rather than read out of the runtime, so treat it as a description of the +/// symptom rather than of the implementation. +pub fn inferred_line_width( + size: usize, + step: usize, +) -> usize { + 1usize << size.trailing_zeros().min(step.trailing_zeros()) +} + +/// The minimal failing case. +/// +/// ```text +/// input unfold(dim=1, size=2, step=2) +/// [[0,1,2,3,4], [[[0,1],[2,3]], +/// [5,6,7,8,9]] [[5,6],[7,8]]] +/// ``` +/// +/// `len = 5`, `v = 2`, so `5 % 2 = 1` and row 1 is read one element early, +/// coming back as `[[4,5],[6,7]]`. +/// +/// `arange` is deliberate: every element is its own flat index, so a displaced +/// row reads as an off-by-one run rather than as arbitrary values. +/// +/// # Panics +/// On an affected backend. That is the point. +pub fn minimal(device: &B::Device) { + let input = Tensor::::arange(0..10, device).reshape([2, 5]); + let unfolded = input.unfold::<3, _>(1, 2, 2); + + assert_eq!(unfolded.dims(), [2, 2, 2]); + + let got: Vec = unfolded.to_data().to_vec().unwrap(); + let want = vec![0, 1, 2, 3, /* row 1 */ 5, 6, 7, 8]; + + assert_eq!( + got, want, + "\n want {want:?}\n got {got:?}\n \ + row 1 should start at flat index 5; it starts at 4, which is \ + (len / v) * v = (5 / 2) * 2.", + ); +} + +/// Control: an odd `step` disables vectorization, same tail, correct result. +/// +/// `size = 2`, `step = 3`, `len = 5` gives the same `num = 2` and the same +/// leftover tail of 1, but `v == 1`. Passing here is what rules out both +/// `unfold`'s stride computation and the mere presence of a tail. +/// +/// # Panics +/// If this fails, the defect is *not* the one described here and the diagnosis +/// needs revisiting. +pub fn control_odd_step(device: &B::Device) { + let input = Tensor::::arange(0..10, device).reshape([2, 5]); + let unfolded = input.unfold::<3, _>(1, 2, 3); + + assert_eq!(unfolded.dims(), [2, 2, 2]); + + let got: Vec = unfolded.to_data().to_vec().unwrap(); + // Row 0: [0,1] [3,4] Row 1: [5,6] [8,9] + assert_eq!(got, vec![0, 1, 3, 4, 5, 6, 8, 9]); +} + +/// Control: no leftover tail, same vectorization, correct result. +/// +/// `size = 2`, `step = 2`, `len = 4` gives `tail = 0`. Passing here rules out +/// vectorization *per se*, and shows why trimming an input to the span its +/// windows cover is a sufficient workaround rather than merely a different +/// shape: `v` divides the covered span, so the truncation becomes a no-op. +/// +/// # Panics +/// If this fails, the diagnosis needs revisiting. +pub fn control_no_tail(device: &B::Device) { + let input = Tensor::::arange(0..8, device).reshape([2, 4]); + let unfolded = input.unfold::<3, _>(1, 2, 2); + + assert_eq!(unfolded.dims(), [2, 2, 2]); + + let got: Vec = unfolded.to_data().to_vec().unwrap(); + // Row 0: [0,1] [2,3] Row 1: [4,5] [6,7] + assert_eq!(got, vec![0, 1, 2, 3, 4, 5, 6, 7]); +} + +/// Separates the two candidate rules; see the module docs. +/// +/// `size = 2, step = 4, len = 9`. Returns the flat index row 1 actually starts +/// at: `6` would mean the covered span is substituted, `8` that the row is +/// rounded down to a whole line, and `9` that the backend is correct. +pub fn discriminate_truncation_rule(device: &B::Device) -> i32 { + let input = Tensor::::arange(0..18, device).reshape([2, 9]); + let got: Vec = input.unfold::<3, _>(1, 2, 4).to_data().to_vec().unwrap(); + + // Row 1's first window begins after row 0's `num * size` elements. + let num = 2usize; + got[num * 2] +} + +/// One configuration's verdict, as reported by [`sweep`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UnfoldCase { + /// The window length. + pub size: usize, + /// The window step. + pub step: usize, + /// The length of the unfolded axis. + pub len: usize, + /// How many windows fit. + pub num: usize, + /// Elements of the axis no window reaches. + pub tail: usize, + /// The line width inferred for this pair; see [`inferred_line_width`]. + pub line_width: usize, +} + +impl UnfoldCase { + /// Whether this case matches the predicted failure condition. + pub fn predicted_wrong(&self) -> bool { + !self.tail.is_multiple_of(self.line_width) + } +} + +/// Sweeps a neighbourhood of geometries and returns those that read wrong. +/// +/// Reports rather than asserts, so it can be run against a candidate fix to +/// watch the failing set shrink to empty. Compare each entry's +/// [`predicted_wrong`](UnfoldCase::predicted_wrong) against its presence here: +/// on an affected backend the two agree exactly. +pub fn sweep(device: &B::Device) -> Vec { + let mut wrong = Vec::new(); + + for size in 2..=8usize { + for step in 1..=5usize { + for num in 2..=4usize { + for extra in 0..=6usize { + let len = (num - 1) * step + size + extra; + if (len - size) / step + 1 != num { + continue; + } + + let rows = 2usize; + let input = Tensor::::arange(0..(rows * len) as i64, device) + .reshape([rows, len]); + let got: Vec = input + .unfold::<3, _>(1, size, step) + .to_data() + .to_vec() + .unwrap(); + + let want: Vec = (0..rows) + .flat_map(|b| { + (0..num).flat_map(move |i| { + (0..size).map(move |j| (b * len + i * step + j) as i32) + }) + }) + .collect(); + + if got != want { + wrong.push(UnfoldCase { + size, + step, + len, + num, + tail: len - ((num - 1) * step + size), + line_width: inferred_line_width(size, step), + }); + } + } + } + } + } + + wrong +} + +#[cfg(test)] +mod tests { + use burn::tensor::backend::BackendTypes; + + use super::*; + use crate::support::testing::{ + CpuBackend, + PerformanceBackend, + }; + + /// The reproduction, against the performance backend. + /// + /// Ignored: it asserts the *correct* semantics, so on an affected backend + /// it fails by design. Run it to check a candidate fix. + #[test] + #[ignore = "asserts correct semantics; fails on affected backends by design"] + fn test_repro_on_performance_backend() { + let device = ::Device::default(); + control_odd_step::(&device); + control_no_tail::(&device); + minimal::(&device); + } + + /// The same reproduction against `Flex`, which is unaffected. + /// + /// Not ignored: it documents that the defect is backend-specific, and + /// would catch a regression that made the CPU path match the broken one. + #[test] + fn test_cpu_backend_is_correct() { + let device = ::Device::default(); + control_odd_step::(&device); + control_no_tail::(&device); + minimal::(&device); + assert_eq!(discriminate_truncation_rule::(&device), 9); + assert!(sweep::(&device).is_empty()); + } + + /// Prints the failing set for the performance backend. + /// + /// Ignored because it is a report, not an assertion. + #[test] + #[ignore = "diagnostic report, not an assertion"] + fn test_report_sweep() { + let device = ::Device::default(); + let wrong = sweep::(&device); + + eprintln!("{} configurations read wrong", wrong.len()); + eprintln!("size step len num tail v predicted"); + for c in &wrong { + eprintln!( + "{:4} {:4} {:4} {:3} {:4} {:4} {}", + c.size, + c.step, + c.len, + c.num, + c.tail, + c.line_width, + c.predicted_wrong(), + ); + } + eprintln!( + "row 1 starts at flat {} (6 = covered-span rule, 8 = truncation rule, 9 = correct)", + discriminate_truncation_rule::(&device), + ); + assert!( + wrong.iter().all(UnfoldCase::predicted_wrong), + "a failure fell outside the predicted condition", + ); + } +} diff --git a/crates/bunsen/src/burner/tensor/burn_behavior.rs b/crates/bunsen/src/burner/tensor/burn_behavior.rs index 63b87ea5..32b1b5af 100644 --- a/crates/bunsen/src/burner/tensor/burn_behavior.rs +++ b/crates/bunsen/src/burner/tensor/burn_behavior.rs @@ -38,11 +38,11 @@ mod tests { /// /// `unfold` itself is correct: `burn-cubecl`'s implementation sets /// `strides[dim] = step * old` and appends `old`, leaving every other - /// stride alone, which is exactly `PyTorch`'s view semantics. The fault is in - /// the *read*. When `size` and `step` share a factor of two the access - /// vectorizes, and the outer stride is truncated to a multiple of the line - /// width `v` -- `(len / v) * v` rather than `len` -- so each subsequent row - /// starts `len % v` elements early. + /// stride alone, which is exactly `PyTorch`'s view semantics. The fault is + /// in the *read*. When `size` and `step` share a factor of two the + /// access vectorizes, and the outer stride is truncated to a multiple + /// of the line width `v` -- `(len / v) * v` rather than `len` -- so + /// each subsequent row starts `len % v` elements early. /// /// Row 0 is always correct, which is what makes this easy to miss: a /// batch-1 test passes and the corruption appears only once a second row From 370eeae3114daa1e538ec05335ca5d131d80aa61 Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Mon, 24 Aug 2026 16:09:11 -0700 Subject: [PATCH 26/32] perf(ten-vad): correlate through `LagSearch`, replacing the sliding energy Runs the experiment the fidelity ledger proposed and never performed: D5 argued the reference's clamped sliding sum should be replaced by a direct reduction, and predicted the ~1e-6 perturbation would be absorbed. It is better than that. The geometry lined up exactly -- lags running backwards from a reference window at the end of the buffer, `2*dot / (lagged + reference + 1)`, with the reference's `1 +` guard as the energy floor -- so the stage delegates rather than reimplements. Octave suppression stays here; it is pitch-specific. Two deliberate departures come with it. The reference maintains the lagged energy as a sliding sum with a `max(..., 0)` inside the recurrence, which serializes the lag range into a 126-step dependency chain and accumulates rounding with no way to recover -- the clamp exists precisely because that error can drive the sum negative. `LagSearch` reduces each window directly: parallel, and strictly more accurate. It also sums the correlation in a different order than the reference's shift-and-accumulate. Both sit upstream of an `argmax`, so neither is free by inspection. The result: * The 3750-hop probability golden is **bit-identical** -- same mean |diff| 5.649e-5, same worst 6.446e-4 at the same hop 2973, same 0.37836984. Not merely within tolerance; unchanged. * The kit's suite drops from ~575 s to 434 s. Bit-identical is the interesting part, and it is not luck. The tracker's output passes through an argmax over 56 states, so a perturbation that does not flip one produces exactly the same period, hence exactly the same feature 40, hence exactly the same probability. Across 3750 hops and 7500 Viterbi steps, not one flipped. That is the same argument the ledger used to explain why small errors are dangerous here -- discrete decisions have no small errors -- read in the other direction: below the flip threshold, they have no errors at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- crates/bunsen/src/burner/repro/unfold.rs | 4 +- .../ten_vad/context/pitch/tensor/correlate.rs | 135 ++++++------------ 2 files changed, 47 insertions(+), 92 deletions(-) diff --git a/crates/bunsen/src/burner/repro/unfold.rs b/crates/bunsen/src/burner/repro/unfold.rs index cffb0caf..4b7ac23e 100644 --- a/crates/bunsen/src/burner/repro/unfold.rs +++ b/crates/bunsen/src/burner/repro/unfold.rs @@ -40,8 +40,8 @@ //! //! * 42 wrong, **all** with `size` and `step` both even. //! * Zero wrong when either is odd — that is `v == 1`, the scalar path. -//! * Wrong exactly when `tail % v != 0`, for a tail of -//! `len - ((num - 1) * step + size)`. +//! * Wrong exactly when `tail % v != 0`, for a tail of `len - ((num - 1) * step +//! + size)`. //! //! ## Why the two rules needed separating //! diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/correlate.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/correlate.rs index 8ceb40a2..ffdca010 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/correlate.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/correlate.rs @@ -28,19 +28,26 @@ //! correlation coefficient rather than a raw correlation; the `1 +` keeps //! silence from amplifying noise into a confident-looking peak. //! -//! ## Three details that shape the implementation +//! ## Two details that shape the implementation //! -//! **The correlation is a shift-and-accumulate, not a matmul.** Written as -//! `[max_period, half]` windows times a reference vector it would materialize -//! a `rows × 64 × 32` intermediate — tens of megabytes over a long sequence. -//! Written as `half` broadcast multiply-adds over `[rows, max_period]` slices -//! it is ~30x smaller, *and* it accumulates in the reference's order. +//! **The correlation itself is [`LagSearch`].** The reference's normalization +//! -- `2·dot / (lagged_energy + reference_energy + 1)` -- is that op's, with +//! the `1 +` guard as its energy floor, and the slot geometry lines up +//! exactly: lags run backwards from a reference window at the end of the +//! buffer. //! -//! **The lagged energy is not a `cumsum`.** The `max(…, 0)` sits inside the -//! recurrence, which makes it non-associative: a prefix-sum difference gives a -//! different answer whenever the clamp fires. It stays a `max_period`-step -//! sequential loop — but batched across every row, so it is ~190 tiny kernels -//! for a whole sequence rather than per hop. +//! **Two deliberate departures from the reference come with it.** The +//! reference maintains the lagged energy as a sliding sum with a `max(…, 0)` +//! *inside* the recurrence, which serializes the lag range and accumulates +//! rounding with no way to recover -- the clamp exists precisely because that +//! error can drive the sum negative. [`LagSearch`] reduces each window +//! directly instead: parallel, and strictly more accurate. It also sums the +//! correlation in a different order than the reference's shift-and-accumulate. +//! +//! Both are ~1e-6-relative perturbations upstream of an `argmax`, so they are +//! not free by inspection. They are justified empirically: the 3750-hop +//! probability golden agrees with the C reference on every frame's voicing +//! decision with them in place. //! //! **The octave suppression vectorizes exactly.** The reference walks lags in //! order and rescales `xcorr[lag]` in place while reading three neighbours at @@ -59,10 +66,16 @@ use super::super::coeff::{ MIN_PERIOD_16KHZ, PROC_RESAMPLE_RATE, }; -use crate::errors::{ - BunsenError, - BunsenResult, - WithOkOrPanic, +use crate::{ + errors::{ + BunsenError, + BunsenResult, + WithOkOrPanic, + }, + ops::signal::{ + LagSearch, + LagSearchConfig, + }, }; /// The number of half-hop slots each hop contributes. @@ -166,6 +179,9 @@ impl PitchCorrelateConfig { min_period: self.min_period(), half_hop: self.half_hop(), exc_len: self.exc_len(), + lag_search: LagSearchConfig::new(self.half_hop(), self.max_period()) + .with_energy_floor(1.0) + .try_init()?, sharpen_a: Tensor::from_ints(a.as_slice(), device), sharpen_b: Tensor::from_ints(b.as_slice(), device), sharpen_c: Tensor::from_ints(c.as_slice(), device), @@ -193,6 +209,9 @@ pub struct PitchCorrelate { half_hop: usize, exc_len: usize, + /// The normalized correlation itself; see [`LagSearch`]. + lag_search: LagSearch, + sharpen_a: Tensor, sharpen_b: Tensor, sharpen_c: Tensor, @@ -235,19 +254,17 @@ impl PitchCorrelate { &self, exc: Tensor, ) -> (Tensor, Tensor) { - let [rows, len] = exc.dims(); + let len = exc.dims()[1]; assert_eq!( len, self.exc_len, "PitchCorrelate expects {} excitation samples", self.exc_len, ); - let squared = exc.clone() * exc.clone(); - let mut slots = Vec::with_capacity(SUBS_PER_HOP); let mut energies = Vec::with_capacity(SUBS_PER_HOP); for sub in 0..SUBS_PER_HOP { - let (xcorr, energy) = self.correlate_sub(&exc, &squared, sub, rows); + let (xcorr, energy) = self.correlate_sub(&exc, sub); slots.push(xcorr.unsqueeze_dim::<3>(1)); energies.push(energy); } @@ -256,89 +273,27 @@ impl PitchCorrelate { } /// One half-hop's correlations and reference energy. + /// + /// The window this slot reads sits at `sub * half_hop`, and spans every + /// lagged window plus the reference behind them -- exactly + /// [`LagSearch`]'s layout, with the reference's `1 +` guard as the + /// energy floor. fn correlate_sub( &self, exc: &Tensor, - squared: &Tensor, sub: usize, - rows: usize, ) -> (Tensor, Tensor) { let base = sub * self.half_hop; - let max_period = self.max_period; - let half = self.half_hop; - let device = exc.device(); - - // Shift-and-accumulate, in the reference's `j` order. Each step is one - // broadcast multiply-add over `[rows, max_period]`. - let mut inst: Tensor = Tensor::zeros([rows, max_period], &device); - for j in 0..half { - let at = max_period + base + j; - let reference = exc.clone().slice_dim(1, at as isize..(at + 1) as isize); - let lagged = exc - .clone() - .slice_dim(1, (base + j) as isize..(base + j + max_period) as isize); - inst = inst + lagged * reference; - } + let span = self.max_period + self.half_hop; - // The reference window's energy, which is also this slot's weight in - // the tracker. - let reference_energy = squared + let buf = exc .clone() - .slice_dim( - 1, - (max_period + base) as isize..(max_period + base + half) as isize, - ) - .sum_dim(1); - - let lagged_energy = self.sliding_energy(squared, base); - - let denominator = - (lagged_energy + reference_energy.clone().add_scalar(1.0f32)).clamp_min(1e-12f32); - let xcorr = inst.mul_scalar(2.0f32) / denominator; + .slice_dim(1, base as isize..(base + span) as isize); + let (xcorr, reference_energy) = self.lag_search.forward(buf); (self.suppress_octaves(xcorr), reference_energy) } - /// The energy under each lagged window, as a clamped sliding sum. - /// - /// Sequential by necessity: the `max(…, 0)` inside the recurrence makes it - /// non-associative, so no prefix-sum reformulation reproduces it. - /// - /// Written into a preallocated `[rows, max_period]` buffer rather than - /// collected and concatenated: there is no same-shaped input to - /// cannibalize here, but `slice_assign` still beats a 64-way `cat` of - /// `[rows, 1]` slices. - fn sliding_energy( - &self, - squared: &Tensor, - base: usize, - ) -> Tensor { - let half = self.half_hop; - let rows = squared.dims()[0]; - - let mut running = squared - .clone() - .slice_dim(1, base as isize..(base + half) as isize) - .sum_dim(1); - - let mut out: Tensor = Tensor::zeros([rows, self.max_period], &squared.device()); - out = out.slice_assign(s![.., 0..1], running.clone()); - - for lag in 1..self.max_period { - let leaving = squared - .clone() - .slice_dim(1, (base + lag - 1) as isize..(base + lag) as isize); - let entering = squared.clone().slice_dim( - 1, - (base + lag + half - 1) as isize..(base + lag + half) as isize, - ); - running = (running - leaving).clamp_min(0.0f32) + entering; - out = out.slice_assign(s![.., lag..lag + 1], running.clone()); - } - - out - } - /// Discounts lags that fail to clearly beat their own half-lag /// neighbourhood, which is where period doubling shows up. /// From a3ef20225b82418404f5a432242d6d767583e521 Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Mon, 24 Aug 2026 16:50:50 -0700 Subject: [PATCH 27/32] refactor(ten-vad): track through `ops::seq::Viterbi` The last extracted op to be put under golden verification. The stage keeps its transition table -- transposed and negated on the way in, since `to_vec_penalty` is a cost indexed `[arrival][source]` and the decoder wants a score indexed `[source][arrival]` -- and hands the decode itself to the shared implementation. That drops the reference's floor-and-fallback: it seeds each search at `path_best - 1e10` and keeps the previous best period when nothing beats it. The fallback is unreachable. Under the reference's own candidate window, `cand = min(idx, 4)` is valid for every `idx`, so no state is ever without a predecessor, and renormalized scores never approach -1e10. Argued that way it is only an argument; the golden makes it a measurement -- with the floor gone the 3750-hop trace is bit-identical, so across 7500 Viterbi steps it never fired. `PitchTrackState` loses `path_best` and `best_period` with it. They existed solely to feed the fallback, so the carried state is now the accumulator and the three ring buffers. Every general op is now exercised against the reference golden: the biquad and decimating FIR through the anti-alias tier, autocorrelation and both LPC forms through the prefilter and excitation, the filterbank through the mel path, the lag search through correlate, and now the Viterbi through track. This also sets up the D4 experiment properly. The asymmetric window is now confined to one table rather than woven through a bespoke step, so correcting it to the symmetric form the reference presumably intended is a change to `to_vec_penalty` alone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../ten_vad/context/pitch/tensor/track.rs | 130 +++++++----------- 1 file changed, 51 insertions(+), 79 deletions(-) diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs index 42ec8d4d..d7616ce0 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs @@ -38,9 +38,14 @@ //! so the window is 5 wide at the short-period end and **52 wide** at the long //! end, where `jdx` reaches −51 and the penalty reaches 52. That is very likely //! a bug in the reference, but it is load-bearing for output parity, so it is -//! reproduced exactly. It is also why the transition is a dense -//! `[dif_period, dif_period]` penalty matrix rather than a narrow band: invalid -//! transitions are simply given a penalty large enough to lose every `max`. +//! reproduced exactly -- as a dense `[dif_period, dif_period]` table rather +//! than a narrow band, since the window is ragged. +//! +//! The decode itself is [`Viterbi`]. Only the table is ten-vad's: it is +//! transposed and negated on the way in, because `to_vec_penalty` is a *cost* +//! indexed `[arrival][source]` and the decoder wants a *score* indexed +//! `[source][arrival]`. Correcting the window to the symmetric one the +//! reference presumably intended is therefore a change to that table alone. use burn::{ config::Config, @@ -70,13 +75,28 @@ use crate::{ HOP_SIZE, SAMPLE_RATE, }, + ops::seq::{ + FORBIDDEN, + Viterbi, + ViterbiConfig, + ViterbiState, + }, }; /// The penalty assigned to a transition the reference would never consider. /// -/// Large enough to lose every `max` against the running floor, which is -/// `path_best_all - 1e10`, so validity falls out of the reduction instead of -/// needing a mask. +/// Mapped onto [`FORBIDDEN`] when the table is handed to [`Viterbi`]; it +/// survives as a separate constant only because `to_vec_penalty` builds costs +/// and the sentinel has to be recognizable on the way back out. +/// +/// The reference also floors its search at `path_best - 1e10` and falls back +/// to the previous best period when nothing beats it. That fallback is +/// unreachable: under the window above, `cand = min(idx, 4)` is valid for +/// every `idx`, so no state is ever without a predecessor, and renormalized +/// scores never approach `-1e10`. It was reproduced here until the decode +/// moved to [`Viterbi`], which has no such floor; dropping it left the +/// 3750-hop golden bit-identical, which is the empirical form of that +/// argument. const INVALID_PENALTY: f32 = 1e30; /// Config for [`PitchTrack`]. @@ -172,11 +192,27 @@ impl PitchTrackConfig { self.validate()?; let dif = self.dif_period(); + // `to_vec_penalty` is indexed `[arrival][source]` and is a cost; + // `Viterbi` wants `[source][arrival]` and a score. Transpose and + // negate, mapping the unreachable sentinel onto `FORBIDDEN`. + let penalty = self.to_vec_penalty(); + let mut transition = vec![0.0f32; dif * dif]; + for arrival in 0..dif { + for source in 0..dif { + let cost = penalty[arrival * dif + source]; + transition[source * dif + arrival] = if cost >= INVALID_PENALTY { + FORBIDDEN + } else { + -cost + }; + } + } + Ok(PitchTrack { max_period: self.max_period(), dif_period: dif, n_feat: self.n_feat(), - penalty: Tensor::from_data(TensorData::new(self.to_vec_penalty(), [dif, dif]), device), + viterbi: ViterbiConfig::new(dif).try_init(&transition, device)?, }) } @@ -199,8 +235,9 @@ pub struct PitchTrack { dif_period: usize, n_feat: usize, - /// `[dif_period, dif_period]` transition penalties. - pub penalty: Tensor, + /// The decoder, over the transition table + /// [`PitchTrackConfig::to_vec_penalty`] describes. + pub viterbi: Viterbi, } /// The state [`PitchTrack`] carries between calls. @@ -209,12 +246,6 @@ pub struct PitchTrackState { /// `[batch, dif_period]` Viterbi accumulator, renormalized to peak zero. pub path_score: Tensor, - /// `[batch, 1]` best score reached so far. - pub path_best: Tensor, - - /// `[batch, 1]` best period index reached so far. - pub best_period: Tensor, - /// `[batch, carry_slots, max_period]` correlations not yet aged out. pub slot_xcorr: Tensor, @@ -225,24 +256,6 @@ pub struct PitchTrackState { pub slot_prev: Tensor, } -/// The three fields the Viterbi recursion threads from one half-hop to the -/// next. -/// -/// Split out of [`PitchTrackState`] because the forward pass steps these twice -/// per hop while the ring carries advance once, and because naming the triple -/// keeps [`PitchTrack::viterbi_step`]'s signature legible. -#[derive(Debug, Clone)] -struct ViterbiCarry { - /// `[batch, dif_period]` accumulator, renormalized to peak zero. - score: Tensor, - - /// `[batch, 1]` best score reached so far. - best: Tensor, - - /// `[batch, 1]` best period index reached so far. - period: Tensor, -} - impl PitchTrack { /// The width of the tracker's state space. pub fn dif_period(&self) -> usize { @@ -263,8 +276,6 @@ impl PitchTrack { let carry = self.slots() - SUBS_PER_HOP; PitchTrackState { path_score: Tensor::zeros([batch_size, self.dif_period], device), - path_best: Tensor::zeros([batch_size, 1], device), - best_period: Tensor::zeros([batch_size, 1], device), slot_xcorr: Tensor::zeros([batch_size, carry, self.max_period], device), slot_energy: Tensor::zeros([batch_size, carry], device), slot_prev: Tensor::zeros([batch_size, carry, self.dif_period], device), @@ -321,10 +332,8 @@ impl PitchTrack { let weights = Self::normalize_weights(energy_hist.clone(), slots, steps); // --- forward pass: two dependent steps per hop --- - let mut carry_state = ViterbiCarry { + let mut decoded = ViterbiState { score: state.path_score, - best: state.path_best, - period: state.best_period, }; let mut new_prev = Vec::with_capacity(steps * SUBS_PER_HOP); let mut hop_best = Vec::with_capacity(steps); @@ -346,11 +355,11 @@ impl PitchTrack { ]) .reshape([batch, 1]); - let (next, prev) = self.viterbi_step(carry_state, xc, w); - carry_state = next; + let (next, prev) = self.viterbi.step(decoded, xc * w); + decoded = next; new_prev.push(prev.unsqueeze_dim::<3>(1)); } - hop_best.push(carry_state.period.clone()); + hop_best.push(self.viterbi.best_state(&decoded)); } let prev_hist = Tensor::cat(vec![state.slot_prev, Tensor::cat(new_prev, 1)], 1); @@ -377,9 +386,7 @@ impl PitchTrack { ( pitch.reshape([steps, batch, 1]), PitchTrackState { - path_score: carry_state.score, - path_best: carry_state.best, - best_period: carry_state.period, + path_score: decoded.score, slot_xcorr: xcorr_hist.slice_dim(1, keep as isize..), slot_energy: energy_hist.slice_dim(1, keep as isize..), slot_prev: prev_hist.slice_dim(1, keep as isize..), @@ -413,41 +420,6 @@ impl PitchTrack { windows * (total.recip().mul_scalar(slots as f32)) } - /// One Viterbi step: the dense transition max, then the renormalization. - /// - /// Returns the advanced carry and the `[batch, dif_period]` backpointer row - /// this half-hop emits. - fn viterbi_step( - &self, - carry: ViterbiCarry, - xcorr: Tensor, - weight: Tensor, - ) -> (ViterbiCarry, Tensor) { - // [batch, dif, dif]: score of arriving at `idx` from `cand`. - let transitions = - carry.score.unsqueeze_dim::<3>(1) - self.penalty.clone().unsqueeze_dim::<3>(0); - let (best_in, arg_in) = transitions.max_dim_with_indices(2); - - // The reference seeds its search at `path_best - 1e10` and keeps the - // previous best period when nothing beats it; invalid transitions sit - // far below that floor, so they never win. - let floor = carry.best.sub_scalar(1e10f32).unsqueeze_dim::<3>(2); - let stalled = best_in.clone().lower_equal(floor.clone()); - let prev = arg_in.mask_where(stalled, carry.period.unsqueeze_dim::<3>(2)); - - let scored = best_in.max_pair(floor).squeeze_dim::<2>(2) + xcorr * weight; - let (top, arg_top) = scored.clone().max_dim_with_indices(1); - - ( - ViterbiCarry { - score: scored - top.clone(), - best: top, - period: arg_top, - }, - prev.squeeze_dim::<2>(2), - ) - } - /// Walks each hop's path back six slots and fits a period contour. #[allow(clippy::too_many_arguments)] fn backtrace_and_fit( From 385bab196d0b0d399514aa969fca53bc6b1f3aff Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Mon, 24 Aug 2026 16:55:06 -0700 Subject: [PATCH 28/32] docs(ten-vad): measure what correcting the Viterbi window costs D4 in the old fidelity ledger predicted the reference's asymmetric candidate window was "probably output-neutral on ordinary speech". Now that the decode runs through `ops::seq::Viterbi` the window lives in one table, so the experiment is a one-line change and worth actually running. The prediction was half wrong. Replacing the window with the symmetric `cand in [idx - 4, idx + 4]` moves the trace materially against the 3750-hop golden -- mean |diff| 5.6e-5 -> 1.9e-4, worst 6.4e-4 -> 1.8e-2, which is past the test's own bound. So `argmax` flips do occur and the bug is load-bearing for numeric parity. It is not load-bearing for behavior: voicing decisions still agree with the reference on every one of the 3750 frames. A port needing parity keeps the window; one wanting the intended algorithm corrects it and loses nothing a VAD consumer can observe. ten_vad keeps the reference window, since parity is what it is for. The measurement is recorded on the stage so the choice is informed rather than inherited. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../ten_vad/context/pitch/tensor/track.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs index d7616ce0..01f8bd63 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs @@ -44,8 +44,21 @@ //! The decode itself is [`Viterbi`]. Only the table is ten-vad's: it is //! transposed and negated on the way in, because `to_vec_penalty` is a *cost* //! indexed `[arrival][source]` and the decoder wants a *score* indexed -//! `[source][arrival]`. Correcting the window to the symmetric one the -//! reference presumably intended is therefore a change to that table alone. +//! `[source][arrival]`. +//! +//! ## What the correction costs, measured +//! +//! Replacing the window with the symmetric `cand ∈ [idx − 4, idx + 4]` the +//! reference presumably intended is a one-line change to +//! [`to_vec_penalty`](PitchTrackConfig::to_vec_penalty). Against the 3750-hop +//! golden it moves the trace materially -- mean |diff| 5.6e-5 → 1.9e-4, worst +//! 6.4e-4 → 1.8e-2 -- so `argmax` flips really do occur, and the bug is +//! load-bearing for numeric parity. +//! +//! It is **not** load-bearing for behavior: voicing decisions still agree with +//! the reference on every one of the 3750 frames. A port that needs parity +//! must keep the window; one that wants the intended algorithm can correct it +//! and lose nothing that a VAD consumer observes. use burn::{ config::Config, From 7e149e488a857972c760008d7daebb5ac7564bef Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Mon, 24 Aug 2026 17:15:47 -0700 Subject: [PATCH 29/32] refactor(speech): promote the pitch estimator to `kits::speech::pitch` Moves the estimator out from under `ten_vad` and cuts its dependency on that kit entirely, so it survives when ten_vad is deleted. `grep ten_vad` inside `kits::speech::pitch` now returns nothing but two doc cross-references. Mechanically: * the whole `context/pitch` tree relocates, history intact; * `TenVadPitch*` become `Pitch*`, and `TenVadPitchEstimator` becomes `HostPitchEstimator`, which is what it always was -- the scalar oracle every device stage is validated against; * `SAMPLE_RATE` and `HOP_SIZE` move into the estimator's own `coeff`, since they are the geometry it is defined for rather than the feature path's; * `ZeroPitch::normalized_feature` goes the other way. Pinning feature `40` to `(0 - mean) / (std + eps)` is the *feature path's* business, not the estimator's, so it becomes a test helper in `ten_vad::context::features` where those tables live. ten_vad's context no longer glob-re-exports pitch, which is what surfaced the last of the coupling: several of its doc links were resolving only through that re-export, and now name the real paths. The module docs are rewritten for the new home. They lead with what the thing *is* -- four stages, host and device, cross-tested -- rather than with which feature index of which model consumes it, and they state the LPCNet lineage and what that implies: the band tables are reference data, and a consumer whose model was fitted against them cannot change them without refitting. Behavior is untouched: 188 speech tests pass and the golden is unchanged at mean |diff| 5.649e-5, worst 6.446e-4, decisions agreeing on all 3750 frames. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- crates/bunsen/src/kits/speech/mod.rs | 2 + .../{ten_vad/context => }/pitch/coeff.rs | 22 ++- .../{ten_vad/context => }/pitch/estimator.rs | 80 +++++------ .../{ten_vad/context => }/pitch/host.rs | 49 ++++--- .../speech/{ten_vad/context => }/pitch/lpc.rs | 0 crates/bunsen/src/kits/speech/pitch/mod.rs | 63 +++++++++ .../{ten_vad/context => }/pitch/source.rs | 127 +++++++----------- .../context => }/pitch/tensor/antialias.rs | 0 .../context => }/pitch/tensor/correlate.rs | 6 +- .../context => }/pitch/tensor/excitation.rs | 8 +- .../context => }/pitch/tensor/hybrid.rs | 20 +-- .../{ten_vad/context => }/pitch/tensor/mod.rs | 2 +- .../context => }/pitch/tensor/prefilter.rs | 8 +- .../context => }/pitch/tensor/source.rs | 16 +-- .../context => }/pitch/tensor/tables.rs | 0 .../context => }/pitch/tensor/track.rs | 12 +- .../src/kits/speech/ten_vad/context/driver.rs | 97 +++++++------ .../kits/speech/ten_vad/context/features.rs | 112 ++++++++------- .../src/kits/speech/ten_vad/context/mod.rs | 20 +-- .../kits/speech/ten_vad/context/pitch/mod.rs | 63 --------- .../src/kits/speech/ten_vad/cross_test.rs | 40 +++--- 21 files changed, 366 insertions(+), 381 deletions(-) rename crates/bunsen/src/kits/speech/{ten_vad/context => }/pitch/coeff.rs (87%) rename crates/bunsen/src/kits/speech/{ten_vad/context => }/pitch/estimator.rs (94%) rename crates/bunsen/src/kits/speech/{ten_vad/context => }/pitch/host.rs (84%) rename crates/bunsen/src/kits/speech/{ten_vad/context => }/pitch/lpc.rs (100%) create mode 100644 crates/bunsen/src/kits/speech/pitch/mod.rs rename crates/bunsen/src/kits/speech/{ten_vad/context => }/pitch/source.rs (71%) rename crates/bunsen/src/kits/speech/{ten_vad/context => }/pitch/tensor/antialias.rs (100%) rename crates/bunsen/src/kits/speech/{ten_vad/context => }/pitch/tensor/correlate.rs (99%) rename crates/bunsen/src/kits/speech/{ten_vad/context => }/pitch/tensor/excitation.rs (99%) rename crates/bunsen/src/kits/speech/{ten_vad/context => }/pitch/tensor/hybrid.rs (91%) rename crates/bunsen/src/kits/speech/{ten_vad/context => }/pitch/tensor/mod.rs (97%) rename crates/bunsen/src/kits/speech/{ten_vad/context => }/pitch/tensor/prefilter.rs (98%) rename crates/bunsen/src/kits/speech/{ten_vad/context => }/pitch/tensor/source.rs (98%) rename crates/bunsen/src/kits/speech/{ten_vad/context => }/pitch/tensor/tables.rs (100%) rename crates/bunsen/src/kits/speech/{ten_vad/context => }/pitch/tensor/track.rs (99%) delete mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/pitch/mod.rs diff --git a/crates/bunsen/src/kits/speech/mod.rs b/crates/bunsen/src/kits/speech/mod.rs index 72a6961f..4a7c6046 100644 --- a/crates/bunsen/src/kits/speech/mod.rs +++ b/crates/bunsen/src/kits/speech/mod.rs @@ -1,5 +1,7 @@ //! Speech Models +pub mod pitch; + /// A fully functional Silero VAD model. pub mod silero_vad; diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/coeff.rs b/crates/bunsen/src/kits/speech/pitch/coeff.rs similarity index 87% rename from crates/bunsen/src/kits/speech/ten_vad/context/pitch/coeff.rs rename to crates/bunsen/src/kits/speech/pitch/coeff.rs index f21b2bab..5ffe49ec 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/coeff.rs +++ b/crates/bunsen/src/kits/speech/pitch/coeff.rs @@ -1,13 +1,23 @@ //! # Pitch-estimator coefficients. //! -//! The fixed constants of the reference pitch estimator, transcribed from -//! `src/pitch_est_st.h` and `src/pitch_est.cc` in the ten-vad reference. +//! The fixed constants the estimator is defined by: the band layout, the +//! period bounds, the anti-alias design and the tracker's weights. //! -//! Like [`super::super::coeff`], these are reference data rather than -//! tunables: the estimator's output feeds feature `40`, whose mean and -//! standard deviation were fitted against exactly these values. +//! These are **reference data rather than tunables**. The band edges and +//! per-band compensation come from `LPCNet`'s feature front end, and a consumer +//! whose downstream model was fitted against these values cannot change them +//! without refitting that model. Geometry that a caller may legitimately vary +//! -- hop size, chunk size, filter length -- lives on the stage configs +//! instead. + +/// The sample rate, in Hz, the estimator's constants are defined for. +/// +/// The period bounds, band layout and anti-alias design below are all tied to +/// it; changing it alone does not rescale them. +pub const SAMPLE_RATE: usize = 16000; -use crate::kits::speech::ten_vad::context::coeff::SAMPLE_RATE; +/// The hop size, in samples, the estimator advances per call. +pub const HOP_SIZE: usize = 256; /// The number of bands the pitch estimator's LPC front end works in. /// diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/estimator.rs b/crates/bunsen/src/kits/speech/pitch/estimator.rs similarity index 94% rename from crates/bunsen/src/kits/speech/ten_vad/context/pitch/estimator.rs rename to crates/bunsen/src/kits/speech/pitch/estimator.rs index d8b5804f..434fe157 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/estimator.rs +++ b/crates/bunsen/src/kits/speech/pitch/estimator.rs @@ -32,7 +32,7 @@ //! subtracting the running maximum each step rather than being cleared, so //! clearing it per hop would discard the tracker's whole memory. //! * The correlation buffer is a circular buffer of `2 * n_feat` half-hops -//! indexed off [`xcorr_offset`](TenVadPitchEstimator), and the DP walks it in +//! indexed off [`xcorr_offset`](HostPitchEstimator), and the DP walks it in //! stream order, not slot order. //! //! ## Divergences from the reference @@ -56,12 +56,14 @@ use super::{ ANTI_ALIAS_SECTIONS, FEAT_MAX_NFRM, FEAT_TIME_WINDOW_MS, + HOP_SIZE, LPC_ORDER, MAX_PERIOD_16KHZ, MIN_PERIOD_16KHZ, PITCH_MAX_PATH_W, PROC_FS, PROC_RESAMPLE_RATE, + SAMPLE_RATE, VOICED_THRESHOLD, XCORR_TRAINING_OFFSET, }, @@ -75,15 +77,11 @@ use super::{ lpc_from_cepstrum, }, source::{ - TenVadPitchScalarSource, - TenVadPitchSourceInit, + PitchScalarSource, + PitchSourceInit, }, }; use crate::{ - kits::speech::ten_vad::context::coeff::{ - HOP_SIZE, - SAMPLE_RATE, - }, ops::signal::{ Autocorrelator, BiquadCascade, @@ -99,14 +97,14 @@ const WINDOW_SIZE: usize = 768; /// The reference pitch estimator. /// -/// A [`TenVadPitchScalarSource`]: one instance per stream, stepped one hop at +/// A [`PitchScalarSource`]: one instance per stream, stepped one hop at /// a time. It reaches the driver's tensor seam through /// [`HostPitch`](super::HostPitch), which this type's -/// [`TenVadPitchSourceInit`] impl wraps it in — so it can be named directly +/// [`PitchSourceInit`] impl wraps it in — so it can be named directly /// where a pitch source is expected: /// /// ```rust,ignore -/// let ctx = vad.init_context_with(&cfg, TenVadPitchEstimator::new(), &device)?; +/// let ctx = vad.init_context_with(&cfg, HostPitchEstimator::new(), &device)?; /// ``` /// /// This is also the permanent oracle a device-side port is validated against: @@ -115,9 +113,9 @@ const WINDOW_SIZE: usize = 768; /// boundary that carries no state. /// /// Built by [`new`](Self::new), rewound by -/// [`reset`](TenVadPitchScalarSource::reset). +/// [`reset`](PitchScalarSource::reset). #[derive(Debug, Clone)] -pub struct TenVadPitchEstimator { +pub struct HostPitchEstimator { // --- Fixed geometry, all derived from the ten-vad configuration. --- /// The shortest candidate period, in samples at [`PROC_FS`]. min_period: usize, @@ -211,13 +209,13 @@ pub struct TenVadPitchEstimator { pitch_hz: f32, } -impl Default for TenVadPitchEstimator { +impl Default for HostPitchEstimator { fn default() -> Self { Self::new() } } -impl TenVadPitchEstimator { +impl HostPitchEstimator { /// Builds an estimator for the ten-vad front end, in start-of-stream state. /// /// The geometry is fixed: 16 kHz in, a 256-sample hop, a 1024-point STFT @@ -318,7 +316,7 @@ impl TenVadPitchEstimator { /// Estimates the pitch of one hop against an externally supplied /// pre-filter, skipping the stage that would design one. /// - /// [`frame_pitch`](TenVadPitchScalarSource::frame_pitch) is exactly + /// [`frame_pitch`](PitchScalarSource::frame_pitch) is exactly /// "design the pre-filter from `bin_power`, then this". The stage that /// designs it carries no state, so the split is exact: feeding this the /// coefficients that stage would have produced reproduces `frame_pitch` @@ -344,7 +342,7 @@ impl TenVadPitchEstimator { assert_eq!( raw_hop.len(), self.hop_size(), - "TenVadPitchEstimator expects a {}-sample hop", + "HostPitchEstimator expects a {}-sample hop", self.hop_size(), ); @@ -638,7 +636,7 @@ impl TenVadPitchEstimator { /// internal layout — `exc_buf`'s length, `path_prev`'s slot shape — which /// should not become public API just to let a differential test read them. #[cfg(test)] -impl TenVadPitchEstimator { +impl HostPitchEstimator { /// The decimated excitation history. /// /// Part of the oracle surface: this is what a device-side excitation stage @@ -691,7 +689,7 @@ impl TenVadPitchEstimator { } } -impl TenVadPitchScalarSource for TenVadPitchEstimator { +impl PitchScalarSource for HostPitchEstimator { /// Estimates the pitch of one hop. /// /// # Panics @@ -705,13 +703,13 @@ impl TenVadPitchScalarSource for TenVadPitchEstimator { assert_eq!( raw_hop.len(), self.hop_size(), - "TenVadPitchEstimator expects a {}-sample hop", + "HostPitchEstimator expects a {}-sample hop", self.hop_size(), ); assert_eq!( bin_power.len(), self.n_bins(), - "TenVadPitchEstimator expects {} bins", + "HostPitchEstimator expects {} bins", self.n_bins(), ); @@ -759,21 +757,17 @@ impl TenVadPitchScalarSource for TenVadPitchEstimator { /// Builds the estimator behind a [`HostPitch`] adapter, one instance per /// stream. /// -/// This is what lets `init_context_with(cfg, TenVadPitchEstimator::new(), dev)` +/// This is what lets `init_context_with(cfg, HostPitchEstimator::new(), dev)` /// read naturally while the driver's seam stays tensor-in, tensor-out. -impl TenVadPitchSourceInit for TenVadPitchEstimator { - type Source = HostPitch; +impl PitchSourceInit for HostPitchEstimator { + type Source = HostPitch; fn try_init_source( &self, batch_size: usize, device: &B::Device, ) -> BunsenResult { - TenVadPitchSourceInit::::try_init_source( - &HostPitchInit(self.clone()), - batch_size, - device, - ) + PitchSourceInit::::try_init_source(&HostPitchInit(self.clone()), batch_size, device) } } @@ -879,7 +873,7 @@ mod tests { /// Runs `signal` through the estimator hop by hop, returning per-hop pitch. fn run(signal: &[f32]) -> Vec { - let mut est = TenVadPitchEstimator::new(); + let mut est = HostPitchEstimator::new(); let mut stft = HostStft::new(); hops(signal) .map(|hop| { @@ -890,8 +884,8 @@ mod tests { } #[test] - fn test_geometry_matches_the_ten_vad_configuration() { - let est = TenVadPitchEstimator::new(); + fn test_geometry_matches_the_reference_configuration() { + let est = HostPitchEstimator::new(); assert_eq!(PROC_RESAMPLE_RATE, 4); assert_eq!(est.hop_size(), 256); assert_eq!(est.n_bins(), 513); @@ -917,7 +911,7 @@ mod tests { fn test_silence_leaves_no_nan_behind() { // The reference divides by an unguarded weight sum here; make sure the // guarded form still reports a clean zero rather than a NaN. - let mut est = TenVadPitchEstimator::new(); + let mut est = HostPitchEstimator::new(); for _ in 0..8 { let p = est.frame_pitch(&[0.0; HOP_SIZE], &[0.0; N_BINS]); assert!(p.is_finite(), "pitch went non-finite on silence"); @@ -968,7 +962,7 @@ mod tests { fn test_reset_rewinds_to_start_of_stream() { let signal = pulse_train(140.0, HOP_SIZE * 12, 0); - let mut est = TenVadPitchEstimator::new(); + let mut est = HostPitchEstimator::new(); let mut stft = HostStft::new(); let first: Vec = hops(&signal) .map(|hop| est.frame_pitch(hop, &stft.push(hop))) @@ -1007,7 +1001,7 @@ mod tests { #[test] fn test_clone_is_an_independent_stream() { let signal = pulse_train(130.0, HOP_SIZE * 16, 0); - let mut a = TenVadPitchEstimator::new(); + let mut a = HostPitchEstimator::new(); let mut stft = HostStft::new(); let mut powers = Vec::new(); @@ -1042,7 +1036,7 @@ mod tests { #[test] fn test_voiced_flag_agrees_with_the_reported_pitch() { let signal = pulse_train(180.0, HOP_SIZE * 24, 0); - let mut est = TenVadPitchEstimator::new(); + let mut est = HostPitchEstimator::new(); let mut stft = HostStft::new(); for hop in hops(&signal) { @@ -1055,13 +1049,13 @@ mod tests { #[test] #[should_panic(expected = "expects a 256-sample hop")] fn test_wrong_hop_length_panics() { - TenVadPitchEstimator::new().frame_pitch(&[0.0; 128], &[0.0; N_BINS]); + HostPitchEstimator::new().frame_pitch(&[0.0; 128], &[0.0; N_BINS]); } #[test] #[should_panic(expected = "expects 513 bins")] fn test_wrong_bin_count_panics() { - TenVadPitchEstimator::new().frame_pitch(&[0.0; HOP_SIZE], &[0.0; 257]); + HostPitchEstimator::new().frame_pitch(&[0.0; HOP_SIZE], &[0.0; 257]); } #[test] @@ -1071,8 +1065,8 @@ mod tests { // against the filter that hop produced must be exact. let signal = pulse_train(150.0, HOP_SIZE * 24, 0); - let mut whole = TenVadPitchEstimator::new(); - let mut split = TenVadPitchEstimator::new(); + let mut whole = HostPitchEstimator::new(); + let mut split = HostPitchEstimator::new(); let mut stft = HostStft::new(); for hop in hops(&signal) { @@ -1094,7 +1088,7 @@ mod tests { #[test] fn test_oracle_accessors_report_the_documented_shapes() { let signal = pulse_train(140.0, HOP_SIZE * 8, 0); - let mut est = TenVadPitchEstimator::new(); + let mut est = HostPitchEstimator::new(); let mut stft = HostStft::new(); for hop in hops(&signal) { est.frame_pitch(hop, &stft.push(hop)); @@ -1116,7 +1110,7 @@ mod tests { // `slots-2` and `slots-1`, and everything older shifts left by two. // This is the translation the ring offset exists to hide. let signal = pulse_train(160.0, HOP_SIZE * 12, 0); - let mut est = TenVadPitchEstimator::new(); + let mut est = HostPitchEstimator::new(); let mut stft = HostStft::new(); let mut hop_iter = hops(&signal); @@ -1145,7 +1139,7 @@ mod tests { // The Viterbi accumulator is renormalized, never cleared — so after a // voiced run it must be non-trivial, with its peak pinned at zero. let signal = pulse_train(170.0, HOP_SIZE * 20, 0); - let mut est = TenVadPitchEstimator::new(); + let mut est = HostPitchEstimator::new(); let mut stft = HostStft::new(); for hop in hops(&signal) { est.frame_pitch(hop, &stft.push(hop)); @@ -1166,7 +1160,7 @@ mod tests { #[test] fn test_usable_as_a_scalar_trait_object() { - let mut source: Box = Box::new(TenVadPitchEstimator::new()); + let mut source: Box = Box::new(HostPitchEstimator::new()); let p = source.frame_pitch(&[0.0; HOP_SIZE], &[0.0; N_BINS]); assert_eq!(p, 0.0); source.reset(); diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/host.rs b/crates/bunsen/src/kits/speech/pitch/host.rs similarity index 84% rename from crates/bunsen/src/kits/speech/ten_vad/context/pitch/host.rs rename to crates/bunsen/src/kits/speech/pitch/host.rs index b8e4e5fb..00d8ec48 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/host.rs +++ b/crates/bunsen/src/kits/speech/pitch/host.rs @@ -1,8 +1,8 @@ //! # Host-side pitch sources, adapted to the device seam. //! -//! [`HostPitch`] wraps a per-stream [`TenVadPitchScalarSource`] — notably -//! [`TenVadPitchEstimator`](super::TenVadPitchEstimator), the reference port — -//! and presents it as a [`TenVadPitchSource`]. +//! [`HostPitch`] wraps a per-stream [`PitchScalarSource`] — notably +//! [`HostPitchEstimator`](super::HostPitchEstimator), the reference port — +//! and presents it as a [`PitchSource`]. //! //! ## The readback lives here, and only here //! @@ -20,9 +20,9 @@ use burn::prelude::*; use super::source::{ - TenVadPitchScalarSource, - TenVadPitchSource, - TenVadPitchSourceInit, + PitchScalarSource, + PitchSource, + PitchSourceInit, }; use crate::{ errors::{ @@ -36,17 +36,17 @@ use crate::{ }, }; -/// Adapts a per-stream host [`TenVadPitchScalarSource`] to the device seam. +/// Adapts a per-stream host [`PitchScalarSource`] to the device seam. /// /// Holds one scalar estimator per batch row, cloned from a prototype. Built by /// [`HostPitch::new`] or, through the driver, by [`HostPitchInit`]. #[derive(Debug, Clone, PartialEq)] -pub struct HostPitch { +pub struct HostPitch { /// The per-stream estimators; one entry per batch row. pub sources: Vec

, } -impl HostPitch

{ +impl HostPitch

{ /// Builds a host adapter over `batch_size` independent streams. /// /// # Arguments @@ -66,14 +66,14 @@ impl HostPitch

{ } } -impl HostPitch

{ +impl HostPitch

{ /// The batch size; each entry is an independent stream. pub fn batch_size(&self) -> usize { self.sources.len() } } -impl TenVadPitchSource for HostPitch

{ +impl PitchSource for HostPitch

{ fn forward( &mut self, raw: Tensor, @@ -148,15 +148,15 @@ impl TenVadPitchSource for HostPitch< /// Builds a [`HostPitch`] from a scalar prototype. /// -/// Third-party [`TenVadPitchScalarSource`] implementations reach the driver +/// Third-party [`PitchScalarSource`] implementations reach the driver /// through this; the ten-vad reference estimator has its own -/// [`TenVadPitchSourceInit`] impl so that -/// `init_context_with(cfg, TenVadPitchEstimator::new(), device)` reads +/// [`PitchSourceInit`] impl so that +/// `init_context_with(cfg, HostPitchEstimator::new(), device)` reads /// naturally. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct HostPitchInit

(pub P); -impl TenVadPitchSourceInit for HostPitchInit

{ +impl PitchSourceInit for HostPitchInit

{ type Source = HostPitch

; fn try_init_source( @@ -188,7 +188,7 @@ mod tests { last: f32, } - impl TenVadPitchScalarSource for EchoPitch { + impl PitchScalarSource for EchoPitch { fn frame_pitch( &mut self, raw_hop: &[f32], @@ -213,7 +213,7 @@ mod tests { let raw = Tensor::::from_floats([[10.0, 0.0], [20.0, 0.0], [30.0, 0.0]], &device); let power = Tensor::::ones([3, 4], &device); - let out = TenVadPitchSource::::forward(&mut pitch, raw, power); + let out = PitchSource::::forward(&mut pitch, raw, power); out.into_data() .assert_eq(&TensorData::from([[10.0f32], [20.0], [30.0]]), true); @@ -240,8 +240,7 @@ mod tests { let power = Tensor::::ones([steps, batch, 5], &device); let mut seq = HostPitch::new(EchoPitch::default(), batch); - let seq_out = - TenVadPitchSource::::forward_sequence(&mut seq, raw.clone(), power.clone()); + let seq_out = PitchSource::::forward_sequence(&mut seq, raw.clone(), power.clone()); let mut step = HostPitch::new(EchoPitch::default(), batch); let mut rows = Vec::new(); @@ -254,7 +253,7 @@ mod tests { .clone() .slice_dim(0, s as isize..(s + 1) as isize) .squeeze_dim::<2>(0); - rows.push(TenVadPitchSource::::forward(&mut step, r, p)); + rows.push(PitchSource::::forward(&mut step, r, p)); } let step_out: Tensor = Tensor::stack(rows, 0); @@ -270,10 +269,10 @@ mod tests { let raw = Tensor::::from_floats([[5.0], [6.0]], &device); let power = Tensor::::ones([2, 2], &device); - TenVadPitchSource::::forward(&mut pitch, raw, power); + PitchSource::::forward(&mut pitch, raw, power); assert!(pitch.sources.iter().all(|s| s.calls == 1)); - TenVadPitchSource::::reset(&mut pitch); + PitchSource::::reset(&mut pitch); assert!(pitch.sources.iter().all(|s| *s == EchoPitch::default())); } @@ -281,15 +280,15 @@ mod tests { fn test_init_rejects_zero_batch() { let device = Default::default(); let init = HostPitchInit(EchoPitch::default()); - assert!(TenVadPitchSourceInit::::try_init_source(&init, 0, &device).is_err()); - assert!(TenVadPitchSourceInit::::try_init_source(&init, 2, &device).is_ok()); + assert!(PitchSourceInit::::try_init_source(&init, 0, &device).is_err()); + assert!(PitchSourceInit::::try_init_source(&init, 2, &device).is_ok()); } #[test] fn test_init_clones_the_prototype_per_row() { let device = Default::default(); let init = HostPitchInit(EchoPitch::default()); - let built = TenVadPitchSourceInit::::init_source(&init, 3, &device); + let built = PitchSourceInit::::init_source(&init, 3, &device); assert_eq!(built.batch_size(), 3); } } diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/lpc.rs b/crates/bunsen/src/kits/speech/pitch/lpc.rs similarity index 100% rename from crates/bunsen/src/kits/speech/ten_vad/context/pitch/lpc.rs rename to crates/bunsen/src/kits/speech/pitch/lpc.rs diff --git a/crates/bunsen/src/kits/speech/pitch/mod.rs b/crates/bunsen/src/kits/speech/pitch/mod.rs new file mode 100644 index 00000000..33a06f19 --- /dev/null +++ b/crates/bunsen/src/kits/speech/pitch/mod.rs @@ -0,0 +1,63 @@ +//! # Time-domain pitch estimation. +//! +//! A pitch estimate in Hz per hop, `0.0` when unvoiced. Four stages: +//! +//! 1. **pre-filter design** — fold the power spectrum into bands, round-trip +//! through a cepstrum, and solve for an LPC whitening filter. Stateless. +//! 2. **excitation** — whiten the raw hop with that filter, smooth, and +//! decimate by four. Carries a FIFO and the anti-alias filter's state. +//! 3. **lag search** — normalized cross-correlation over every candidate +//! period, with octave suppression. Carries a correlation ring. +//! 4. **tracking** — a Viterbi pass over the period candidates, then a weighted +//! fit over the recovered contour. Carries the accumulator. +//! +//! The design follows `LPCNet`'s `compute_frame_features` / +//! `process_superframe` (Xiph/Mozilla, BSD-2-Clause), and the coefficient +//! tables in [`coeff`] come from that lineage. They are reference data rather +//! than tunables: a consumer whose downstream model was fitted against these +//! values cannot change them without refitting. +//! +//! ## Host and device +//! +//! Both forms are in the tree and cross-tested against each other: +//! +//! * [`HostPitchEstimator`] — scalar, one stream, a serial recurrence. The +//! oracle every device stage is validated against, and the cheaper choice for +//! a caller stepping hop by hop. +//! * [`tensor`] — the device port, built stage by stage against that oracle. +//! Batches over streams and keeps the whole estimate resident; it is built +//! for sequences, so a single-hop call pays sequence-shaped setup. +//! +//! [`PitchSource`] is the seam both satisfy — tensor in, tensor out, so a +//! caller's pipeline can stay device-resident — with [`HostPitch`] adapting a +//! scalar [`PitchScalarSource`] to it at the cost of one readback per call. +//! [`ZeroPitch`] reports silence and never inspects its arguments, for callers +//! that want the branch gone. +//! +//! ## What is shared, and what is here +//! +//! The reusable machinery lives in [`ops::signal`](crate::ops::signal) and +//! [`ops::seq`](crate::ops::seq): the biquad cascade and decimating FIR, the +//! autocorrelator and both Levinson forms, the LPC analysis filter, the +//! triangular filterbank, the normalized lag search, and the Viterbi decoder. +//! What remains here is the *assembly* — the geometry that ties them together, +//! the band tables, the octave suppression, and the period fit. + +pub mod coeff; +mod estimator; +mod host; +mod lpc; +mod source; + +pub mod tensor; + +#[doc(inline)] +pub use coeff::*; +#[doc(inline)] +pub use estimator::*; +#[doc(inline)] +pub use host::*; +#[doc(inline)] +pub use lpc::*; +#[doc(inline)] +pub use source::*; diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/source.rs b/crates/bunsen/src/kits/speech/pitch/source.rs similarity index 71% rename from crates/bunsen/src/kits/speech/ten_vad/context/pitch/source.rs rename to crates/bunsen/src/kits/speech/pitch/source.rs index 8d6417e5..67efb0c1 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/source.rs +++ b/crates/bunsen/src/kits/speech/pitch/source.rs @@ -3,29 +3,28 @@ //! Feature `40` of the ten-vad feature vector is a pitch estimate, in Hz, //! with `0.0` meaning "unvoiced" (`ALGO_TRACE.md` §3.5). //! -//! The driver reaches it through [`TenVadPitchSource`], which is tensor-in, +//! The driver reaches it through [`PitchSource`], which is tensor-in, //! tensor-out so the front end can stay device-resident. Implementations: //! //! * [`ZeroPitch`] — a constant stub that never inspects its input. -//! * [`HostPitch`](super::HostPitch) — adapts a host-side -//! [`TenVadPitchScalarSource`] (notably -//! [`TenVadPitchEstimator`](super::TenVadPitchEstimator), the reference port) -//! at the cost of a device-to-host readback. +//! * [`HostPitch`](super::HostPitch) — adapts a host-side [`PitchScalarSource`] +//! (notably [`HostPitchEstimator`](super::HostPitchEstimator), the reference +//! port) at the cost of a device-to-host readback. //! //! ## Why there are three traits //! -//! * [`TenVadPitchSource`] is the device seam the driver calls. -//! * [`TenVadPitchSourceInit`] builds one. A tensor-native source has to -//! *allocate* its carried buffers for a `(batch_size, device)` pair, so it -//! cannot be a prototype cloned per batch row the way a host source can. -//! * [`TenVadPitchScalarSource`] is the per-stream host contract the reference +//! * [`PitchSource`] is the device seam the driver calls. +//! * [`PitchSourceInit`] builds one. A tensor-native source has to *allocate* +//! its carried buffers for a `(batch_size, device)` pair, so it cannot be a +//! prototype cloned per batch row the way a host source can. +//! * [`PitchScalarSource`] is the per-stream host contract the reference //! estimator implements, kept separate because the reference algorithm is a //! serial recurrence over scalars, not a tensor op. use burn::prelude::*; use super::{ - estimator::TenVadPitchEstimator, + estimator::HostPitchEstimator, host::HostPitch, tensor::source::{ TensorPitchConfig, @@ -34,20 +33,14 @@ use super::{ }; use crate::{ errors::WithOkOrPanic, - kits::speech::ten_vad::context::coeff::{ - FEATURE_EPS, - FEATURE_MEANS, - FEATURE_STDS, - N_MELS, - }, prelude::BunsenResult, }; /// A source for the ten-vad pitch feature. /// /// The driver holds exactly one of these per context, covering every stream in -/// the batch. Built by [`TenVadPitchSourceInit`]. -pub trait TenVadPitchSource { +/// the batch. Built by [`PitchSourceInit`]. +pub trait PitchSource { /// Estimates the pitch of one hop. /// /// # Arguments @@ -87,15 +80,15 @@ pub trait TenVadPitchSource { fn reset(&mut self); } -/// Builds a [`TenVadPitchSource`] bound to a batch size and a device. +/// Builds a [`PitchSource`] bound to a batch size and a device. /// /// This is the seam /// [`TenVadFeatures::init_state`](crate::kits::speech::ten_vad::context::TenVadFeatures::init_state) /// threads through. It exists because a tensor-native source allocates its /// carried buffers at construction and so cannot be cloned per batch row. -pub trait TenVadPitchSourceInit { +pub trait PitchSourceInit { /// The source this builds. - type Source: TenVadPitchSource; + type Source: PitchSource; /// Builds a start-of-stream source over `batch_size` independent streams. /// @@ -129,8 +122,8 @@ pub trait TenVadPitchSourceInit { /// The reference algorithm is a serial recurrence over scalars rather than a /// tensor op, so it is expressed here and adapted to the device seam by /// [`HostPitch`](super::HostPitch). Implemented by -/// [`TenVadPitchEstimator`](super::TenVadPitchEstimator). -pub trait TenVadPitchScalarSource { +/// [`HostPitchEstimator`](super::HostPitchEstimator). +pub trait PitchScalarSource { /// Estimates the pitch of one hop. /// /// # Arguments @@ -151,27 +144,18 @@ pub trait TenVadPitchScalarSource { fn reset(&mut self); } -/// A [`TenVadPitchSource`] that always reports unvoiced. +/// A [`PitchSource`] that always reports unvoiced. /// -/// Feature `40` is then pinned to the constant -/// `(0.0 - FEATURE_MEANS[40]) / (FEATURE_STDS[40] + FEATURE_EPS)`, which -/// [`ZeroPitch::normalized_feature`] reports. +/// Always reports `0.0` Hz, and never inspects its arguments, so a pipeline +/// using it stays entirely on-device. /// -/// The other 40 features are unaffected: nothing upstream of the pitch branch -/// reads its output. This is a deliberate approximation, not a placeholder — -/// it never inspects its arguments, so the whole front end stays on-device, -/// which the faithful sources cannot offer. +/// A deliberate approximation rather than a placeholder: a caller that does +/// not need pitch, or that wants to measure what the branch costs, can drop it +/// without changing anything upstream. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct ZeroPitch; -impl ZeroPitch { - /// The normalized value feature `40` takes under [`ZeroPitch`]. - pub fn normalized_feature() -> f32 { - (0.0 - FEATURE_MEANS[N_MELS]) / (FEATURE_STDS[N_MELS] + FEATURE_EPS) - } -} - -impl TenVadPitchSource for ZeroPitch { +impl PitchSource for ZeroPitch { fn forward( &mut self, raw: Tensor, @@ -192,7 +176,7 @@ impl TenVadPitchSource for ZeroPitch { fn reset(&mut self) {} } -impl TenVadPitchSourceInit for ZeroPitch { +impl PitchSourceInit for ZeroPitch { type Source = ZeroPitch; fn try_init_source( @@ -221,7 +205,7 @@ mod tests { let raw = Tensor::::from_floats([[1.0, -2.0, 3.0]], &device); let power = Tensor::::ones([1, 513], &device); - let out = TenVadPitchSource::::forward(&mut pitch, raw, power); + let out = PitchSource::::forward(&mut pitch, raw, power); assert_eq!(out.dims(), [1, 1]); assert_eq!(out.into_scalar().elem::(), 0.0); } @@ -234,7 +218,7 @@ mod tests { let raw = Tensor::::zeros([5, 2, 256], &device); let power = Tensor::::ones([5, 2, 513], &device); - let out = TenVadPitchSource::::forward_sequence(&mut pitch, raw, power); + let out = PitchSource::::forward_sequence(&mut pitch, raw, power); assert_eq!(out.dims(), [5, 2, 1]); assert_eq!(out.sum().into_scalar().elem::(), 0.0); } @@ -250,8 +234,8 @@ mod tests { let loud = Tensor::::full([1, 256], 30000.0, &device); let power = Tensor::::ones([1, 513], &device); - let a = TenVadPitchSource::::forward(&mut pitch, quiet, power.clone()); - let b = TenVadPitchSource::::forward(&mut pitch, loud, power); + let a = PitchSource::::forward(&mut pitch, quiet, power.clone()); + let b = PitchSource::::forward(&mut pitch, loud, power); a.into_data().assert_eq(&b.into_data(), true); } @@ -262,29 +246,18 @@ mod tests { let raw = Tensor::::ones([1, 256], &device); let power = Tensor::::ones([1, 513], &device); - let before = TenVadPitchSource::::forward(&mut pitch, raw.clone(), power.clone()); - TenVadPitchSource::::reset(&mut pitch); - let after = TenVadPitchSource::::forward(&mut pitch, raw, power); + let before = PitchSource::::forward(&mut pitch, raw.clone(), power.clone()); + PitchSource::::reset(&mut pitch); + let after = PitchSource::::forward(&mut pitch, raw, power); before.into_data().assert_eq(&after.into_data(), true); assert_eq!(pitch, ZeroPitch); } - #[test] - fn test_normalized_feature_matches_the_normalization_formula() { - let expected = (0.0 - FEATURE_MEANS[N_MELS]) / (FEATURE_STDS[N_MELS] + FEATURE_EPS); - assert_eq!(ZeroPitch::normalized_feature(), expected); - - // A pitch mean of ~92.36 Hz over a std of ~115.21 puts silence a bit - // over half a standard deviation below the mean. - assert!(ZeroPitch::normalized_feature() < 0.0); - assert!((ZeroPitch::normalized_feature() - (-0.80161)).abs() < 1e-4); - } - #[test] fn test_usable_as_a_trait_object() { let device = Default::default(); - let mut pitch: Box> = Box::new(ZeroPitch); + let mut pitch: Box> = Box::new(ZeroPitch); let raw = Tensor::::zeros([1, 256], &device); let power = Tensor::::zeros([1, 513], &device); @@ -295,7 +268,7 @@ mod tests { #[test] fn test_zero_pitch_init_ignores_batch_and_device() { let device = Default::default(); - let built = TenVadPitchSourceInit::::init_source(&ZeroPitch, 4, &device); + let built = PitchSourceInit::::init_source(&ZeroPitch, 4, &device); assert_eq!(built, ZeroPitch); } @@ -305,7 +278,7 @@ mod tests { calls: usize, } - impl TenVadPitchScalarSource for CountingPitch { + impl PitchScalarSource for CountingPitch { fn frame_pitch( &mut self, raw_hop: &[f32], @@ -336,12 +309,12 @@ mod tests { /// /// A `Config` enum whose variants wrap each implementation's own config, /// following [`StftWindowConfig`](crate::ops::signal::StftWindowConfig): the -/// contract is [`TenVadPitchSourceInit`], and this dispatches to it. +/// contract is [`PitchSourceInit`], and this dispatches to it. /// /// [`Self::default`] selects [`Self::Tensor`], which is both the faithful /// choice and the one that keeps the front end device-resident. #[derive(Config, Debug)] -pub enum TenVadPitchSourceConfig { +pub enum PitchSourceConfig { /// Pin feature `40` to a constant and skip the branch entirely. /// /// Never inspects its input, so the sequence path stays on-device with no @@ -365,14 +338,14 @@ pub enum TenVadPitchSourceConfig { Tensor(TensorPitchConfig), } -impl Default for TenVadPitchSourceConfig { +impl Default for PitchSourceConfig { fn default() -> Self { Self::Tensor(TensorPitchConfig::new()) } } -impl TenVadPitchSourceInit for TenVadPitchSourceConfig { - type Source = TenVadPitchSourceKind; +impl PitchSourceInit for PitchSourceConfig { + type Source = PitchSourceKind; fn try_init_source( &self, @@ -380,33 +353,33 @@ impl TenVadPitchSourceInit for TenVadPitchSourceConfig { device: &B::Device, ) -> BunsenResult { Ok(match self { - Self::Zero => TenVadPitchSourceKind::Zero(ZeroPitch), + Self::Zero => PitchSourceKind::Zero(ZeroPitch), Self::Host => { - TenVadPitchSourceKind::Host(HostPitch::new(TenVadPitchEstimator::new(), batch_size)) + PitchSourceKind::Host(HostPitch::new(HostPitchEstimator::new(), batch_size)) } Self::Tensor(cfg) => { - TenVadPitchSourceKind::Tensor(cfg.try_init(device)?.init_state(batch_size, device)) + PitchSourceKind::Tensor(cfg.try_init(device)?.init_state(batch_size, device)) } }) } } -/// A pitch source selected by [`TenVadPitchSourceConfig`]. +/// A pitch source selected by [`PitchSourceConfig`]. /// /// An enum rather than a boxed trait object: the contract returns a *stateful* /// source, and an associated type must be one type across every variant. This /// keeps dispatch static and the state concrete enough to inspect. #[derive(Debug, Clone)] -pub enum TenVadPitchSourceKind { +pub enum PitchSourceKind { /// See [`ZeroPitch`]. Zero(ZeroPitch), /// See [`HostPitch`]. - Host(HostPitch), + Host(HostPitch), /// See [`TensorPitchContext`]. Tensor(TensorPitchContext), } -impl TenVadPitchSource for TenVadPitchSourceKind { +impl PitchSource for PitchSourceKind { fn forward( &mut self, raw: Tensor, @@ -433,9 +406,9 @@ impl TenVadPitchSource for TenVadPitchSourceKind { fn reset(&mut self) { match self { - Self::Zero(s) => TenVadPitchSource::::reset(s), - Self::Host(s) => TenVadPitchSource::::reset(s), - Self::Tensor(s) => TenVadPitchSource::::reset(s), + Self::Zero(s) => PitchSource::::reset(s), + Self::Host(s) => PitchSource::::reset(s), + Self::Tensor(s) => PitchSource::::reset(s), } } } diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/antialias.rs b/crates/bunsen/src/kits/speech/pitch/tensor/antialias.rs similarity index 100% rename from crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/antialias.rs rename to crates/bunsen/src/kits/speech/pitch/tensor/antialias.rs diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/correlate.rs b/crates/bunsen/src/kits/speech/pitch/tensor/correlate.rs similarity index 99% rename from crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/correlate.rs rename to crates/bunsen/src/kits/speech/pitch/tensor/correlate.rs index ffdca010..30728d20 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/correlate.rs +++ b/crates/bunsen/src/kits/speech/pitch/tensor/correlate.rs @@ -354,8 +354,8 @@ mod tests { use super::{ super::super::{ - TenVadPitchEstimator, - TenVadPitchScalarSource, + HostPitchEstimator, + PitchScalarSource, }, *, }; @@ -401,7 +401,7 @@ mod tests { /// Drives the host estimator and captures, per hop, the excitation history /// it correlated and the two slots it produced. fn host_reference(steps: usize) -> (Vec, Vec, Vec) { - let mut est = TenVadPitchEstimator::new(); + let mut est = HostPitchEstimator::new(); let slots = est.slots(); let mut exc = Vec::new(); diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/excitation.rs b/crates/bunsen/src/kits/speech/pitch/tensor/excitation.rs similarity index 99% rename from crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/excitation.rs rename to crates/bunsen/src/kits/speech/pitch/tensor/excitation.rs index a0086840..ebd8c0bd 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/excitation.rs +++ b/crates/bunsen/src/kits/speech/pitch/tensor/excitation.rs @@ -56,6 +56,7 @@ use burn::{ use super::{ super::coeff::{ + HOP_SIZE, LPC_ORDER, MAX_PERIOD_16KHZ, PROC_RESAMPLE_RATE, @@ -73,7 +74,6 @@ use crate::{ BunsenResult, WithOkOrPanic, }, - kits::speech::ten_vad::context::coeff::HOP_SIZE, ops::signal::lpc_residual_batched, }; @@ -365,8 +365,8 @@ mod tests { use super::{ super::super::{ - TenVadPitchEstimator, - TenVadPitchScalarSource, + HostPitchEstimator, + PitchScalarSource, }, *, }; @@ -406,7 +406,7 @@ mod tests { /// Drives the host and captures, per hop, the raw input, the filter it /// designed, and the excitation history it produced. fn host_reference(steps: usize) -> (Vec, Vec, Vec) { - let mut est = TenVadPitchEstimator::new(); + let mut est = HostPitchEstimator::new(); let (mut raw, mut lpc, mut exc) = (Vec::new(), Vec::new(), Vec::new()); for step in 0..steps { diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/hybrid.rs b/crates/bunsen/src/kits/speech/pitch/tensor/hybrid.rs similarity index 91% rename from crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/hybrid.rs rename to crates/bunsen/src/kits/speech/pitch/tensor/hybrid.rs index 76f735c5..9d3c897f 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/hybrid.rs +++ b/crates/bunsen/src/kits/speech/pitch/tensor/hybrid.rs @@ -12,8 +12,8 @@ //! //! The split is exact rather than approximate: the pre-filter design carries //! no state, and nothing downstream rewrites the coefficients it produces, so -//! [`TenVadPitchEstimator::frame_pitch_with_lpc`] resumes from precisely the -//! point [`frame_pitch`](TenVadPitchScalarSource::frame_pitch) would have +//! [`HostPitchEstimator::frame_pitch_with_lpc`] resumes from precisely the +//! point [`frame_pitch`](PitchScalarSource::frame_pitch) would have //! reached. //! //! Test-only, and deliberately so — it is scaffolding for the migration, not @@ -23,9 +23,9 @@ use burn::prelude::*; use super::{ super::{ - TenVadPitchEstimator, - TenVadPitchSource, - TenVadPitchSourceInit, + HostPitchEstimator, + PitchSource, + PitchSourceInit, coeff::LPC_ORDER, }, prefilter::{ @@ -53,7 +53,7 @@ pub(crate) struct HybridPitch { pub prefilter: PitchPrefilter, /// The host estimators, one per stream, resumed past their own stage 1. - pub sources: Vec, + pub sources: Vec, } impl HybridPitch { @@ -68,7 +68,7 @@ impl HybridPitch { assert_ne!(batch_size, 0, "HybridPitch batch_size must be non-zero"); Self { prefilter, - sources: vec![TenVadPitchEstimator::new(); batch_size], + sources: vec![HostPitchEstimator::new(); batch_size], } } @@ -104,7 +104,7 @@ impl HybridPitch { } } -impl TenVadPitchSource for HybridPitch { +impl PitchSource for HybridPitch { fn forward( &mut self, raw: Tensor, @@ -149,7 +149,7 @@ impl TenVadPitchSource for HybridPitch { } fn reset(&mut self) { - use super::super::TenVadPitchScalarSource; + use super::super::PitchScalarSource; for source in &mut self.sources { source.reset(); } @@ -170,7 +170,7 @@ impl HybridPitchInit { } } -impl TenVadPitchSourceInit for HybridPitchInit { +impl PitchSourceInit for HybridPitchInit { type Source = HybridPitch; fn try_init_source( diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs b/crates/bunsen/src/kits/speech/pitch/tensor/mod.rs similarity index 97% rename from crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs rename to crates/bunsen/src/kits/speech/pitch/tensor/mod.rs index bf413371..2537b3f5 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/mod.rs +++ b/crates/bunsen/src/kits/speech/pitch/tensor/mod.rs @@ -2,7 +2,7 @@ //! //! A device-side implementation of the reference pitch estimator, built //! stage by stage against -//! [`TenVadPitchEstimator`](super::TenVadPitchEstimator) as its oracle. The +//! [`HostPitchEstimator`](super::HostPitchEstimator) as its oracle. The //! host implementation stays: it is pinned to the C reference by //! `testdata/ten/pitch.json`, and every stage here is validated differentially //! against the corresponding stage there. diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/prefilter.rs b/crates/bunsen/src/kits/speech/pitch/tensor/prefilter.rs similarity index 98% rename from crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/prefilter.rs rename to crates/bunsen/src/kits/speech/pitch/tensor/prefilter.rs index 07f21292..3dca4909 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/prefilter.rs +++ b/crates/bunsen/src/kits/speech/pitch/tensor/prefilter.rs @@ -2,7 +2,7 @@ //! //! Maps `[rows, n_bins]` bin powers to `[rows, lpc_order]` whitening filter //! coefficients — the tensor form of -//! [`TenVadPitchEstimator`](super::super::TenVadPitchEstimator)'s pre-filter +//! [`HostPitchEstimator`](super::super::HostPitchEstimator)'s pre-filter //! stage. //! //! **This stage carries no state.** It reads one hop's spectrum and writes one @@ -280,8 +280,8 @@ mod tests { use super::{ super::super::{ - TenVadPitchEstimator, - TenVadPitchScalarSource, + HostPitchEstimator, + PitchScalarSource, coeff::LPC_ORDER, lpc::celt_lpc, }, @@ -315,7 +315,7 @@ mod tests { /// The host stage, reached through the oracle: `frame_pitch` runs the /// pre-filter design first and nothing afterwards rewrites `lpc`. fn host_lpc(bin_power: &[f32]) -> [f32; LPC_ORDER] { - let mut est = TenVadPitchEstimator::new(); + let mut est = HostPitchEstimator::new(); est.frame_pitch(&[0.0; 256], bin_power); *est.lpc() } diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/source.rs b/crates/bunsen/src/kits/speech/pitch/tensor/source.rs similarity index 98% rename from crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/source.rs rename to crates/bunsen/src/kits/speech/pitch/tensor/source.rs index 30fd512e..881a30ab 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/source.rs +++ b/crates/bunsen/src/kits/speech/pitch/tensor/source.rs @@ -1,6 +1,6 @@ //! # The device-side pitch source. //! -//! Composes the four stages into a [`TenVadPitchSource`]: +//! Composes the four stages into a [`PitchSource`]: //! //! ```text //! bin_power ──prefilter──▶ lpc ──┐ @@ -33,7 +33,7 @@ use burn::{ use super::{ super::{ coeff::LPC_ORDER, - source::TenVadPitchSource, + source::PitchSource, }, antialias::PitchAntiAliasConfig, correlate::{ @@ -245,7 +245,7 @@ impl TensorPitch { /// The device-side pitch estimator's streaming state. /// -/// Implements [`TenVadPitchSource`]. Built by [`TensorPitch::init_state`]. +/// Implements [`PitchSource`]. Built by [`TensorPitch::init_state`]. #[derive(Debug, Clone)] pub struct TensorPitchContext { /// The fixed coefficients. @@ -267,7 +267,7 @@ impl TensorPitchContext { } } -impl TenVadPitchSource for TensorPitchContext { +impl PitchSource for TensorPitchContext { fn forward( &mut self, raw: Tensor, @@ -330,7 +330,7 @@ impl TensorPitchContext { /// One pass of all four stages over `steps` hops. /// /// The whole estimator, and the unit - /// [`forward_sequence`](TenVadPitchSource::forward_sequence) chunks into. + /// [`forward_sequence`](PitchSource::forward_sequence) chunks into. fn forward_chunk( &mut self, raw: Tensor, @@ -376,8 +376,8 @@ mod tests { use super::{ super::super::{ - TenVadPitchEstimator, - TenVadPitchScalarSource, + HostPitchEstimator, + PitchScalarSource, }, *, }; @@ -416,7 +416,7 @@ mod tests { /// Drives the host over `steps` hops, returning the inputs it saw and the /// pitch it reported. fn host_reference(steps: usize) -> (Vec, Vec, Vec) { - let mut est = TenVadPitchEstimator::new(); + let mut est = HostPitchEstimator::new(); let (mut raw, mut power, mut hz) = (Vec::new(), Vec::new(), Vec::new()); for step in 0..steps { let hop = pulse_hop(150.0, step * HOP); diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/tables.rs b/crates/bunsen/src/kits/speech/pitch/tensor/tables.rs similarity index 100% rename from crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/tables.rs rename to crates/bunsen/src/kits/speech/pitch/tensor/tables.rs diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs b/crates/bunsen/src/kits/speech/pitch/tensor/track.rs similarity index 99% rename from crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs rename to crates/bunsen/src/kits/speech/pitch/tensor/track.rs index 01f8bd63..3b7fb4ba 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/tensor/track.rs +++ b/crates/bunsen/src/kits/speech/pitch/tensor/track.rs @@ -69,11 +69,13 @@ use super::{ super::coeff::{ FEAT_MAX_NFRM, FEAT_TIME_WINDOW_MS, + HOP_SIZE, MAX_PERIOD_16KHZ, MIN_PERIOD_16KHZ, PITCH_MAX_PATH_W, PROC_FS, PROC_RESAMPLE_RATE, + SAMPLE_RATE, VOICED_THRESHOLD, }, correlate::SUBS_PER_HOP, @@ -84,10 +86,6 @@ use crate::{ BunsenResult, WithOkOrPanic, }, - kits::speech::ten_vad::context::coeff::{ - HOP_SIZE, - SAMPLE_RATE, - }, ops::seq::{ FORBIDDEN, Viterbi, @@ -532,8 +530,8 @@ mod tests { use super::{ super::super::{ - TenVadPitchEstimator, - TenVadPitchScalarSource, + HostPitchEstimator, + PitchScalarSource, }, *, }; @@ -572,7 +570,7 @@ mod tests { /// Drives the host and captures, per hop, the two correlation slots it /// produced, their energies, and the pitch it reported. fn host_reference(steps: usize) -> (Vec, Vec, Vec) { - let mut est = TenVadPitchEstimator::new(); + let mut est = HostPitchEstimator::new(); let slots = est.slots(); let (mut xc, mut fw, mut hz) = (Vec::new(), Vec::new(), Vec::new()); diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs b/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs index 44860bbe..6e3b012a 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs @@ -16,7 +16,7 @@ //! //! [`SileroVadContext`] is a burn `Module` moved in and out by value. This //! context cannot be: it owns a [`SlidingStftContext`] and a -//! [`TenVadPitchSource`], neither of which is a tensor. It is a plain struct +//! [`PitchSource`], neither of which is a tensor. It is a plain struct //! driven through `&mut`, matching [`SlidingStftContext`]'s own style. //! //! ## Batch size @@ -39,25 +39,27 @@ use crate::{ BunsenError, BunsenResult, }, - kits::speech::ten_vad::{ - TenVad, - TenVadMeta, - context::{ - coeff::{ - D_CTX, - RESET_FRAMES, - SAMPLE_RATE, - }, - features::{ - TenVadFeatureConfig, - TenVadFeatureContext, - TenVadFeatureMeta, - }, - pitch::{ - TenVadPitchSource, - TenVadPitchSourceConfig, - TenVadPitchSourceInit, - TenVadPitchSourceKind, + kits::speech::{ + pitch::{ + PitchSource, + PitchSourceConfig, + PitchSourceInit, + PitchSourceKind, + }, + ten_vad::{ + TenVad, + TenVadMeta, + context::{ + coeff::{ + D_CTX, + RESET_FRAMES, + SAMPLE_RATE, + }, + features::{ + TenVadFeatureConfig, + TenVadFeatureContext, + TenVadFeatureMeta, + }, }, }, }, @@ -113,10 +115,10 @@ pub struct TenVadContextConfig { /// How feature `40` is obtained. /// /// Defaults to the device-side estimator. See - /// [`TenVadPitchSourceConfig`] for the alternatives, and for how to select + /// [`PitchSourceConfig`] for the alternatives, and for how to select /// the literal-transcription tier. - #[config(default = "TenVadPitchSourceConfig::default()")] - pub pitch: TenVadPitchSourceConfig, + #[config(default = "PitchSourceConfig::default()")] + pub pitch: PitchSourceConfig, /// How often to zero the LSTM states, in model calls; `None` never does. /// @@ -196,7 +198,7 @@ impl TenVadContextConfig { /// /// Built by [`TenVad::init_context`]. Implements [`TenVadContextMeta`]. #[derive(Debug, Clone)] -pub struct TenVadContext = TenVadPitchSourceKind> { +pub struct TenVadContext = PitchSourceKind> { /// The audio front-end streaming state. pub features: TenVadFeatureContext, @@ -228,7 +230,7 @@ pub struct TenVadContext = TenVadPitchSource pub frames_since_reset: usize, } -impl> TenVadContextMeta for TenVadContext { +impl> TenVadContextMeta for TenVadContext { fn sample_rate(&self) -> usize { self.features.sample_rate() } @@ -250,7 +252,7 @@ impl> TenVadContextMeta for TenVadContext> TenVadContext { +impl> TenVadContext { /// The recurrent hidden width. pub fn d_hidden(&self) -> usize { self.state1.hidden.dims()[1] @@ -400,7 +402,7 @@ impl TenVad { &self, cfg: &TenVadContextConfig, device: &B::Device, - ) -> BunsenResult>> { + ) -> BunsenResult>> { self.init_context_with(cfg, cfg.pitch.clone(), device) } @@ -408,13 +410,13 @@ impl TenVad { /// /// # Arguments /// * `cfg`: the context geometry. - /// * `pitch`: builds the pitch source; see [`TenVadPitchSourceInit`]. + /// * `pitch`: builds the pitch source; see [`PitchSourceInit`]. /// /// # Errors /// /// [`BunsenError::Invalid`] if the config is invalid, or if its context /// depth or feature width disagrees with this model. - pub fn init_context_with>( + pub fn init_context_with>( &self, cfg: &TenVadContextConfig, pitch: I, @@ -464,7 +466,7 @@ impl TenVad { /// /// # Returns /// `[batch]` speech probabilities in `[0, 1]`. - pub fn context_forward>( + pub fn context_forward>( &self, hop: Tensor, ctx: &mut TenVadContext, @@ -510,7 +512,7 @@ impl TenVad { /// /// # Returns /// `[steps, batch]` speech probabilities in `[0, 1]`. - pub fn context_forward_sequence>( + pub fn context_forward_sequence>( &self, hop_seq: Tensor, ctx: &mut TenVadContext, @@ -610,7 +612,7 @@ impl TenVad { /// [`BunsenError::Invalid`] if the row count disagrees with the context's /// batch size, if the rows differ in length, or if a row is empty or not a /// whole number of hops. - pub fn context_forward_audio_sequence>( + pub fn context_forward_audio_sequence>( &self, audio: &[&[f32]], ctx: &mut TenVadContext, @@ -668,7 +670,7 @@ impl TenVad { /// # Errors /// [`BunsenError::Invalid`] if `ctx` is not single-stream, or if `audio` /// is empty or not a whole number of hops. - pub fn context_forward_audio>( + pub fn context_forward_audio>( &self, audio: &[f32], ctx: &mut TenVadContext, @@ -718,34 +720,25 @@ mod tests { // stays resident and no stage synchronizes. let cfg = TenVadContextConfig::new(); assert!( - matches!(cfg.pitch, TenVadPitchSourceConfig::Tensor(_)), + matches!(cfg.pitch, PitchSourceConfig::Tensor(_)), "expected the device estimator by default, got {:?}", cfg.pitch, ); let (vad, device) = model(); let ctx = vad.init_context(&cfg, &device).unwrap(); - assert!(matches!( - ctx.features.pitch, - TenVadPitchSourceKind::Tensor(_) - )); + assert!(matches!(ctx.features.pitch, PitchSourceKind::Tensor(_))); // And the other variants are reachable through the same config. let host = vad - .init_context_with(&cfg, TenVadPitchSourceConfig::Host, &device) + .init_context_with(&cfg, PitchSourceConfig::Host, &device) .unwrap(); - assert!(matches!( - host.features.pitch, - TenVadPitchSourceKind::Host(_) - )); + assert!(matches!(host.features.pitch, PitchSourceKind::Host(_))); let zero = vad - .init_context_with(&cfg, TenVadPitchSourceConfig::Zero, &device) + .init_context_with(&cfg, PitchSourceConfig::Zero, &device) .unwrap(); - assert!(matches!( - zero.features.pitch, - TenVadPitchSourceKind::Zero(_) - )); + assert!(matches!(zero.features.pitch, PitchSourceKind::Zero(_))); } #[test] @@ -1158,7 +1151,7 @@ mod tests { /// The largest absolute value in either LSTM state. /// /// Zero exactly when the recurrence has been reset. - fn state_peak>(ctx: &TenVadContext) -> f32 { + fn state_peak>(ctx: &TenVadContext) -> f32 { [ &ctx.state1.hidden, &ctx.state1.cell, @@ -1178,7 +1171,7 @@ mod tests { /// Asserts two contexts are the same continuation: same stack, same /// recurrence, same front end. - fn assert_contexts_agree, Q: TenVadPitchSource>( + fn assert_contexts_agree, Q: PitchSource>( a: &TenVadContext, b: &TenVadContext, ) { @@ -1466,8 +1459,8 @@ mod tests { eprintln!("{:>6} {:>6} {:>12} {:>12}", "pitch", "hops", "cold", "warm"); for (name, pitch) in [ - ("zero", TenVadPitchSourceConfig::Zero), - ("tensor", TenVadPitchSourceConfig::default()), + ("zero", PitchSourceConfig::Zero), + ("tensor", PitchSourceConfig::default()), ] { let cfg = TenVadContextConfig::new().with_pitch(pitch); diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/features.rs b/crates/bunsen/src/kits/speech/ten_vad/context/features.rs index 813c2310..768c2ee2 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/features.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/features.rs @@ -10,7 +10,7 @@ //! 3. [`SlidingStftContext`] over a 768-sample queue, zero-padded to a //! 1024-point FFT, //! 4. bin power `re^2 + im^2`, -//! 5. pitch, from the [`TenVadPitchSource`], reading the raw hop and the +//! 5. pitch, from the [`PitchSource`], reading the raw hop and the //! **un-normalized** bin power, //! 6. `1 / 32768^2` normalization, then the [`TenVadMelBank`], then `ln(x + //! 1e-20)`, @@ -42,29 +42,31 @@ use crate::{ BunsenResult, WithOkOrPanic, }, - kits::speech::ten_vad::context::{ - coeff::{ - FEATURE_EPS, - FEATURE_MEANS, - FEATURE_STDS, - INPUT_SCALE, - N_FREQ, - POWER_NORMAL, - SAMPLE_RATE, - }, - mel::{ - TenVadMelBank, - TenVadMelConfig, - TenVadMelMeta, - }, + kits::speech::{ pitch::{ - TenVadPitchSource, - TenVadPitchSourceInit, + PitchSource, + PitchSourceInit, ZeroPitch, }, - pre_emphasis::{ - PreEmphasisConfig, - PreEmphasisContext, + ten_vad::context::{ + coeff::{ + FEATURE_EPS, + FEATURE_MEANS, + FEATURE_STDS, + INPUT_SCALE, + N_FREQ, + POWER_NORMAL, + SAMPLE_RATE, + }, + mel::{ + TenVadMelBank, + TenVadMelConfig, + TenVadMelMeta, + }, + pre_emphasis::{ + PreEmphasisConfig, + PreEmphasisContext, + }, }, }, ops::signal::{ @@ -249,13 +251,13 @@ impl TenVadFeatureConfig { /// /// # Arguments /// * `batch_size`: the number of independent streams; must be non-zero. - /// * `pitch`: builds the pitch source; see [`TenVadPitchSourceInit`]. + /// * `pitch`: builds the pitch source; see [`PitchSourceInit`]. /// /// # Errors /// /// See [`validate`](Self::validate), plus anything the pitch source's - /// [`try_init_source`](TenVadPitchSourceInit::try_init_source) reports. - pub fn try_init_context>( + /// [`try_init_source`](PitchSourceInit::try_init_source) reports. + pub fn try_init_context>( &self, batch_size: usize, pitch: I, @@ -321,13 +323,13 @@ impl TenVadFeatures { /// /// # Arguments /// * `batch_size`: the number of independent streams; must be non-zero. - /// * `pitch`: builds the pitch source; see [`TenVadPitchSourceInit`]. + /// * `pitch`: builds the pitch source; see [`PitchSourceInit`]. /// /// # Errors /// [`BunsenError::Invalid`] if `batch_size` is zero, plus anything the /// pitch source's - /// [`try_init_source`](TenVadPitchSourceInit::try_init_source) reports. - pub fn try_init_state>( + /// [`try_init_source`](PitchSourceInit::try_init_source) reports. + pub fn try_init_state>( &self, batch_size: usize, pitch: I, @@ -349,7 +351,7 @@ impl TenVadFeatures { /// Builds a [`TenVadFeatureContext`], panicking on error. /// /// See [`try_init_state`](Self::try_init_state). - pub fn init_state>( + pub fn init_state>( &self, batch_size: usize, pitch: I, @@ -361,14 +363,14 @@ impl TenVadFeatures { /// Streaming ten-vad feature-extraction state. /// /// Binds the pre-emphasis carry, the sliding STFT queue, and one -/// [`TenVadPitchSource`] per stream to a [`TenVadFeatures`]. +/// [`PitchSource`] per stream to a [`TenVadFeatures`]. /// /// At stream start every buffer is zero, so — as in the reference — the first /// couple of frames see partially zero-padded analysis windows. /// /// Built by [`TenVadFeatures::init_state`]. Implements [`TenVadFeatureMeta`]. #[derive(Debug, Clone)] -pub struct TenVadFeatureContext = ZeroPitch> { +pub struct TenVadFeatureContext = ZeroPitch> { /// The fixed analysis coefficients. pub coef: TenVadFeatures, @@ -382,7 +384,7 @@ pub struct TenVadFeatureContext = ZeroPitch> pub pitch: P, } -impl> TenVadFeatureMeta for TenVadFeatureContext { +impl> TenVadFeatureMeta for TenVadFeatureContext { fn sample_rate(&self) -> usize { self.coef.sample_rate() } @@ -404,7 +406,7 @@ impl> TenVadFeatureMeta for TenVadFeatureCon } } -impl> TenVadFeatureContext { +impl> TenVadFeatureContext { /// The batch size; each batch row is an independent stream. pub fn batch_size(&self) -> usize { self.stft.batch_size() @@ -606,14 +608,14 @@ mod tests { use super::*; use crate::{ - kits::speech::ten_vad::context::{ - coeff::N_MELS, + kits::speech::{ pitch::{ HostPitch, + HostPitchEstimator, HostPitchInit, - TenVadPitchEstimator, - TenVadPitchScalarSource, + PitchScalarSource, }, + ten_vad::context::coeff::N_MELS, }, ops::signal::{ SamplingWindowBuilder, @@ -624,6 +626,14 @@ mod tests { }; type B = PerformanceBackend; + + /// Feature `40` under [`ZeroPitch`]: an unvoiced frame, normalized. + /// + /// The normalization is the feature path's business, not the pitch + /// estimator's, so it lives here rather than on `ZeroPitch`. + fn zero_pitch_feature() -> f32 { + (0.0 - FEATURE_MEANS[N_MELS]) / (FEATURE_STDS[N_MELS] + FEATURE_EPS) + } type F = ::FloatElem; /// A small geometry whose naive-DFT reference is cheap to evaluate. @@ -790,10 +800,10 @@ mod tests { // Feature 40 is the pitch bin; under ZeroPitch it is the constant. assert!( - (host[N_MELS] - ZeroPitch::normalized_feature()).abs() < 1e-5, + (host[N_MELS] - zero_pitch_feature()).abs() < 1e-5, "pitch feature: {} vs {}", host[N_MELS], - ZeroPitch::normalized_feature(), + zero_pitch_feature(), ); } @@ -1042,8 +1052,8 @@ mod tests { let hops = Tensor::::from_data(TensorData::new(flat, [steps, batch, hop_size]), &device); - let mut seq_ctx: TenVadFeatureContext> = cfg - .try_init_context(batch, TenVadPitchEstimator::new(), &device) + let mut seq_ctx: TenVadFeatureContext> = cfg + .try_init_context(batch, HostPitchEstimator::new(), &device) .unwrap(); let mut step_ctx = seq_ctx.clone(); @@ -1092,14 +1102,14 @@ mod tests { .collect(); let row_refs: Vec<&[f32]> = rows.iter().map(|r| r.as_slice()).collect(); - let mut batched: TenVadFeatureContext> = cfg - .try_init_context(batch, TenVadPitchEstimator::new(), &device) + let mut batched: TenVadFeatureContext> = cfg + .try_init_context(batch, HostPitchEstimator::new(), &device) .unwrap(); let batched_out = batched.forward_audio_sequence(&row_refs).unwrap(); for (b, row) in row_refs.iter().enumerate() { - let mut solo: TenVadFeatureContext> = cfg - .try_init_context(1, TenVadPitchEstimator::new(), &device) + let mut solo: TenVadFeatureContext> = cfg + .try_init_context(1, HostPitchEstimator::new(), &device) .unwrap(); let solo_out = solo.forward_audio_sequence(&[row]).unwrap(); @@ -1121,13 +1131,13 @@ mod tests { let audio = pulse_audio(150.0, steps * hop_size); - let mut via_audio: TenVadFeatureContext> = cfg - .try_init_context(1, TenVadPitchEstimator::new(), &device) + let mut via_audio: TenVadFeatureContext> = cfg + .try_init_context(1, HostPitchEstimator::new(), &device) .unwrap(); let from_audio = via_audio.forward_audio_sequence(&[&audio]).unwrap(); - let mut via_tensor: TenVadFeatureContext> = cfg - .try_init_context(1, TenVadPitchEstimator::new(), &device) + let mut via_tensor: TenVadFeatureContext> = cfg + .try_init_context(1, HostPitchEstimator::new(), &device) .unwrap(); let hops = Tensor::::from_floats(audio.as_slice(), &device).reshape([steps, 1, hop_size]); @@ -1144,8 +1154,8 @@ mod tests { let cfg = TenVadFeatureConfig::new(); let hop_size = cfg.hop_size(); - let mut ctx: TenVadFeatureContext> = cfg - .try_init_context(2, TenVadPitchEstimator::new(), &device) + let mut ctx: TenVadFeatureContext> = cfg + .try_init_context(2, HostPitchEstimator::new(), &device) .unwrap(); let good = vec![0.0f32; hop_size * 2]; @@ -1227,7 +1237,7 @@ mod tests { calls: usize, } - impl TenVadPitchScalarSource for FixedPitch { + impl PitchScalarSource for FixedPitch { fn frame_pitch( &mut self, _raw_hop: &[f32], diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs index f1e66cc4..5a5caef3 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs @@ -38,8 +38,8 @@ //! * [`coeff`](self) — the reference constants and normalization tables. //! * [`PreEmphasisContext`] — the first-order high-pass, with carry. //! * [`TenVadMelBank`] — the 40-band triangular filterbank. -//! * [`TenVadPitchSource`] — the pitch seam, tensor-in and tensor-out; -//! [`TenVadPitchEstimator`] behind [`HostPitch`] is the reference estimator, +//! * [`PitchSource`] — the pitch seam, tensor-in and tensor-out; +//! [`HostPitchEstimator`] behind [`HostPitch`] is the reference estimator, //! [`ZeroPitch`] the constant stub. //! * [`TenVadFeatureContext`] — the 41-dim feature extractor and its state. //! * [`TenVadContext`] — the driving context: features, frame stack, and both @@ -61,8 +61,8 @@ //! //! ## Choosing a pitch source //! -//! Feature `40` is reached through the [`TenVadPitchSource`] seam, selected by -//! [`TenVadPitchSourceConfig`] on [`TenVadContextConfig::pitch`]: +//! Feature `40` is reached through the [`PitchSource`] seam, selected by +//! [`PitchSourceConfig`] on [`TenVadContextConfig::pitch`]: //! //! | variant | what it is | //! |---|---| @@ -141,10 +141,15 @@ //! * **The driver as a whole** — the kit's cross test pins it against the ONNX //! graph over real audio. //! +//! [`PitchSource`]: crate::kits::speech::pitch::PitchSource +//! [`PitchSourceConfig`]: crate::kits::speech::pitch::PitchSourceConfig +//! [`HostPitchEstimator`]: crate::kits::speech::pitch::HostPitchEstimator +//! [`HostPitch`]: crate::kits::speech::pitch::HostPitch +//! [`ZeroPitch`]: crate::kits::speech::pitch::ZeroPitch //! [`TenVadContextConfig::pitch`]: crate::kits::speech::ten_vad::TenVadContextConfig //! [`TenVadContextConfig::reset_frames`]: crate::kits::speech::ten_vad::TenVadContextConfig -//! [`TensorPitchConfig::reference`]: crate::kits::speech::ten_vad::context::pitch::tensor::TensorPitchConfig::reference -//! [`TensorPitchConfig::chunk_steps`]: crate::kits::speech::ten_vad::context::pitch::tensor::TensorPitchConfig +//! [`TensorPitchConfig::reference`]: crate::kits::speech::pitch::tensor::TensorPitchConfig::reference +//! [`TensorPitchConfig::chunk_steps`]: crate::kits::speech::pitch::tensor::TensorPitchConfig //! [`TenVad::forward`]: crate::kits::speech::ten_vad::TenVad::forward //! [`TenVad::context_forward`]: crate::kits::speech::ten_vad::TenVad::context_forward //! [`SlidingStftContext`]: crate::ops::signal::SlidingStftContext @@ -154,7 +159,6 @@ pub mod coeff; mod driver; mod features; mod mel; -mod pitch; mod pre_emphasis; #[doc(inline)] @@ -166,6 +170,4 @@ pub use features::*; #[doc(inline)] pub use mel::*; #[doc(inline)] -pub use pitch::*; -#[doc(inline)] pub use pre_emphasis::*; diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pitch/mod.rs deleted file mode 100644 index 3aa320a8..00000000 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pitch/mod.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! # ten-vad pitch estimation. -//! -//! Feature `40` of the ten-vad feature vector: a pitch estimate in Hz, `0.0` -//! when unvoiced (`ALGO_TRACE.md` §3.5). -//! -//! The driver reaches this branch through [`TenVadPitchSource`], which is -//! tensor-in, tensor-out so the rest of the front end can stay -//! device-resident. -//! -//! ## The pieces -//! -//! * [`TenVadPitchSource`] — the device seam the driver calls through, built by -//! [`TenVadPitchSourceInit`]. -//! * [`TenVadPitchEstimator`] — the port of the reference estimator, and the -//! permanent oracle the rest of the branch is validated against. -//! * [`HostPitch`] — adapts a host-side [`TenVadPitchScalarSource`] to the -//! device seam. **The only place in the front end that synchronizes.** -//! * [`ZeroPitch`] — a constant stub that skips the branch entirely. -//! * [`coeff`](self) — the reference constants. -//! * [`tensor`] — the device-side port, built stage by stage against the host -//! estimator as its oracle. -//! -//! [`lpc`] holds the pre-filter design: band folding, the cepstrum, the -//! autocorrelation, and the Levinson-Durbin solve. -//! -//! ## Choosing a source -//! -//! [`TenVadPitchEstimator`] is the faithful choice: all 41 features then match -//! the reference. Because it is a serial recurrence over scalars, stepping it -//! means reading the raw hops and the bin powers back from the device — once -//! per `forward_sequence` call for the whole sequence, or once per hop on the -//! single-step path, which pays that cost anyway. -//! -//! [`ZeroPitch`] pins feature `40` to a constant and never inspects its -//! arguments, so the sequence path stays entirely on-device. The other 40 -//! features are exact either way. -//! -//! ## Why the estimator is not a tensor op -//! -//! The reference estimator is four stages, and only the first is free of -//! carried state: an LPC fit (stateless), an excitation branch carrying a FIFO -//! and an IIR cascade, a lag search carrying a correlation ring, and a Viterbi -//! tracker carrying its accumulator across hops. The last is a genuine -//! recurrence over 56 states with two steps per hop. - -mod coeff; -mod estimator; -mod host; -mod lpc; -mod source; - -pub mod tensor; - -#[doc(inline)] -pub use coeff::*; -#[doc(inline)] -pub use estimator::*; -#[doc(inline)] -pub use host::*; -#[doc(inline)] -pub use lpc::*; -#[doc(inline)] -pub use source::*; diff --git a/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs b/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs index 281cee0d..886a9e43 100644 --- a/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs +++ b/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs @@ -9,23 +9,27 @@ mod tests { use crate::{ blocks::rnn::lstm::ExtLstmState, - kits::speech::ten_vad::{ - TenVad, - TenVadContextConfig, - TenVadContextMeta, - TenVadMeta, - context::{ - FEATURE_EPS, - FEATURE_MEANS, - FEATURE_STDS, - N_MELS, - TenVadFeatureConfig, - TenVadFeatureMeta, - TenVadPitchEstimator, - TenVadPitchSourceInit, + kits::speech::{ + pitch::{ + HostPitchEstimator, + PitchSourceInit, tensor::hybrid::HybridPitchInit, }, - reference::ReferenceModel, + ten_vad::{ + TenVad, + TenVadContextConfig, + TenVadContextMeta, + TenVadMeta, + context::{ + FEATURE_EPS, + FEATURE_MEANS, + FEATURE_STDS, + N_MELS, + TenVadFeatureConfig, + TenVadFeatureMeta, + }, + reference::ReferenceModel, + }, }, prelude::*, support::testing::{ @@ -254,7 +258,7 @@ mod tests { /// the un-normalized bin powers exactly as it does in the C driver. fn golden_pitch_hz(pitch: I) -> Result<(Vec, Vec), Box> where - I: TenVadPitchSourceInit, + I: PitchSourceInit, { type B = PerformanceBackend; type F = ::FloatElem; @@ -370,7 +374,7 @@ mod tests { /// [`TenVad::forward_sequence`]: /// crate::kits::speech::ten_vad::TenVad::forward_sequence /// [`TensorPitchConfig::chunk_steps`]: - /// crate::kits::speech::ten_vad::context::pitch::tensor::TensorPitchConfig + /// crate::kits::speech::pitch::tensor::TensorPitchConfig #[test] #[serial_test::serial] fn test_reference_probability_golden() -> Result<(), Box> { @@ -461,7 +465,7 @@ mod tests { #[test] #[serial_test::serial] fn test_pitch_estimator_reference_golden() -> Result<(), Box> { - let (got, expected) = golden_pitch_hz(TenVadPitchEstimator::new())?; + let (got, expected) = golden_pitch_hz(HostPitchEstimator::new())?; assert_golden_pitch(&got, &expected, 1e-4); Ok(()) } From b48a61770dec3f103e984387edcae0eabf1d251d Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Mon, 24 Aug 2026 17:18:13 -0700 Subject: [PATCH 30/32] test(pitch): anchor the host estimator without the reference golden `HostPitchEstimator` is pinned by `testdata/ten/pitch.json`, which is ten-vad data and goes when that kit does. Two behaviours were covered only there, and both are worth having independently. **Octave robustness.** A pulse train with alternate pulses attenuated correlates strongly at twice its period, so a tracker without octave suppression reports f0/2. That is the entire reason `suppress_octaves` exists and it had no behavioural test -- only a static check that its neighbour reads are write-safe. Verified by disabling the suppression, which makes the new test report 75.47 Hz against a 150 Hz fixture: precisely the octave error, so the test fails for the intended reason rather than by coincidence. **Aperiodic rejection.** Noise has no period to find. The estimator may call some frames voiced -- noise does correlate by chance -- so the test compares against a tonal fixture rather than demanding silence: either most noise frames are rejected, or what survives is far less consistent than a real period. Neither replaces what the golden did, which was agree with an independent implementation on real speech. What remains after deletion is a three-layer argument instead: closed-form tests on the shared ops underneath, these behavioural anchors on the host oracle, and the host-versus-device cross tests that pin the port to the oracle. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../bunsen/src/kits/speech/pitch/estimator.rs | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/crates/bunsen/src/kits/speech/pitch/estimator.rs b/crates/bunsen/src/kits/speech/pitch/estimator.rs index 434fe157..2436ef96 100644 --- a/crates/bunsen/src/kits/speech/pitch/estimator.rs +++ b/crates/bunsen/src/kits/speech/pitch/estimator.rs @@ -943,6 +943,76 @@ mod tests { } } + #[test] + fn test_a_harmonic_rich_signal_does_not_report_the_octave() { + // What `suppress_octaves` exists for. A pulse train whose alternate + // pulses are attenuated correlates strongly at *twice* its period, so + // a tracker without octave suppression reports f0/2. This is the + // behaviour the reference golden used to cover implicitly. + let f0 = 150.0f32; + let period = SAMPLE_RATE as f32 / f0; + + let signal: Vec = (0..HOP_SIZE * 40) + .map(|i| { + let cycle = (i as f32 / period).floor() as usize; + let pos = i as f32 % period; + let amp = if cycle % 2 == 0 { 8000.0 } else { 5200.0 }; + amp * (-pos / (period * 0.08)).exp() + }) + .collect(); + + let pitches = run(&signal); + let voiced: Vec = pitches[20..].iter().copied().filter(|p| *p > 0.0).collect(); + assert!(!voiced.is_empty(), "fixture should be voiced"); + + let mean = voiced.iter().sum::() / voiced.len() as f32; + assert!( + (mean - f0).abs() / f0 < 0.15, + "expected ~{f0} Hz, got {mean} Hz; ~{} Hz would be the octave error", + f0 / 2.0, + ); + } + + #[test] + fn test_noise_is_not_reported_as_a_stable_pitch() { + // Aperiodic input has no period to find. The estimator may call some + // frames voiced -- noise does correlate by chance -- but it must not + // settle on one period the way it does for a real pulse train. + let mut seed = 0x5eed_1234u32; + let noise: Vec = (0..HOP_SIZE * 40) + .map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + ((seed >> 8) as f32 / (1u32 << 23) as f32 - 1.0) * 6000.0 + }) + .collect(); + + let noisy: Vec = run(&noise)[20..].iter().copied().filter(|p| *p > 0.0).collect(); + let tonal: Vec = run(&pulse_train(150.0, HOP_SIZE * 40, 0))[20..] + .iter() + .copied() + .filter(|p| *p > 0.0) + .collect(); + + let spread = |v: &[f32]| -> f32 { + if v.len() < 2 { + return 0.0; + } + let mean = v.iter().sum::() / v.len() as f32; + (v.iter().map(|p| (p - mean).powi(2)).sum::() / v.len() as f32).sqrt() / mean + }; + + // Either noise is mostly rejected, or what survives is far less + // consistent than a real period. + assert!( + noisy.len() < tonal.len() / 2 || spread(&noisy) > 4.0 * spread(&tonal).max(1e-3), + "noise gave {} voiced frames (spread {:.3}) against {} tonal (spread {:.3})", + noisy.len(), + spread(&noisy), + tonal.len(), + spread(&tonal), + ); + } + #[test] fn test_pitch_is_bounded_by_the_search_range() { // The regression can extrapolate, but never far outside the lags the From 6ec429ded1ce9f1658c135892aa7b40625725338 Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Mon, 24 Aug 2026 17:29:21 -0700 Subject: [PATCH 31/32] feat(speech)!: remove the ten_vad kit Drops the model, its driver, its weights and its reference goldens. The extraction is complete: everything general has a home elsewhere, and what remains here would have been the ten-vad-shaped parts alone. Gone: the network and pretrained weights, the 41-dim feature path, the non-standard mel bank, `coeff.h`'s transcribed normalization tables, the driver, the cross tests, and `testdata/ten` -- both goldens plus the C and Python harnesses that produced them. Also gone: `pitch::tensor::hybrid`, the staged-migration harness that ran stage 1 on the device and the rest on the host so a single stage could be measured against the golden end to end. Its own doc said it comes out when the last stage lands. The last stage landed, and the golden it drove is gone. What survives, and where: * `ops::signal` -- biquad cascade, decimating FIR, autocorrelation, Levinson in scalar and batched forms, the LPC analysis filter, triangular filterbanks with mel scales, and the normalized lag search. * `ops::seq` -- the batched Viterbi decoder. * `burner::tensor::burn_behavior` and `burner::repro` -- three pinned upstream burn defects and a standalone reproduction of one. * `kits::speech::pitch` -- the assembled estimator, host and device, built on the above and cross-tested. The verification story changes shape and is worth being explicit about. The goldens compared bunsen against an independent implementation on real speech; nothing replaces that. What stands in its place is three layers that were built while the goldens were still there to check them: closed-form tests on the shared ops, behavioural anchors on the host oracle, and host-versus-device cross tests pinning the port to that oracle. 456 tests pass across the crate. BREAKING CHANGE: `bunsen::kits::speech::ten_vad` is removed. The pitch estimator it contained is now `bunsen::kits::speech::pitch`, with `TenVadPitch*` renamed to `Pitch*` and `TenVadPitchEstimator` to `HostPitchEstimator`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- crates/bunsen/src/kits/speech/mod.rs | 5 - .../bunsen/src/kits/speech/pitch/estimator.rs | 6 +- crates/bunsen/src/kits/speech/pitch/source.rs | 1 - .../src/kits/speech/pitch/tensor/hybrid.rs | 183 -- .../src/kits/speech/pitch/tensor/mod.rs | 3 - .../src/kits/speech/pitch/tensor/prefilter.rs | 2 +- .../src/kits/speech/ten_vad/blocks/mod.rs | 6 - .../src/kits/speech/ten_vad/blocks/module.rs | 544 ------ .../src/kits/speech/ten_vad/context/coeff.rs | 192 --- .../src/kits/speech/ten_vad/context/driver.rs | 1503 ----------------- .../kits/speech/ten_vad/context/features.rs | 1281 -------------- .../src/kits/speech/ten_vad/context/mel.rs | 509 ------ .../src/kits/speech/ten_vad/context/mod.rs | 173 -- .../speech/ten_vad/context/pre_emphasis.rs | 394 ----- .../src/kits/speech/ten_vad/cross_test.rs | 495 ------ crates/bunsen/src/kits/speech/ten_vad/mod.rs | 23 - .../kits/speech/ten_vad/pretrained/load.rs | 87 - .../src/kits/speech/ten_vad/pretrained/mod.rs | 3 - crates/bunsen/testdata/ten/README.md | 96 -- crates/bunsen/testdata/ten/dump_pitch.cc | 116 -- crates/bunsen/testdata/ten/gen_probs.py | 46 - crates/bunsen/testdata/ten/pitch.json | 1 - crates/bunsen/testdata/ten/probs.json | 1 - 23 files changed, 6 insertions(+), 5664 deletions(-) delete mode 100644 crates/bunsen/src/kits/speech/pitch/tensor/hybrid.rs delete mode 100644 crates/bunsen/src/kits/speech/ten_vad/blocks/mod.rs delete mode 100644 crates/bunsen/src/kits/speech/ten_vad/blocks/module.rs delete mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/coeff.rs delete mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/driver.rs delete mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/features.rs delete mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/mel.rs delete mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/mod.rs delete mode 100644 crates/bunsen/src/kits/speech/ten_vad/context/pre_emphasis.rs delete mode 100644 crates/bunsen/src/kits/speech/ten_vad/cross_test.rs delete mode 100644 crates/bunsen/src/kits/speech/ten_vad/mod.rs delete mode 100644 crates/bunsen/src/kits/speech/ten_vad/pretrained/load.rs delete mode 100644 crates/bunsen/src/kits/speech/ten_vad/pretrained/mod.rs delete mode 100644 crates/bunsen/testdata/ten/README.md delete mode 100644 crates/bunsen/testdata/ten/dump_pitch.cc delete mode 100644 crates/bunsen/testdata/ten/gen_probs.py delete mode 100644 crates/bunsen/testdata/ten/pitch.json delete mode 100644 crates/bunsen/testdata/ten/probs.json diff --git a/crates/bunsen/src/kits/speech/mod.rs b/crates/bunsen/src/kits/speech/mod.rs index 4a7c6046..9f81b95b 100644 --- a/crates/bunsen/src/kits/speech/mod.rs +++ b/crates/bunsen/src/kits/speech/mod.rs @@ -5,11 +5,6 @@ pub mod pitch; /// A fully functional Silero VAD model. pub mod silero_vad; -/// A ten-vad model, with a full audio pre-processing driver. -/// -/// The pitch feature is stubbed; see [`ten_vad::context`]. -pub mod ten_vad; - /// A structural whisper model. /// Loads, runs; no driver implementation yet. pub mod whisper; diff --git a/crates/bunsen/src/kits/speech/pitch/estimator.rs b/crates/bunsen/src/kits/speech/pitch/estimator.rs index 2436ef96..89314412 100644 --- a/crates/bunsen/src/kits/speech/pitch/estimator.rs +++ b/crates/bunsen/src/kits/speech/pitch/estimator.rs @@ -986,7 +986,11 @@ mod tests { }) .collect(); - let noisy: Vec = run(&noise)[20..].iter().copied().filter(|p| *p > 0.0).collect(); + let noisy: Vec = run(&noise)[20..] + .iter() + .copied() + .filter(|p| *p > 0.0) + .collect(); let tonal: Vec = run(&pulse_train(150.0, HOP_SIZE * 40, 0))[20..] .iter() .copied() diff --git a/crates/bunsen/src/kits/speech/pitch/source.rs b/crates/bunsen/src/kits/speech/pitch/source.rs index 67efb0c1..a4d01004 100644 --- a/crates/bunsen/src/kits/speech/pitch/source.rs +++ b/crates/bunsen/src/kits/speech/pitch/source.rs @@ -83,7 +83,6 @@ pub trait PitchSource { /// Builds a [`PitchSource`] bound to a batch size and a device. /// /// This is the seam -/// [`TenVadFeatures::init_state`](crate::kits::speech::ten_vad::context::TenVadFeatures::init_state) /// threads through. It exists because a tensor-native source allocates its /// carried buffers at construction and so cannot be cloned per batch row. pub trait PitchSourceInit { diff --git a/crates/bunsen/src/kits/speech/pitch/tensor/hybrid.rs b/crates/bunsen/src/kits/speech/pitch/tensor/hybrid.rs deleted file mode 100644 index 9d3c897f..00000000 --- a/crates/bunsen/src/kits/speech/pitch/tensor/hybrid.rs +++ /dev/null @@ -1,183 +0,0 @@ -//! # The staged-migration harness. -//! -//! [`HybridPitch`] runs the pre-filter design on the device and the remaining -//! three stages on the host, so a device stage can be measured against the C -//! reference **end to end** while everything downstream of it is held fixed. -//! -//! That is the point: the stage-level differential tests say a device stage -//! reproduces its host counterpart to a tolerance, but they cannot say whether -//! that tolerance survives the tracker's `argmax` and voicing threshold. This -//! can, by driving the real golden through a pipeline that differs from the -//! pinned one in exactly one stage. -//! -//! The split is exact rather than approximate: the pre-filter design carries -//! no state, and nothing downstream rewrites the coefficients it produces, so -//! [`HostPitchEstimator::frame_pitch_with_lpc`] resumes from precisely the -//! point [`frame_pitch`](PitchScalarSource::frame_pitch) would have -//! reached. -//! -//! Test-only, and deliberately so — it is scaffolding for the migration, not -//! a configuration anyone should ship. It comes out when the last stage lands. - -use burn::prelude::*; - -use super::{ - super::{ - HostPitchEstimator, - PitchSource, - PitchSourceInit, - coeff::LPC_ORDER, - }, - prefilter::{ - PitchPrefilter, - PitchPrefilterConfig, - }, -}; -use crate::{ - errors::{ - BunsenResult, - WithOkOrPanic, - }, - prelude::{ - TensorDataToVecAsExt, - TensorElemOpExt, - }, -}; - -/// Device pre-filter design, host everything else. -/// -/// See the module docs. Built by [`HybridPitch::new`]. -#[derive(Debug, Clone)] -pub(crate) struct HybridPitch { - /// The device-side stage under test. - pub prefilter: PitchPrefilter, - - /// The host estimators, one per stream, resumed past their own stage 1. - pub sources: Vec, -} - -impl HybridPitch { - /// Builds a hybrid source over `batch_size` independent streams. - /// - /// # Panics - /// If `batch_size` is zero. - pub fn new( - prefilter: PitchPrefilter, - batch_size: usize, - ) -> Self { - assert_ne!(batch_size, 0, "HybridPitch batch_size must be non-zero"); - Self { - prefilter, - sources: vec![HostPitchEstimator::new(); batch_size], - } - } - - /// The batch size. - pub fn batch_size(&self) -> usize { - self.sources.len() - } - - /// Steps every stream over one hop's worth of already-read-back host data. - /// - /// # Arguments - /// * `raw_host`: `rows * hop_size` raw samples, row-major. - /// * `lpc_host`: `rows * LPC_ORDER` device-designed coefficients. - /// * `rows`: how many `(step, stream)` pairs are present. - /// * `hop_size`: samples per hop. - /// * `batch`: the stream count, so row `r` maps to stream `r % batch`. - fn step_rows( - &mut self, - raw_host: &[f32], - lpc_host: &[f32], - rows: usize, - hop_size: usize, - batch: usize, - ) -> Vec { - (0..rows) - .map(|row| { - let mut lpc = [0.0f32; LPC_ORDER]; - lpc.copy_from_slice(&lpc_host[row * LPC_ORDER..(row + 1) * LPC_ORDER]); - self.sources[row % batch] - .frame_pitch_with_lpc(&raw_host[row * hop_size..(row + 1) * hop_size], &lpc) - }) - .collect() - } -} - -impl PitchSource for HybridPitch { - fn forward( - &mut self, - raw: Tensor, - bin_power: Tensor, - ) -> Tensor { - let [batch, hop_size] = raw.dims(); - assert_eq!(batch, self.batch_size(), "HybridPitch batch mismatch"); - - let device = raw.device(); - let lpc = self.prefilter.forward(bin_power); - - let raw_host: Vec = raw.to_data_as::().to_vec_as::().ok_or_panic(); - let lpc_host: Vec = lpc.to_data_as::().to_vec_as::().ok_or_panic(); - - let values = self.step_rows(&raw_host, &lpc_host, batch, hop_size, batch); - Tensor::from_data(TensorData::new(values, [batch, 1]), &device) - } - - fn forward_sequence( - &mut self, - raw: Tensor, - bin_power: Tensor, - ) -> Tensor { - let [steps, batch, hop_size] = raw.dims(); - let n_bins = bin_power.dims()[2]; - assert_eq!(batch, self.batch_size(), "HybridPitch batch mismatch"); - - let device = raw.device(); - - // Stage 1 is stateless, so the whole sequence designs in one pass. - let lpc = self - .prefilter - .forward(bin_power.reshape([steps * batch, n_bins])); - - let raw_host: Vec = raw.to_data_as::().to_vec_as::().ok_or_panic(); - let lpc_host: Vec = lpc.to_data_as::().to_vec_as::().ok_or_panic(); - - // Row-major `[steps, batch]` means row `r` is stream `r % batch`, which - // walks each stream's hops in order. - let values = self.step_rows(&raw_host, &lpc_host, steps * batch, hop_size, batch); - Tensor::from_data(TensorData::new(values, [steps, batch, 1]), &device) - } - - fn reset(&mut self) { - use super::super::PitchScalarSource; - for source in &mut self.sources { - source.reset(); - } - } -} - -/// Builds a [`HybridPitch`] for the driver. -/// -/// Holds only the stage's config: the prefilter's tables are device-resident, -/// so they cannot be built until `try_init_source` is handed a device. -#[derive(Debug, Clone)] -pub(crate) struct HybridPitchInit(pub PitchPrefilterConfig); - -impl HybridPitchInit { - /// Builds an init over the default ten-vad pre-filter geometry. - pub fn new() -> Self { - Self(PitchPrefilterConfig::new()) - } -} - -impl PitchSourceInit for HybridPitchInit { - type Source = HybridPitch; - - fn try_init_source( - &self, - batch_size: usize, - device: &B::Device, - ) -> BunsenResult { - Ok(HybridPitch::new(self.0.try_init(device)?, batch_size)) - } -} diff --git a/crates/bunsen/src/kits/speech/pitch/tensor/mod.rs b/crates/bunsen/src/kits/speech/pitch/tensor/mod.rs index 2537b3f5..3e3c14db 100644 --- a/crates/bunsen/src/kits/speech/pitch/tensor/mod.rs +++ b/crates/bunsen/src/kits/speech/pitch/tensor/mod.rs @@ -61,9 +61,6 @@ pub mod source; pub mod tables; pub mod track; -#[cfg(test)] -pub(crate) mod hybrid; - #[doc(inline)] pub use antialias::*; #[doc(inline)] diff --git a/crates/bunsen/src/kits/speech/pitch/tensor/prefilter.rs b/crates/bunsen/src/kits/speech/pitch/tensor/prefilter.rs index 3dca4909..05a86814 100644 --- a/crates/bunsen/src/kits/speech/pitch/tensor/prefilter.rs +++ b/crates/bunsen/src/kits/speech/pitch/tensor/prefilter.rs @@ -10,7 +10,7 @@ //! `steps × batch` sequence flattened into the row axis and the entire stage //! runs in one pass. That is what makes it a coefficient-only object with no //! `*Context`, structurally like -//! [`TenVadMelBank`](crate::kits::speech::ten_vad::context::TenVadMelBank). +//! a filterbank. //! //! ```text //! bands = bin_power @ BANDS # [rows, 18] diff --git a/crates/bunsen/src/kits/speech/ten_vad/blocks/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/blocks/mod.rs deleted file mode 100644 index 93f1954d..00000000 --- a/crates/bunsen/src/kits/speech/ten_vad/blocks/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Ten-VAD model blocks. - -mod module; - -#[doc(inline)] -pub use module::*; diff --git a/crates/bunsen/src/kits/speech/ten_vad/blocks/module.rs b/crates/bunsen/src/kits/speech/ten_vad/blocks/module.rs deleted file mode 100644 index 738a0587..00000000 --- a/crates/bunsen/src/kits/speech/ten_vad/blocks/module.rs +++ /dev/null @@ -1,544 +0,0 @@ -//! # ten-vad model. -//! -//! [ten-vad][t] is a small, streaming voice-activity-detection model: given a -//! short stack of consecutive feature frames and the previous recurrent -//! states, it emits a per-frame speech probability and the next states. -//! -//! [t]: https://github.com/TEN-framework/ten-vad -//! -//! The pipeline is: -//! -//! 1. a `[1, 3, 41]` feature stack through a 2D conv stem ([`ConvSeq2d`] + -//! [`MaxPool2d`] + a depthwise/pointwise [`ConvSeq2d`]), producing an -//! `[f_ctx, d_features]` embedding, -//! 2. two stacked single-step [`Lstm`] blocks, whose outputs are concatenated, -//! 3. a two-layer `ReLU` / sigmoid [`Linear`] head producing the speech -//! probability. -//! -//! [`TenVad::forward`] is the stateless call through the network. It starts at -//! an already-widened feature stack; everything that turns audio into those -//! 41 bins — pre-emphasis, the sliding STFT, the mel filterbank, the -//! normalization, and the rolling frame history — lives in -//! [`context`](crate::kits::speech::ten_vad::context), and is driven through -//! [`TenVadContext`] by -//! [`context_forward`](TenVad::context_forward) / -//! [`context_forward_sequence`](TenVad::context_forward_sequence). -//! -//! [`TenVadContext`]: crate::kits::speech::ten_vad::TenVadContext - -use burn::{ - nn::{ - Linear, - LinearConfig, - Lstm, - LstmConfig, - PaddingConfig2d, - activation::ActivationConfig, - conv::Conv2dConfig, - pool::{ - MaxPool2d, - MaxPool2dConfig, - }, - }, - prelude::*, - tensor::activation::{ - relu, - sigmoid, - }, -}; -#[cfg(feature = "store")] -use burn_store::{ - BurnpackStore, - ModuleSnapshot, -}; - -use crate::{ - blocks::{ - conv::{ - ConvBlock2dConfig, - ConvBlock2dMeta, - ConvSeq2d, - ConvSeq2dConfig, - }, - rnn::lstm::ExtLstmState, - }, - burner::module::ModuleInit, - errors::BunsenResult, -}; - -/// Common meta for [`TenVad`]. -pub trait TenVadMeta { - /// The context length. - fn d_ctx(&self) -> usize { - 3 - } - - /// The internal feature context length. - fn f_ctx(&self) -> usize { - 2 * self.d_ctx() - 1 - } - - /// The number of frequency bins. - fn n_freq(&self) -> usize { - 41 - } - - /// The dimension of the feature space. - fn d_features(&self) -> usize; - - /// The dimension of the embedding space. - fn d_hidden(&self) -> usize; -} - -/// Config for [`TenVad`]. -/// -/// Builds [`TenVad`]. -#[derive(Config, Debug)] -pub struct TenVadStructureConfig { - /// Embedding first `ConvSeq2d` block. - pub cs1: ConvSeq2dConfig, - - /// Embedding `MaxPool2d` block. - pub pool: MaxPool2dConfig, - - /// Embedding second `ConvSeq2d` block. - pub cs2: ConvSeq2dConfig, - - /// The first Lstm block. - pub lstm1: LstmConfig, - - /// The second Lstm block. - pub lstm2: LstmConfig, - - /// Projection first `Linear` block. - pub linear1: LinearConfig, - - /// Projection second `Linear` block. - pub linear2: LinearConfig, -} - -impl Default for TenVadStructureConfig { - fn default() -> Self { - let d_hidden = 64; - Self { - cs1: ConvSeq2dConfig { - blocks: vec![ - ConvBlock2dConfig::new(Conv2dConfig::new([1, 1], [3, 3]).with_bias(false)) - .with_act(None), - ConvBlock2dConfig::new(Conv2dConfig::new([1, 16], [1, 1])) - .with_act(Some(ActivationConfig::Relu)), - ], - }, - pool: MaxPool2dConfig::new([1, 3]).with_strides([1, 2]), - cs2: ConvSeq2dConfig { - blocks: vec![ - ConvBlock2dConfig::new( - Conv2dConfig::new([16, 16], [1, 3]) - .with_stride([2, 2]) - .with_padding(PaddingConfig2d::Explicit(0, 1, 0, 1)) - .with_groups(16) - .with_bias(false), - ) - .with_act(None), - ConvBlock2dConfig::new(Conv2dConfig::new([16, 16], [1, 1])) - .with_act(Some(ActivationConfig::Relu)), - ConvBlock2dConfig::new( - Conv2dConfig::new([16, 16], [1, 3]) - .with_stride([2, 2]) - .with_padding(PaddingConfig2d::Explicit(0, 0, 0, 1)) - .with_groups(16) - .with_bias(false), - ) - .with_act(None), - ConvBlock2dConfig::new(Conv2dConfig::new([16, 16], [1, 1])) - .with_act(Some(ActivationConfig::Relu)), - ], - }, - lstm1: LstmConfig::new(80, d_hidden, true) - .with_batch_first(false) - .with_input_forget(false), - lstm2: LstmConfig::new(d_hidden, d_hidden, true) - .with_batch_first(false) - .with_input_forget(false), - linear1: LinearConfig::new(2 * d_hidden, d_hidden / 2).with_bias(true), - linear2: LinearConfig::new(d_hidden / 2, 1).with_bias(true), - } - } -} - -impl ModuleInit> for TenVadStructureConfig { - fn try_init( - &self, - device: &B::Device, - ) -> BunsenResult> { - Ok(TenVad { - cs1: self.cs1.try_init(device)?, - pool: self.pool.init(), - cs2: self.cs2.try_init(device)?, - lstm1: self.lstm1.init(device), - lstm2: self.lstm2.init(device), - linear1: self.linear1.init(device), - linear2: self.linear2.init(device), - }) - } -} - -/// ten-vad module. -/// -/// Built by [`TenVadStructureConfig`]. -#[derive(Module, Debug)] -pub struct TenVad { - /// Embedding first `ConvSeq2d` block. - pub cs1: ConvSeq2d, - - /// Embedding `MaxPool2d` block. - pub pool: MaxPool2d, - - /// Embedding second `ConvSeq2d` block. - pub cs2: ConvSeq2d, - - /// The first Lstm block. - pub lstm1: Lstm, - - /// The second Lstm block. - pub lstm2: Lstm, - - /// Projection first `Linear` block. - pub linear1: Linear, - - /// Projection second `Linear` block. - pub linear2: Linear, -} - -impl TenVadMeta for TenVad { - fn d_features(&self) -> usize { - self.cs1.blocks.last().unwrap().out_channels() - } - - fn d_hidden(&self) -> usize { - self.lstm1.d_hidden - } -} - -#[cfg(feature = "store")] -impl TenVad { - /// Load model weights from a burnpack file. - pub fn from_file>( - file: P, - device: &B::Device, - ) -> Self { - let mut model = TenVadStructureConfig::default().try_init(device).unwrap(); - let mut store = BurnpackStore::from_file(file); - model - .load_from(&mut store) - .expect("Failed to load burnpack file"); - model - } - - /// Load model weights from in-memory bytes. - /// - /// The bytes must be the contents of a `.bpk` file. - pub fn from_bytes( - bytes: burn::tensor::Bytes, - device: &B::Device, - ) -> Self { - let mut model = TenVadStructureConfig::default().try_init(device).unwrap(); - let mut store = BurnpackStore::from_bytes(Some(bytes)); - model - .load_from(&mut store) - .expect("Failed to load burnpack bytes"); - model - } -} - -impl TenVad { - /// Stateless forward pass over one feature stack. - /// - /// This is the model half only: `input` must already be the widened, - /// normalized context stack. See - /// [`context_forward`](Self::context_forward) to drive raw audio. - /// - /// # The leading axis is time, not stream-batch - /// - /// In the reference ONNX graph the leading dimension of the feature input - /// lands on the LSTM's *sequence* axis, with the LSTM batch fixed at 1 by a - /// graph constant (`new_shape__177 = [-1, 1, 80]`); `ALGO_TRACE.md` §8 - /// documents this and verifies it empirically. This method pins it to `1` - /// and is the single-step form; - /// [`forward_sequence`](Self::forward_sequence) is the same computation - /// over a run of steps. - /// - /// **Multi-stream batching is structurally impossible** against the stock - /// graph: batched states fail shape validation outright. It requires - /// patching two reshape constants (§8.3), i.e. a different model file. - /// - /// # Arguments - /// * `input`: `[1, d_ctx, n_freq]` the widened feature stack. - /// * `state1`: `[1, d_hidden]` first-LSTM state, or `None` to start zeroed. - /// * `state2`: `[1, d_hidden]` second-LSTM state, or `None` to start - /// zeroed. - /// - /// # Returns - /// `(probabilities, state1, state2)`, with: - /// * `probabilities`: `[1, 1]` speech probability in `[0, 1]` - /// * `state1` / `state2`: `[1, d_hidden]` next LSTM states - pub fn forward( - &self, - input: Tensor, - state1: Option>, - state2: Option>, - ) -> (Tensor, ExtLstmState, ExtLstmState) { - #[cfg(any(test, debug_assertions))] - crate::contracts::assert_shape_contract!( - ["steps", "d_ctx", "n_freq"], - &input, - &[ - ("steps", 1), - ("d_ctx", self.d_ctx()), - ("n_freq", self.n_freq()) - ] - ); - - self.forward_sequence(input, state1, state2) - } - - /// Stateless forward pass over a run of consecutive feature stacks. - /// - /// Equivalent to `steps` calls of [`forward`](Self::forward), final states - /// included — the reference verified exactly that against its own graph - /// (`ALGO_TRACE.md` §8.2) — but it runs as **one** pass instead of `steps`. - /// - /// Only the recurrence is inherently sequential, and it is the one part - /// that does not become a Rust-side loop: - /// - /// | stage | over a run of `steps` | - /// |---|---| - /// | [`frame_features`](Self::frame_features) | one conv pass, `steps` images | - /// | `lstm_step` | one call; burn walks the recurrence internally | - /// | `output_head` | one pass, `steps` rows | - /// - /// So a `steps`-hop run costs a constant number of dispatches rather than - /// a number proportional to `steps`. The reference measured ~46x on CPU at - /// `steps = 1875` (1756 ms sequential vs 38 ms batched). - /// - /// # The periodic reset - /// - /// This threads one unbroken recurrence through the whole input, so a - /// caller reproducing the reference's periodic state reset must chunk at - /// reset boundaries and zero the states between chunks — §8.2's own usage - /// note. [`context_forward_sequence`](Self::context_forward_sequence) does - /// that for you. - /// - /// # Arguments - /// * `input`: `[steps, d_ctx, n_freq]` consecutive widened feature stacks, - /// with `steps` non-zero. - /// * `state1`: `[1, d_hidden]` first-LSTM state, or `None` to start zeroed. - /// * `state2`: `[1, d_hidden]` second-LSTM state, or `None` to start - /// zeroed. - /// - /// # Returns - /// `(probabilities, state1, state2)`, with: - /// * `probabilities`: `[steps, 1]` speech probabilities in `[0, 1]`, in - /// order - /// * `state1` / `state2`: `[1, d_hidden]` states after the final step - pub fn forward_sequence( - &self, - input: Tensor, - state1: Option>, - state2: Option>, - ) -> (Tensor, ExtLstmState, ExtLstmState) { - assert_eq!(state1.is_some(), state2.is_some()); - assert_ne!(input.dims()[0], 0, "TenVad input must be non-empty"); - - // [steps, f_ctx, d_features] - let x = self.frame_features(input); - - // [1, steps, 2 * d_hidden] - let (x, state1, state2) = self.lstm_step(x, state1, state2); - - // [steps, 1] - let x = self.output_head(x); - - (x, state1, state2) - } - - /// Runs the conv stem over a feature stack. - /// - /// Purely per-step: each stack is convolved on its own, so this runs once - /// over a whole sequence rather than once per step. That is the other half - /// of what makes [`forward_sequence`](Self::forward_sequence) worth having. - /// - /// # Arguments - /// * `x`: `[steps, d_ctx, n_freq]` the widened feature stacks. - /// - /// # Returns - /// `[steps, f_ctx, d_features]` embeddings. - /// - /// # The `[-1, 1, d_ctx, n_freq]` reshape - /// - /// This matches the reference graph, which reshapes `input_1` per item so - /// that the leading dimension is a true conv batch (`ALGO_TRACE.md` §8.1). - /// An earlier form here used `[1, -1, d_ctx, n_freq]`, which puts the - /// leading axis on the *channel* dimension instead — the two agree only at - /// `steps == 1`, and above it the stem would not even typecheck against - /// `cs1`'s single input channel. - pub fn frame_features( - &self, - x: Tensor, - ) -> Tensor { - #[cfg(any(test, debug_assertions))] - let [steps] = crate::contracts::unpack_shape_contract!( - ["steps", "d_ctx", "n_freq"], - &x, - &["steps"], - &[("d_ctx", self.d_ctx()), ("n_freq", self.n_freq())] - ); - - // [steps, 1, d_ctx, n_freq]: one single-channel image per step. - let x = x.reshape([-1, 1, self.d_ctx() as isize, self.n_freq() as isize]); - let x = self.cs1.forward(x); - let x = self.pool.forward(x); - let x = self.cs2.forward(x); - - // The stem collapses the context axis to 1. - let x = x.squeeze_dim(2); - let x = x.permute([0, 2, 1]); - - #[cfg(any(test, debug_assertions))] - crate::contracts::assert_shape_contract!( - ["steps", "f_ctx", "d_features"], - &x, - &[ - ("steps", steps), - ("f_ctx", self.f_ctx()), - ("d_features", self.d_features()) - ] - ); - x - } - - /// Runs both LSTMs over a whole sequence, threading the states through. - /// - /// This is the model's only recurrence, and it is the reason the reference - /// graph's leading axis behaves the way it does: `new_shape__177 = - /// [-1, 1, 80]` lands it on the LSTM's *sequence* axis with the LSTM batch - /// pinned to 1 (`ALGO_TRACE.md` §8.1). Both [`Lstm`]s here are configured - /// `batch_first(false)`, so `[steps, 1, ..]` is that same layout, and burn - /// walks the recurrence inside one call — `steps` sequential steps of the - /// cell, not `steps` dispatches from Rust. - /// - /// The reference verified this equals `steps` separate batch-1 calls, - /// final states included (`ALGO_TRACE.md` §8.2). - /// - /// # Arguments - /// * `x`: `[steps, f_ctx, d_features]` per-step embeddings. - /// * `state1` / `state2`: `[1, d_hidden]` states, or `None` to start - /// zeroed. These stay batch-1 whatever `steps` is — they are the state of - /// *one* stream, before and after the run. - /// - /// # Returns - /// `([1, steps, 2 * d_hidden]` concatenated outputs, next `state1`, next - /// `state2)`. - fn lstm_step( - &self, - x: Tensor, - state1: Option>, - state2: Option>, - ) -> (Tensor, ExtLstmState, ExtLstmState) { - assert_eq!(state1.is_some(), state2.is_some()); - #[cfg(any(test, debug_assertions))] - let steps = { - use crate::contracts::assert_shape_contract; - let [steps] = crate::contracts::unpack_shape_contract!( - ["steps", "f_ctx", "d_features"], - &x, - &["steps"], - &[("f_ctx", self.f_ctx()), ("d_features", self.d_features())] - ); - for state in [&state1, &state2].into_iter().flatten() { - assert_shape_contract!( - ["batch", "d_hidden"], - state.shape(), - &[("batch", 1), ("d_hidden", self.d_hidden())], - ); - } - steps - }; - - // [steps, 1, f_ctx * d_features]: the reference's `new_shape__177`, - // which is `[seq, batch, input]` for a `batch_first(false)` LSTM. - let x = x.reshape([-1, 1, (self.f_ctx() * self.d_features()) as isize]); - let (x, state1) = self.lstm1.forward(x, state1.map(Into::into)); - - // [1, steps, d_hidden]: `new_shape__176`. - let y = x.reshape([1, -1, self.d_hidden() as isize]); - - // [steps, 1, d_hidden]: the graph's `[1, 0, 2]` transpose, putting the - // second LSTM back on the same sequence-major layout. - let x = y.clone().swap_dims(0, 1); - let (x, state2) = self.lstm2.forward(x, state2.map(Into::into)); - let x = x.swap_dims(0, 1); - - // [1, steps, 2 * d_hidden] - let x = Tensor::cat([x, y].into(), 2); - let state1: ExtLstmState = state1.into(); - let state2: ExtLstmState = state2.into(); - #[cfg(any(test, debug_assertions))] - { - use crate::contracts::assert_shape_contract; - assert_shape_contract!( - [1, "steps", 2 * "d_hidden"], - &x, - &[("steps", steps), ("d_hidden", self.d_hidden())] - ); - for state in [&state1, &state2] { - assert_shape_contract!( - ["batch", "d_hidden"], - &state.shape(), - &[("batch", 1), ("d_hidden", self.d_hidden())], - ); - } - } - (x, state1, state2) - } - - /// Projects the recurrent output to a probability per step. - /// - /// Purely per-step: every row of `x` goes through the same two linears, so - /// this runs once over a whole sequence rather than once per step. That is - /// half of what makes [`forward_sequence`](Self::forward_sequence) worth - /// having. - /// - /// # Arguments - /// * `x`: `[1, steps, 2 * d_hidden]` the concatenated LSTM outputs. - /// - /// # Returns - /// `[steps, 1]` speech probabilities in `[0, 1]`. - fn output_head( - &self, - x: Tensor, - ) -> Tensor { - #[cfg(any(test, debug_assertions))] - let [steps] = crate::contracts::unpack_shape_contract!( - [1, "steps", 2 * "d_hidden"], - &x, - &["steps"], - &[("d_hidden", self.d_hidden())] - ); - - // The leading axis is a pinned batch of 1, so folding it away leaves - // one row per step, in order. - // [steps, 2 * d_hidden] - let x = x.reshape([-1, (self.d_hidden() * 2) as isize]); - - // [steps, d_hidden / 2] - let x = relu(self.linear1.forward(x)); - - // [steps, 1] - let x = sigmoid(self.linear2.forward(x)); - - #[cfg(any(test, debug_assertions))] - crate::contracts::assert_shape_contract!(["steps", 1], &x, &[("steps", steps)]); - x - } -} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/coeff.rs b/crates/bunsen/src/kits/speech/ten_vad/context/coeff.rs deleted file mode 100644 index af1dc15f..00000000 --- a/crates/bunsen/src/kits/speech/ten_vad/context/coeff.rs +++ /dev/null @@ -1,192 +0,0 @@ -//! # ten-vad front-end coefficients. -//! -//! The fixed scalar constants and normalization tables the ten-vad -//! pre-processing driver is built from. -//! -//! [`FEATURE_MEANS`] and [`FEATURE_STDS`] are transcribed verbatim from the -//! reference implementation's `src/coeff.h`; the remaining constants come -//! from the reference feature path (see `ALGO_TRACE.md` §3.1, §3.3, §3.6). -//! -//! These are reference data, not tunables: changing any of them decouples the -//! driver from the pretrained weights. - -/// The number of mel filterbank bands. -/// -/// Features `0..N_MELS` are the log-mel energies; feature `N_MELS` is the -/// pitch. See [`crate::kits::speech::ten_vad::TenVadMeta::n_freq`]. -pub const N_MELS: usize = 40; - -/// The ten-vad feature width: [`N_MELS`] log-mel bands plus one pitch bin. -pub const N_FREQ: usize = N_MELS + 1; - -/// The frame context depth; the model consumes `[f_{t-2}, f_{t-1}, f_t]`. -/// -/// See `ALGO_TRACE.md` §3.7. -pub const D_CTX: usize = 3; - -/// The hop size, in samples, of one ten-vad frame (16 ms at 16 kHz). -/// -/// The reference C API accepts other hop sizes but drains its FIFO in -/// 256-sample steps and reports only the last internal frame, so 256 is the -/// only size that yields one score per model call (`ALGO_TRACE.md` §7). -pub const HOP_SIZE: usize = 256; - -/// The sample rate, in Hz, the ten-vad front end is defined for. -pub const SAMPLE_RATE: usize = 16000; - -/// The reference driver's periodic LSTM reset period, in model calls. -/// -/// 1875 hops is 30 s at 16 kHz. The C driver zeroes both LSTM states this -/// often, leaving the feature stack intact (`ALGO_TRACE.md` §5, -/// `src/aed.cc:476-481`). The reference marks the value `// TODO` -/// (`src/aed.cc:640`); it is reproduced here for parity, not because it is -/// obviously right. -/// -/// See [`TenVadContextConfig::reset_frames`] to change or disable it. -/// -/// [`TenVadContextConfig::reset_frames`]: -/// crate::kits::speech::ten_vad::TenVadContextConfig -pub const RESET_FRAMES: usize = 1875; - -/// The epsilon used both as the log floor and as the normalization guard. -/// -/// The reference applies it twice: `log(melPower + EPS)` and -/// `(v - MEAN) / (STD + EPS)` (`ALGO_TRACE.md` §3.6). -pub const FEATURE_EPS: f32 = 1e-20; - -/// The pre-emphasis coefficient: `y[n] = x[n] - PRE_EMPHASIS_COEFF * x[n-1]`. -/// -/// See `ALGO_TRACE.md` §3.3. -pub const PRE_EMPHASIS_COEFF: f32 = 0.97; - -/// The bin-power normalizer, `32768^2`. -/// -/// The reference pipeline runs at int16 scale, and divides the bin powers by -/// this before the mel filterbank (`ALGO_TRACE.md` §3.6). -pub const POWER_NORMAL: f32 = 32768.0 * 32768.0; - -/// The scale from unit-range audio to the reference's int16 scale. -/// -/// The reference casts `i16` samples to `f32` without rescaling. bunsen's -/// driver takes `[-1, 1]` audio (matching the rest of the crate) and -/// multiplies by this on entry, so both paths see the same values. -pub const INPUT_SCALE: f32 = 32768.0; - -/// Per-feature means, from the reference `src/coeff.h`. -/// -/// Index `40` is the pitch mean, in Hz. -/// -/// Transcribed at the reference's full written precision rather than rounded -/// to what `f32` can hold, so the table stays diffable against `coeff.h`. -#[allow(clippy::excessive_precision)] -#[rustfmt::skip] -pub const FEATURE_MEANS: [f32; N_FREQ] = [ - -8.198236465454e+00, -6.265716552734e+00, -5.483818531036e+00, - -4.758691310883e+00, -4.417088985443e+00, -4.142892837524e+00, - -3.912850379944e+00, -3.845927953720e+00, -3.657090425491e+00, - -3.723418712616e+00, -3.876134157181e+00, -3.843890905380e+00, - -3.690405130386e+00, -3.756065845490e+00, -3.698696136475e+00, - -3.650463104248e+00, -3.700468778610e+00, -3.567321300507e+00, - -3.498900175095e+00, -3.477807044983e+00, -3.458816051483e+00, - -3.444923877716e+00, -3.401328563690e+00, -3.306261301041e+00, - -3.278556823730e+00, -3.233250856400e+00, -3.198616027832e+00, - -3.204526424408e+00, -3.208798646927e+00, -3.257838010788e+00, - -3.381376743317e+00, -3.534021377563e+00, -3.640867948532e+00, - -3.726858854294e+00, -3.773730993271e+00, -3.804667234421e+00, - -3.832901000977e+00, -3.871120452881e+00, -3.990592956543e+00, - -4.480289459229e+00, 9.235690307617e+01,]; - -/// Per-feature standard deviations, from the reference `src/coeff.h`. -/// -/// Index `40` is the pitch standard deviation, in Hz. -/// -/// Transcribed at the reference's full written precision rather than rounded -/// to what `f32` can hold, so the table stays diffable against `coeff.h`. -#[allow(clippy::excessive_precision)] -#[rustfmt::skip] -pub const FEATURE_STDS: [f32; N_FREQ] = [ - 5.166063785553e+00, 4.977209568024e+00, 4.698895931244e+00, - 4.630621433258e+00, 4.634347915649e+00, 4.641156196594e+00, - 4.640676498413e+00, 4.666367053986e+00, 4.650534629822e+00, - 4.640020847321e+00, 4.637400150299e+00, 4.620099067688e+00, - 4.596316337585e+00, 4.562654972076e+00, 4.554360389709e+00, - 4.566910743713e+00, 4.562489986420e+00, 4.562412738800e+00, - 4.585299491882e+00, 4.600179672241e+00, 4.592845916748e+00, - 4.585922718048e+00, 4.583496570587e+00, 4.626092910767e+00, - 4.626957893372e+00, 4.626289367676e+00, 4.637005805969e+00, - 4.683015823364e+00, 4.726813793182e+00, 4.734289646149e+00, - 4.753227233887e+00, 4.849722862244e+00, 4.869434833527e+00, - 4.884482860565e+00, 4.921327114105e+00, 4.959212303162e+00, - 4.996619224548e+00, 5.044823646545e+00, 5.072216987610e+00, - 5.096439361572e+00, 1.152136917114e+02,]; - -#[cfg(test)] -// The anchors below are quoted from the reference `coeff.h` at its full -// written precision, so they stay greppable against the source table. -#[allow(clippy::excessive_precision)] -mod tests { - use super::*; - - #[test] - fn test_shape_constants() { - assert_eq!(N_MELS, 40); - assert_eq!(N_FREQ, 41); - assert_eq!(D_CTX, 3); - assert_eq!(HOP_SIZE, 256); - assert_eq!(SAMPLE_RATE, 16000); - - // The tables are indexed by feature, so they must be N_FREQ wide. - assert_eq!(FEATURE_MEANS.len(), N_FREQ); - assert_eq!(FEATURE_STDS.len(), N_FREQ); - } - - #[test] - fn test_scalar_constants() { - assert_eq!(FEATURE_EPS, 1e-20); - assert_eq!(PRE_EMPHASIS_COEFF, 0.97); - assert_eq!(INPUT_SCALE, 32768.0); - assert_eq!(POWER_NORMAL, 32768.0 * 32768.0); - assert_eq!(POWER_NORMAL, 1073741824.0); - } - - #[test] - fn test_table_anchors() { - // Anchors against the reference `src/coeff.h` table, at the two ends - // and at the pitch entry. - assert_eq!(FEATURE_MEANS[0], -8.198236465454e+00); - assert_eq!(FEATURE_MEANS[N_MELS - 1], -4.480289459229e+00); - assert_eq!(FEATURE_MEANS[N_MELS], 9.235690307617e+01); - - assert_eq!(FEATURE_STDS[0], 5.166063785553e+00); - assert_eq!(FEATURE_STDS[N_MELS - 1], 5.096439361572e+00); - assert_eq!(FEATURE_STDS[N_MELS], 1.152136917114e+02); - } - - #[test] - fn test_stds_are_usable_divisors() { - // Every std is used as `1 / (std + EPS)`; a non-positive entry would - // make the normalization explode or flip sign. - for (i, &std) in FEATURE_STDS.iter().enumerate() { - assert!(std > 0.0, "FEATURE_STDS[{i}] = {std} is not positive"); - assert!(std.is_finite(), "FEATURE_STDS[{i}] = {std} is not finite"); - } - for (i, &mean) in FEATURE_MEANS.iter().enumerate() { - assert!( - mean.is_finite(), - "FEATURE_MEANS[{i}] = {mean} is not finite" - ); - } - } - - #[test] - fn test_log_mel_means_are_negative() { - // The mel bands are `log(power / 32768^2 + eps)` of speech-scale - // audio, so their means sit well below zero; the pitch entry (in Hz) - // is the only positive one. - for (i, &mean) in FEATURE_MEANS[..N_MELS].iter().enumerate() { - assert!(mean < 0.0, "FEATURE_MEANS[{i}] = {mean} should be negative"); - } - // The pitch mean is in Hz, so a compile-time check is available. - const { assert!(FEATURE_MEANS[N_MELS] > 0.0) }; - } -} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs b/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs deleted file mode 100644 index 6e3b012a..00000000 --- a/crates/bunsen/src/kits/speech/ten_vad/context/driver.rs +++ /dev/null @@ -1,1503 +0,0 @@ -//! # ten-vad streaming driver. -//! -//! The mutable context that carries a ten-vad stream across calls, and the -//! `context_forward` family that drives raw audio through it. -//! -//! [`TenVad::forward`] is the stateless call through the network: it consumes -//! an already-widened `[batch, d_ctx, n_freq]` feature stack. Everything -//! needed to *build* that stack from audio — the pre-emphasis carry, the -//! sliding STFT queue, the pitch recurrence, and the rolling frame history — -//! lives in [`TenVadContext`], and -//! [`context_forward`](TenVad::context_forward) is what ties the two -//! together. This mirrors the split in -//! [`SileroVad`](crate::kits::speech::silero_vad::SileroVad). -//! -//! ## Mutable, not moved -//! -//! [`SileroVadContext`] is a burn `Module` moved in and out by value. This -//! context cannot be: it owns a [`SlidingStftContext`] and a -//! [`PitchSource`], neither of which is a tensor. It is a plain struct -//! driven through `&mut`, matching [`SlidingStftContext`]'s own style. -//! -//! ## Batch size -//! -//! The stock ten-vad ONNX graph pins its LSTM batch to 1, so this driver is -//! built and tested at `batch_size = 1`. See [`TenVad::forward`] for what the -//! leading axis actually means and why multi-stream batching needs a patched -//! graph. -//! -//! [`SileroVadContext`]: crate::kits::speech::silero_vad::SileroVadContext - -use burn::{ - config::Config, - prelude::*, -}; - -use crate::{ - blocks::rnn::lstm::ExtLstmState, - errors::{ - BunsenError, - BunsenResult, - }, - kits::speech::{ - pitch::{ - PitchSource, - PitchSourceConfig, - PitchSourceInit, - PitchSourceKind, - }, - ten_vad::{ - TenVad, - TenVadMeta, - context::{ - coeff::{ - D_CTX, - RESET_FRAMES, - SAMPLE_RATE, - }, - features::{ - TenVadFeatureConfig, - TenVadFeatureContext, - TenVadFeatureMeta, - }, - }, - }, - }, - ops::signal::SlidingStftContext, - prelude::TensorOpExt, -}; - -/// Common meta for [`TenVadContextConfig`] and [`TenVadContext`]. -pub trait TenVadContextMeta { - /// The sample rate, in Hz, this context expects. - fn sample_rate(&self) -> usize; - - /// The batch size; each batch row is an independent stream. - fn batch_size(&self) -> usize; - - /// The hop size, in samples; one hop yields one probability. - fn hop_size(&self) -> usize; - - /// The frame context depth carried into the model. - fn d_ctx(&self) -> usize; - - /// The feature width of one frame. - fn n_freq(&self) -> usize; -} - -/// Config for [`TenVadContext`]. -/// -/// Defaults match the ten-vad reference driver: one 16 kHz stream, hop 256, -/// a 3-frame context stack, and its 30 s periodic LSTM reset. -/// -/// Builds [`TenVadContext`] via [`TenVad::init_context`]. Implements -/// [`TenVadContextMeta`]. -#[derive(Config, Debug)] -pub struct TenVadContextConfig { - /// The sample rate, in Hz. - #[config(default = "SAMPLE_RATE")] - pub sample_rate: usize, - - /// The number of independent streams. - /// - /// The stock ONNX graph pins this to 1; see the module docs. - #[config(default = "1")] - pub batch_size: usize, - - /// The frame context depth. - #[config(default = "D_CTX")] - pub d_ctx: usize, - - /// The feature front-end geometry. - #[config(default = "TenVadFeatureConfig::new()")] - pub features: TenVadFeatureConfig, - - /// How feature `40` is obtained. - /// - /// Defaults to the device-side estimator. See - /// [`PitchSourceConfig`] for the alternatives, and for how to select - /// the literal-transcription tier. - #[config(default = "PitchSourceConfig::default()")] - pub pitch: PitchSourceConfig, - - /// How often to zero the LSTM states, in model calls; `None` never does. - /// - /// Defaults to [`RESET_FRAMES`], the reference driver's 30 s period. See - /// [`TenVadContext::reset_states`] for exactly what a reset touches, and - /// the module docs for why the reference does this at all. - #[config(default = "Some(RESET_FRAMES)")] - pub reset_frames: Option, -} - -impl TenVadContextMeta for TenVadContextConfig { - fn sample_rate(&self) -> usize { - self.sample_rate - } - - fn batch_size(&self) -> usize { - self.batch_size - } - - fn hop_size(&self) -> usize { - self.features.hop_size() - } - - fn d_ctx(&self) -> usize { - self.d_ctx - } - - fn n_freq(&self) -> usize { - self.features.n_freq() - } -} - -impl TenVadContextConfig { - /// Validates the context geometry. - /// - /// # Errors - /// - /// [`BunsenError::Invalid`] if the batch size or context depth is zero, if - /// the sample rate disagrees with the front end, or if the front-end - /// geometry is itself invalid. - pub fn validate(&self) -> BunsenResult<()> { - self.features.validate()?; - - if self.batch_size == 0 { - return Err(BunsenError::Invalid( - "TenVadContext batch_size must be non-zero".to_string(), - )); - } - if self.d_ctx == 0 { - return Err(BunsenError::Invalid( - "TenVadContext d_ctx must be non-zero".to_string(), - )); - } - if self.reset_frames == Some(0) { - return Err(BunsenError::Invalid( - "TenVadContext reset_frames must be non-zero; use None to disable the \ - periodic reset" - .to_string(), - )); - } - if self.sample_rate != self.features.sample_rate() { - return Err(BunsenError::Invalid(format!( - "TenVadContext sample_rate ({}) != feature sample_rate ({})", - self.sample_rate, - self.features.sample_rate(), - ))); - } - Ok(()) - } -} - -/// The mutable driving context for a ten-vad stream. -/// -/// Carries everything the model cannot: the audio front-end state -/// ([`TenVadFeatureContext`]), the rolling `[batch, d_ctx, n_freq]` feature -/// stack, and the two LSTM states. -/// -/// Built by [`TenVad::init_context`]. Implements [`TenVadContextMeta`]. -#[derive(Debug, Clone)] -pub struct TenVadContext = PitchSourceKind> { - /// The audio front-end streaming state. - pub features: TenVadFeatureContext, - - /// The `[batch, d_ctx, n_freq]` rolling feature stack. - /// - /// Row `d_ctx - 1` is the most recent frame; row `0` the oldest. This is - /// exactly what [`TenVad::forward`] consumes. - pub stack: Tensor, - - /// The `[batch, d_hidden]` first-LSTM state. - pub state1: ExtLstmState, - - /// The `[batch, d_hidden]` second-LSTM state. - pub state2: ExtLstmState, - - /// How often to zero the LSTM states, in model calls; `None` never does. - /// - /// Copied from [`TenVadContextConfig::reset_frames`] at init, and honoured - /// identically by [`TenVad::context_forward`] and - /// [`TenVad::context_forward_sequence`]. - pub reset_frames: Option, - - /// Model calls since the last state reset. - /// - /// Advanced on every model call -- including while - /// [`reset_frames`](Self::reset_frames) is `None`, so the field always - /// means what its name says -- and rewound by - /// [`reset_states`](Self::reset_states). - pub frames_since_reset: usize, -} - -impl> TenVadContextMeta for TenVadContext { - fn sample_rate(&self) -> usize { - self.features.sample_rate() - } - - fn batch_size(&self) -> usize { - self.stack.dims()[0] - } - - fn hop_size(&self) -> usize { - self.features.hop_size() - } - - fn d_ctx(&self) -> usize { - self.stack.dims()[1] - } - - fn n_freq(&self) -> usize { - self.stack.dims()[2] - } -} - -impl> TenVadContext { - /// The recurrent hidden width. - pub fn d_hidden(&self) -> usize { - self.state1.hidden.dims()[1] - } - - /// The sliding STFT queue, for inspection. - pub fn stft(&self) -> &SlidingStftContext { - &self.features.stft - } - - /// Resets the whole context to the start-of-stream condition. - /// - /// Zeroes the feature stack, both LSTM states, the STFT queue, the - /// pre-emphasis carry, every pitch source, and the reset counter. - /// - /// Contrast [`reset_states`](Self::reset_states), which zeroes only the - /// recurrence and leaves the front end running. - pub fn reset(&mut self) { - self.features.reset(); - self.stack = Tensor::zeros_like(&self.stack); - self.reset_states(); - } - - /// Zeroes both LSTM states, leaving the front end running. - /// - /// This is the reference's periodic reset (`ALGO_TRACE.md` §5) as a - /// callable primitive. The feature stack, the STFT queue and the - /// pre-emphasis carry are deliberately untouched, so the next frame still - /// sees its predecessors in the context stack -- only the recurrence - /// restarts from zero. Also rewinds - /// [`frames_since_reset`](Self::frames_since_reset). - /// - /// Useful on its own for callers segmenting a stream themselves; the - /// periodic form is [`reset_frames`](Self::reset_frames). - pub fn reset_states(&mut self) { - let device = self.stack.device(); - self.state1 = ExtLstmState::initial(self.state1.hidden.dims(), &device); - self.state2 = ExtLstmState::initial(self.state2.hidden.dims(), &device); - self.frames_since_reset = 0; - } - - /// Advances the reset counter, and fires at a period boundary. - /// - /// Called by both drivers immediately after a model call, which is where - /// the reference increments (`src/aed.cc:476-481`): the counter fires on - /// `>=`, so call `n` runs with the states it inherited and call `n + 1` - /// runs from zero. - /// - /// The reference defers the zeroing to the top of its next call, via a - /// `clear_hidden` flag. Zeroing here instead is observationally identical - /// for any continuation, and keeps the context self-consistent between - /// calls. - fn tick_state_reset(&mut self) { - self.advance_state_reset(1); - } - - /// How many model calls may run before the next reset has to fire. - /// - /// [`usize::MAX`] when the reset is disabled, and never zero: a caller - /// that lowered `reset_frames` below the live count still gets to run one - /// call, after which [`advance_state_reset`](Self::advance_state_reset) - /// fires immediately -- the same thing a per-hop tick would have done. - /// - /// This is what lets [`TenVad::context_forward_sequence`] hand whole runs - /// to [`TenVad::forward_sequence`]: the recurrence may be threaded - /// uninterrupted for exactly this many steps. - fn calls_until_state_reset(&self) -> usize { - match self.reset_frames { - None => usize::MAX, - Some(period) => period.saturating_sub(self.frames_since_reset).max(1), - } - } - - /// Advances the reset counter by `calls`, and fires at a period boundary. - /// - /// `calls` must not exceed - /// [`calls_until_state_reset`](Self::calls_until_state_reset), or it would - /// step over a boundary the reference stops at. - fn advance_state_reset( - &mut self, - calls: usize, - ) { - debug_assert!( - calls <= self.calls_until_state_reset(), - "advancing {calls} calls would skip a reset boundary", - ); - - self.frames_since_reset += calls; - if self - .reset_frames - .is_some_and(|period| self.frames_since_reset >= period) - { - self.reset_states(); - } - } - - /// Rolls one feature frame into the context stack. - /// - /// Drops the oldest frame and appends `feats`, matching the reference's - /// shift-left-and-write (`ALGO_TRACE.md` §3.7). - /// - /// This is the widened-context construction itself: the result is what - /// [`TenVad::forward`] expects, so callers driving the model by hand (or - /// cross-checking it against the ONNX graph) can use it directly. - /// - /// # Arguments - /// * `feats`: `[batch, n_freq]` the newest feature frame. - /// - /// # Returns - /// The updated `[batch, d_ctx, n_freq]` stack. - pub fn push_features( - &mut self, - feats: Tensor, - ) -> Tensor { - #[cfg(any(test, debug_assertions))] - crate::contracts::assert_shape_contract!( - ["batch", "n_freq"], - &feats, - &[("batch", self.batch_size()), ("n_freq", self.n_freq())], - ); - - // [batch, 1, n_freq] - let newest: Tensor = feats.unsqueeze_dim(1); - - // Inplace, so the old stack's storage can be reused. - let older = self.stack.extract().slice_dim(1, 1..); - self.stack = Tensor::cat(vec![older, newest], 1); - - self.stack.clone() - } -} - -impl TenVad { - /// Builds a zeroed driving context, with the pitch source `cfg` selects. - /// - /// Defaults to the device-side estimator, so the whole front end stays - /// resident and no stage synchronizes. Set - /// [`TenVadContextConfig::pitch`] to choose otherwise — the host oracle, - /// the constant stub, or the device estimator's literal-transcription - /// tier. - /// - /// # Errors - /// - /// [`BunsenError::Invalid`] if the config is invalid, or if its context - /// depth or feature width disagrees with this model. - pub fn init_context( - &self, - cfg: &TenVadContextConfig, - device: &B::Device, - ) -> BunsenResult>> { - self.init_context_with(cfg, cfg.pitch.clone(), device) - } - - /// Builds a zeroed driving context over a specific pitch source. - /// - /// # Arguments - /// * `cfg`: the context geometry. - /// * `pitch`: builds the pitch source; see [`PitchSourceInit`]. - /// - /// # Errors - /// - /// [`BunsenError::Invalid`] if the config is invalid, or if its context - /// depth or feature width disagrees with this model. - pub fn init_context_with>( - &self, - cfg: &TenVadContextConfig, - pitch: I, - device: &B::Device, - ) -> BunsenResult> { - cfg.validate()?; - - if cfg.d_ctx() != self.d_ctx() { - return Err(BunsenError::Invalid(format!( - "TenVadContext d_ctx ({}) != model d_ctx ({})", - cfg.d_ctx(), - self.d_ctx(), - ))); - } - if cfg.n_freq() != self.n_freq() { - return Err(BunsenError::Invalid(format!( - "TenVadContext n_freq ({}) != model n_freq ({})", - cfg.n_freq(), - self.n_freq(), - ))); - } - - let batch = cfg.batch_size(); - let state_shape = [batch, self.d_hidden()]; - - Ok(TenVadContext { - features: cfg.features.try_init_context(batch, pitch, device)?, - stack: Tensor::zeros([batch, cfg.d_ctx(), cfg.n_freq()], device), - state1: ExtLstmState::initial(state_shape, device), - state2: ExtLstmState::initial(state_shape, device), - reset_frames: cfg.reset_frames, - frames_since_reset: 0, - }) - } - - /// Drives one hop of audio through the front end and the model. - /// - /// Extracts the frame's features, rolls them into the context stack, and - /// runs one [`forward`](Self::forward) with the carried LSTM states. The - /// context is advanced in place, including its periodic - /// [`reset_frames`](TenVadContext::reset_frames) counter. - /// - /// # Arguments - /// * `hop`: `[batch, hop_size]` mono audio in `[-1, 1]`, at this model's - /// sample rate. - /// * `ctx`: the driving context, advanced in place. - /// - /// # Returns - /// `[batch]` speech probabilities in `[0, 1]`. - pub fn context_forward>( - &self, - hop: Tensor, - ctx: &mut TenVadContext, - ) -> Tensor { - #[cfg(any(test, debug_assertions))] - crate::contracts::assert_shape_contract!( - ["batch", "hop_size"], - &hop, - &[("batch", ctx.batch_size()), ("hop_size", ctx.hop_size()),], - ); - - // [batch, n_freq] - let feats = ctx.features.forward(hop); - - // [batch, d_ctx, n_freq] - let stack = ctx.push_features(feats); - - let (probs, state1, state2) = - self.forward(stack, Some(ctx.state1.clone()), Some(ctx.state2.clone())); - ctx.state1 = state1; - ctx.state2 = state2; - ctx.tick_state_reset(); - - // [batch, 1] -> [batch] - probs.squeeze_dim(1) - } - - /// Drives `steps` consecutive hops through the front end and the model. - /// - /// Equivalent to `steps` calls of - /// [`context_forward`](Self::context_forward), but the entire front end - /// runs batched across the sequence: one pre-emphasis pass, one `stft` - /// call, one filterbank matmul, and every step's context stack - /// materialized in a single concatenation. Only the recurrence itself is - /// stepped -- and it is stepped identically, so a periodic - /// [`reset_frames`](TenVadContext::reset_frames) fires on exactly the same - /// hops either way. - /// - /// # Arguments - /// * `hop_seq`: `[steps, batch, hop_size]` consecutive mono audio hops in - /// `[-1, 1]`, with `steps` non-zero. - /// * `ctx`: the driving context, advanced in place. - /// - /// # Returns - /// `[steps, batch]` speech probabilities in `[0, 1]`. - pub fn context_forward_sequence>( - &self, - hop_seq: Tensor, - ctx: &mut TenVadContext, - ) -> Tensor { - #[cfg(any(test, debug_assertions))] - let [steps] = crate::contracts::unpack_shape_contract!( - ["steps", "batch", "hop_size"], - &hop_seq, - &["steps"], - &[("batch", ctx.batch_size()), ("hop_size", ctx.hop_size()),], - ); - #[cfg(not(any(test, debug_assertions)))] - let steps = hop_seq.dims()[0]; - - assert_ne!(steps, 0, "TenVad hop_seq must be non-empty"); - assert_eq!( - ctx.batch_size(), - 1, - "TenVad pins its LSTM batch to 1; see `TenVad::forward`", - ); - - let d_ctx = ctx.d_ctx(); - - // [steps, batch, n_freq] - let feats = ctx.features.forward_sequence(hop_seq); - - // The carried stack followed by every new frame, so that step `s`'s - // widened context is a slice of one tensor. - // [batch, d_ctx + steps, n_freq] - let history = Tensor::cat(vec![ctx.stack.extract(), feats.swap_dims(0, 1)], 1); - - // The carry-out stack is the final `d_ctx` frames. - ctx.stack = history.clone().slice_dim(1, steps as isize..); - - // Every step's widened context, materialized once: step `s` sees - // frames `s + 1 ..= s + d_ctx`, so the windows are the history's own - // sliding view with the carried stack dropped. The batch axis goes - // away because the model pins it to 1 and reads its leading axis as - // time; `assert_batch_one` above is what makes that sound. - // [steps, d_ctx, n_freq] - // `unfold` appends the window axis last, so this lands as - // `[steps + 1, n_freq, d_ctx]` and has to be transposed back. - let stacks = history - .squeeze_dim::<2>(0) - .unfold::<3, _>(0, d_ctx, 1) - .slice_dim(0, 1..) - .swap_dims(1, 2); - - // One pass per reset period rather than one per hop. `forward_sequence` - // walks the recurrence internally, so the only thing that has to - // interrupt it is a state reset -- which is exactly the chunking rule - // the reference gives for batched calls (`ALGO_TRACE.md` §8.2). - let mut probs = Vec::new(); - let mut done = 0; - while done < steps { - let take = ctx.calls_until_state_reset().min(steps - done); - - // [take, 1] - let (chunk, state1, state2) = self.forward_sequence( - stacks - .clone() - .slice_dim(0, done as isize..(done + take) as isize), - Some(ctx.state1.clone()), - Some(ctx.state2.clone()), - ); - ctx.state1 = state1; - ctx.state2 = state2; - ctx.advance_state_reset(take); - - probs.push(chunk); - done += take; - } - - // [steps, 1] is [steps, batch], the batch being the pinned 1. - Tensor::cat(probs, 0) - } - - /// Uploads host audio and drives it through the model. - /// - /// The convenience form of - /// [`context_forward_sequence`](Self::context_forward_sequence) for - /// callers whose audio is already host-side — a decoded file, or a capture - /// buffer. Equivalent to framing the audio into hops, uploading it, and - /// calling that method; the device comes from `ctx`, so the caller never - /// names one. - /// - /// # Arguments - /// * `audio`: one row per stream, each a whole number of hops of mono audio - /// in `[-1, 1]`, at [`sample_rate`](TenVadContextMeta::sample_rate). - /// Every row must be the same length. - /// * `ctx`: the driving context, advanced in place. - /// - /// # Returns - /// `[steps, batch]` speech probabilities, one per hop per stream. - /// - /// # Errors - /// [`BunsenError::Invalid`] if the row count disagrees with the context's - /// batch size, if the rows differ in length, or if a row is empty or not a - /// whole number of hops. - pub fn context_forward_audio_sequence>( - &self, - audio: &[&[f32]], - ctx: &mut TenVadContext, - ) -> BunsenResult> { - let hop_size = ctx.hop_size(); - let batch = ctx.batch_size(); - - if audio.len() != batch { - return Err(BunsenError::Invalid(format!( - "TenVad expected {batch} audio rows, got {}", - audio.len(), - ))); - } - let samples = audio[0].len(); - if samples == 0 || !samples.is_multiple_of(hop_size) { - return Err(BunsenError::Invalid(format!( - "TenVad audio length ({samples}) must be a non-zero multiple of the hop \ - size ({hop_size})", - ))); - } - if let Some(bad) = audio.iter().position(|row| row.len() != samples) { - return Err(BunsenError::Invalid(format!( - "TenVad audio rows must be equal length; row {bad} has {} samples, row 0 \ - has {samples}", - audio[bad].len(), - ))); - } - - let steps = samples / hop_size; - let mut flat = Vec::with_capacity(steps * batch * hop_size); - for step in 0..steps { - for row in audio { - flat.extend_from_slice(&row[step * hop_size..(step + 1) * hop_size]); - } - } - - let device = ctx.stack.device(); - let hop_seq = Tensor::from_data(TensorData::new(flat, [steps, batch, hop_size]), &device); - - Ok(self.context_forward_sequence(hop_seq, ctx)) - } - - /// Uploads a single stream of host audio and drives it through the model. - /// - /// The one-stream form of - /// [`context_forward_audio_sequence`](Self::context_forward_audio_sequence). - /// - /// # Arguments - /// * `audio`: a whole number of hops of mono audio in `[-1, 1]`. - /// * `ctx`: the driving context, which must be over a single stream. - /// - /// # Returns - /// `[steps, 1]` speech probabilities, one per hop. - /// - /// # Errors - /// [`BunsenError::Invalid`] if `ctx` is not single-stream, or if `audio` - /// is empty or not a whole number of hops. - pub fn context_forward_audio>( - &self, - audio: &[f32], - ctx: &mut TenVadContext, - ) -> BunsenResult> { - if ctx.batch_size() != 1 { - return Err(BunsenError::Invalid(format!( - "context_forward_audio needs a single-stream context, got batch {}; use \ - context_forward_audio_sequence", - ctx.batch_size(), - ))); - } - self.context_forward_audio_sequence(&[audio], ctx) - } -} - -#[cfg(test)] -mod tests { - use burn::tensor::{ - Distribution, - Tolerance, - backend::BackendTypes, - }; - - use super::*; - use crate::{ - burner::module::ModuleInit, - kits::speech::ten_vad::{ - TenVadStructureConfig, - context::coeff::N_FREQ, - }, - prelude::*, - support::testing::PerformanceBackend, - }; - - type B = PerformanceBackend; - type F = ::FloatElem; - - fn model() -> (TenVad, ::Device) { - let device = Default::default(); - let vad: TenVad = TenVadStructureConfig::default().init(&device); - (vad, device) - } - - #[test] - fn test_default_pitch_source_is_the_device_estimator() { - // The driver's default is the device path, so the whole front end - // stays resident and no stage synchronizes. - let cfg = TenVadContextConfig::new(); - assert!( - matches!(cfg.pitch, PitchSourceConfig::Tensor(_)), - "expected the device estimator by default, got {:?}", - cfg.pitch, - ); - - let (vad, device) = model(); - let ctx = vad.init_context(&cfg, &device).unwrap(); - assert!(matches!(ctx.features.pitch, PitchSourceKind::Tensor(_))); - - // And the other variants are reachable through the same config. - let host = vad - .init_context_with(&cfg, PitchSourceConfig::Host, &device) - .unwrap(); - assert!(matches!(host.features.pitch, PitchSourceKind::Host(_))); - - let zero = vad - .init_context_with(&cfg, PitchSourceConfig::Zero, &device) - .unwrap(); - assert!(matches!(zero.features.pitch, PitchSourceKind::Zero(_))); - } - - #[test] - fn test_config_meta() { - let cfg = TenVadContextConfig::new(); - - assert_eq!(cfg.sample_rate(), 16000); - assert_eq!(cfg.batch_size(), 1); - assert_eq!(cfg.hop_size(), 256); - assert_eq!(cfg.d_ctx(), 3); - assert_eq!(cfg.n_freq(), N_FREQ); - - // The reference driver's 30 s period, on by default. - assert_eq!(cfg.reset_frames, Some(RESET_FRAMES)); - assert_eq!(RESET_FRAMES * cfg.hop_size() / cfg.sample_rate(), 30); - - cfg.validate().unwrap(); - } - - #[test] - fn test_validate_rejects_bad_geometry() { - for bad in [ - TenVadContextConfig::new().with_batch_size(0), - TenVadContextConfig::new().with_d_ctx(0), - // The declared rate must agree with the front end's. - TenVadContextConfig::new().with_sample_rate(8000), - // `None` disables the reset; `Some(0)` is a mistake, not a way to. - TenVadContextConfig::new().with_reset_frames(Some(0)), - ] { - assert!( - matches!(bad.validate(), Err(BunsenError::Invalid(_))), - "expected Invalid: {bad:?}", - ); - } - } - - #[test] - fn test_validate_accepts_a_disabled_reset() { - TenVadContextConfig::new() - .with_reset_frames(None) - .validate() - .unwrap(); - } - - #[test] - fn test_init_context_shapes_and_zeroing() { - let (vad, device) = model(); - let cfg = TenVadContextConfig::new(); - let ctx = vad.init_context(&cfg, &device).unwrap(); - - assert_eq!(ctx.sample_rate(), 16000); - assert_eq!(ctx.batch_size(), 1); - assert_eq!(ctx.hop_size(), 256); - assert_eq!(ctx.d_ctx(), vad.d_ctx()); - assert_eq!(ctx.n_freq(), vad.n_freq()); - assert_eq!(ctx.d_hidden(), vad.d_hidden()); - - assert_eq!(ctx.stack.dims(), [1, 3, 41]); - assert_eq!(ctx.state1.hidden.dims(), [1, 64]); - assert_eq!(ctx.state2.cell.dims(), [1, 64]); - - // Everything starts at zero, as in the reference driver. - let zeros = Tensor::::zeros([1, 3, 41], &device); - ctx.stack.to_data().assert_eq(&zeros.to_data(), true); - - let zeros2 = Tensor::::zeros([1, 64], &device); - ctx.state1 - .hidden - .to_data() - .assert_eq(&zeros2.to_data(), true); - ctx.state1.cell.to_data().assert_eq(&zeros2.to_data(), true); - ctx.state2 - .hidden - .to_data() - .assert_eq(&zeros2.to_data(), true); - ctx.state2.cell.to_data().assert_eq(&zeros2.to_data(), true); - - // The STFT queue starts zeroed too. - assert_eq!(ctx.stft().queue.dims(), [1, 768]); - } - - #[test] - fn test_init_context_rejects_model_mismatch() { - let (vad, device) = model(); - - // A context depth the model does not consume. - let bad = TenVadContextConfig::new().with_d_ctx(5); - assert!(matches!( - vad.init_context(&bad, &device), - Err(BunsenError::Invalid(_)), - )); - - // A feature width the model does not consume. The front end accepts - // it (it is under the table bound); the driver must not. - let bad = TenVadContextConfig::new().with_features( - TenVadFeatureConfig::new().with_mel( - crate::kits::speech::ten_vad::context::mel::TenVadMelConfig::new() - .with_n_mels(6) - .with_fft_size(1024), - ), - ); - bad.validate().unwrap(); - assert!(matches!( - vad.init_context(&bad, &device), - Err(BunsenError::Invalid(_)), - )); - } - - #[test] - fn test_push_features_rolls_the_stack() { - let (vad, device) = model(); - let cfg = TenVadContextConfig::new(); - let mut ctx = vad.init_context(&cfg, &device).unwrap(); - - let d_ctx = ctx.d_ctx(); - let n_freq = ctx.n_freq(); - - // Push distinguishable frames: frame `f` is all-`f`. - let mut pushed: Vec = Vec::new(); - for f in 1..=(d_ctx + 2) { - let value = f as f32; - pushed.push(value); - - let feats = Tensor::::full([1, n_freq], value, &device); - let stack = ctx.push_features(feats); - assert_eq!(stack.dims(), [1, d_ctx, n_freq]); - - let host: Vec = stack.to_data_as::().to_vec_as::().unwrap(); - - // The stack holds the last `d_ctx` frames, oldest first; slots - // not yet filled are still zero. - for row in 0..d_ctx { - let age = d_ctx - 1 - row; - let expected = if age < pushed.len() { - pushed[pushed.len() - 1 - age] - } else { - 0.0 - }; - for col in 0..n_freq { - assert_eq!( - host[row * n_freq + col], - expected, - "after {f} pushes, row {row} col {col}", - ); - } - } - } - } - - #[test] - fn test_context_forward_shapes_and_range() { - let (vad, device) = model(); - let cfg = TenVadContextConfig::new(); - let mut ctx = vad.init_context(&cfg, &device).unwrap(); - - let hop = Tensor::::random([1, cfg.hop_size()], Distribution::Default, &device); - let probs = vad.context_forward(hop, &mut ctx); - - assert_eq!(probs.dims(), [1]); - - let host: Vec = probs.to_data_as::().to_vec_as::().unwrap(); - assert!(host.iter().all(|&p| (0.0..=1.0).contains(&p)), "{host:?}"); - } - - #[test] - fn test_context_forward_sequence_shapes_and_range() { - let (vad, device) = model(); - let cfg = TenVadContextConfig::new(); - let mut ctx = vad.init_context(&cfg, &device).unwrap(); - - let steps = 7; - let hops = - Tensor::::random([steps, 1, cfg.hop_size()], Distribution::Default, &device); - let probs = vad.context_forward_sequence(hops, &mut ctx); - - assert_eq!(probs.dims(), [steps, 1]); - - let host: Vec = probs.to_data_as::().to_vec_as::().unwrap(); - assert!(host.iter().all(|&p| (0.0..=1.0).contains(&p)), "{host:?}"); - } - - #[test] - fn test_sequence_matches_stepwise() { - // The whole point of the sequence form: same answer, fewer passes. - let (vad, device) = model(); - let cfg = TenVadContextConfig::new(); - - let steps = 9; - let hops = - Tensor::::random([steps, 1, cfg.hop_size()], Distribution::Default, &device); - - let mut seq_ctx = vad.init_context(&cfg, &device).unwrap(); - let seq_probs = vad.context_forward_sequence(hops.clone(), &mut seq_ctx); - - let mut step_ctx = vad.init_context(&cfg, &device).unwrap(); - let mut step_probs = Vec::with_capacity(steps); - for step in 0..steps { - let hop = hops.clone().select_dim::<2>(0, step); - step_probs.push(vad.context_forward(hop, &mut step_ctx)); - } - let step_probs: Tensor = Tensor::stack(step_probs, 0); - - let tol = Tolerance::::permissive(); - seq_probs - .to_data_as::() - .assert_approx_eq::(&step_probs.to_data_as::(), tol); - - // Every carried field must agree, or the next call diverges. - seq_ctx - .stack - .to_data_as::() - .assert_approx_eq::(&step_ctx.stack.to_data_as::(), tol); - seq_ctx - .state1 - .hidden - .to_data_as::() - .assert_approx_eq::(&step_ctx.state1.hidden.to_data_as::(), tol); - seq_ctx - .state1 - .cell - .to_data_as::() - .assert_approx_eq::(&step_ctx.state1.cell.to_data_as::(), tol); - seq_ctx - .state2 - .hidden - .to_data_as::() - .assert_approx_eq::(&step_ctx.state2.hidden.to_data_as::(), tol); - seq_ctx - .state2 - .cell - .to_data_as::() - .assert_approx_eq::(&step_ctx.state2.cell.to_data_as::(), tol); - seq_ctx - .features - .stft - .queue - .to_data_as::() - .assert_approx_eq::(&step_ctx.features.stft.queue.to_data_as::(), tol); - } - - #[test] - fn test_single_step_sequence_matches_context_forward() { - // The `steps == 1` boundary: the sequence path's history slicing must - // degenerate to a plain roll-and-run. - let (vad, device) = model(); - let cfg = TenVadContextConfig::new(); - - let hop = Tensor::::random([1, cfg.hop_size()], Distribution::Default, &device); - - let mut seq_ctx = vad.init_context(&cfg, &device).unwrap(); - let seq_probs = - vad.context_forward_sequence(hop.clone().unsqueeze_dim::<3>(0), &mut seq_ctx); - - let mut step_ctx = vad.init_context(&cfg, &device).unwrap(); - let step_probs = vad.context_forward(hop, &mut step_ctx); - - let tol = Tolerance::::permissive(); - seq_probs - .squeeze_dim::<1>(0) - .to_data_as::() - .assert_approx_eq::(&step_probs.to_data_as::(), tol); - seq_ctx - .stack - .to_data_as::() - .assert_approx_eq::(&step_ctx.stack.to_data_as::(), tol); - } - - #[test] - fn test_context_forward_audio_matches_the_tensor_path() { - // The host-audio entry point is a framing convenience, nothing more: - // it must agree exactly with uploading the hops yourself. - let (vad, device) = model(); - let cfg = TenVadContextConfig::new(); - let hop_size = cfg.hop_size(); - let steps = 5; - - let audio: Vec = (0..steps * hop_size) - .map(|i| 0.2 * (i as f32 * 0.03).sin()) - .collect(); - - let mut audio_ctx = vad.init_context(&cfg, &device).unwrap(); - let from_audio = vad.context_forward_audio(&audio, &mut audio_ctx).unwrap(); - - let mut tensor_ctx = vad.init_context(&cfg, &device).unwrap(); - let hops = - Tensor::::from_floats(audio.as_slice(), &device).reshape([steps, 1, hop_size]); - let from_tensor = vad.context_forward_sequence(hops, &mut tensor_ctx); - - assert_eq!(from_audio.dims(), [steps, 1]); - from_audio - .to_data_as::() - .assert_eq(&from_tensor.to_data_as::(), true); - } - - #[test] - fn test_context_forward_audio_sequence_takes_row_slices() { - // The multi-row form, exercised at the only batch the stock graph - // accepts. The feature context handles batch > 1 today; the model does - // not -- its LSTM batch is pinned to 1, so a wider driver context - // cannot be stepped until that is unblocked. - let (vad, device) = model(); - let cfg = TenVadContextConfig::new(); - let hop_size = cfg.hop_size(); - let steps = 4; - - let audio: Vec = (0..steps * hop_size) - .map(|i| 0.2 * (i as f32 * 0.03).sin()) - .collect(); - - let mut ctx = vad.init_context(&cfg, &device).unwrap(); - let out = vad - .context_forward_audio_sequence(&[&audio], &mut ctx) - .unwrap(); - assert_eq!(out.dims(), [steps, 1]); - } - - #[test] - fn test_context_forward_audio_rejects_bad_input() { - let (vad, device) = model(); - let cfg = TenVadContextConfig::new(); - let hop_size = cfg.hop_size(); - let mut ctx = vad.init_context(&cfg, &device).unwrap(); - - // Not a whole number of hops. - let ragged = vec![0.0f32; hop_size + 3]; - assert!(vad.context_forward_audio(&ragged, &mut ctx).is_err()); - // Empty. - assert!(vad.context_forward_audio(&[], &mut ctx).is_err()); - // Wrong row count for the batch. - let good = vec![0.0f32; hop_size]; - assert!( - vad.context_forward_audio_sequence(&[&good, &good], &mut ctx) - .is_err() - ); - // And the good case works. - assert!(vad.context_forward_audio(&good, &mut ctx).is_ok()); - } - - #[test] - fn test_context_forward_audio_rejects_multi_stream_context() { - let (vad, device) = model(); - let cfg = TenVadContextConfig::new().with_batch_size(2); - let hop_size = cfg.hop_size(); - let mut ctx = vad.init_context(&cfg, &device).unwrap(); - - let audio = vec![0.0f32; hop_size]; - let err = vad.context_forward_audio(&audio, &mut ctx).unwrap_err(); - assert!( - format!("{err}").contains("single-stream"), - "expected a single-stream diagnostic, got: {err}", - ); - } - - #[test] - fn test_sequence_resumes_across_chunks() { - // Splitting a stream into two sequence calls must match one call over - // the whole thing -- that is what makes the context a *continuation*. - let (vad, device) = model(); - let cfg = TenVadContextConfig::new(); - - let steps = 8; - let hops = - Tensor::::random([steps, 1, cfg.hop_size()], Distribution::Default, &device); - - let mut whole_ctx = vad.init_context(&cfg, &device).unwrap(); - let whole = vad.context_forward_sequence(hops.clone(), &mut whole_ctx); - - let mut split_ctx = vad.init_context(&cfg, &device).unwrap(); - let head = hops.clone().slice_dim(0, ..3); - let tail = hops.slice_dim(0, 3..); - let a = vad.context_forward_sequence(head, &mut split_ctx); - let b = vad.context_forward_sequence(tail, &mut split_ctx); - let split = Tensor::cat(vec![a, b], 0); - - let tol = Tolerance::::permissive(); - whole - .to_data_as::() - .assert_approx_eq::(&split.to_data_as::(), tol); - whole_ctx - .stack - .to_data_as::() - .assert_approx_eq::(&split_ctx.stack.to_data_as::(), tol); - whole_ctx - .state2 - .hidden - .to_data_as::() - .assert_approx_eq::(&split_ctx.state2.hidden.to_data_as::(), tol); - } - - #[test] - fn test_reset_rewinds_the_stream() { - let (vad, device) = model(); - let cfg = TenVadContextConfig::new(); - let mut ctx = vad.init_context(&cfg, &device).unwrap(); - - let hop = Tensor::::random([1, cfg.hop_size()], Distribution::Default, &device); - - let first = vad.context_forward(hop.clone(), &mut ctx); - - vad.context_forward(hop.clone(), &mut ctx); - vad.context_forward(hop.clone(), &mut ctx); - ctx.reset(); - - let again = vad.context_forward(hop, &mut ctx); - again - .to_data_as::() - .assert_approx_eq::(&first.to_data_as::(), Tolerance::permissive()); - } - - /// The largest absolute value in either LSTM state. - /// - /// Zero exactly when the recurrence has been reset. - fn state_peak>(ctx: &TenVadContext) -> f32 { - [ - &ctx.state1.hidden, - &ctx.state1.cell, - &ctx.state2.hidden, - &ctx.state2.cell, - ] - .into_iter() - .flat_map(|t| { - t.clone() - .to_data_as::() - .to_vec_as::() - .ok_or_panic() - .into_iter() - }) - .fold(0.0f32, |acc, v| acc.max(v.abs())) - } - - /// Asserts two contexts are the same continuation: same stack, same - /// recurrence, same front end. - fn assert_contexts_agree, Q: PitchSource>( - a: &TenVadContext, - b: &TenVadContext, - ) { - let tol = Tolerance::::permissive(); - for (x, y) in [ - (&a.state1.hidden, &b.state1.hidden), - (&a.state1.cell, &b.state1.cell), - (&a.state2.hidden, &b.state2.hidden), - (&a.state2.cell, &b.state2.cell), - (&a.features.stft.queue, &b.features.stft.queue), - ] { - x.clone() - .to_data_as::() - .assert_approx_eq::(&y.clone().to_data_as::(), tol); - } - a.stack - .clone() - .to_data_as::() - .assert_approx_eq::(&b.stack.clone().to_data_as::(), tol); - } - - #[test] - fn test_state_reset_fires_on_the_period_boundary() { - // The reference increments *after* the model call and fires on `>=`, - // so call `k` runs with the states it inherited and call `k + 1` runs - // from zero. Off by one in either direction fails here. - const K: usize = 3; - - let (vad, device) = model(); - let cfg = TenVadContextConfig::new().with_reset_frames(Some(K)); - let mut ctx = vad.init_context(&cfg, &device).unwrap(); - - for call in 1..=(2 * K) { - let hop = Tensor::::random([1, cfg.hop_size()], Distribution::Default, &device); - vad.context_forward(hop, &mut ctx); - - let on_boundary = call.is_multiple_of(K); - assert_eq!( - ctx.frames_since_reset, - if on_boundary { 0 } else { call % K }, - "counter wrong after call {call}", - ); - - let peak = state_peak(&ctx); - if on_boundary { - assert_eq!(peak, 0.0, "call {call} should have zeroed the states"); - } else { - assert!(peak > 0.0, "call {call} should not have zeroed the states"); - } - } - } - - #[test] - fn test_state_reset_splits_the_stream_exactly() { - // A periodic reset must be *exactly* a manual one at the same hop -- - // this is the whole semantic, and it needs no golden to check. - const K: usize = 3; - - let (vad, device) = model(); - let cfg = TenVadContextConfig::new(); - let hops = - Tensor::::random([2 * K, 1, cfg.hop_size()], Distribution::Default, &device); - - let periodic = cfg.clone().with_reset_frames(Some(K)); - let mut auto_ctx = vad.init_context(&periodic, &device).unwrap(); - let auto = vad.context_forward_sequence(hops.clone(), &mut auto_ctx); - - // Mirrored block for block, terminal boundary included: a period of - // `K` over `2K` hops fires at `K` *and* at `2K`. The second one lands - // after the last output, so it shows up in the carried state rather - // than in the trace -- which is exactly why the states are compared. - let manual = cfg.with_reset_frames(None); - let mut manual_ctx = vad.init_context(&manual, &device).unwrap(); - let mut blocks = Vec::with_capacity(2); - for block in 0..2 { - let lo = (block * K) as isize; - blocks.push(vad.context_forward_sequence( - hops.clone().slice_dim(0, lo..lo + K as isize), - &mut manual_ctx, - )); - manual_ctx.reset_states(); - } - - auto.to_data_as::().assert_approx_eq::( - &Tensor::cat(blocks, 0).to_data_as::(), - Tolerance::permissive(), - ); - assert_contexts_agree(&auto_ctx, &manual_ctx); - } - - #[test] - fn test_state_reset_changes_the_output() { - // Guard the guard: every assertion above also passes if the reset - // silently never fires, so pin that it is observable at all. - const K: usize = 3; - - let (vad, device) = model(); - let cfg = TenVadContextConfig::new(); - let hops = - Tensor::::random([2 * K, 1, cfg.hop_size()], Distribution::Default, &device); - - let mut on = vad - .init_context(&cfg.clone().with_reset_frames(Some(K)), &device) - .unwrap(); - let mut off = vad - .init_context(&cfg.with_reset_frames(None), &device) - .unwrap(); - - let with = vad.context_forward_sequence(hops.clone(), &mut on); - let without = vad.context_forward_sequence(hops, &mut off); - - let a = with.to_data_as::().to_vec_as::().ok_or_panic(); - let b = without.to_data_as::().to_vec_as::().ok_or_panic(); - let worst = a - .iter() - .zip(b.iter()) - .fold(0.0f32, |acc, (x, y)| acc.max((x - y).abs())); - - // The first `K` hops are identical by construction; the reset only - // shows up from hop `K + 1` on. - assert!( - worst > 1e-6, - "a periodic reset must change the trace, but the worst difference \ - over {} hops was {worst:.3e}", - 2 * K, - ); - } - - #[test] - fn test_reset_states_leaves_the_front_end_alone() { - // The reference resets the recurrence only: the frame after a reset - // still sees its predecessors in the context stack. - let (vad, device) = model(); - let cfg = TenVadContextConfig::new().with_reset_frames(None); - let mut ctx = vad.init_context(&cfg, &device).unwrap(); - - let hops = Tensor::::random([4, 1, cfg.hop_size()], Distribution::Default, &device); - vad.context_forward_sequence(hops, &mut ctx); - - let stack = ctx.stack.clone(); - let queue = ctx.features.stft.queue.clone(); - assert!(state_peak(&ctx) > 0.0, "fixture should leave live states"); - - ctx.reset_states(); - - assert_eq!(state_peak(&ctx), 0.0); - assert_eq!(ctx.frames_since_reset, 0); - ctx.stack.to_data().assert_eq(&stack.to_data(), true); - ctx.features - .stft - .queue - .to_data() - .assert_eq(&queue.to_data(), true); - } - - #[test] - fn test_sequence_matches_stepwise_with_a_periodic_reset() { - // The invariant the whole design rests on: `context_forward_sequence` - // stays exactly "iterating `context_forward`", reset included. - const K: usize = 3; - - let (vad, device) = model(); - let cfg = TenVadContextConfig::new().with_reset_frames(Some(K)); - - let steps = 3 * K; - let hops = - Tensor::::random([steps, 1, cfg.hop_size()], Distribution::Default, &device); - - let mut seq_ctx = vad.init_context(&cfg, &device).unwrap(); - let seq_probs = vad.context_forward_sequence(hops.clone(), &mut seq_ctx); - - let mut step_ctx = vad.init_context(&cfg, &device).unwrap(); - let mut step_probs = Vec::with_capacity(steps); - for step in 0..steps { - step_probs - .push(vad.context_forward(hops.clone().select_dim::<2>(0, step), &mut step_ctx)); - } - let step_probs: Tensor = Tensor::stack(step_probs, 0); - - seq_probs - .to_data_as::() - .assert_approx_eq::(&step_probs.to_data_as::(), Tolerance::permissive()); - assert_contexts_agree(&seq_ctx, &step_ctx); - assert_eq!(seq_ctx.frames_since_reset, step_ctx.frames_since_reset); - } - - #[test] - fn test_sequence_resumes_across_chunks_with_a_periodic_reset() { - // The chunk boundary lands *on* the reset boundary, which is where a - // counter that ticks in the wrong place gives itself away. - const K: usize = 3; - - let (vad, device) = model(); - let cfg = TenVadContextConfig::new().with_reset_frames(Some(K)); - - let hops = Tensor::::random([8, 1, cfg.hop_size()], Distribution::Default, &device); - - let mut whole_ctx = vad.init_context(&cfg, &device).unwrap(); - let whole = vad.context_forward_sequence(hops.clone(), &mut whole_ctx); - - let mut split_ctx = vad.init_context(&cfg, &device).unwrap(); - let a = - vad.context_forward_sequence(hops.clone().slice_dim(0, ..K as isize), &mut split_ctx); - let b = vad.context_forward_sequence(hops.slice_dim(0, K as isize..), &mut split_ctx); - - whole.to_data_as::().assert_approx_eq::( - &Tensor::cat(vec![a, b], 0).to_data_as::(), - Tolerance::permissive(), - ); - assert_contexts_agree(&whole_ctx, &split_ctx); - assert_eq!(whole_ctx.frames_since_reset, split_ctx.frames_since_reset); - } - - /// Reports cold and warm cost of [`TenVad::context_forward_sequence`]. - /// - /// A measurement, not an assertion -- it prints and asserts nothing, so it - /// is ignored by default. Run it against an optimized build: - /// - /// ```text - /// cargo test --release -p bunsen --lib --features wgpu -- \ - /// test_where_the_time_goes --ignored --exact --nocapture - /// ``` - /// - /// **Cold is the first call at a given `steps`; warm is a later one.** - /// The gap is kernel selection, which cubecl keys on shape. - /// - /// Warm cost is linear and small -- about 0.75 ms/hop for the model and - /// 0.50 ms/hop for the device pitch estimator, flat across the sweep. - /// Cold cost is the interesting half, and it is what - /// [`TensorPitchConfig::chunk_steps`] exists to control. Measured on wgpu, - /// one stream, before and after fixing the estimator's pass size: - /// - /// | pitch | hops | cold, unchunked | cold, chunked | warm | - /// |---|---|---|---|---| - /// | zero | 1600 | 1.26 s | 1.30 s | 1.22 s | - /// | tensor | 400 | 4.75 s | 4.77 s | 0.51 s | - /// | tensor | 800 | 15.09 s | 15.92 s | 1.03 s | - /// | tensor | 1600 | 66.61 s | 12.74 s | 2.09 s | - /// - /// Unchunked, cold grows roughly quadratically. Chunked, the sweep shows - /// the mechanism directly: **1600 hops costs less cold than 800 does**, - /// because by then the 512-hop shape is already tuned and only the 64-hop - /// remainder is new. 400 is unchanged either way -- it fits in one chunk. - /// - /// The model half barely tunes at all (`zero` cold is warm plus a little), - /// which is what [`TenVad::forward_sequence`] bought. - /// - /// End to end, the 3750-hop reference probability golden went from 612 s - /// to 19.6 s in release, and from over an hour to 126 s in debug -- which - /// is why it now runs in the suite instead of being capped at 400 hops. - - #[test] - #[ignore = "measurement, not an assertion"] - #[serial_test::serial] - fn test_where_the_time_goes() { - use std::time::{ - Duration, - Instant, - }; - - /// Runs per measurement; the minimum is reported. - const REPS: usize = 3; - - let (vad, device) = model(); - - /// Wall time for a single run of `run`, which must synchronize. - fn best_of_1(mut run: impl FnMut()) -> Duration { - let start = Instant::now(); - run(); - start.elapsed() - } - - /// Best-of-`REPS` wall time for `run`, which must synchronize. - fn best_of(mut run: impl FnMut()) -> Duration { - (0..REPS) - .map(|_| { - let start = Instant::now(); - run(); - start.elapsed() - }) - .min() - .unwrap() - } - - eprintln!("{:>6} {:>6} {:>12} {:>12}", "pitch", "hops", "cold", "warm"); - - for (name, pitch) in [ - ("zero", PitchSourceConfig::Zero), - ("tensor", PitchSourceConfig::default()), - ] { - let cfg = TenVadContextConfig::new().with_pitch(pitch); - - for steps in [400usize, 800, 1600] { - let hops = Tensor::::random( - [steps, 1, cfg.hop_size()], - Distribution::Default, - &device, - ); - - // The *first* run at this shape, kernel selection included. - let cold = best_of_1(|| { - let mut ctx = vad.init_context(&cfg, &device).unwrap(); - vad.context_forward_sequence(hops.clone(), &mut ctx) - .to_data(); - }); - - // And a subsequent one, with the same shapes already tuned. - let warm = best_of(|| { - let mut ctx = vad.init_context(&cfg, &device).unwrap(); - vad.context_forward_sequence(hops.clone(), &mut ctx) - .to_data(); - }); - - eprintln!("{name:>6} {steps:>6} {cold:>12.2?} {warm:>12.2?}"); - } - } - } - - #[test] - #[should_panic(expected = "hop_seq must be non-empty")] - fn test_sequence_rejects_empty_input() { - let (vad, device) = model(); - let cfg = TenVadContextConfig::new(); - let mut ctx = vad.init_context(&cfg, &device).unwrap(); - - let empty = Tensor::::zeros([0, 1, cfg.hop_size()], &device); - vad.context_forward_sequence(empty, &mut ctx); - } -} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/features.rs b/crates/bunsen/src/kits/speech/ten_vad/context/features.rs deleted file mode 100644 index 768c2ee2..00000000 --- a/crates/bunsen/src/kits/speech/ten_vad/context/features.rs +++ /dev/null @@ -1,1281 +0,0 @@ -//! # ten-vad feature extraction. -//! -//! Turns `[-1, 1]` mono audio hops into the 41-dimensional feature vectors -//! [`TenVad`] consumes, reproducing the reference front end -//! (`ALGO_TRACE.md` §3.3 - §3.6) stage for stage: -//! -//! 1. scale to the reference's int16 range ([`INPUT_SCALE`]), -//! 2. [`PreEmphasisContext`] on the STFT branch only — the pitch branch keeps -//! the raw samples, -//! 3. [`SlidingStftContext`] over a 768-sample queue, zero-padded to a -//! 1024-point FFT, -//! 4. bin power `re^2 + im^2`, -//! 5. pitch, from the [`PitchSource`], reading the raw hop and the -//! **un-normalized** bin power, -//! 6. `1 / 32768^2` normalization, then the [`TenVadMelBank`], then `ln(x + -//! 1e-20)`, -//! 7. per-feature standardization against the reference mean / std tables. -//! -//! Two orderings here are load-bearing and easy to get backwards: -//! -//! * **Pitch runs before the power normalization**, and reads the raw, -//! un-pre-emphasized hop. -//! * **The `1 / 32768^2` division happens before the filterbank matmul**, not -//! after the log. The two commute algebraically but not in `f32`. -//! -//! The pieces are: -//! * [`TenVadFeatureConfig`] — the geometry. -//! * [`TenVadFeatures`] — the fixed analysis coefficients; stateless. -//! * [`TenVadFeatureContext`] — a streaming state bound to a batch; built by -//! [`TenVadFeatureConfig::try_init_context`]. -//! -//! [`TenVad`]: crate::kits::speech::ten_vad::TenVad - -use burn::{ - config::Config, - prelude::*, -}; - -use crate::{ - errors::{ - BunsenError, - BunsenResult, - WithOkOrPanic, - }, - kits::speech::{ - pitch::{ - PitchSource, - PitchSourceInit, - ZeroPitch, - }, - ten_vad::context::{ - coeff::{ - FEATURE_EPS, - FEATURE_MEANS, - FEATURE_STDS, - INPUT_SCALE, - N_FREQ, - POWER_NORMAL, - SAMPLE_RATE, - }, - mel::{ - TenVadMelBank, - TenVadMelConfig, - TenVadMelMeta, - }, - pre_emphasis::{ - PreEmphasisConfig, - PreEmphasisContext, - }, - }, - }, - ops::signal::{ - SlidingStft, - SlidingStftConfig, - SlidingStftContext, - SlidingStftMeta, - }, -}; - -/// Common meta for [`TenVadFeatureConfig`], [`TenVadFeatures`], and -/// [`TenVadFeatureContext`]. -pub trait TenVadFeatureMeta { - /// The sample rate, in Hz, this front end expects. - fn sample_rate(&self) -> usize; - - /// The hop size, in samples; one hop yields one feature frame. - fn hop_size(&self) -> usize; - - /// The STFT analysis window length, in samples. - fn win_len(&self) -> usize; - - /// The FFT size the analysis window is zero-padded to. - fn fft_size(&self) -> usize; - - /// The number of frequency bins: `fft_size / 2 + 1`. - fn n_bins(&self) -> usize { - self.fft_size() / 2 + 1 - } - - /// The number of mel bands. - fn n_mels(&self) -> usize; - - /// The feature width: [`n_mels`](Self::n_mels) log-mel bands plus one - /// pitch bin. - fn n_freq(&self) -> usize { - self.n_mels() + 1 - } -} - -/// Config for [`TenVadFeatures`] and [`TenVadFeatureContext`]. -/// -/// Defaults match the ten-vad reference front end: 16 kHz, hop 256, a -/// 768-sample periodic Hann window over a 1024-point FFT, and 40 mel bands. -/// -/// Builds [`TenVadFeatures`] and [`TenVadFeatureContext`]. Implements -/// [`TenVadFeatureMeta`]. -#[derive(Config, Debug)] -pub struct TenVadFeatureConfig { - /// The sample rate, in Hz. - #[config(default = "SAMPLE_RATE")] - pub sample_rate: usize, - - /// The sliding STFT analyzer geometry. - /// - /// Its defaults are already the ten-vad analyzer - /// (`win_len = 768`, `hop_size = 256`, `fft_size = 1024`, periodic Hann). - #[config(default = "Default::default()")] - pub stft: SlidingStftConfig, - - /// The mel filterbank geometry. - #[config(default = "Default::default()")] - pub mel: TenVadMelConfig, - - /// The pre-emphasis filter applied to the STFT branch. - #[config(default = "Default::default()")] - pub pre_emphasis: PreEmphasisConfig, -} - -impl TenVadFeatureMeta for TenVadFeatureConfig { - fn sample_rate(&self) -> usize { - self.sample_rate - } - - fn hop_size(&self) -> usize { - self.stft.hop_size() - } - - fn win_len(&self) -> usize { - self.stft.win_len() - } - - fn fft_size(&self) -> usize { - self.stft.fft_size() - } - - fn n_mels(&self) -> usize { - self.mel.n_mels() - } -} - -impl TenVadFeatureConfig { - /// Validates the front-end geometry. - /// - /// # Errors - /// - /// [`BunsenError::Invalid`] if the STFT or mel geometry is itself invalid, - /// if the two disagree on the FFT size or bin count, if the mel config - /// disagrees with the sample rate, or if the feature width exceeds the - /// [`N_FREQ`] reference normalization entries. - /// - /// This deliberately does *not* pin the feature width to [`N_FREQ`]: the - /// front end is geometry-generic, and only the driver has to match the - /// pretrained model. Smaller geometries are useful for testing. - pub fn validate(&self) -> BunsenResult<()> { - self.stft.validate()?; - self.mel.validate()?; - - if self.stft.fft_size() != self.mel.fft_size { - return Err(BunsenError::Invalid(format!( - "TenVadFeature stft fft_size ({}) != mel fft_size ({})", - self.stft.fft_size(), - self.mel.fft_size, - ))); - } - if self.stft.n_bins() != self.mel.n_bins() { - return Err(BunsenError::Invalid(format!( - "TenVadFeature stft n_bins ({}) != mel n_bins ({})", - self.stft.n_bins(), - self.mel.n_bins(), - ))); - } - if self.sample_rate != self.mel.sample_rate { - return Err(BunsenError::Invalid(format!( - "TenVadFeature sample_rate ({}) != mel sample_rate ({})", - self.sample_rate, self.mel.sample_rate, - ))); - } - // The normalization tables are the reference's, so they bound the - // feature width from above. The exact match against the model is - // enforced where it belongs, in - // [`TenVad::init_context_with`](crate::kits::speech::ten_vad::TenVad::init_context_with). - if self.n_freq() > N_FREQ { - return Err(BunsenError::Invalid(format!( - "TenVadFeature n_freq ({}) exceeds the {N_FREQ} reference \ - normalization entries", - self.n_freq(), - ))); - } - Ok(()) - } - - /// Initializes the fixed analysis coefficients on `device`. - /// - /// # Errors - /// - /// See [`validate`](Self::validate). - pub fn try_init( - &self, - device: &B::Device, - ) -> BunsenResult> { - self.validate()?; - - let n_freq = self.n_freq(); - - // Precompute the reciprocal so the per-frame path is a multiply. - let stds_recip: Vec = FEATURE_STDS[..n_freq] - .iter() - .map(|&s| 1.0 / (s + FEATURE_EPS)) - .collect(); - - Ok(TenVadFeatures { - sample_rate: self.sample_rate, - stft: self.stft.try_init(device)?, - mel: self.mel.try_init(device)?, - pre_emphasis: self.pre_emphasis, - means: Tensor::from_data(TensorData::from(&FEATURE_MEANS[..n_freq]), device), - stds_recip: Tensor::from_data(TensorData::new(stds_recip, [n_freq]), device), - }) - } - - /// Initializes the fixed analysis coefficients, panicking on error. - pub fn init( - &self, - device: &B::Device, - ) -> TenVadFeatures { - self.try_init(device).ok_or_panic() - } - - /// Initializes a streaming [`TenVadFeatureContext`] over `batch_size` - /// independent streams. - /// - /// # Arguments - /// * `batch_size`: the number of independent streams; must be non-zero. - /// * `pitch`: builds the pitch source; see [`PitchSourceInit`]. - /// - /// # Errors - /// - /// See [`validate`](Self::validate), plus anything the pitch source's - /// [`try_init_source`](PitchSourceInit::try_init_source) reports. - pub fn try_init_context>( - &self, - batch_size: usize, - pitch: I, - device: &B::Device, - ) -> BunsenResult> { - self.try_init(device)?.try_init_state(batch_size, pitch) - } -} - -/// The fixed ten-vad feature-extraction coefficients. -/// -/// Holds the STFT analysis window, the mel filterbank, and the normalization -/// tables. Stateless, so one instance can be shared by (or cheaply cloned -/// into) any number of streams. This is deliberately **not** a burn `Module`: -/// nothing here is a learnable parameter. -/// -/// Built by [`TenVadFeatureConfig`]. Implements [`TenVadFeatureMeta`]. -/// Streaming states are built by [`init_state`](Self::init_state). -#[derive(Debug, Clone)] -pub struct TenVadFeatures { - sample_rate: usize, - pre_emphasis: PreEmphasisConfig, - - /// The sliding STFT analysis coefficients. - pub stft: SlidingStft, - - /// The mel filterbank. - pub mel: TenVadMelBank, - - /// The `[n_freq]` per-feature means. - pub means: Tensor, - - /// The `[n_freq]` per-feature reciprocal standard deviations, - /// `1 / (std + eps)`. - pub stds_recip: Tensor, -} - -impl TenVadFeatureMeta for TenVadFeatures { - fn sample_rate(&self) -> usize { - self.sample_rate - } - - fn hop_size(&self) -> usize { - self.stft.hop_size() - } - - fn win_len(&self) -> usize { - self.stft.win_len() - } - - fn fft_size(&self) -> usize { - self.stft.fft_size() - } - - fn n_mels(&self) -> usize { - self.mel.n_mels() - } -} - -impl TenVadFeatures { - /// Builds a [`TenVadFeatureContext`] streaming state over these - /// coefficients. - /// - /// # Arguments - /// * `batch_size`: the number of independent streams; must be non-zero. - /// * `pitch`: builds the pitch source; see [`PitchSourceInit`]. - /// - /// # Errors - /// [`BunsenError::Invalid`] if `batch_size` is zero, plus anything the - /// pitch source's - /// [`try_init_source`](PitchSourceInit::try_init_source) reports. - pub fn try_init_state>( - &self, - batch_size: usize, - pitch: I, - ) -> BunsenResult> { - if batch_size == 0 { - return Err(BunsenError::Invalid( - "TenVadFeatures batch_size must be non-zero".to_string(), - )); - } - let device = self.means.device(); - Ok(TenVadFeatureContext { - stft: self.stft.init_state(batch_size), - pre_emphasis: self.pre_emphasis.init(batch_size, &device), - pitch: pitch.try_init_source(batch_size, &device)?, - coef: self.clone(), - }) - } - - /// Builds a [`TenVadFeatureContext`], panicking on error. - /// - /// See [`try_init_state`](Self::try_init_state). - pub fn init_state>( - &self, - batch_size: usize, - pitch: I, - ) -> TenVadFeatureContext { - self.try_init_state(batch_size, pitch).ok_or_panic() - } -} - -/// Streaming ten-vad feature-extraction state. -/// -/// Binds the pre-emphasis carry, the sliding STFT queue, and one -/// [`PitchSource`] per stream to a [`TenVadFeatures`]. -/// -/// At stream start every buffer is zero, so — as in the reference — the first -/// couple of frames see partially zero-padded analysis windows. -/// -/// Built by [`TenVadFeatures::init_state`]. Implements [`TenVadFeatureMeta`]. -#[derive(Debug, Clone)] -pub struct TenVadFeatureContext = ZeroPitch> { - /// The fixed analysis coefficients. - pub coef: TenVadFeatures, - - /// The sliding STFT queue, over the pre-emphasized signal. - pub stft: SlidingStftContext, - - /// The pre-emphasis carry. - pub pre_emphasis: PreEmphasisContext, - - /// The pitch source, covering every stream in the batch. - pub pitch: P, -} - -impl> TenVadFeatureMeta for TenVadFeatureContext { - fn sample_rate(&self) -> usize { - self.coef.sample_rate() - } - - fn hop_size(&self) -> usize { - self.coef.hop_size() - } - - fn win_len(&self) -> usize { - self.coef.win_len() - } - - fn fft_size(&self) -> usize { - self.coef.fft_size() - } - - fn n_mels(&self) -> usize { - self.coef.n_mels() - } -} - -impl> TenVadFeatureContext { - /// The batch size; each batch row is an independent stream. - pub fn batch_size(&self) -> usize { - self.stft.batch_size() - } - - /// Resets every streaming buffer to the start-of-stream condition. - pub fn reset(&mut self) { - self.stft.reset(); - self.pre_emphasis.reset(); - self.pitch.reset(); - } - - /// Extracts the feature frame for one hop. - /// - /// # Arguments - /// * `hop`: `[batch, hop_size]` mono audio in `[-1, 1]`. - /// - /// # Returns - /// `[batch, n_freq]` normalized features. - pub fn forward( - &mut self, - hop: Tensor, - ) -> Tensor { - #[cfg(any(test, debug_assertions))] - crate::contracts::assert_shape_contract!( - ["batch", "hop_size"], - &hop, - &[("batch", self.batch_size()), ("hop_size", self.hop_size()),], - ); - - // The reference runs at int16 scale; bunsen takes unit-range audio. - let raw = hop.mul_scalar(INPUT_SCALE); - - // Pre-emphasis feeds the STFT branch only; pitch sees `raw`. - let emph = self.pre_emphasis.forward(raw.clone()); - - // [batch, n_bins] - let bin_power = self.stft.forward(emph).square().sum_dim(2).squeeze_dim(2); - - // Pitch reads the raw hop and the *un-normalized* bin power. - // [batch, 1] - let pitch = self.pitch.forward(raw, bin_power.clone()); - - self.finish_frame(bin_power, pitch) - } - - /// Extracts `steps` consecutive feature frames at once. - /// - /// Equivalent to `steps` calls of [`forward`](Self::forward), but the - /// whole hop stream is pre-emphasized and analyzed in one pass — a single - /// `stft` call for the sequence, and one matmul for the filterbank. - /// - /// # Arguments - /// * `hops`: `[steps, batch, hop_size]` consecutive mono audio hops in - /// `[-1, 1]`. - /// - /// # Returns - /// `[steps, batch, n_freq]` normalized features. - pub fn forward_sequence( - &mut self, - hops: Tensor, - ) -> Tensor { - #[cfg(any(test, debug_assertions))] - let [steps] = crate::contracts::unpack_shape_contract!( - ["steps", "batch", "hop_size"], - &hops, - &["steps"], - &[("batch", self.batch_size()), ("hop_size", self.hop_size()),], - ); - #[cfg(not(any(test, debug_assertions)))] - let steps = hops.dims()[0]; - - let batch = self.batch_size(); - let n_freq = self.n_freq(); - - let raw = hops.mul_scalar(INPUT_SCALE); - let emph = self.pre_emphasis.forward_sequence(raw.clone()); - - // [steps, batch, n_bins] - let bin_power = self - .stft - .forward_sequence(emph) - .square() - .sum_dim(3) - .squeeze_dim(3); - - // [steps, batch, 1] - let pitch = self.pitch.forward_sequence(raw, bin_power.clone()); - - // Fold the step axis into the row axis for the batched stages. - let feat = self.finish_frame( - bin_power.reshape([steps * batch, self.n_bins()]), - pitch.reshape([steps * batch, 1]), - ); - - feat.reshape([steps, batch, n_freq]) - } - - /// Uploads host audio and extracts its feature frames. - /// - /// The convenience form of [`forward_sequence`](Self::forward_sequence) - /// for callers whose audio is already host-side, which is the usual case - /// for a file or a capture stream. The device is taken from the context, - /// so the caller never names one. - /// - /// # Arguments - /// * `audio`: one row per stream, each a whole number of hops of mono audio - /// in `[-1, 1]`. Every row must be the same length. - /// - /// # Returns - /// `[steps, batch, n_freq]` normalized features. - /// - /// # Errors - /// [`BunsenError::Invalid`] if the row count disagrees with the batch - /// size, if the rows differ in length, or if a row is empty or not a whole - /// number of hops. - pub fn forward_audio_sequence( - &mut self, - audio: &[&[f32]], - ) -> BunsenResult> { - let batch = self.batch_size(); - let hop_size = self.hop_size(); - - if audio.len() != batch { - return Err(BunsenError::Invalid(format!( - "TenVadFeatureContext expected {batch} audio rows, got {}", - audio.len(), - ))); - } - - let samples = audio[0].len(); - if let Some(bad) = audio.iter().position(|row| row.len() != samples) { - return Err(BunsenError::Invalid(format!( - "TenVadFeatureContext audio rows must be equal length; row {bad} has {} \ - samples, row 0 has {samples}", - audio[bad].len(), - ))); - } - if samples == 0 || !samples.is_multiple_of(hop_size) { - return Err(BunsenError::Invalid(format!( - "TenVadFeatureContext audio length ({samples}) must be a non-zero multiple \ - of the hop size ({hop_size})", - ))); - } - - let steps = samples / hop_size; - - // Interleave into the `[steps, batch, hop_size]` the sequence path - // wants; the caller's rows are per-stream contiguous. - let mut flat = Vec::with_capacity(steps * batch * hop_size); - for step in 0..steps { - for row in audio { - flat.extend_from_slice(&row[step * hop_size..(step + 1) * hop_size]); - } - } - - let device = self.coef.means.device(); - let hops = Tensor::from_data(TensorData::new(flat, [steps, batch, hop_size]), &device); - - Ok(self.forward_sequence(hops)) - } - - /// The shared tail of the per-frame pipeline: power normalization, mel - /// filterbank, log, pitch concatenation, and standardization. - /// - /// # Arguments - /// * `bin_power`: `[rows, n_bins]` un-normalized bin powers. - /// * `pitch`: `[rows, 1]` pitch estimates, in Hz. - /// - /// # Returns - /// `[rows, n_freq]` normalized features. - fn finish_frame( - &self, - bin_power: Tensor, - pitch: Tensor, - ) -> Tensor { - // The division precedes the filterbank matmul, as in the reference; - // the two commute algebraically but not in f32. - let mel_power = self.coef.mel.forward(bin_power.div_scalar(POWER_NORMAL)); - - // [rows, n_mels] - let log_mel = mel_power.add_scalar(FEATURE_EPS).log(); - - // [rows, n_freq] - let feat = Tensor::cat(vec![log_mel, pitch], 1); - - (feat - self.coef.means.clone().unsqueeze::<2>()) - * self.coef.stds_recip.clone().unsqueeze::<2>() - } -} - -#[cfg(test)] -mod tests { - use burn::tensor::{ - Distribution, - Tolerance, - backend::BackendTypes, - }; - - use super::*; - use crate::{ - kits::speech::{ - pitch::{ - HostPitch, - HostPitchEstimator, - HostPitchInit, - PitchScalarSource, - }, - ten_vad::context::coeff::N_MELS, - }, - ops::signal::{ - SamplingWindowBuilder, - StftWindowConfig, - }, - prelude::*, - support::testing::PerformanceBackend, - }; - - type B = PerformanceBackend; - - /// Feature `40` under [`ZeroPitch`]: an unvoiced frame, normalized. - /// - /// The normalization is the feature path's business, not the pitch - /// estimator's, so it lives here rather than on `ZeroPitch`. - fn zero_pitch_feature() -> f32 { - (0.0 - FEATURE_MEANS[N_MELS]) / (FEATURE_STDS[N_MELS] + FEATURE_EPS) - } - type F = ::FloatElem; - - /// A small geometry whose naive-DFT reference is cheap to evaluate. - /// - /// The feature width (7) is below [`N_FREQ`], so it exercises the same - /// code path with hand-checkable numbers. - fn tiny_config() -> TenVadFeatureConfig { - TenVadFeatureConfig::new() - .with_stft( - SlidingStftConfig::new() - .with_win_len(48) - .with_hop_size(16) - .with_fft_size(64), - ) - .with_mel(TenVadMelConfig::new().with_n_mels(6).with_fft_size(64)) - } - - #[test] - fn test_config_meta() { - let cfg = TenVadFeatureConfig::new(); - - assert_eq!(cfg.sample_rate(), 16000); - assert_eq!(cfg.hop_size(), 256); - assert_eq!(cfg.win_len(), 768); - assert_eq!(cfg.fft_size(), 1024); - assert_eq!(cfg.n_bins(), 513); - assert_eq!(cfg.n_mels(), 40); - assert_eq!(cfg.n_freq(), 41); - assert_eq!(cfg.n_freq(), N_FREQ); - - cfg.validate().unwrap(); - tiny_config().validate().unwrap(); - } - - #[test] - fn test_stft_window_matches_the_reference_table() { - // The reference ships a 768-entry Hann table in `coeff.h`. We generate - // it instead, from `StftWindowConfig::Hann { periodic: true }`; this - // pins that the generated window really is that table. - let window = StftWindowConfig::Hann { periodic: true }.to_vec_window(768); - assert_eq!(window.len(), 768); - - // Anchors read straight out of the reference table. - for (n, expected) in [ - (0usize, 0.0000000e+00f64), - (1, 1.6733041e-05), - (2, 6.6931045e-05), - (96, 1.4644661e-01), - (192, 5.0000000e-01), - (384, 1.0000000e+00), - (576, 5.0000000e-01), - (700, 7.5398909e-02), - (766, 6.6931045e-05), - (767, 1.6733041e-05), - ] { - assert!( - (window[n] - expected).abs() < 1e-7, - "window[{n}]: {} vs {expected}", - window[n], - ); - } - - // A periodic window is symmetric about its midpoint, unlike the - // symmetric variant; getting this wrong shifts every spectrum. - for n in 1..768 { - assert!((window[n] - window[768 - n]).abs() < 1e-9); - } - } - - #[test] - fn test_validate_rejects_bad_geometry() { - for bad in [ - // STFT and mel disagree on the FFT size. - TenVadFeatureConfig::new().with_mel(TenVadMelConfig::new().with_fft_size(512)), - // The mel config disagrees with the declared sample rate. - TenVadFeatureConfig::new().with_sample_rate(8000), - // Too many bands for the reference normalization tables. - TenVadFeatureConfig::new().with_mel(TenVadMelConfig::new().with_n_mels(64)), - // A structurally invalid STFT is rejected by delegation. - TenVadFeatureConfig::new().with_stft(SlidingStftConfig::new().with_hop_size(0)), - ] { - assert!( - matches!(bad.validate(), Err(BunsenError::Invalid(_))), - "expected Invalid: {bad:?}", - ); - } - } - - #[test] - fn test_init_meta_matches_config() { - let device = Default::default(); - let cfg = TenVadFeatureConfig::new(); - - let coef: TenVadFeatures = cfg.init(&device); - assert_eq!(coef.sample_rate(), cfg.sample_rate()); - assert_eq!(coef.hop_size(), cfg.hop_size()); - assert_eq!(coef.win_len(), cfg.win_len()); - assert_eq!(coef.fft_size(), cfg.fft_size()); - assert_eq!(coef.n_bins(), cfg.n_bins()); - assert_eq!(coef.n_mels(), cfg.n_mels()); - assert_eq!(coef.n_freq(), cfg.n_freq()); - - assert_eq!(coef.means.dims(), [41]); - assert_eq!(coef.stds_recip.dims(), [41]); - - let ctx = coef.init_state(2, ZeroPitch); - assert_eq!(ctx.batch_size(), 2); - assert_eq!(ctx.n_freq(), 41); - assert_eq!(ctx.stft.batch_size(), 2); - assert_eq!(ctx.pre_emphasis.batch_size(), 2); - assert_eq!(ctx.pitch, ZeroPitch); - } - - #[test] - fn test_stds_recip_is_the_normalization_divisor() { - let device = Default::default(); - let coef: TenVadFeatures = TenVadFeatureConfig::new().init(&device); - - let host: Vec = coef - .stds_recip - .to_data_as::() - .to_vec_as::() - .unwrap(); - for (i, &r) in host.iter().enumerate() { - let expected = 1.0 / (FEATURE_STDS[i] + FEATURE_EPS); - assert!((r - expected).abs() < 1e-6, "stds_recip[{i}]"); - } - } - - #[test] - #[should_panic(expected = "batch_size must be non-zero")] - fn test_init_state_rejects_zero_batch() { - let device = Default::default(); - let coef: TenVadFeatures = TenVadFeatureConfig::new().init(&device); - let _ = coef.init_state(0, ZeroPitch); - } - - #[test] - fn test_silence_hits_the_log_floor() { - // Digital silence drives every mel band to zero, so the log floor - // `ln(0 + FEATURE_EPS)` is what reaches the normalization. This pins - // the epsilon, the standardization, and the pitch column's position - // all at once. - let device = Default::default(); - let cfg = TenVadFeatureConfig::new(); - let mut ctx: TenVadFeatureContext = - cfg.try_init_context(1, ZeroPitch, &device).unwrap(); - - let hop = Tensor::::zeros([1, cfg.hop_size()], &device); - let feats = ctx.forward(hop); - assert_eq!(feats.dims(), [1, 41]); - - let host: Vec = feats.to_data_as::().to_vec_as::().unwrap(); - - let floor = FEATURE_EPS.ln(); - for i in 0..N_MELS { - let expected = (floor - FEATURE_MEANS[i]) / (FEATURE_STDS[i] + FEATURE_EPS); - assert!( - (host[i] - expected).abs() < 1e-4, - "silence feature {i}: {} vs {expected}", - host[i], - ); - } - - // Feature 40 is the pitch bin; under ZeroPitch it is the constant. - assert!( - (host[N_MELS] - zero_pitch_feature()).abs() < 1e-5, - "pitch feature: {} vs {}", - host[N_MELS], - zero_pitch_feature(), - ); - } - - #[test] - fn test_input_gain_shifts_log_mel_by_two_log_k() { - // The mel path is `ln(|k * X|^2 / c) = ln(|X|^2 / c) + 2 ln k`, so - // scaling the input shifts every normalized mel feature by a known - // amount. This pins that the log wraps the *power* and that the - // standardization is a plain affine map applied afterwards. - let device = Default::default(); - let cfg = TenVadFeatureConfig::new(); - let k = 4.0f32; - - // Loud enough that the epsilon floor is irrelevant. - let hop = Tensor::::random( - [1, cfg.hop_size()], - Distribution::Uniform(-0.5, 0.5), - &device, - ); - - let mut base: TenVadFeatureContext = - cfg.try_init_context(1, ZeroPitch, &device).unwrap(); - let mut scaled: TenVadFeatureContext = - cfg.try_init_context(1, ZeroPitch, &device).unwrap(); - - let a: Vec = base - .forward(hop.clone()) - .to_data_as::() - .to_vec_as::() - .unwrap(); - let b: Vec = scaled - .forward(hop.mul_scalar(k)) - .to_data_as::() - .to_vec_as::() - .unwrap(); - - let shift = 2.0 * k.ln(); - for i in 0..N_MELS { - let expected = shift / (FEATURE_STDS[i] + FEATURE_EPS); - assert!( - ((b[i] - a[i]) - expected).abs() < 1e-3, - "band {i}: delta {} vs {expected}", - b[i] - a[i], - ); - } - - // The pitch bin is scale-invariant under ZeroPitch. - assert!((b[N_MELS] - a[N_MELS]).abs() < 1e-6); - } - - /// A fully independent host reference for the whole front end. - /// - /// Deliberately written from the reference ordering rather than from the - /// tensor implementation: scale, pre-emphasize, slide the queue, window, - /// zero-pad, naive DFT, power, normalize, filterbank, log, standardize. - struct HostFeatures { - cfg: TenVadFeatureConfig, - window: Vec, - weights: Vec, - queue: Vec, - prev: f32, - } - - impl HostFeatures { - fn new(cfg: &TenVadFeatureConfig) -> Self { - Self { - window: cfg.stft.window.to_vec_window(cfg.win_len()), - weights: cfg.mel.to_vec_weights(), - queue: vec![0.0; cfg.win_len()], - prev: 0.0, - cfg: cfg.clone(), - } - } - - fn push( - &mut self, - hop: &[f32], - ) -> Vec { - let n_bins = self.cfg.n_bins(); - let n_mels = self.cfg.n_mels(); - let fft_size = self.cfg.fft_size(); - let win_len = self.cfg.win_len(); - - // 1. Scale to the reference's int16 range. - let raw: Vec = hop.iter().map(|&x| x * INPUT_SCALE).collect(); - - // 2. Pre-emphasis, on the STFT branch only. - let mut emph = Vec::with_capacity(raw.len()); - for (i, &x) in raw.iter().enumerate() { - let back = if i == 0 { self.prev } else { raw[i - 1] }; - emph.push(x - self.cfg.pre_emphasis.coeff * back); - } - self.prev = *raw.last().unwrap(); - - // 3. Slide the analysis queue. - self.queue.drain(..hop.len()); - self.queue.extend_from_slice(&emph); - - // 4. Window, zero-pad to fft_size, naive real DFT. - let mut bin_power = Vec::with_capacity(n_bins); - for k in 0..n_bins { - let (mut re, mut im) = (0.0f64, 0.0f64); - for n in 0..win_len { - let x = self.queue[n] as f64 * self.window[n]; - let theta = - core::f64::consts::TAU * ((n * k) % fft_size) as f64 / fft_size as f64; - re += x * theta.cos(); - im -= x * theta.sin(); - } - bin_power.push((re * re + im * im) as f32); - } - - // 5. Normalize the power, fold through the filterbank, log. - let mut feats = Vec::with_capacity(n_mels + 1); - for m in 0..n_mels { - let mut acc = 0.0f32; - for (j, &p) in bin_power.iter().enumerate() { - acc += (p / POWER_NORMAL) * self.weights[m * n_bins + j]; - } - feats.push((acc + FEATURE_EPS).ln()); - } - - // 6. The pitch bin, then standardization. - feats.push(0.0); - for (i, f) in feats.iter_mut().enumerate() { - *f = (*f - FEATURE_MEANS[i]) / (FEATURE_STDS[i] + FEATURE_EPS); - } - feats - } - } - - #[test] - fn test_forward_matches_independent_host_pipeline() { - let device = Default::default(); - let cfg = tiny_config(); - let hop_size = cfg.hop_size(); - let n_freq = cfg.n_freq(); - - let mut ctx: TenVadFeatureContext = - cfg.try_init_context(1, ZeroPitch, &device).unwrap(); - let mut host = HostFeatures::new(&cfg); - - // Several hops, so the warm-up frames and the steady state are both - // covered, and so the pre-emphasis carry is exercised. - for step in 0..5 { - let row: Vec = (0..hop_size) - .map(|i| { - let t = (step * hop_size + i) as f32; - 0.4 * (t * 0.11).sin() + 0.2 * (t * 0.37).cos() - }) - .collect(); - - let input = - Tensor::::from_data(TensorData::new(row.clone(), [1, hop_size]), &device); - let out = ctx.forward(input); - assert_eq!(out.dims(), [1, n_freq]); - - let expected = host.push(&row); - out.to_data_as::().assert_approx_eq::( - &TensorData::new(expected, [1, n_freq]).convert::(), - Tolerance::permissive(), - ); - } - } - - #[test] - fn test_forward_sequence_matches_stepwise() { - let device = Default::default(); - - for cfg in [tiny_config(), TenVadFeatureConfig::new()] { - let steps = 6; - let batch = 2; - let hop_size = cfg.hop_size(); - - let hops = - Tensor::::random([steps, batch, hop_size], Distribution::Default, &device); - - let mut seq_ctx: TenVadFeatureContext = - cfg.try_init_context(batch, ZeroPitch, &device).unwrap(); - let mut step_ctx = seq_ctx.clone(); - - let seq_out = seq_ctx.forward_sequence(hops.clone()); - assert_eq!(seq_out.dims(), [steps, batch, cfg.n_freq()]); - - let mut step_outs = Vec::with_capacity(steps); - for step in 0..steps { - step_outs.push(step_ctx.forward(hops.clone().select_dim::<2>(0, step))); - } - let step_out: Tensor = Tensor::stack(step_outs, 0); - - let tol = Tolerance::::permissive(); - seq_out - .to_data_as::() - .assert_approx_eq::(&step_out.to_data_as::(), tol); - - // Residual state must agree, or the next call diverges. - seq_ctx - .stft - .queue - .to_data_as::() - .assert_approx_eq::(&step_ctx.stft.queue.to_data_as::(), tol); - seq_ctx - .pre_emphasis - .prev - .to_data_as::() - .assert_approx_eq::(&step_ctx.pre_emphasis.prev.to_data_as::(), tol); - } - } - - /// A 16 kHz pulse train at `f0`, in `[-1, 1]` — structured enough that the - /// pitch branch actually tracks something. - fn pulse_audio( - f0: f32, - samples: usize, - ) -> Vec { - let period = 16000.0 / f0; - (0..samples) - .map(|i| { - let pos = i as f32 % period; - 0.25 * (-pos / (period * 0.08)).exp() - }) - .collect() - } - - #[test] - fn test_forward_sequence_matches_stepwise_with_estimator() { - // The ZeroPitch arm of `test_forward_sequence_matches_stepwise` cannot - // see the pitch branch at all. This is the arm that does: the source - // carries a per-stream recurrence, so the sequence path has to walk it - // in exactly the stepwise order and leave identical residual state. - let device = Default::default(); - let cfg = TenVadFeatureConfig::new(); - let hop_size = cfg.hop_size(); - let steps = 8; - let batch = 2; - - let mut flat = Vec::with_capacity(steps * batch * hop_size); - let rows: Vec> = (0..batch) - .map(|b| pulse_audio(140.0 + 30.0 * b as f32, steps * hop_size)) - .collect(); - for step in 0..steps { - for row in &rows { - flat.extend_from_slice(&row[step * hop_size..(step + 1) * hop_size]); - } - } - let hops = - Tensor::::from_data(TensorData::new(flat, [steps, batch, hop_size]), &device); - - let mut seq_ctx: TenVadFeatureContext> = cfg - .try_init_context(batch, HostPitchEstimator::new(), &device) - .unwrap(); - let mut step_ctx = seq_ctx.clone(); - - let seq_out = seq_ctx.forward_sequence(hops.clone()); - let mut step_outs = Vec::with_capacity(steps); - for step in 0..steps { - step_outs.push(step_ctx.forward(hops.clone().select_dim::<2>(0, step))); - } - let step_out: Tensor = Tensor::stack(step_outs, 0); - - seq_out - .to_data_as::() - .assert_approx_eq::(&step_out.to_data_as::(), Tolerance::permissive()); - - // The pitch branch's residual state has to agree, not just its output. - for b in 0..batch { - let seq = &seq_ctx.pitch.sources[b]; - let step = &step_ctx.pitch.sources[b]; - assert_eq!(seq.exc_buf(), step.exc_buf(), "row {b} excitation history"); - assert_eq!(seq.path_score(), step.path_score(), "row {b} tracker state"); - assert_eq!(seq.best_period(), step.best_period(), "row {b} best period"); - } - - // Guard the guard: a tracker that never ran would compare equal too. - assert!( - seq_ctx.pitch.sources[0] - .path_score() - .iter() - .any(|v| *v < -1e-6), - "the tracker should have accumulated over 8 hops", - ); - } - - #[test] - fn test_batch_rows_are_independent_with_estimator() { - // With `Vec

` per-row isolation was structural. With a batch-aware - // source it is a property that has to be tested. - let device = Default::default(); - let cfg = TenVadFeatureConfig::new(); - let hop_size = cfg.hop_size(); - let steps = 6; - let batch = 3; - - let rows: Vec> = (0..batch) - .map(|b| pulse_audio(120.0 + 40.0 * b as f32, steps * hop_size)) - .collect(); - let row_refs: Vec<&[f32]> = rows.iter().map(|r| r.as_slice()).collect(); - - let mut batched: TenVadFeatureContext> = cfg - .try_init_context(batch, HostPitchEstimator::new(), &device) - .unwrap(); - let batched_out = batched.forward_audio_sequence(&row_refs).unwrap(); - - for (b, row) in row_refs.iter().enumerate() { - let mut solo: TenVadFeatureContext> = cfg - .try_init_context(1, HostPitchEstimator::new(), &device) - .unwrap(); - let solo_out = solo.forward_audio_sequence(&[row]).unwrap(); - - let expected = batched_out - .clone() - .slice_dim(1, b as isize..(b + 1) as isize); - solo_out - .to_data_as::() - .assert_approx_eq::(&expected.to_data_as::(), Tolerance::permissive()); - } - } - - #[test] - fn test_forward_audio_sequence_matches_the_tensor_path() { - let device = Default::default(); - let cfg = TenVadFeatureConfig::new(); - let hop_size = cfg.hop_size(); - let steps = 5; - - let audio = pulse_audio(150.0, steps * hop_size); - - let mut via_audio: TenVadFeatureContext> = cfg - .try_init_context(1, HostPitchEstimator::new(), &device) - .unwrap(); - let from_audio = via_audio.forward_audio_sequence(&[&audio]).unwrap(); - - let mut via_tensor: TenVadFeatureContext> = cfg - .try_init_context(1, HostPitchEstimator::new(), &device) - .unwrap(); - let hops = - Tensor::::from_floats(audio.as_slice(), &device).reshape([steps, 1, hop_size]); - let from_tensor = via_tensor.forward_sequence(hops); - - from_audio - .to_data_as::() - .assert_eq(&from_tensor.to_data_as::(), true); - } - - #[test] - fn test_forward_audio_sequence_rejects_bad_input() { - let device = Default::default(); - let cfg = TenVadFeatureConfig::new(); - let hop_size = cfg.hop_size(); - - let mut ctx: TenVadFeatureContext> = cfg - .try_init_context(2, HostPitchEstimator::new(), &device) - .unwrap(); - - let good = vec![0.0f32; hop_size * 2]; - let short = vec![0.0f32; hop_size]; - let ragged = vec![0.0f32; hop_size + 7]; - - // Wrong row count. - assert!(ctx.forward_audio_sequence(&[&good]).is_err()); - // Rows of differing length. - assert!(ctx.forward_audio_sequence(&[&good, &short]).is_err()); - // Not a whole number of hops. - assert!(ctx.forward_audio_sequence(&[&ragged, &ragged]).is_err()); - // Empty. - assert!(ctx.forward_audio_sequence(&[&[], &[]]).is_err()); - // And the good case still works. - assert!(ctx.forward_audio_sequence(&[&good, &good]).is_ok()); - } - - #[test] - fn test_batch_rows_are_independent() { - let device = Default::default(); - let cfg = tiny_config(); - let hop_size = cfg.hop_size(); - let batch = 3; - - let hops = Tensor::::random([4, batch, hop_size], Distribution::Default, &device); - - let mut batched: TenVadFeatureContext = - cfg.try_init_context(batch, ZeroPitch, &device).unwrap(); - let batched_out = batched.forward_sequence(hops.clone()); - - for b in 0..batch { - let mut solo: TenVadFeatureContext = - cfg.try_init_context(1, ZeroPitch, &device).unwrap(); - // [steps, 1, hop_size] - let row = hops.clone().slice_dim(1, b as isize..(b + 1) as isize); - let solo_out = solo.forward_sequence(row); - - let expected = batched_out - .clone() - .slice_dim(1, b as isize..(b + 1) as isize); - solo_out - .to_data_as::() - .assert_approx_eq::(&expected.to_data_as::(), Tolerance::permissive()); - } - } - - #[test] - fn test_reset() { - let device = Default::default(); - let cfg = tiny_config(); - let hop_size = cfg.hop_size(); - - let mut ctx: TenVadFeatureContext = - cfg.try_init_context(1, ZeroPitch, &device).unwrap(); - - let hop = Tensor::::random([1, hop_size], Distribution::Default, &device); - - let first = ctx.forward(hop.clone()); - - // Advance the state, then wind it back. - ctx.forward(hop.clone()); - ctx.forward(hop.clone()); - ctx.reset(); - - let again = ctx.forward(hop); - again - .to_data_as::() - .assert_approx_eq::(&first.to_data_as::(), Tolerance::permissive()); - } - - #[test] - fn test_custom_pitch_source_reaches_feature_40() { - // A source that reports a fixed pitch must land, normalized, in the - // last feature slot -- and must be driven once per stream per frame. - #[derive(Clone, Default)] - struct FixedPitch { - hz: f32, - calls: usize, - } - - impl PitchScalarSource for FixedPitch { - fn frame_pitch( - &mut self, - _raw_hop: &[f32], - _bin_power: &[f32], - ) -> f32 { - self.calls += 1; - self.hz - } - - fn reset(&mut self) { - self.calls = 0; - } - } - - let device = Default::default(); - let cfg = TenVadFeatureConfig::new(); - let hz = 220.0f32; - - let mut ctx: TenVadFeatureContext> = cfg - .try_init_context(1, HostPitchInit(FixedPitch { hz, calls: 0 }), &device) - .unwrap(); - - let steps = 3; - let hops = Tensor::::zeros([steps, 1, cfg.hop_size()], &device); - let out = ctx.forward_sequence(hops); - - let host: Vec = out.to_data_as::().to_vec_as::().unwrap(); - let expected = (hz - FEATURE_MEANS[N_MELS]) / (FEATURE_STDS[N_MELS] + FEATURE_EPS); - - for step in 0..steps { - let got = host[step * cfg.n_freq() + N_MELS]; - assert!( - (got - expected).abs() < 1e-4, - "step {step} pitch feature: {got} vs {expected}", - ); - } - - // One call per frame, in order. - assert_eq!(ctx.pitch.sources[0].calls, steps); - } -} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/mel.rs b/crates/bunsen/src/kits/speech/ten_vad/context/mel.rs deleted file mode 100644 index 659d9917..00000000 --- a/crates/bunsen/src/kits/speech/ten_vad/context/mel.rs +++ /dev/null @@ -1,509 +0,0 @@ -//! # ten-vad mel filterbank. -//! -//! The 40-band triangular filterbank the ten-vad front end folds its 513 bin -//! powers through (`ALGO_TRACE.md` §3.6). -//! -//! The triangles are the ordinary construction, shared with -//! [`TriangularBankConfig`]. What is **not** ordinary is where the band edges -//! land, and that difference is enough that a librosa- or Slaney-style builder -//! will not reproduce this bank. -//! -//! **Edge mapping.** Band edges are equally spaced on the HTK mel scale, then -//! mapped to FFT bins by a truncating integer cast: -//! -//! ```text -//! mel = 2595 * log10(1 + hz / 700) -//! bin = (usize) ((fft_size + 1) * hz / sample_rate) -//! ``` -//! -//! Note both the `fft_size + 1` — the usual factor is `fft_size` — and that the -//! cast truncates rather than rounding. Together they move edges by up to a -//! bin, which at 1024 points over 16 kHz is 15.6 Hz: a large fraction of a low -//! band's width. -//! -//! Because the edges are integers, the slopes follow where the truncation -//! landed rather than the exact edge frequencies. That is a consequence of the -//! edges, not a separate deviation — handing integer edges to the shared -//! builder reproduces it exactly. -//! -//! The edge arithmetic is deliberately done in `f32`, matching the reference; -//! computing it in `f64` can round an edge across an integer boundary and -//! silently produce a different filterbank. -//! -//! **No area normalization.** Each filter peaks at exactly `1.0` regardless of -//! width (`BankNorm::Peak`), so wide bands accumulate more energy than narrow -//! ones. This is HTK's convention rather than Slaney's. -//! -//! The pieces are: -//! * [`TenVadMelConfig`] — the geometry. -//! * [`TenVadMelBank`] — the materialized `[n_mels, n_bins]` filter matrix; -//! built by [`TenVadMelConfig::try_init`]. - -use burn::{ - config::Config, - prelude::*, -}; - -use crate::{ - errors::{ - BunsenError, - BunsenResult, - WithOkOrPanic, - }, - kits::speech::ten_vad::context::coeff::{ - N_MELS, - SAMPLE_RATE, - }, - ops::signal::TriangularBankConfig, -}; - -/// Common meta for [`TenVadMelConfig`] and [`TenVadMelBank`]. -pub trait TenVadMelMeta { - /// The number of mel bands. - fn n_mels(&self) -> usize; - - /// The number of frequency bins consumed: `fft_size / 2 + 1`. - fn n_bins(&self) -> usize; -} - -/// Config for [`TenVadMelBank`]. -/// -/// Defaults match the ten-vad reference: 40 bands spanning 0 Hz to 8 kHz over -/// a 1024-point FFT at 16 kHz. -/// -/// Builds [`TenVadMelBank`]. Implements [`TenVadMelMeta`]. -#[derive(Config, Debug)] -pub struct TenVadMelConfig { - /// The number of mel bands. - #[config(default = "N_MELS")] - pub n_mels: usize, - - /// The FFT size the bin powers came from. - #[config(default = "1024")] - pub fft_size: usize, - - /// The sample rate, in Hz. - #[config(default = "SAMPLE_RATE")] - pub sample_rate: usize, - - /// The low edge of the filterbank, in Hz. - #[config(default = "0.0")] - pub f_min: f32, - - /// The high edge of the filterbank, in Hz. - #[config(default = "8000.0")] - pub f_max: f32, -} - -impl Default for TenVadMelConfig { - fn default() -> Self { - Self::new() - } -} - -impl TenVadMelMeta for TenVadMelConfig { - fn n_mels(&self) -> usize { - self.n_mels - } - - fn n_bins(&self) -> usize { - self.fft_size / 2 + 1 - } -} - -/// Converts a frequency, in Hz, to the HTK mel scale. -/// -/// `mel = 2595 * log10(1 + hz / 700)`, evaluated in `f32` to match the -/// reference. -pub fn hz_to_mel(hz: f32) -> f32 { - 2595.0f32 * (1.0f32 + hz / 700.0f32).log10() -} - -/// Converts an HTK mel value back to a frequency, in Hz. -/// -/// The inverse of [`hz_to_mel`], evaluated in `f32` to match the reference. -pub fn mel_to_hz(mel: f32) -> f32 { - 700.0f32 * (10.0f32.powf(mel / 2595.0f32) - 1.0f32) -} - -impl TenVadMelConfig { - /// The `n_mels + 2` triangular band edges, as FFT bin indices. - /// - /// Edges are equally spaced on the mel scale between - /// [`f_min`](Self::f_min) and [`f_max`](Self::f_max), then mapped to bins - /// by the reference's truncating `(fft_size + 1) * hz / sample_rate` cast. - /// - /// Band `i` rises from `edges[i]`, peaks at `edges[i + 1]`, and falls to - /// `edges[i + 2]`. - pub fn bin_edges(&self) -> Vec { - let low_mel = hz_to_mel(self.f_min); - let high_mel = hz_to_mel(self.f_max); - - let steps = self.n_mels + 1; - (0..=steps) - .map(|i| { - let mel = low_mel + (high_mel - low_mel) * i as f32 / steps as f32; - let hz = mel_to_hz(mel); - // Truncating, as in the reference; note the `fft_size + 1`. - ((self.fft_size + 1) as f32 * hz / self.sample_rate as f32) as usize - }) - .collect() - } - - /// Validates the filterbank geometry. - /// - /// # Errors - /// - /// [`BunsenError::Invalid`] if `n_mels` or `fft_size` is zero, if the - /// frequency range is not increasing, or if two adjacent band edges land - /// on the same FFT bin (which would make a triangle infinitely steep). - pub fn validate(&self) -> BunsenResult<()> { - if self.n_mels == 0 { - return Err(BunsenError::Invalid( - "TenVadMel n_mels must be non-zero".to_string(), - )); - } - if self.fft_size == 0 { - return Err(BunsenError::Invalid( - "TenVadMel fft_size must be non-zero".to_string(), - )); - } - if self.sample_rate == 0 { - return Err(BunsenError::Invalid( - "TenVadMel sample_rate must be non-zero".to_string(), - )); - } - // Written through `partial_cmp` so a NaN bound is rejected, rather - // than silently passing a negated comparison. - if !matches!( - self.f_max.partial_cmp(&self.f_min), - Some(core::cmp::Ordering::Greater), - ) { - return Err(BunsenError::Invalid(format!( - "TenVadMel f_max ({}) must be > f_min ({})", - self.f_max, self.f_min, - ))); - } - - let edges = self.bin_edges(); - for i in 1..edges.len() { - if edges[i] == edges[i - 1] { - return Err(BunsenError::Invalid(format!( - "TenVadMel band edges {} and {} both land on bin {}; \ - the geometry is too coarse for {} bands", - i - 1, - i, - edges[i], - self.n_mels, - ))); - } - } - Ok(()) - } - - /// The `[n_mels, n_bins]` filter matrix, as a host-side row-major vector. - /// - /// Exposed so callers (and tests) can inspect the coefficients without a - /// device round trip. - pub fn to_vec_weights(&self) -> Vec { - // The triangles themselves are the ordinary construction, so they come - // from the shared builder. What is *not* ordinary is where the edges - // land -- see [`bin_edges`](Self::bin_edges) and the module docs -- and - // that is the only ten-vad-specific part left here. - let edges: Vec = self.bin_edges().iter().map(|e| *e as f32).collect(); - TriangularBankConfig::new(self.n_bins()).to_vec_weights(&edges) - } - - /// Initializes a [`TenVadMelBank`] on `device`. - /// - /// # Errors - /// - /// See [`validate`](Self::validate). - pub fn try_init( - &self, - device: &B::Device, - ) -> BunsenResult> { - self.validate()?; - - let weights = Tensor::from_data( - TensorData::new(self.to_vec_weights(), [self.n_mels, self.n_bins()]), - device, - ); - - Ok(TenVadMelBank { weights }) - } - - /// Initializes a [`TenVadMelBank`] on `device`, panicking on error. - pub fn init( - &self, - device: &B::Device, - ) -> TenVadMelBank { - self.try_init(device).ok_or_panic() - } -} - -/// The materialized ten-vad mel filterbank. -/// -/// Holds the dense `[n_mels, n_bins]` filter matrix. Stateless, so one -/// instance can be shared by (or cheaply cloned into) any number of streams. -/// This is deliberately **not** a burn `Module`: nothing here is a learnable -/// parameter, and the coefficients are fixed by the pretrained weights. -/// -/// Built by [`TenVadMelConfig`]. Implements [`TenVadMelMeta`]. -#[derive(Debug, Clone)] -pub struct TenVadMelBank { - /// The `[n_mels, n_bins]` triangular filter matrix. - pub weights: Tensor, -} - -impl TenVadMelMeta for TenVadMelBank { - fn n_mels(&self) -> usize { - self.weights.dims()[0] - } - - fn n_bins(&self) -> usize { - self.weights.dims()[1] - } -} - -impl TenVadMelBank { - /// Folds bin powers into mel band energies. - /// - /// # Arguments - /// * `bin_power`: `[rows, n_bins]` non-negative bin powers. The driver - /// passes a `[steps * batch, n_bins]` flatten, so `rows` is whatever - /// leading extent the caller has collapsed. - /// - /// # Returns - /// `[rows, n_mels]` band energies. - pub fn forward( - &self, - bin_power: Tensor, - ) -> Tensor { - #[cfg(any(test, debug_assertions))] - crate::contracts::assert_shape_contract!( - ["rows", "n_bins"], - &bin_power, - &[("n_bins", self.n_bins())], - ); - - bin_power.matmul(self.weights.clone().transpose()) - } -} - -#[cfg(test)] -mod tests { - use burn::tensor::{ - Distribution, - Tolerance, - backend::BackendTypes, - }; - - use super::*; - use crate::{ - prelude::*, - support::testing::PerformanceBackend, - }; - - type B = PerformanceBackend; - type F = ::FloatElem; - - /// The reference band edges, in FFT bins, for the stock ten-vad geometry. - /// - /// Independently recomputed from the reference formula in `f32`; these are - /// the numbers the pretrained weights were trained against. - const REFERENCE_EDGES: [usize; 42] = [ - 0, 2, 5, 9, 12, 16, 19, 24, 28, 33, 38, 43, 48, 54, 61, 67, 75, 82, 90, 99, 108, 118, 128, - 139, 151, 163, 176, 190, 205, 221, 238, 256, 275, 296, 317, 340, 365, 391, 418, 448, 479, - 512, - ]; - - #[test] - fn test_config_meta() { - let cfg = TenVadMelConfig::new(); - assert_eq!(cfg.n_mels, 40); - assert_eq!(cfg.fft_size, 1024); - assert_eq!(cfg.sample_rate, 16000); - assert_eq!(cfg.f_min, 0.0); - assert_eq!(cfg.f_max, 8000.0); - - assert_eq!(cfg.n_mels(), 40); - assert_eq!(cfg.n_bins(), 513); - - cfg.validate().unwrap(); - } - - #[test] - fn test_mel_scale_round_trips() { - // The HTK mel scale, anchored at its defining points. - assert_eq!(hz_to_mel(0.0), 0.0); - assert_eq!(mel_to_hz(0.0), 0.0); - - // 700 Hz is one doubling of the `1 + hz/700` term: 2595 * log10(2). - assert!((hz_to_mel(700.0) - 2595.0 * 2.0f32.log10()).abs() < 1e-2); - - for hz in [100.0f32, 700.0, 1000.0, 4000.0, 8000.0] { - let back = mel_to_hz(hz_to_mel(hz)); - assert!((back - hz).abs() < 1e-2, "{hz} -> {back}"); - } - } - - #[test] - fn test_bin_edges_match_the_reference() { - let edges = TenVadMelConfig::new().bin_edges(); - assert_eq!(edges.len(), 42, "n_mels + 2 edges"); - assert_eq!(edges.as_slice(), REFERENCE_EDGES.as_slice()); - - // The bank spans the whole spectrum, exactly: bin 0 through bin 512. - assert_eq!(edges[0], 0); - assert_eq!(*edges.last().unwrap(), 512); - assert_eq!(*edges.last().unwrap(), TenVadMelConfig::new().n_bins() - 1); - - // Strictly increasing, so no triangle is degenerate. - for i in 1..edges.len() { - assert!(edges[i] > edges[i - 1], "edge {i} did not advance"); - } - } - - #[test] - fn test_weights_shape_and_range() { - let cfg = TenVadMelConfig::new(); - let weights = cfg.to_vec_weights(); - assert_eq!(weights.len(), 40 * 513); - - for (i, &w) in weights.iter().enumerate() { - assert!((0.0..=1.0).contains(&w), "weight[{i}] = {w} outside [0, 1]",); - } - - // 949 non-zero coefficients across the bank; a change here means the - // triangle geometry moved. - assert_eq!(weights.iter().filter(|&&w| w > 0.0).count(), 949); - } - - #[test] - fn test_each_filter_peaks_at_exactly_one() { - // The filters are *not* area-normalized: each rises 0 -> 1 and falls - // 1 -> 0, peaking at its centre edge. - let cfg = TenVadMelConfig::new(); - let edges = cfg.bin_edges(); - let weights = cfg.to_vec_weights(); - let n_bins = cfg.n_bins(); - - for i in 0..cfg.n_mels { - let row = &weights[i * n_bins..(i + 1) * n_bins]; - let peak = row.iter().copied().fold(0.0f32, f32::max); - assert_eq!(peak, 1.0, "filter {i} peak"); - assert_eq!(row[edges[i + 1]], 1.0, "filter {i} peak position"); - } - } - - #[test] - fn test_first_filter_coefficients() { - // Band 0 spans edges (0, 2, 5): it rises across bins 0..2 and falls - // across bins 2..5, so the exact triangle is checkable by hand. - let cfg = TenVadMelConfig::new(); - let weights = cfg.to_vec_weights(); - - let expected = [0.0, 0.5, 1.0, 2.0 / 3.0, 1.0 / 3.0, 0.0]; - for (j, &e) in expected.iter().enumerate() { - assert!( - (weights[j] - e).abs() < 1e-6, - "band 0 bin {j}: {} vs {e}", - weights[j], - ); - } - } - - #[test] - fn test_validate_rejects_bad_geometry() { - for bad in [ - TenVadMelConfig::new().with_n_mels(0), - TenVadMelConfig::new().with_fft_size(0), - TenVadMelConfig::new().with_sample_rate(0), - // f_max must exceed f_min. - TenVadMelConfig::new().with_f_max(0.0), - TenVadMelConfig::new().with_f_min(9000.0), - // Far too few bins to separate 40 bands: edges collide. - TenVadMelConfig::new().with_fft_size(64), - ] { - assert!( - matches!(bad.validate(), Err(BunsenError::Invalid(_))), - "expected Invalid: {bad:?}", - ); - } - } - - #[test] - fn test_init_meta_matches_config() { - let device = Default::default(); - let cfg = TenVadMelConfig::new(); - let bank: TenVadMelBank = cfg.init(&device); - - assert_eq!(bank.n_mels(), cfg.n_mels()); - assert_eq!(bank.n_bins(), cfg.n_bins()); - assert_eq!(bank.weights.dims(), [40, 513]); - - // The device matrix matches the host construction elementwise. - bank.weights.to_data_as::().assert_approx_eq::( - &TensorData::new(cfg.to_vec_weights(), [40, 513]).convert::(), - Tolerance::default(), - ); - } - - #[test] - fn test_forward_matches_naive_host_dot() { - let device = Default::default(); - let cfg = TenVadMelConfig::new(); - let bank: TenVadMelBank = cfg.init(&device); - - let rows = 3; - let n_bins = cfg.n_bins(); - - // Bin powers are non-negative by construction. - let bin_power = - Tensor::::random([rows, n_bins], Distribution::Uniform(0.0, 4.0), &device); - - let out = bank.forward(bin_power.clone()); - assert_eq!(out.dims(), [rows, cfg.n_mels()]); - - let host_power: Vec = bin_power.to_data_as::().to_vec_as::().unwrap(); - let host_weights = cfg.to_vec_weights(); - - let mut expected = Vec::with_capacity(rows * cfg.n_mels()); - for r in 0..rows { - for m in 0..cfg.n_mels() { - let mut acc = 0.0f32; - for j in 0..n_bins { - acc += host_power[r * n_bins + j] * host_weights[m * n_bins + j]; - } - expected.push(acc); - } - } - - out.to_data_as::().assert_approx_eq::( - &TensorData::new(expected, [rows, cfg.n_mels()]).convert::(), - Tolerance::permissive(), - ); - } - - #[test] - fn test_forward_is_non_negative_and_linear() { - // The bank is a non-negative linear map, so scaling the input scales - // the output and the result never goes negative. - let device = Default::default(); - let bank: TenVadMelBank = TenVadMelConfig::new().init(&device); - - let bin_power = Tensor::::random([2, 513], Distribution::Uniform(0.0, 1.0), &device); - - let once = bank.forward(bin_power.clone()); - let twice = bank.forward(bin_power.mul_scalar(2.0)); - - let host: Vec = once.to_data_as::().to_vec_as::().unwrap(); - assert!(host.iter().all(|&v| v >= 0.0)); - - twice.to_data_as::().assert_approx_eq::( - &once.mul_scalar(2.0).to_data_as::(), - Tolerance::permissive(), - ); - } -} diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs deleted file mode 100644 index 5a5caef3..00000000 --- a/crates/bunsen/src/kits/speech/ten_vad/context/mod.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! # ten-vad pre-processing driver. -//! -//! Everything between raw audio and the `[batch, d_ctx, n_freq]` feature -//! stack [`TenVad::forward`] consumes, plus the mutable context that carries -//! it across calls. -//! -//! [`TenVad::forward`] is the stateless call through the network; -//! [`TenVad::context_forward`] is what turns audio into the widened, -//! normalized input that call expects. The `_sequence` forms are equivalent -//! to iterating their single-step counterparts, but run the whole front end -//! batched across the sequence. -//! -//! ## The pipeline -//! -//! Per 256-sample hop, reproducing the reference C driver -//! (`ALGO_TRACE.md` §3.3 - §3.7, §5): -//! -//! ```text -//! raw = hop * 32768 # [-1, 1] -> int16 scale -//! emph = raw[n] - 0.97 * raw[n-1] # carry = previous raw sample -//! binpow = |rfft(hann768 * queue768, n=1024)|^2 # 513 bins, queue = last 3 hops -//! pitch = pitch_estimate(raw, binpow) # Hz, 0 = unvoiced -//! mel = ln(melbank40(binpow / 32768^2) + 1e-20) # 40 triangular filters -//! feat = (concat(mel, [pitch]) - MEANS) / (STDS + 1e-20) -//! stack = concat(stack[:, 1:], feat) # [1, 3, 41] -//! ``` -//! -//! Two orderings are load-bearing and easy to get backwards: -//! -//! * **Pitch runs before the power normalization**, and reads the raw, -//! un-pre-emphasized hop. The reference keeps two parallel FIFOs for exactly -//! this reason. -//! * **The `1 / 32768^2` division happens before the filterbank matmul.** It -//! commutes algebraically with the matmul but not in `f32`. -//! -//! ## The pieces -//! -//! * [`coeff`](self) — the reference constants and normalization tables. -//! * [`PreEmphasisContext`] — the first-order high-pass, with carry. -//! * [`TenVadMelBank`] — the 40-band triangular filterbank. -//! * [`PitchSource`] — the pitch seam, tensor-in and tensor-out; -//! [`HostPitchEstimator`] behind [`HostPitch`] is the reference estimator, -//! [`ZeroPitch`] the constant stub. -//! * [`TenVadFeatureContext`] — the 41-dim feature extractor and its state. -//! * [`TenVadContext`] — the driving context: features, frame stack, and both -//! LSTM states. -//! -//! The sliding STFT itself is [`SlidingStftContext`], which already ports the -//! reference analyzer. -//! -//! ## Feeding it audio -//! -//! [`TenVad::context_forward`] and `_sequence` take hops as tensors, for -//! callers already holding device-resident audio. -//! [`TenVad::context_forward_audio`] and `_audio_sequence` take host `&[f32]` -//! rows, frame and upload them, and take the device from the context — the -//! usual path for a decoded file or a capture buffer. -//! -//! [`TenVad::context_forward_audio`]: crate::kits::speech::ten_vad::TenVad::context_forward_audio -//! [`TenVad::context_forward_audio_sequence`]: crate::kits::speech::ten_vad::TenVad::context_forward_audio_sequence -//! -//! ## Choosing a pitch source -//! -//! Feature `40` is reached through the [`PitchSource`] seam, selected by -//! [`PitchSourceConfig`] on [`TenVadContextConfig::pitch`]: -//! -//! | variant | what it is | -//! |---|---| -//! | `Tensor(..)` | the device estimator. **The default.** Keeps the whole front end resident; nothing synchronizes. | -//! | `Host` | the host scalar port — the reference oracle. Costs a device-to-host readback per call. | -//! | `Zero` | pins feature `40` to a constant and skips the branch. Features `0..40` are exact regardless. | -//! -//! The device variant has two tiers, differing only in how the anti-alias -//! filter before decimation is realized. The default folds the filter and its -//! decimation into one GEMM against a truncated impulse response; -//! [`TensorPitchConfig::reference`] instead selects a literal transcription of -//! the reference's IIR cascade. That tier is sample-sequential — five sections -//! stepping one sample at a time — so it is a correctness reference for short -//! inputs rather than a workload path. It is not a fidelity trade in the usual -//! direction: the truncated FIR is measurably *more* accurate than the -//! recurrence, being a better-conditioned realization of the same filter. -//! -//! All three implementations are cross-tested against each other, and the host -//! oracle is pinned to the C reference. -//! -//! ### Cost shape -//! -//! The device path is built for sequences. `forward_sequence` runs stages 1 -//! and 3 over the whole run in one pass, and threads a carry through 2 and 4; -//! a single-hop `forward` pays the same setup for one frame's worth of work. -//! Callers stepping hop by hop through the device path should expect that, -//! and the `Host` variant may well be cheaper for them. -//! -//! Internally it works in fixed-size passes -//! ([`TensorPitchConfig::chunk_steps`]), which does not change the answer but -//! matters a great deal for cost: cubecl selects kernels per shape, so a -//! pinned pass size is tuned once instead of once per distinct input length. -//! -//! ## The periodic state reset -//! -//! The reference driver zeroes both LSTM states every `resetFrameNum = 1875` -//! model calls — 30 s of audio — and this driver reproduces that, on -//! [`TenVadContextConfig::reset_frames`]. `None` disables it. -//! -//! Three details matter, and all three are the reference's -//! (`src/aed.cc:476-481`, `ALGO_TRACE.md` §5): -//! -//! * **Only the recurrence is zeroed.** The feature stack, the STFT queue and -//! the pre-emphasis carry keep running, so the frame after a reset still sees -//! its two predecessors in the context stack. That is what separates -//! [`TenVadContext::reset_states`] from [`TenVadContext::reset`]. -//! * **The counter fires after the model call**, on `>=`. Call 1875 runs with -//! the states it inherited; call 1876 runs from zero. -//! * **Both drivers tick identically**, so `context_forward_sequence` stays -//! exactly "iterating `context_forward`" — a reset lands on the same hops -//! whether or not the caller batches, and whatever chunk boundaries it uses. -//! -//! The reference zeroes lazily, at the top of its next call, via a -//! `clear_hidden` flag; this driver zeroes eagerly at the end of the current -//! one. Those are observationally identical for any continuation. -//! -//! The reference marks the constant `// TODO` (`src/aed.cc:640`), so treat it -//! as reproduced behavior rather than a recommendation — but it is on by -//! default, because parity with the pretrained weights is what this kit is -//! for, and the shipped golden covers 3750 hops, exactly two periods. -//! -//! ## Known deviations from the reference driver -//! -//! * **Batch size 1.** The stock ONNX graph pins its LSTM batch to 1; see -//! [`TenVad::forward`] for what the leading axis actually means. -//! -//! ## What is pinned numerically -//! -//! * **Feature `40`** — `testdata/ten/pitch.json` holds one reference pitch per -//! hop over the 60 s fixture, dumped from the C `AUP_PE_proc` driven by the C -//! STFT. The kit's cross test asserts the voicing decision matches on every -//! frame and the voiced estimates agree to within f32 rounding. -//! * **Features `0..40`** — [`TenVadFeatureContext`]'s own tests pin the mel -//! path against an independent host implementation written from the reference -//! ordering. -//! * **The driver as a whole** — the kit's cross test pins it against the ONNX -//! graph over real audio. -//! -//! [`PitchSource`]: crate::kits::speech::pitch::PitchSource -//! [`PitchSourceConfig`]: crate::kits::speech::pitch::PitchSourceConfig -//! [`HostPitchEstimator`]: crate::kits::speech::pitch::HostPitchEstimator -//! [`HostPitch`]: crate::kits::speech::pitch::HostPitch -//! [`ZeroPitch`]: crate::kits::speech::pitch::ZeroPitch -//! [`TenVadContextConfig::pitch`]: crate::kits::speech::ten_vad::TenVadContextConfig -//! [`TenVadContextConfig::reset_frames`]: crate::kits::speech::ten_vad::TenVadContextConfig -//! [`TensorPitchConfig::reference`]: crate::kits::speech::pitch::tensor::TensorPitchConfig::reference -//! [`TensorPitchConfig::chunk_steps`]: crate::kits::speech::pitch::tensor::TensorPitchConfig -//! [`TenVad::forward`]: crate::kits::speech::ten_vad::TenVad::forward -//! [`TenVad::context_forward`]: crate::kits::speech::ten_vad::TenVad::context_forward -//! [`SlidingStftContext`]: crate::ops::signal::SlidingStftContext - -pub mod coeff; - -mod driver; -mod features; -mod mel; -mod pre_emphasis; - -#[doc(inline)] -pub use coeff::*; -#[doc(inline)] -pub use driver::*; -#[doc(inline)] -pub use features::*; -#[doc(inline)] -pub use mel::*; -#[doc(inline)] -pub use pre_emphasis::*; diff --git a/crates/bunsen/src/kits/speech/ten_vad/context/pre_emphasis.rs b/crates/bunsen/src/kits/speech/ten_vad/context/pre_emphasis.rs deleted file mode 100644 index df3d0243..00000000 --- a/crates/bunsen/src/kits/speech/ten_vad/context/pre_emphasis.rs +++ /dev/null @@ -1,394 +0,0 @@ -//! # Streaming pre-emphasis filter. -//! -//! The first-order high-pass the ten-vad front end applies to the audio -//! before the STFT: -//! -//! ```text -//! y[n] = x[n] - coeff * x[n-1] -//! ``` -//! -//! The reference keeps two parallel FIFOs (`ALGO_TRACE.md` §3.3): raw samples -//! feed the pitch estimator, pre-emphasized samples feed the STFT. Only the -//! STFT branch is filtered, and the filter is continuous across hop -//! boundaries — the previous hop's last **raw** sample is carried forward, so -//! a stream chopped into hops yields exactly the same output as the whole -//! stream filtered at once. -//! -//! The pieces are: -//! * [`PreEmphasisConfig`] — the coefficient. -//! * [`PreEmphasisContext`] — a streaming state (the carried sample) bound to a -//! batch; built by [`PreEmphasisConfig::init`]. -//! -//! Note: this is the most generalizable block in the ten-vad front end — a -//! first-order FIR with carry, with nothing ten-vad-specific but its default -//! coefficient. It lives here rather than in [`crate::ops::signal`] until a -//! second caller justifies the general form. - -use burn::{ - config::Config, - prelude::*, -}; - -#[cfg(any(test, debug_assertions))] -use crate::contracts::unpack_shape_contract; -use crate::{ - kits::speech::ten_vad::context::coeff::PRE_EMPHASIS_COEFF, - prelude::TensorOpExt, -}; - -/// Config for [`PreEmphasisContext`]. -/// -/// Builds [`PreEmphasisContext`] via [`init`](Self::init). -#[derive(Config, Debug, Copy)] -pub struct PreEmphasisConfig { - /// The pre-emphasis coefficient. - /// - /// Defaults to the ten-vad reference value, - /// [`PRE_EMPHASIS_COEFF`]. - #[config(default = "PRE_EMPHASIS_COEFF")] - pub coeff: f32, -} - -impl Default for PreEmphasisConfig { - fn default() -> Self { - Self::new() - } -} - -impl PreEmphasisConfig { - /// Initializes a zeroed [`PreEmphasisContext`] for `batch_size` streams. - /// - /// The carried sample starts at zero, so the first sample of the first - /// hop passes through unfiltered. - /// - /// # Arguments - /// * `batch_size`: the number of independent streams; must be non-zero. - pub fn init( - &self, - batch_size: usize, - device: &B::Device, - ) -> PreEmphasisContext { - assert_ne!(batch_size, 0, "PreEmphasis batch_size must be non-zero"); - PreEmphasisContext { - coeff: self.coeff, - prev: Tensor::zeros([batch_size], device), - } - } -} - -/// Streaming pre-emphasis state. -/// -/// Holds the coefficient and the `[batch]` last raw sample of each stream. -/// -/// Built by [`PreEmphasisConfig::init`]. -#[derive(Debug, Clone)] -pub struct PreEmphasisContext { - coeff: f32, - - /// The `[batch]` last raw input sample of each stream. - /// - /// Zero before the stream starts. - pub prev: Tensor, -} - -impl PreEmphasisContext { - /// The pre-emphasis coefficient. - pub fn coeff(&self) -> f32 { - self.coeff - } - - /// The batch size; each batch row is an independent stream. - pub fn batch_size(&self) -> usize { - self.prev.dims()[0] - } - - /// Resets the carried sample to zero. - pub fn reset(&mut self) { - self.prev = Tensor::zeros_like(&self.prev); - } - - /// Filters one hop, carrying the boundary sample forward. - /// - /// # Arguments - /// * `hop`: `[batch, samples]` new raw samples, with `samples` non-zero. - /// - /// # Returns - /// `[batch, samples]` pre-emphasized samples. - pub fn forward( - &mut self, - hop: Tensor, - ) -> Tensor { - #[cfg(any(test, debug_assertions))] - let [samples] = unpack_shape_contract!( - ["batch", "samples"], - &hop, - &["samples"], - &[("batch", self.batch_size())], - ); - #[cfg(not(any(test, debug_assertions)))] - let samples = hop.dims()[1]; - - assert_ne!(samples, 0, "PreEmphasis hop must be non-empty"); - - // `[batch, samples]`: the input delayed by one, with the carried - // sample filling the first slot. - // [batch, 1] - let carry: Tensor = self.prev.extract().unsqueeze_dim(1); - let delayed = if samples == 1 { - carry - } else { - let head = hop.clone().slice_dim(1, ..(samples - 1) as isize); - Tensor::cat(vec![carry, head], 1) - }; - - // Carry the last *raw* sample, not the filtered one. - self.prev = hop.clone().select_dim::<1>(1, samples - 1); - - hop - delayed.mul_scalar(self.coeff) - } - - /// Filters `steps` consecutive hops at once. - /// - /// Equivalent to `steps` calls of [`forward`](Self::forward): the hops are - /// concatenated into one per-stream signal, filtered in a single pass, and - /// split back apart. The filter is causal with a one-sample memory, so - /// splitting the pass at hop boundaries changes nothing. - /// - /// # Arguments - /// * `hops`: `[steps, batch, samples]` consecutive raw hops. - /// - /// # Returns - /// `[steps, batch, samples]` pre-emphasized hops. - pub fn forward_sequence( - &mut self, - hops: Tensor, - ) -> Tensor { - #[cfg(any(test, debug_assertions))] - let [steps, samples] = unpack_shape_contract!( - ["steps", "batch", "samples"], - &hops, - &["steps", "samples"], - &[("batch", self.batch_size())], - ); - #[cfg(not(any(test, debug_assertions)))] - let [steps, _, samples] = hops.dims(); - - assert_ne!(steps, 0, "PreEmphasis hops must be non-empty"); - - let batch = self.batch_size(); - - // [batch, steps * samples] - let stream = hops.swap_dims(0, 1).flatten::<2>(1, 2); - - // [batch, steps * samples] - let out = self.forward(stream); - - // [steps, batch, samples] - out.reshape([batch, steps, samples]).swap_dims(0, 1) - } -} - -#[cfg(test)] -mod tests { - use burn::tensor::{ - Distribution, - Tolerance, - backend::BackendTypes, - }; - - use super::*; - use crate::{ - prelude::*, - support::testing::PerformanceBackend, - }; - - type B = PerformanceBackend; - type F = ::FloatElem; - - /// Host reference: the scalar filter, over one stream, with carry. - fn host_pre_emphasis( - signal: &[f32], - coeff: f32, - prev: &mut f32, - ) -> Vec { - let mut out = Vec::with_capacity(signal.len()); - for (i, &x) in signal.iter().enumerate() { - let back = if i == 0 { *prev } else { signal[i - 1] }; - out.push(x - coeff * back); - } - if let Some(&last) = signal.last() { - *prev = last; - } - out - } - - #[test] - fn test_config_default() { - let cfg = PreEmphasisConfig::new(); - assert_eq!(cfg.coeff, PRE_EMPHASIS_COEFF); - assert_eq!(cfg.coeff, 0.97); - - let cfg = PreEmphasisConfig::new().with_coeff(0.5); - assert_eq!(cfg.coeff, 0.5); - } - - #[test] - fn test_init_state() { - let device = Default::default(); - let ctx: PreEmphasisContext = PreEmphasisConfig::new().init(3, &device); - - assert_eq!(ctx.batch_size(), 3); - assert_eq!(ctx.coeff(), PRE_EMPHASIS_COEFF); - assert_eq!(ctx.prev.dims(), [3]); - - // The carried sample starts at zero. - ctx.prev - .to_data() - .assert_eq(&Tensor::::zeros([3], &device).to_data(), true); - } - - #[test] - #[should_panic(expected = "batch_size must be non-zero")] - fn test_init_rejects_zero_batch() { - let device = Default::default(); - let _: PreEmphasisContext = PreEmphasisConfig::new().init(0, &device); - } - - #[test] - fn test_forward_matches_host_reference() { - let device = Default::default(); - let coeff = PRE_EMPHASIS_COEFF; - let mut ctx: PreEmphasisContext = PreEmphasisConfig::new().init(1, &device); - - // Two consecutive hops: the second must see the first's last sample. - let hops = [vec![1.0f32, 2.0, 3.0, 4.0], vec![5.0f32, -6.0, 7.0, -8.0]]; - - let mut host_prev = 0.0f32; - for hop in &hops { - let expected = host_pre_emphasis(hop, coeff, &mut host_prev); - - let input = - Tensor::::from_data(TensorData::new(hop.clone(), [1, hop.len()]), &device); - let out = ctx.forward(input); - - out.to_data_as::().assert_approx_eq::( - &TensorData::new(expected, [1, hop.len()]).convert::(), - Tolerance::default(), - ); - } - - // The carried sample is the last *raw* input, not the filtered one. - ctx.prev.to_data_as::().assert_approx_eq::( - &TensorData::new(vec![-8.0f32], [1]).convert::(), - Tolerance::default(), - ); - } - - #[test] - fn test_forward_single_sample_hop() { - // A one-sample hop is entirely carry-driven; it exercises the branch - // where there is no in-hop history at all. - let device = Default::default(); - let mut ctx: PreEmphasisContext = - PreEmphasisConfig::new().with_coeff(0.5).init(1, &device); - - let first = Tensor::::from_data(TensorData::new(vec![4.0f32], [1, 1]), &device); - let out = ctx.forward(first); - // 4.0 - 0.5 * 0.0 - out.to_data_as::().assert_approx_eq::( - &TensorData::new(vec![4.0f32], [1, 1]).convert::(), - Tolerance::default(), - ); - - let second = Tensor::::from_data(TensorData::new(vec![10.0f32], [1, 1]), &device); - let out = ctx.forward(second); - // 10.0 - 0.5 * 4.0 - out.to_data_as::().assert_approx_eq::( - &TensorData::new(vec![8.0f32], [1, 1]).convert::(), - Tolerance::default(), - ); - } - - #[test] - fn test_batch_rows_are_independent() { - let device = Default::default(); - let coeff = PRE_EMPHASIS_COEFF; - let batch = 3; - let samples = 5; - - let rows: Vec> = (0..batch) - .map(|b| (0..samples).map(|i| (b * 10 + i) as f32).collect()) - .collect(); - - let mut ctx: PreEmphasisContext = PreEmphasisConfig::new().init(batch, &device); - let input = - Tensor::::from_data(TensorData::new(rows.concat(), [batch, samples]), &device); - let out = ctx.forward(input); - - let expected: Vec = rows - .iter() - .flat_map(|row| { - let mut prev = 0.0f32; - host_pre_emphasis(row, coeff, &mut prev) - }) - .collect(); - - out.to_data_as::().assert_approx_eq::( - &TensorData::new(expected, [batch, samples]).convert::(), - Tolerance::default(), - ); - } - - #[test] - fn test_forward_sequence_matches_stepwise() { - let device = Default::default(); - let steps = 4; - let batch = 2; - let samples = 6; - - let hops = Tensor::::random([steps, batch, samples], Distribution::Default, &device); - - let mut seq_ctx: PreEmphasisContext = PreEmphasisConfig::new().init(batch, &device); - let mut step_ctx = seq_ctx.clone(); - - let seq_out = seq_ctx.forward_sequence(hops.clone()); - assert_eq!(seq_out.dims(), [steps, batch, samples]); - - let mut step_outs = Vec::with_capacity(steps); - for step in 0..steps { - step_outs.push(step_ctx.forward(hops.clone().select_dim::<2>(0, step))); - } - let step_out: Tensor = Tensor::stack(step_outs, 0); - - let tol = Tolerance::::default(); - seq_out - .to_data_as::() - .assert_approx_eq::(&step_out.to_data_as::(), tol); - - // The residual carry must agree too, or the next call diverges. - seq_ctx - .prev - .to_data_as::() - .assert_approx_eq::(&step_ctx.prev.to_data_as::(), tol); - } - - #[test] - fn test_reset() { - let device = Default::default(); - let mut ctx: PreEmphasisContext = PreEmphasisConfig::new().init(1, &device); - - let hop = Tensor::::from_data(TensorData::new(vec![3.0f32, 9.0], [1, 2]), &device); - let first = ctx.forward(hop.clone()); - - ctx.reset(); - ctx.prev - .to_data() - .assert_eq(&Tensor::::zeros([1], &device).to_data(), true); - - // After a reset the same hop reproduces the very first output. - let again = ctx.forward(hop); - again - .to_data_as::() - .assert_approx_eq::(&first.to_data_as::(), Tolerance::default()); - } -} diff --git a/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs b/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs deleted file mode 100644 index 886a9e43..00000000 --- a/crates/bunsen/src/kits/speech/ten_vad/cross_test.rs +++ /dev/null @@ -1,495 +0,0 @@ -#[cfg(test)] -mod tests { - use burn::tensor::{ - Distribution, - Tensor, - Tolerance, - backend::BackendTypes, - }; - - use crate::{ - blocks::rnn::lstm::ExtLstmState, - kits::speech::{ - pitch::{ - HostPitchEstimator, - PitchSourceInit, - tensor::hybrid::HybridPitchInit, - }, - ten_vad::{ - TenVad, - TenVadContextConfig, - TenVadContextMeta, - TenVadMeta, - context::{ - FEATURE_EPS, - FEATURE_MEANS, - FEATURE_STDS, - N_MELS, - TenVadFeatureConfig, - TenVadFeatureMeta, - }, - reference::ReferenceModel, - }, - }, - prelude::*, - support::testing::{ - PerformanceBackend, - audio::load_audio_mono_sr, - }, - }; - - #[test] - #[serial_test::serial] - fn test_reference_model_forward_cross_test() { - type B = PerformanceBackend; - type F = ::FloatElem; - - let device = Default::default(); - - let ref_vad: ReferenceModel = ReferenceModel::load_pretrained(&device); - - let vad: TenVad = TenVad::load_pretrained(&device).unwrap(); - - // The leading axis is the graph's sequence axis, not a stream batch; - // see `TenVad::forward`. Both are 1 here. - let shape = [1, 64]; - - let input = Tensor::random([1, 3, 41], Distribution::Default, &device); - - let state1_init = ExtLstmState::initial(shape, &device); - let state2_init = ExtLstmState::initial(shape, &device); - - let (ref_prob, ref_lstm1_hidden, ref_lstm1_cell, ref_lstm2_hidden, ref_lstm2_cell) = - ref_vad.forward( - input.clone(), - state1_init.hidden.clone(), - state1_init.cell.clone(), - state2_init.hidden.clone(), - state2_init.cell.clone(), - ); - - let (mod_prob, mod_lstm1_state, mod_lstm2_state) = vad.forward(input.clone(), None, None); - - mod_prob - .unsqueeze_dim::<3>(2) - .to_data_as::() - .assert_approx_eq::(&ref_prob.to_data_as::(), Tolerance::permissive()); - - mod_lstm1_state - .hidden - .to_data_as::() - .assert_approx_eq::(&ref_lstm1_hidden.to_data_as::(), Tolerance::permissive()); - - mod_lstm1_state - .cell - .to_data_as::() - .assert_approx_eq::(&ref_lstm1_cell.to_data_as::(), Tolerance::permissive()); - - mod_lstm2_state - .hidden - .to_data_as::() - .assert_approx_eq::(&ref_lstm2_hidden.to_data_as::(), Tolerance::permissive()); - - mod_lstm2_state - .cell - .to_data_as::() - .assert_approx_eq::(&ref_lstm2_cell.to_data_as::(), Tolerance::permissive()); - } - - /// Drives real audio end-to-end through the context driver, and pins - /// three independent arms against each other: - /// - /// 1. [`TenVad::context_forward_sequence`] — the batched front end, - /// 2. [`TenVad::context_forward`] — the same work, one hop at a time, - /// 3. the ONNX reference graph, stepped by hand over the *driver's own* - /// feature stacks with the recurrence threaded externally. - /// - /// Arms 1 and 2 pin that the sequence form really is "iterating the - /// non-sequence form"; arm 3 pins that the widened stack the driver builds - /// is what the reference model expects, and that bunsen's port of the - /// network agrees with the graph over a whole stream rather than a single - /// random frame. - #[test] - #[serial_test::serial] - fn test_reference_model_context_forward_sequence_cross_test() - -> Result<(), Box> { - type B = PerformanceBackend; - type F = ::FloatElem; - - let device = Default::default(); - - let vad: TenVad = TenVad::load_pretrained(&device)?; - let ref_vad: ReferenceModel = ReferenceModel::load_pretrained(&device); - - let cfg = TenVadContextConfig::new(); - let sample_rate = cfg.sample_rate(); - let hop_size = cfg.hop_size(); - - // A bounded slice of the shared 16 kHz fixture: the reference arm runs - // one ONNX call per hop, and the full file is 3750 of them. - const STEPS: usize = 400; - - let wav_path = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata/silero/test.wav"); - let (_, wav_vec) = load_audio_mono_sr(wav_path, sample_rate)?; - assert!( - wav_vec.len() >= STEPS * hop_size, - "fixture is too short: {} samples", - wav_vec.len(), - ); - - // [steps, batch=1, hop_size] - let hop_seq: Tensor = - Tensor::::from_floats(&wav_vec[..STEPS * hop_size], &device) - .reshape([STEPS, 1, hop_size]); - - // Arm 1: the batched sequence path. - let mut seq_ctx = vad.init_context(&cfg, &device)?; - let seq_probs = vad.context_forward_sequence(hop_seq.clone(), &mut seq_ctx); - assert_eq!(seq_probs.dims(), [STEPS, 1]); - - // Arm 2: the same stream, one hop at a time. - let mut step_ctx = vad.init_context(&cfg, &device)?; - let mut step_probs = Vec::with_capacity(STEPS); - for step in 0..STEPS { - let hop = hop_seq.clone().select_dim::<2>(0, step); - step_probs.push(vad.context_forward(hop, &mut step_ctx)); - } - let step_probs: Tensor = Tensor::stack(step_probs, 0); - - // Arm 3: the reference graph, fed the driver's own feature stacks. - let mut ref_ctx = vad.init_context(&cfg, &device)?; - let mut ref_state1 = ExtLstmState::initial([1, vad.d_hidden()], &device); - let mut ref_state2 = ExtLstmState::initial([1, vad.d_hidden()], &device); - let mut ref_probs = Vec::with_capacity(STEPS); - - for step in 0..STEPS { - let hop = hop_seq.clone().select_dim::<2>(0, step); - - // [batch, n_freq] -> [batch, d_ctx, n_freq] - let feats = ref_ctx.features.forward(hop); - let stack = ref_ctx.push_features(feats); - - // The graph takes `(features, h1, c1, h2, c2)` positionally. - let (prob, h1, c1, h2, c2) = ref_vad.forward( - stack, - ref_state1.hidden.clone(), - ref_state1.cell.clone(), - ref_state2.hidden.clone(), - ref_state2.cell.clone(), - ); - ref_state1 = ExtLstmState::new(c1, h1); - ref_state2 = ExtLstmState::new(c2, h2); - - // [1, 1, 1] -> [1] - ref_probs.push(prob.reshape([1])); - } - let ref_probs: Tensor = Tensor::stack(ref_probs, 0); - - // The sequence and stepwise arms run identical arithmetic in a - // different order. - let probs = seq_probs.clone(); - seq_probs - .clone() - .to_data_as::() - .assert_approx_eq::(&step_probs.to_data_as::(), Tolerance::permissive()); - - // The reference arm crosses an independently generated graph. - seq_probs - .to_data_as::() - .assert_approx_eq::(&ref_probs.to_data_as::(), Tolerance::permissive()); - - // The carried recurrence must land in the same place, or a resumed - // stream would diverge from the reference after the first chunk. - for (ours, theirs) in [ - (&seq_ctx.state1, &ref_state1), - (&seq_ctx.state2, &ref_state2), - ] { - ours.hidden - .to_data_as::() - .assert_approx_eq::(&theirs.hidden.to_data_as::(), Tolerance::permissive()); - ours.cell - .to_data_as::() - .assert_approx_eq::(&theirs.cell.to_data_as::(), Tolerance::permissive()); - } - - // And the driver's feature history must match what the reference arm - // was actually fed. - seq_ctx - .stack - .to_data_as::() - .assert_approx_eq::(&ref_ctx.stack.to_data_as::(), Tolerance::permissive()); - - // All three arms share the driver's features, so agreement alone - // would also hold for a driver emitting constants. Check the output - // is actually tracking the audio: the fixture is continuous speech - // entered from a zeroed context, so the probabilities must be valid, - // must span a real range, and must sit mostly high. - let host: Vec = probs.to_data_as::().to_vec_as::().unwrap(); - assert!( - host.iter().all(|&p| (0.0..=1.0).contains(&p)), - "probabilities outside [0, 1]", - ); - - let min = host.iter().copied().fold(f32::INFINITY, f32::min); - let max = host.iter().copied().fold(f32::NEG_INFINITY, f32::max); - assert!( - max - min > 0.2, - "probabilities are nearly constant ({min} ..= {max}); \ - the front end is not tracking the audio", - ); - - let voiced = host.iter().filter(|&&p| p > 0.5).count(); - assert!( - voiced * 2 > host.len(), - "only {voiced}/{} frames read as speech on a speech fixture", - host.len(), - ); - - Ok(()) - } - - /// Drives the whole golden fixture through a pitch source and recovers the - /// per-hop pitch in Hz, alongside the reference values to compare against. - /// - /// `testdata/ten/pitch.json` holds one pitch estimate per 256-sample hop - /// over the 60 s fixture, produced by driving the reference `AUP_PE_proc` - /// (`src/pitch_est.cc`) from the reference `AUP_Analyzer` STFT — the same - /// wiring `AUP_Aed_runOneFrm` uses, so the estimator sees the raw hop and - /// the un-normalized bin powers exactly as it does in the C driver. - fn golden_pitch_hz(pitch: I) -> Result<(Vec, Vec), Box> - where - I: PitchSourceInit, - { - type B = PerformanceBackend; - type F = ::FloatElem; - - let device = Default::default(); - let cfg = TenVadFeatureConfig::new(); - let hop_size = cfg.hop_size(); - let sample_rate = cfg.sample_rate(); - - let wav_path = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata/silero/test.wav"); - let golden_path = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata/ten/pitch.json"); - - let (_, wav_vec) = load_audio_mono_sr(wav_path, sample_rate)?; - let expected: Vec = serde_json::from_reader( - std::fs::File::open(golden_path).map_err(BunsenError::external)?, - ) - .map_err(BunsenError::external)?; - - let steps = expected.len(); - assert!( - wav_vec.len() >= steps * hop_size, - "fixture is too short for {steps} hops: {} samples", - wav_vec.len(), - ); - - // [steps, batch=1, hop_size] - let hop_seq: Tensor = - Tensor::::from_floats(&wav_vec[..steps * hop_size], &device) - .reshape([steps, 1, hop_size]); - - let mut ctx = cfg.try_init_context::(1, pitch, &device)?; - - // [steps, batch=1, n_freq] - let feats = ctx.forward_sequence(hop_seq); - let flat: Vec = feats.to_data_as::().to_vec_as::().ok_or_panic(); - - // Undo the standardization to recover Hz, which is what the reference - // reports and what stays legible in a failure message. - let n_freq = N_MELS + 1; - let scale = FEATURE_STDS[N_MELS] + FEATURE_EPS; - let got: Vec = (0..steps) - .map(|t| flat[t * n_freq + N_MELS] * scale + FEATURE_MEANS[N_MELS]) - .collect(); - - Ok((got, expected)) - } - - /// Asserts a recovered pitch track against the C reference. - /// - /// The two arms reach their bin powers through different FFTs, so they are - /// never bit-identical. Two things are asserted instead: - /// - /// * the **voicing decision** agrees on every frame — the discrete output, - /// and the one a porting error would flip; and - /// * where both call a frame voiced, the estimates agree within - /// `rel_bound`. - fn assert_golden_pitch( - got: &[f32], - expected: &[f32], - rel_bound: f32, - ) { - let steps = expected.len(); - let mut voiced_frames = 0usize; - for (t, (&g, &e)) in got.iter().zip(expected.iter()).enumerate() { - assert_eq!( - g > 0.0, - e > 0.0, - "frame {t}: voicing disagrees, got {g} Hz vs reference {e} Hz", - ); - if e > 0.0 { - voiced_frames += 1; - let rel = (g - e).abs() / e; - assert!( - rel < rel_bound, - "frame {t}: {g} Hz vs reference {e} Hz (rel err {rel})", - ); - } - } - - // Guard the guard: a golden of all-unvoiced frames would pass the loop - // above without exercising the estimator at all. - assert!( - voiced_frames * 2 > steps, - "fixture should be mostly voiced, got {voiced_frames} of {steps}", - ); - } - - /// Pins the whole driver against the reference implementation. - /// - /// `testdata/ten/probs.json` holds one speech probability per hop over the - /// 60 s fixture, produced by driving the shipped `libten_vad.so` through - /// the reference Python binding -- the same `ten_vad_process` path every - /// other binding takes. See `testdata/ten/README.md` for the recipe. - /// - /// Unlike the other tests here, this one is end to end: the reference's own - /// front end and its own inference engine, against bunsen's front end and - /// its burn port of the graph. Nothing is shared between the two arms - /// except the audio. - /// - /// It runs the **whole** fixture, and the length is the point: 3750 hops is - /// exactly two [`RESET_FRAMES`] periods, so this is the only test that can - /// show the periodic LSTM reset fires on the same hop as the reference's. A - /// reset on the wrong hop -- or a missing one -- diverges immediately after - /// hop 1875. - /// - /// About 20 s in release and 126 s in debug. It was capped at 400 hops - /// while `context_forward_sequence` stepped the model per hop and the - /// device pitch estimator re-tuned per input length; with - /// [`TenVad::forward_sequence`] and [`TensorPitchConfig::chunk_steps`] it - /// no longer needs to be. - /// - /// [`RESET_FRAMES`]: crate::kits::speech::ten_vad::context::coeff::RESET_FRAMES - /// [`TenVad::forward_sequence`]: - /// crate::kits::speech::ten_vad::TenVad::forward_sequence - /// [`TensorPitchConfig::chunk_steps`]: - /// crate::kits::speech::pitch::tensor::TensorPitchConfig - #[test] - #[serial_test::serial] - fn test_reference_probability_golden() -> Result<(), Box> { - type B = PerformanceBackend; - type F = ::FloatElem; - - let device = Default::default(); - let vad: TenVad = TenVad::load_pretrained(&device)?; - let cfg = TenVadContextConfig::new(); - - let wav_path = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata/silero/test.wav"); - let golden_path = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata/ten/probs.json"); - - let (_, wav_vec) = load_audio_mono_sr(wav_path, cfg.sample_rate())?; - let expected: Vec = serde_json::from_reader( - std::fs::File::open(golden_path).map_err(BunsenError::external)?, - ) - .map_err(BunsenError::external)?; - - let steps = expected.len(); - let samples = steps * cfg.hop_size(); - assert!( - wav_vec.len() >= samples, - "fixture too short for {steps} hops" - ); - - let mut ctx = vad.init_context(&cfg, &device)?; - let probs = vad.context_forward_audio(&wav_vec[..samples], &mut ctx)?; - let got: Vec = probs.to_data_as::().to_vec_as::().ok_or_panic(); - - let mut worst = 0.0f32; - let mut worst_at = 0usize; - let mut sum = 0.0f64; - let mut decisions = 0usize; - for (t, (&g, &e)) in got.iter().zip(expected.iter()).enumerate() { - let d = (g - e).abs(); - sum += d as f64; - if d > worst { - worst = d; - worst_at = t; - } - if (g >= 0.5) == (e >= 0.5) { - decisions += 1; - } - } - let mean = sum / steps as f64; - let agreement = decisions as f64 / steps as f64; - - eprintln!( - "reference probability golden: mean |diff| = {mean:.3e}, worst = {worst:.3e} \ - at hop {worst_at} (got {}, want {}), decisions agree {:.3}%", - got[worst_at], - expected[worst_at], - 100.0 * agreement, - ); - - // Measured, not guessed: two independent front ends and two independent - // inference engines land within 3e-5 of each other on average, and never - // disagree on the decision. The bounds sit an order of magnitude above - // that, so ordinary backend drift passes and a real regression does not. - assert_eq!( - decisions, - steps, - "speech/no-speech decisions disagree on {} of {steps} hops", - steps - decisions, - ); - assert!( - worst < 5e-3, - "worst probability error {worst:.3e} at hop {worst_at} is too large" - ); - assert!( - mean < 1e-3, - "mean probability error {mean:.3e} is too large" - ); - - Ok(()) - } - - /// Pins the ported host pitch estimator against the ten-vad C reference. - /// - /// This is the anchor of the whole chain: every device stage is validated - /// differentially against the host estimator, and the host estimator is - /// validated here. It covers feature `40`; the other 40 are pinned by - /// [`TenVadFeatureContext`]'s own tests against an independent host - /// implementation of the mel path. - /// - /// [`TenVadFeatureContext`]: crate::kits::speech::ten_vad::context::TenVadFeatureContext - #[test] - #[serial_test::serial] - fn test_pitch_estimator_reference_golden() -> Result<(), Box> { - let (got, expected) = golden_pitch_hz(HostPitchEstimator::new())?; - assert_golden_pitch(&got, &expected, 1e-4); - Ok(()) - } - - /// The go/no-go gate for the device-side port: stage 1 on the device, the - /// remaining three stages on the host, measured against the same C golden. - /// - /// The stage-level differential tests establish that the device pre-filter - /// reproduces the host one to a tolerance. They cannot establish that the - /// tolerance survives the tracker's `argmax` and its voicing threshold, - /// which are discrete — a single `argmax` step of ±1 moves the reported - /// pitch by roughly half a percent, some fifty times the bound above. This - /// test answers that, by differing from the pinned pipeline in exactly one - /// stage. - /// - /// The value bound is looser than the host arm's because the device - /// contracts the band projections in `f32` where the host accumulates the - /// autocorrelation in `f64`. The **voicing** bound is not loosened: that is - /// the assertion with teeth. - #[test] - #[serial_test::serial] - fn test_tensor_prefilter_hybrid_reference_golden() -> Result<(), Box> { - let (got, expected) = golden_pitch_hz(HybridPitchInit::new())?; - assert_golden_pitch(&got, &expected, 1e-3); - Ok(()) - } -} diff --git a/crates/bunsen/src/kits/speech/ten_vad/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/mod.rs deleted file mode 100644 index 1985c733..00000000 --- a/crates/bunsen/src/kits/speech/ten_vad/mod.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! ten-vad model. - -/// The reference model. -#[cfg(feature = "store")] -pub mod reference { - pub use bunsen_onnx_gen::ten::*; - - /// Reference ONNX Model. - pub type ReferenceModel = Model; -} - -#[cfg(feature = "store")] -mod cross_test; -#[cfg(feature = "store")] -pub mod pretrained; - -pub mod blocks; -pub mod context; - -#[doc(inline)] -pub use blocks::*; -#[doc(inline)] -pub use context::*; diff --git a/crates/bunsen/src/kits/speech/ten_vad/pretrained/load.rs b/crates/bunsen/src/kits/speech/ten_vad/pretrained/load.rs deleted file mode 100644 index fc80a7b7..00000000 --- a/crates/bunsen/src/kits/speech/ten_vad/pretrained/load.rs +++ /dev/null @@ -1,87 +0,0 @@ -use burn::prelude::Backend; -use burn_store::{ - BurnpackStore, - KeyRemapper, - ModuleSnapshot, -}; - -use crate::{ - burner::module::ModuleInit, - errors::{ - BunsenError, - BunsenResult, - WithOkOrPanic, - }, - kits::speech::ten_vad::{ - TenVad, - TenVadStructureConfig, - reference, - }, -}; - -impl TenVad { - /// Load the common pretrained `TenVAD` model. - pub fn load_pretrained(device: &B::Device) -> BunsenResult { - Self::load_from_burnpack_bytes(reference::burnpack_as_burn_bytes(), device) - } - - /// The key remapping for the pretrained model. - pub fn pretrained_mapper() -> KeyRemapper { - KeyRemapper::from_patterns(vec![ - ("conv2d1", "cs1.blocks.0.conv"), - ("conv2d2", "cs1.blocks.1.conv"), - ("conv2d3", "cs2.blocks.0.conv"), - ("conv2d4", "cs2.blocks.1.conv"), - ("conv2d5", "cs2.blocks.2.conv"), - ("conv2d6", "cs2.blocks.3.conv"), - ("constant23", "linear1.bias"), - ("constant27", "linear2.bias"), - ]) - .ok_or_panic() - } - - /// Load from pretrained burnpack bytes. - pub fn load_from_burnpack_bytes( - bytes: burn::tensor::Bytes, - device: &B::Device, - ) -> BunsenResult { - Self::load_from_burnpack( - BurnpackStore::from_bytes(Some(bytes)), - TenVadStructureConfig::default(), - Self::pretrained_mapper(), - device, - ) - } - - /// Load from a burnpack file. - pub fn load_from_burnpack_file( - path: impl AsRef, - device: &B::Device, - ) -> BunsenResult { - Self::load_from_burnpack( - BurnpackStore::from_file(path), - TenVadStructureConfig::default(), - Self::pretrained_mapper(), - device, - ) - } - - /// Load from a burnpack store. - pub fn load_from_burnpack( - store: BurnpackStore, - cfg: C, - remapper: KeyRemapper, - device: &B::Device, - ) -> BunsenResult - where - C: ModuleInit, - { - let mut store = store.remap(remapper); - let mut module = cfg.try_init(device)?; - module - .load_from(&mut store) - .map_err(BunsenError::external)?; - - Ok(module) - } -} diff --git a/crates/bunsen/src/kits/speech/ten_vad/pretrained/mod.rs b/crates/bunsen/src/kits/speech/ten_vad/pretrained/mod.rs deleted file mode 100644 index 34979c6b..00000000 --- a/crates/bunsen/src/kits/speech/ten_vad/pretrained/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! Pretrained `TenVAD` models. - -mod load; diff --git a/crates/bunsen/testdata/ten/README.md b/crates/bunsen/testdata/ten/README.md deleted file mode 100644 index dbe60786..00000000 --- a/crates/bunsen/testdata/ten/README.md +++ /dev/null @@ -1,96 +0,0 @@ -# ten-vad reference fixtures - -Two goldens over the same audio, `../silero/test.wav` (16 kHz mono, 60 s, -3750 hops of 256 samples): - -| file | what it pins | produced by | -|---|---|---| -| `probs.json` | the **whole driver** — front end and model | the reference Python binding | -| `pitch.json` | feature `40` alone | a harness around the reference C pitch estimator | - -`probs.json` is the stronger of the two: nothing is shared between it and -bunsen except the audio. `pitch.json` isolates one feature, which is what makes -a pitch regression legible instead of showing up as a drifting probability. - -## `pitch.json` - -One pitch estimate in Hz per 256-sample hop — `0.0` meaning unvoiced — over -`../silero/test.wav` (16 kHz mono, 60 s, 3750 hops). Produced by the ten-vad -**C reference**, not by a port. - -Consumed by -`kits::speech::ten_vad::cross_test::tests::test_pitch_estimator_reference_golden`, -which pins `TenVadPitchEstimator` against it. - -### Regenerating - -`dump_pitch.cc` reproduces the reference's pitch branch: it drives -`AUP_PE_proc` (`src/pitch_est.cc`) from the reference `AUP_Analyzer` STFT, -feeding the estimator the raw hop and the un-normalized bin powers — the same -wiring `AUP_Aed_runOneFrm` uses. It needs a checkout of the ten-vad reference -for its sources; only the front end is linked, so no ONNX runtime is involved. - -`coeff.h` cannot be included directly (it pulls in `aed_st.h`, which needs -`onnxruntime_c_api.h`), so the Hann-768 analysis window is extracted from it -and given external linkage: - -```sh -TENVAD=/path/to/ten-vad # https://github.com/TEN-framework/ten-vad -awk '/^const float AUP_AED_STFTWindow_Hann768/,/};/' "$TENVAD/src/coeff.h" \ - | sed '1s/^const float/extern const float/' > window.cc - -g++ -O2 -w -I"$TENVAD/src" -o dump_pitch dump_pitch.cc window.cc \ - "$TENVAD/src/stft.cc" "$TENVAD/src/pitch_est.cc" \ - "$TENVAD/src/biquad.cc" "$TENVAD/src/fftw.c" - -./dump_pitch ../silero/test.wav \ - | awk '{printf "%s%s", (NR>1 ? ", " : "["), $2} END {print "]"}' > pitch.json -``` - -The dump prints `frameIndex pitchHz voiced` per line; only the pitch column is -checked in, since the voicing flag is recoverable as `pitch > 0`. - - -## `probs.json` - -One speech probability per hop, from the reference implementation end to end: -its own front end, its own inference engine. Consumed by -`kits::speech::ten_vad::cross_test::tests::test_reference_probability_golden`. - -### Regenerating - -`gen_probs.py` drives the shipped `lib/Linux/x64/libten_vad.so` through -`include/ten_vad.py` — the same `ten_vad_process` entry point every binding -uses — from the reference repo's own venv. - -```sh -TENVAD=/path/to/ten-vad # https://github.com/TEN-framework/ten-vad -cd "$TENVAD" && .venv/bin/python /path/to/gen_probs.py # see the snippet below -``` - -`gen_probs.py` exposes `main(wav_path, out_path)`; call it with this repo's -fixture and `probs.json`. - -**The prebuilt `.so` needs LLVM's libc++, which Ubuntu does not install by -default.** It is not in the base image and the failure is an opaque -`OSError: libc++.so.1: cannot open shared object file`. You do not need root — -fetch the packages and unpack them locally: - -```sh -mkdir -p /tmp/libcxx && cd /tmp/libcxx -apt-get download libc++1-18 libc++abi1-18 libunwind-18 -for d in *.deb; do dpkg -x "$d" root; done -export LD_LIBRARY_PATH=/tmp/libcxx/root/usr/lib/x86_64-linux-gnu -``` - -Note `libunwind-18` specifically, not `libunwind8`: `libc++abi` wants -`libunwind.so.1`, and Ubuntu's `libunwind8` provides `libunwind.so.8`. -Verify with `ldd "$TENVAD/lib/Linux/x64/libten_vad.so" | grep "not found"` -returning nothing before running the generator. - -Building from source instead is *not* currently an option here: the ONNX -Runtime C headers `examples_onnx` needs are not on this machine, and the venv -ships the runtime shared library without them. - -At the time of writing the fixture yields 3750 hops, 76.2% flagged voiced, with -probabilities spanning `[0.158018, 0.992463]`. diff --git a/crates/bunsen/testdata/ten/dump_pitch.cc b/crates/bunsen/testdata/ten/dump_pitch.cc deleted file mode 100644 index 36879d43..00000000 --- a/crates/bunsen/testdata/ten/dump_pitch.cc +++ /dev/null @@ -1,116 +0,0 @@ -// Reference dump: drives the ten-vad C front end (STFT + pitch estimator) -// over a 16 kHz mono 16-bit WAV and prints one line per hop: -// frameIdx pitchFreq voiced -// -// Reproduces AUP_Aed_procAudio's pitch branch exactly: -// * pre-emphasis feeds the STFT branch only, -// * the pitch estimator reads the raw hop and the un-normalized bin power. -#include -#include -#include -#include -#include - -#include "stft.h" -#include "pitch_est.h" - -extern const float AUP_AED_STFTWindow_Hann768[768]; - -static bool read_wav_i16(const char* path, std::vector& out, int& sr, int& ch) { - FILE* f = fopen(path, "rb"); - if (!f) return false; - char riff[12]; - if (fread(riff, 1, 12, f) != 12 || memcmp(riff, "RIFF", 4) || memcmp(riff + 8, "WAVE", 4)) { - fclose(f); return false; - } - int bits = 0; sr = 0; ch = 0; - while (true) { - char id[4]; uint32_t sz; - if (fread(id, 1, 4, f) != 4) break; - if (fread(&sz, 4, 1, f) != 1) break; - if (!memcmp(id, "fmt ", 4)) { - uint16_t fmt, nch, bps; uint32_t rate, brate; uint16_t align; - fread(&fmt, 2, 1, f); fread(&nch, 2, 1, f); fread(&rate, 4, 1, f); - fread(&brate, 4, 1, f); fread(&align, 2, 1, f); fread(&bps, 2, 1, f); - ch = nch; sr = (int)rate; bits = bps; - if (sz > 16) fseek(f, (long)sz - 16, SEEK_CUR); - } else if (!memcmp(id, "data", 4)) { - if (bits != 16) { fclose(f); return false; } - out.resize(sz / 2); - fread(out.data(), 1, sz, f); - fclose(f); - return true; - } else { - fseek(f, (long)sz + (sz & 1), SEEK_CUR); - } - } - fclose(f); - return false; -} - -int main(int argc, char** argv) { - if (argc < 2) { fprintf(stderr, "usage: dump_pitch \n"); return 1; } - - std::vector pcm; int sr = 0, ch = 0; - if (!read_wav_i16(argv[1], pcm, sr, ch)) { fprintf(stderr, "bad wav\n"); return 1; } - if (sr != 16000 || ch != 1) { fprintf(stderr, "need 16k mono, got %d/%d\n", sr, ch); return 1; } - - const int HOP = 256, FFT = 1024, WIN = 768, NBINS = FFT / 2 + 1; - - void* analyzer = NULL; - if (AUP_Analyzer_create(&analyzer) < 0) return 1; - Analyzer_StaticCfg acfg; - AUP_Analyzer_getStaticCfg(analyzer, &acfg); - acfg.win_len = WIN; acfg.hop_size = HOP; acfg.fft_size = FFT; - acfg.ana_win_coeff = AUP_AED_STFTWindow_Hann768; - if (AUP_Analyzer_memAllocate(analyzer, &acfg) < 0) return 1; - if (AUP_Analyzer_init(analyzer) < 0) return 1; - - void* pe = NULL; - if (AUP_PE_create(&pe) < 0) return 1; - PE_StaticCfg pcfg; - AUP_PE_getStaticCfg(pe, &pcfg); - pcfg.fftSz = FFT; pcfg.anaWindowSz = WIN; pcfg.hopSz = HOP; - pcfg.useLPCPreFiltering = 1; pcfg.procFs = 4000; - if (AUP_PE_memAllocate(pe, &pcfg) < 0) return 1; - if (AUP_PE_init(pe) < 0) return 1; - PE_DynamCfg dcfg; AUP_PE_getDynamCfg(pe, &dcfg); - dcfg.voicedThr = 0.4f; - AUP_PE_setDynamCfg(pe, &dcfg); - - std::vector raw(HOP), emph(HOP), spec(FFT), binPow(NBINS); - float pre = 0.0f; - size_t nFrames = pcm.size() / HOP; - - for (size_t fr = 0; fr < nFrames; fr++) { - for (int i = 0; i < HOP; i++) { - float x = (float)pcm[fr * HOP + i]; - raw[i] = x; - emph[i] = x - 0.97f * pre; - pre = x; - } - - Analyzer_InputData ain; ain.input = emph.data(); ain.iLength = HOP; - Analyzer_OutputData aout; aout.output = spec.data(); aout.oLength = FFT; - if (AUP_Analyzer_proc(analyzer, &ain, &aout) < 0) return 1; - - // FFTW half-complex unpack, matching AUP_Aed_CalcBinPow. - binPow[0] = spec[0] * spec[0]; - binPow[NBINS - 1] = spec[1] * spec[1]; - for (int i = 1; i < NBINS - 1; i++) { - binPow[i] = spec[2 * i] * spec[2 * i] + spec[2 * i + 1] * spec[2 * i + 1]; - } - - PE_InputData pin; - pin.timeSignal = raw.data(); pin.hopSz = HOP; - pin.inBinPow = binPow.data(); pin.nBins = NBINS; - PE_OutputData pout = {0, 0}; - if (AUP_PE_proc(pe, &pin, &pout) < 0) return 1; - - printf("%zu %.9g %d\n", fr, pout.pitchFreq, pout.voiced); - } - - AUP_PE_destroy(&pe); - AUP_Analyzer_destroy(&analyzer); - return 0; -} diff --git a/crates/bunsen/testdata/ten/gen_probs.py b/crates/bunsen/testdata/ten/gen_probs.py deleted file mode 100644 index 4f927d92..00000000 --- a/crates/bunsen/testdata/ten/gen_probs.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Dump per-hop ten-vad speech probabilities from the reference Python API. - -Drives the shipped `libten_vad.so` through `include/ten_vad.py` -- the same -path every binding takes -- over a 16 kHz mono 16-bit WAV, and writes one -probability per 256-sample hop as a JSON array. -""" - -import json -import sys -import wave - -import numpy as np - -TENVAD = "/home/crutcher/git/ten-vad" -sys.path.insert(0, f"{TENVAD}/include") - -from ten_vad import TenVad # noqa: E402 - -HOP = 256 - - -def read_wav_i16(path): - with wave.open(path, "rb") as w: - assert w.getnchannels() == 1, "need mono" - assert w.getframerate() == 16000, "need 16 kHz" - assert w.getsampwidth() == 2, "need 16-bit" - return np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16) - - -def main(wav_path, out_path): - pcm = read_wav_i16(wav_path) - vad = TenVad(HOP) - - probs, flags = [], [] - for start in range(0, len(pcm) - HOP + 1, HOP): - p, f = vad.process(pcm[start : start + HOP]) - probs.append(float(p)) - flags.append(int(f)) - - with open(out_path, "w") as fh: - json.dump(probs, fh) - - voiced = sum(flags) - print(f"hops={len(probs)} voiced={voiced} ({100.0*voiced/len(probs):.1f}%)") - print(f"prob range = [{min(probs):.6f}, {max(probs):.6f}]") - print(f"first 6 = {[round(p, 6) for p in probs[:6]]}") diff --git a/crates/bunsen/testdata/ten/pitch.json b/crates/bunsen/testdata/ten/pitch.json deleted file mode 100644 index f38b6586..00000000 --- a/crates/bunsen/testdata/ten/pitch.json +++ /dev/null @@ -1 +0,0 @@ -[0.0, 236.305634, 185.766739, 165.370819, 162.740585, 173.863266, 176.755951, 173.913086, 173.91304, 170.879242, 154.120285, 132.944672, 124.231209, 0.0, 173.826492, 174.746933, 168.49086, 174.72197, 169.403168, 213.351257, 183.606445, 175.185272, 190.476135, 190.476227, 190.476196, 186.02478, 171.959015, 151.640503, 142.80777, 127.070587, 122.848747, 190.47612, 190.476196, 190.476196, 190.476135, 190.476227, 190.476257, 190.476196, 190.476242, 200.817734, 210.243225, 213.635956, 225.009857, 234.551605, 239.180161, 253.695938, 251.015823, 232.937073, 241.855499, 255.045502, 266.688354, 273.096924, 266.666656, 254.558975, 245.177017, 247.330215, 250.0, 250.0, 242.714188, 250.028625, 252.755737, 234.487778, 222.174896, 222.160828, 228.274689, 181.251801, 169.388184, 181.818192, 190.66748, 204.079605, 211.695618, 214.170792, 225.520462, 227.159821, 216.591949, 203.456116, 180.490723, 0.0, 211.146896, 221.350891, 226.815018, 225.963318, 217.623077, 224.365143, 196.905045, 189.365387, 191.462769, 178.857437, 178.253326, 166.915237, 148.783279, 131.465866, 136.604156, 134.098297, 202.101898, 180.402802, 175.440628, 188.397263, 200.245773, 211.241074, 215.247421, 213.246872, 210.526321, 203.654221, 179.463135, 159.964066, 143.513977, 157.485184, 165.469009, 161.443451, 152.449402, 146.548431, 140.834534, 138.201172, 133.906067, 128.660919, 120.917137, 113.957741, 106.666656, 99.8888474, 104.050163, 100.560471, 96.8465424, 93.0832977, 100.4478, 102.815971, 99.3955383, 94.3346634, 96.8852615, 0.0, 0.0, 0.0, 91.4744492, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 79.1586227, 88.8572922, 92.9733582, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 112.849922, 94.0779419, 0.0, 0.0, 0.0, 0.0, 78.7552795, 0.0, 0.0, 0.0, 73.5747986, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 95.7609253, 166.561615, 161.959259, 166.057999, 166.666672, 163.981415, 162.50386, 165.492294, 166.666687, 166.666641, 158.855347, 158.039078, 167.607086, 176.995941, 184.332825, 184.832962, 181.818207, 173.073242, 176.448105, 189.273376, 201.340485, 225.582642, 239.922501, 242.226944, 247.715683, 254.182404, 254.791458, 250.0, 235.231277, 227.181992, 236.285324, 237.006638, 0.0, 235.722885, 223.674454, 235.294113, 225.804932, 215.21904, 203.326859, 210.526352, 210.526291, 210.526321, 210.526382, 210.526291, 199.228485, 195.941727, 193.120209, 164.216064, 223.711945, 0.0, 210.526276, 210.526291, 210.526352, 210.526321, 210.526382, 190.966461, 186.23587, 183.016556, 179.215912, 175.265106, 181.818176, 181.818146, 181.818192, 177.59288, 167.382935, 156.68425, 137.7108, 128.411865, 121.08696, 129.299881, 0.0, 0.0, 144.053345, 156.119217, 190.351089, 0.0, 0.0, 200.647369, 178.920837, 170.06105, 173.913055, 173.913055, 173.91301, 173.91304, 173.913071, 173.912994, 173.913025, 169.79216, 164.686813, 171.221024, 138.71936, 137.909088, 136.982544, 173.913025, 173.91304, 157.726669, 183.084183, 172.577133, 161.723312, 163.848297, 166.666656, 166.666702, 166.666672, 158.116989, 154.979568, 152.397812, 142.904602, 121.749687, 120.896461, 165.359283, 168.035812, 166.049088, 169.4758, 166.666687, 166.666718, 166.666611, 166.666687, 166.666672, 166.666718, 174.231247, 112.141747, 0.0, 169.571579, 163.636398, 157.78334, 171.351181, 0.0, 93.6799164, 0.0, 0.0, 152.074036, 123.050034, 111.221588, 97.7656708, 0.0, 0.0, 0.0, 0.0, 0.0, 116.686768, 0.0, 0.0, 0.0, 188.444977, 165.992752, 172.295593, 181.483322, 175.575439, 173.098495, 173.91304, 173.91304, 181.49939, 184.193756, 177.859772, 171.727036, 173.91304, 173.913086, 173.912994, 173.91304, 170.830978, 169.9431, 173.346573, 182.83194, 209.182999, 229.637512, 244.48494, 256.416534, 260.658234, 250.0, 268.445984, 270.223022, 256.217987, 245.351822, 260.915955, 272.611389, 260.521667, 232.247269, 234.45134, 253.383957, 251.420959, 250.0, 250.0, 266.406464, 265.570129, 245.92012, 239.798462, 234.779617, 217.198242, 217.26152, 222.222244, 222.222153, 222.222198, 222.222244, 222.222168, 222.222229, 222.222198, 211.572296, 196.224747, 217.290359, 230.944366, 222.222229, 222.222229, 222.222198, 284.054443, 307.764954, 233.129135, 196.100983, 193.091965, 180.72374, 172.369247, 165.389145, 159.808365, 137.759689, 125.592026, 116.932976, 109.129021, 124.140656, 188.447617, 200.813187, 191.210037, 195.834076, 202.230606, 202.136963, 200.000046, 199.999969, 200.0, 200.000046, 188.303223, 175.133881, 159.919464, 135.222, 139.19873, 201.606842, 198.532608, 190.268433, 185.754745, 194.838318, 200.023788, 179.785889, 163.409332, 155.15889, 148.094055, 144.904709, 143.44754, 133.893723, 135.655869, 134.283051, 129.496796, 125.402565, 113.256752, 107.622726, 101.265785, 92.2017975, 85.9127426, 83.9371643, 85.4037247, 87.9024277, 0.0, 0.0, 0.0, 114.205803, 129.065964, 147.496964, 0.0, 0.0, 93.7852478, 98.1013489, 0.0, 0.0, 91.0110168, 89.8495178, 89.2221375, 90.8929749, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 102.815781, 102.50061, 97.868576, 85.974411, 75.2401199, 78.5986633, 0.0, 195.240433, 190.476227, 190.476135, 95.2381058, 96.888382, 191.657516, 171.660339, 161.638245, 0.0, 0.0, 0.0, 0.0, 189.3125, 179.640594, 0.0, 0.0, 0.0, 0.0, 0.0, 263.727783, 220.519882, 0.0, 0.0, 0.0, 244.003845, 0.0, 256.434265, 282.4487, 0.0, 257.297241, 0.0, 263.130188, 0.0, 0.0, 0.0, 0.0, 238.979996, 237.865433, 194.786636, 225.643829, 247.635086, 242.591156, 198.778488, 245.073837, 288.37561, 0.0, 0.0, 179.631821, 177.255753, 180.288895, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 193.613968, 173.790955, 199.525574, 0.0, 0.0, 0.0, 218.338562, 0.0, 195.379761, 0.0, 248.513855, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 72.8454132, 73.5682831, 77.2593231, 91.0831833, 102.612236, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 75.3774109, 97.6036758, 0.0, 109.587006, 0.0, 0.0, 93.8106003, 98.6367722, 95.0866623, 0.0, 0.0, 135.017609, 167.205307, 0.0, 0.0, 0.0, 151.213623, 146.286499, 127.841759, 0.0, 107.245674, 109.064583, 134.335373, 148.55574, 146.54274, 150.388809, 131.984207, 99.7053146, 86.7923889, 81.1009598, 71.8542557, 120.201431, 0.0, 237.014084, 214.726074, 183.721268, 149.946152, 132.98053, 94.5137558, 94.4321289, 0.0, 176.935043, 182.866302, 194.407944, 204.338028, 209.684372, 213.325241, 221.34964, 225.956161, 231.87529, 238.798004, 254.652634, 242.248322, 239.404465, 255.011627, 240.824783, 223.966949, 214.271271, 231.575668, 240.800079, 243.19046, 239.20433, 244.97554, 256.364563, 253.381729, 231.883026, 221.54541, 216.585846, 198.320007, 185.117355, 175.847794, 217.944733, 235.818893, 227.606552, 222.222229, 238.90004, 229.033707, 220.595367, 212.986465, 206.533508, 206.706406, 210.526382, 210.526321, 210.526245, 210.526337, 210.526276, 210.526291, 204.411163, 197.115219, 191.552597, 184.804962, 164.356262, 151.626038, 225.939896, 210.526291, 210.526321, 206.263092, 210.526337, 204.632935, 210.526276, 210.526321, 210.526245, 204.538116, 206.537247, 210.79718, 199.040436, 211.198364, 196.687134, 169.823334, 160.701553, 166.666611, 166.666672, 166.666672, 174.52504, 181.622116, 183.982498, 192.637207, 203.850937, 214.835968, 222.273575, 234.548157, 251.813553, 268.949463, 275.680145, 260.560364, 266.666565, 255.983841, 221.814636, 0.0, 0.0, 0.0, 246.51947, 242.785324, 261.864777, 242.385345, 251.079758, 257.301331, 0.0, 0.0, 0.0, 188.363174, 203.796341, 207.814255, 0.0, 130.95433, 127.422562, 126.045898, 228.645798, 199.902283, 185.208618, 186.676682, 189.370132, 190.476288, 190.476242, 190.476196, 190.476212, 190.476196, 190.476196, 190.476242, 190.476242, 190.476242, 190.476196, 151.635178, 190.476212, 0.0, 190.476242, 190.476166, 190.476166, 190.47612, 190.476212, 190.476227, 0.0, 0.0, 0.0, 0.0, 192.470642, 197.03363, 187.218903, 176.092926, 168.403091, 164.817993, 164.272522, 174.926743, 184.303986, 194.977005, 202.6492, 215.380508, 213.560486, 210.526291, 218.826157, 225.073608, 223.471985, 222.222336, 204.572067, 191.412369, 192.42305, 0.0, 222.054672, 0.0, 0.0, 0.0, 211.219696, 194.063385, 181.627747, 174.436218, 178.948624, 181.818176, 181.818176, 181.818192, 174.712906, 170.877029, 173.913055, 141.476334, 129.242615, 177.181427, 190.001343, 179.077377, 173.306839, 183.281601, 184.921341, 171.612457, 181.87648, 184.392105, 177.567841, 168.89679, 164.76355, 166.682602, 157.174576, 151.715103, 151.058014, 150.829086, 147.026382, 146.663589, 153.619202, 167.97403, 166.929443, 175.691879, 187.204529, 194.170227, 198.99733, 202.495819, 201.923477, 200.0, 212.141571, 213.790283, 210.526291, 201.753677, 194.408844, 207.185867, 200.0, 206.152573, 199.999969, 207.97435, 237.884811, 235.941696, 192.180954, 176.829758, 160.196701, 165.154892, 133.75, 172.344742, 181.403183, 182.065216, 182.528046, 0.0, 241.408539, 0.0, 0.0, 147.976456, 140.155548, 135.180328, 135.600403, 137.93103, 132.894897, 131.656113, 133.333328, 133.333328, 133.333328, 133.333313, 133.333374, 133.333328, 133.333328, 137.070053, 139.29277, 133.724823, 139.411087, 129.389648, 131.209702, 134.202377, 0.0, 0.0, 134.261765, 144.199081, 149.011749, 0.0, 0.0, 0.0, 0.0, 93.75103, 104.578957, 0.0, 132.073624, 130.592514, 135.362167, 203.442413, 180.047089, 167.960251, 164.517044, 164.640579, 157.34903, 141.4776, 133.134781, 125.500549, 133.490341, 154.591507, 172.137024, 189.251083, 185.207855, 191.451996, 199.640335, 203.462097, 213.804962, 215.687546, 215.603683, 225.430206, 225.40242, 222.222275, 222.222153, 222.222168, 222.222275, 222.22229, 222.222076, 222.222168, 0.0, 222.222229, 213.147217, 0.0, 93.9785843, 113.812653, 114.916504, 111.111076, 107.948677, 222.222275, 222.222321, 222.22229, 222.222229, 0.0, 0.0, 116.516068, 112.610847, 109.386681, 111.111137, 111.111099, 0.0, 111.111115, 111.111115, 0.0, 0.0, 0.0, 235.334412, 232.132721, 238.807419, 235.294037, 235.294113, 235.294098, 235.294144, 235.294113, 235.294098, 229.95166, 214.359512, 202.990234, 196.881607, 187.349197, 174.582275, 165.07782, 156.504761, 149.899628, 145.998718, 140.89505, 141.501953, 142.857132, 142.857147, 142.857208, 148.200592, 156.133286, 162.441925, 169.068451, 160.49054, 148.100723, 145.987991, 165.692047, 199.051224, 0.0, 0.0, 153.971542, 154.573227, 158.412857, 153.846191, 146.614365, 143.622849, 154.692291, 136.507324, 112.090759, 102.907516, 106.547668, 98.7610397, 93.6974335, 95.6813507, 0.0, 0.0, 0.0, 0.0, 0.0, 152.117661, 0.0, 0.0, 0.0, 258.56192, 188.991516, 161.815094, 155.401489, 166.254028, 169.181, 163.16806, 158.492142, 158.313385, 160.0, 160.000031, 160.000031, 160.0, 160.0, 160.0, 160.0, 160.000015, 159.999954, 160.0, 162.862411, 168.383133, 167.658096, 158.712097, 156.381821, 0.0, 0.0, 0.0, 78.3349991, 81.1692657, 116.447945, 0.0, 0.0, 0.0, 0.0, 118.409294, 0.0, 131.550095, 124.262466, 126.519119, 0.0, 0.0, 79.638443, 80.2706528, 92.978447, 0.0, 0.0, 0.0, 86.2039108, 90.2519073, 0.0, 0.0, 67.3331299, 60.200367, 0.0, 63.4920616, 0.0, 150.165771, 154.610901, 0.0, 83.0646591, 0.0, 173.940659, 169.479034, 178.359665, 185.170364, 175.37236, 171.464584, 173.91301, 173.913055, 173.91304, 173.912994, 173.913132, 173.913193, 0.0, 173.91304, 173.91301, 173.912994, 173.913025, 0.0, 173.91304, 173.912979, 182.072144, 207.150345, 203.25502, 200.000061, 199.999985, 199.999985, 199.999969, 200.000046, 192.281693, 184.016449, 153.069031, 200.000046, 200.000061, 199.999969, 174.283142, 158.276489, 159.364029, 160.48201, 168.101944, 169.416565, 166.666626, 172.861359, 173.148148, 174.982391, 184.454163, 199.809677, 212.446762, 224.423874, 237.421127, 242.011627, 238.019424, 235.294113, 248.507584, 257.013855, 250.0, 250.0, 234.286331, 211.878525, 211.980103, 236.483658, 0.0, 243.137405, 243.42598, 250.0, 250.0, 250.0, 243.547989, 203.743179, 202.266571, 210.390854, 210.526382, 225.652252, 224.502945, 222.222198, 210.696182, 196.447083, 182.894226, 166.02272, 152.130829, 140.350891, 128.343475, 119.165817, 118.587685, 119.748466, 121.212151, 119.260094, 122.826591, 118.423737, 132.986099, 122.524109, 116.790764, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 62.421196, 77.613739, 0.0, 0.0, 0.0, 0.0, 120.171295, 0.0, 0.0, 0.0, 0.0, 0.0, 80.4837646, 83.0080032, 84.2415085, 88.7075653, 84.0106354, 0.0, 119.078918, 0.0, 0.0, 0.0, 74.5202103, 71.0040588, 75.3416901, 77.6109695, 68.4541245, 0.0, 0.0, 0.0, 79.1593933, 193.134613, 174.904312, 167.712982, 173.009796, 173.91304, 173.913086, 173.912964, 173.91301, 166.175339, 164.193985, 166.666626, 166.666687, 166.666672, 166.666672, 166.666641, 160.449692, 157.018768, 151.768387, 136.800125, 126.984123, 163.598404, 174.1474, 169.556107, 162.92157, 166.666672, 162.243805, 162.706024, 165.850647, 0.0, 0.0, 0.0, 166.666672, 166.666672, 161.976715, 164.939056, 239.536133, 212.207581, 175.063873, 161.442444, 165.507385, 157.459854, 150.949142, 152.381805, 153.846191, 153.846115, 153.846161, 153.846115, 153.846161, 148.081161, 146.252182, 148.148117, 148.148132, 142.84256, 140.477295, 142.857132, 142.857178, 140.812012, 136.910217, 139.308243, 135.382217, 129.794342, 126.758408, 127.580498, 129.032242, 129.032257, 123.438881, 121.351784, 119.816399, 121.21212, 119.177452, 118.148956, 132.861359, 130.637192, 138.440109, 127.987579, 137.507217, 123.540886, 125.70266, 0.0, 136.367691, 129.505051, 123.041336, 136.514511, 131.39003, 125.5942, 140.846619, 135.697525, 120.079971, 118.240921, 125.337418, 133.258682, 144.328293, 145.58403, 109.162148, 0.0, 0.0, 0.0, 0.0, 90.4759521, 96.7560577, 110.615784, 0.0, 0.0, 0.0, 84.5022964, 77.9272995, 81.2372894, 91.6435471, 0.0, 112.268021, 122.688454, 0.0, 87.549942, 0.0, 93.0410767, 0.0, 74.5619507, 78.5225143, 0.0, 0.0, 0.0, 153.621368, 148.357391, 154.100861, 168.494904, 175.373596, 193.618729, 196.104156, 198.612717, 202.821304, 208.486221, 213.242828, 212.969955, 210.52623, 210.52623, 210.526276, 222.356735, 225.791031, 222.222168, 209.521393, 201.316818, 198.003754, 190.94342, 187.077316, 183.591293, 195.219437, 0.0, 0.0, 189.399811, 170.745758, 168.864746, 179.311127, 190.155426, 201.745819, 211.328186, 214.751526, 208.84285, 203.770004, 205.94223, 200.937302, 197.47789, 205.7892, 206.716156, 196.428772, 178.290634, 211.680359, 212.807297, 216.532486, 0.0, 191.509781, 171.571381, 161.090714, 173.913025, 173.912964, 173.91304, 168.792984, 164.680481, 158.271133, 157.816452, 166.759521, 170.338715, 164.228287, 165.134064, 169.876587, 170.039856, 152.604797, 151.7164, 153.8461, 153.846161, 153.846161, 153.846237, 153.846191, 153.846161, 153.846161, 150.320496, 146.517227, 146.477066, 148.148117, 148.148148, 148.148178, 148.148148, 151.535461, 155.603592, 158.442657, 151.286453, 146.92952, 0.0, 0.0, 0.0, 0.0, 170.955505, 158.929932, 144.428482, 150.454651, 156.733795, 146.895142, 145.373123, 136.378571, 130.729523, 127.335854, 127.062996, 145.623672, 149.952362, 0.0, 208.859924, 188.75946, 194.990906, 202.462814, 200.0, 200.000046, 199.999969, 193.716644, 188.226547, 188.258209, 190.476166, 190.476151, 190.476227, 190.476196, 190.476242, 190.47612, 190.476212, 190.476242, 148.090698, 147.153427, 146.863556, 157.881866, 182.572662, 184.443695, 166.782089, 150.838211, 142.896362, 122.551575, 118.631248, 127.800179, 0.0, 125.636009, 190.977234, 0.0, 0.0, 219.630569, 197.934464, 186.332657, 187.641708, 190.476196, 199.279419, 204.103973, 199.999939, 188.521179, 184.969238, 190.476212, 190.476212, 185.268997, 179.668747, 180.226074, 178.768921, 173.296585, 169.286804, 158.10968, 150.737061, 136.283936, 126.984169, 117.209366, 118.608688, 126.894287, 0.0, 95.0450592, 0.0, 80.7949524, 0.0, 184.08609, 175.162949, 164.464264, 159.573456, 158.271591, 158.361053, 153.381989, 154.08728, 161.510422, 163.154022, 154.404709, 148.3582, 160.0, 163.804337, 165.56134, 160.175476, 158.64357, 160.000076, 166.411682, 188.786407, 203.983627, 198.901199, 181.677521, 174.799118, 164.645966, 154.189362, 156.542755, 0.0, 195.061981, 192.747665, 190.955322, 173.560318, 166.974655, 157.490982, 157.82103, 160.000046, 160.0, 158.539337, 157.282089, 153.830353, 0.0, 103.437302, 176.468933, 161.190582, 163.879196, 173.751007, 176.551605, 182.809662, 184.585007, 176.602768, 170.773605, 167.99614, 173.91301, 157.791245, 166.666672, 174.591934, 176.234634, 173.913071, 173.913177, 173.913086, 173.91304, 170.283203, 164.730148, 155.163803, 135.593246, 129.4617, 134.017151, 174.653046, 162.671402, 156.967941, 159.192017, 160.0, 155.594025, 152.049896, 152.240845, 153.846161, 160.358231, 162.574249, 159.999985, 160.000031, 160.0, 160.000031, 160.0, 161.524414, 159.576462, 159.161865, 159.999969, 160.0, 156.840897, 152.422119, 151.660858, 153.846054, 153.846191, 153.846161, 153.84613, 147.129395, 146.756958, 148.148132, 148.148117, 141.435287, 137.58783, 132.186768, 120.843628, 125.418442, 153.723892, 154.901642, 0.0, 163.674423, 166.841675, 166.808456, 166.666687, 166.666718, 158.466034, 158.476273, 167.163651, 168.755295, 173.462189, 177.038513, 173.913116, 170.097763, 164.787384, 160.612534, 158.553162, 159.013306, 159.999954, 159.999939, 159.999969, 159.999954, 159.999954, 160.000031, 153.647766, 145.656662, 117.409264, 105.921104, 169.051056, 162.079514, 160.000031, 136.652863, 155.425262, 163.275482, 144.08255, 146.086868, 143.075928, 140.50148, 142.857147, 142.857147, 142.857147, 142.857132, 142.857147, 142.857101, 142.857101, 142.857193, 146.939072, 161.463379, 169.005859, 174.619766, 180.150009, 190.775467, 202.304626, 224.403488, 250.500641, 271.040558, 272.563568, 291.314911, 291.937775, 285.714294, 285.714417, 285.714386, 285.714172, 264.05246, 243.898392, 229.742722, 217.731445, 203.132355, 185.527954, 176.261292, 250.364151, 258.653107, 268.949463, 225.344177, 260.82428, 264.098541, 200.719437, 182.803253, 165.840195, 158.708969, 145.534912, 145.310501, 146.693054, 148.148148, 146.410202, 142.020142, 120.347893, 120.323669, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 71.828125, 0.0, 119.264, 146.858582, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 152.452026, 203.238754, 230.524261, 209.321426, 207.615097, 230.79277, 253.71373, 249.969421, 222.990311, 209.596848, 222.222229, 222.222244, 228.039642, 222.647461, 223.153748, 242.504395, 236.969681, 204.94574, 192.108963, 186.950699, 174.057037, 167.940598, 209.882324, 135.151535, 142.735916, 237.230316, 203.206573, 186.475281, 185.056549, 190.476105, 153.196808, 143.369598, 140.278717, 140.08754, 149.889053, 166.013504, 178.708649, 177.953842, 173.37085, 172.286346, 173.91304, 173.912994, 173.91304, 173.913101, 180.473618, 186.263321, 181.818237, 190.441483, 198.220169, 184.06604, 180.592834, 180.256119, 181.818192, 181.818207, 186.711807, 195.377869, 175.156616, 161.959152, 172.333832, 111.285988, 0.0, 180.759811, 189.676056, 187.929672, 179.912231, 169.423386, 166.179199, 174.698288, 173.912964, 173.913086, 173.912964, 173.913101, 165.808746, 165.042938, 166.666672, 166.666656, 158.796204, 155.545868, 140.49205, 126.984154, 153.185913, 168.025436, 180.231842, 169.839966, 160.162109, 151.937729, 156.028122, 161.80368, 161.232712, 160.000076, 159.999969, 160.0, 159.999863, 159.999969, 153.344849, 146.04744, 146.764236, 153.638367, 147.012741, 142.106812, 135.357147, 136.613693, 141.279221, 147.542725, 156.874832, 157.234985, 150.050308, 150.719986, 147.083054, 148.148132, 148.148148, 148.148148, 148.148148, 148.148178, 148.148148, 148.148163, 148.148148, 148.148193, 143.525986, 130.725906, 119.402969, 147.784348, 154.487366, 148.148117, 148.148178, 148.148163, 148.148148, 148.148163, 148.148148, 148.148193, 148.148178, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 148.148178, 148.148148, 148.148148, 145.572098, 131.425385, 0.0, 173.918411, 159.116501, 153.393646, 148.910431, 143.738525, 142.20726, 153.828217, 163.353241, 154.291351, 150.63118, 176.817703, 167.875366, 142.071182, 137.478119, 140.51593, 141.705887, 152.844604, 158.922531, 162.143539, 162.154724, 159.999969, 153.109604, 152.087112, 147.752899, 140.940002, 136.164337, 131.677826, 127.488411, 127.233192, 124.952217, 123.860703, 125.0, 131.256378, 141.344681, 152.85379, 148.085968, 0.0, 0.0, 0.0, 180.633545, 177.65329, 180.042419, 184.152878, 180.886032, 176.067841, 201.739349, 190.476166, 234.732254, 239.696762, 254.74028, 254.219345, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 222.248367, 214.560135, 228.996628, 239.212936, 238.011047, 235.294113, 235.294144, 222.814941, 217.829132, 222.222229, 222.222198, 214.216949, 198.416962, 174.948715, 151.49794, 133.990768, 0.0, 226.913254, 225.226044, 207.290421, 188.704727, 176.738022, 177.887344, 180.554871, 190.601807, 203.454681, 214.733383, 226.97261, 225.290283, 239.269913, 225.264297, 216.454422, 206.898651, 196.708145, 168.18544, 150.996719, 162.010986, 183.474091, 200.333008, 196.108704, 180.134216, 178.711945, 172.343262, 171.949631, 173.913025, 164.619583, 158.165558, 143.383423, 139.509232, 0.0, 0.0, 0.0, 173.654465, 174.938599, 170.746933, 189.546967, 173.233826, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 119.392708, 107.523628, 214.708023, 209.493423, 210.526398, 223.792267, 227.314011, 228.662399, 239.984741, 269.53653, 198.538025, 224.028656, 0.0, 0.0, 0.0, 0.0, 279.684937, 231.147934, 203.677094, 200.57074, 197.103424, 193.33815, 192.405655, 187.515472, 190.47612, 188.014816, 186.643417, 192.022202, 205.163727, 202.524429, 211.465195, 213.137726, 210.526382, 210.526215, 210.526321, 210.526276, 210.526337, 219.307129, 221.586853, 210.214798, 207.622467, 210.526352, 210.526382, 210.526337, 210.526321, 210.526321, 203.637695, 193.971191, 199.999969, 200.000015, 200.0, 199.999924, 199.999863, 189.027481, 162.686691, 155.433609, 177.400253, 203.431473, 223.304672, 200.000015, 202.635757, 0.0, 222.107651, 190.290466, 183.287216, 188.889664, 190.476151, 190.476196, 190.476242, 190.476151, 186.026031, 184.649399, 188.478241, 168.962601, 156.04895, 173.661499, 197.571045, 194.082016, 190.476212, 190.476135, 190.476227, 190.476196, 190.476212, 190.476227, 190.476135, 190.476196, 180.861389, 179.334534, 190.963791, 196.296387, 190.476135, 181.133179, 174.65535, 0.0, 0.0, 192.008408, 192.87941, 182.469894, 180.922012, 192.760559, 188.764847, 190.476196, 185.045593, 0.0, 184.142303, 0.0, 89.8254623, 0.0, 0.0, 190.476196, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 153.243164, 155.660843, 166.769058, 168.669891, 175.258881, 174.528931, 180.416763, 184.485809, 183.932465, 191.161835, 194.060577, 190.476166, 201.003235, 202.606827, 200.000061, 199.999939, 200.000061, 190.131638, 186.58577, 190.476212, 190.476242, 190.476196, 186.402176, 151.2995, 141.677216, 157.893738, 168.479248, 162.359833, 135.593231, 128.924057, 133.012375, 144.292648, 151.242447, 148.148148, 148.148117, 182.354126, 183.248642, 173.734543, 170.758286, 166.062973, 165.978973, 166.666672, 166.666672, 166.666611, 166.666626, 166.666687, 166.666702, 166.666748, 174.048172, 176.727432, 173.913071, 173.913055, 173.913071, 173.91304, 173.91304, 173.91304, 173.912994, 170.135239, 164.735886, 164.761917, 166.666626, 172.718918, 178.033798, 173.913025, 173.91304, 173.913055, 0.0, 0.0, 104.095032, 188.733139, 164.670959, 159.347946, 158.201447, 158.301697, 160.000092, 160.0, 160.000076, 164.440292, 168.637466, 168.434814, 166.666672, 166.666672, 166.666672, 166.666656, 166.666702, 166.666702, 166.666626, 166.666611, 172.054718, 187.608124, 172.461349, 232.083633, 227.095032, 209.510147, 196.007111, 210.555862, 237.093735, 253.657013, 261.392395, 256.633362, 227.828476, 235.294144, 255.785278, 250.47731, 247.471069, 250.0, 250.0, 250.0, 250.0, 235.940643, 229.807983, 223.856583, 217.533478, 213.566818, 204.597366, 210.526398, 210.526321, 179.531952, 164.957382, 0.0, 250.0, 250.0, 250.0, 250.0, 250.0, 241.818436, 0.0, 0.0, 0.0, 0.0, 225.411285, 267.608582, 250.0, 0.0, 223.686142, 211.329147, 197.945496, 196.443222, 187.438614, 187.157211, 186.619507, 179.865707, 179.121979, 174.485611, 170.690201, 170.932968, 164.858566, 157.831345, 151.647415, 139.441864, 132.111862, 126.337486, 122.628723, 119.895767, 119.535469, 121.212105, 121.212105, 121.212135, 121.21212, 118.630577, 0.0, 0.0, 0.0, 122.35083, 118.358559, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 100.228539, 0.0, 0.0, 0.0, 205.159897, 213.584366, 197.544144, 200.000015, 199.999939, 200.000015, 199.999924, 200.0, 203.892654, 215.755219, 210.526321, 210.526291, 214.791824, 203.459274, 189.853088, 160.698349, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 200.000015, 253.275848, 258.514832, 224.411896, 206.214172, 187.822372, 191.541687, 214.113235, 0.0, 0.0, 231.39772, 217.595154, 225.172501, 249.71756, 254.67128, 253.877045, 240.540543, 228.844452, 206.235428, 235.294098, 235.294006, 228.562637, 236.526474, 243.865295, 236.564011, 204.994247, 190.374008, 161.018097, 150.36525, 143.776718, 138.703308, 132.46843, 130.865402, 132.097885, 135.00058, 142.936462, 169.945374, 157.527496, 236.569046, 281.597961, 298.606049, 261.960693, 208.165649, 188.763412, 175.904816, 179.683563, 173.470413, 171.174423, 181.842438, 185.292603, 178.781128, 176.816269, 180.687561, 181.818176, 181.818207, 164.321442, 151.751053, 147.520203, 122.280716, 117.60804, 148.484436, 178.546555, 199.047333, 191.619583, 190.476227, 190.476166, 185.938019, 179.686234, 179.184174, 181.818192, 181.818146, 173.902435, 170.786942, 173.913071, 165.030914, 169.115555, 127.877762, 129.897507, 109.265854, 118.284714, 124.749451, 115.044159, 171.388397, 181.830261, 185.420059, 181.818161, 181.81813, 181.818115, 178.482712, 142.268814, 0.0, 0.0, 0.0, 72.2406158, 0.0, 0.0, 92.9286118, 0.0, 0.0, 84.7425537, 75.6991119, 81.6735535, 0.0, 81.7762756, 0.0, 80.7975693, 73.9593582, 0.0, 0.0, 0.0, 0.0, 90.5318756, 90.2107544, 0.0, 95.0225067, 96.0334015, 180.972122, 170.268906, 172.777069, 173.91304, 173.913086, 169.870285, 164.772308, 164.944626, 166.666626, 166.666687, 166.666718, 166.666672, 159.873993, 157.852142, 159.999954, 160.000015, 160.0, 155.974503, 152.00882, 152.255447, 153.846115, 150.188965, 146.537079, 138.468735, 131.861176, 124.670662, 118.959244, 106.666611, 161.379684, 155.7798, 153.846191, 168.143951, 185.526047, 189.586304, 183.776443, 186.969162, 192.887436, 192.368881, 190.476151, 190.476257, 181.329163, 178.332535, 194.457413, 190.476196, 190.476105, 190.476196, 190.476196, 190.476288, 190.476242, 190.476166, 163.413437, 143.238342, 132.034149, 120.235268, 110.476906, 112.475304, 190.476166, 190.476151, 190.476212, 190.476166, 190.476135, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 187.210739, 0.0, 0.0, 0.0, 0.0, 103.608955, 121.479774, 132.202301, 143.44136, 136.520218, 124.510353, 115.068512, 107.896446, 121.185783, 128.132935, 145.983856, 0.0, 0.0, 123.853271, 143.933914, 132.120544, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 153.022079, 0.0, 0.0, 0.0, 0.0, 175.0728, 0.0, 87.0820923, 91.4717102, 94.0197296, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 237.803589, 0.0, 173.678513, 177.470886, 171.080521, 173.194626, 183.455994, 184.601868, 190.658768, 189.574951, 179.053177, 167.980286, 181.818207, 181.818161, 181.818146, 224.11705, 203.883224, 185.078995, 162.684265, 168.906967, 193.6716, 200.893478, 193.620438, 190.476227, 190.476196, 190.476212, 185.059738, 179.124237, 179.832672, 177.815109, 182.530365, 174.880447, 171.09082, 173.91304, 173.913086, 173.913101, 173.913071, 173.91304, 173.91304, 173.913116, 165.463898, 161.542419, 150.579147, 121.600029, 177.825531, 0.0, 173.132797, 165.522934, 225.923889, 241.529404, 189.861069, 178.720673, 181.971497, 183.379913, 192.886581, 190.295181, 186.438065, 190.832733, 180.202972, 178.442352, 184.127808, 144.710968, 133.737091, 192.231781, 186.046112, 180.886826, 0.0, 0.0, 0.0, 188.05188, 190.476242, 190.476196, 190.476135, 180.894501, 170.984055, 170.081619, 138.666901, 127.117424, 122.128059, 194.182999, 193.064468, 190.476166, 190.476151, 225.063934, 219.212036, 215.894043, 226.069473, 225.882401, 230.045624, 239.144653, 240.202362, 235.294067, 224.988739, 180.088531, 157.5298, 213.979095, 221.62532, 226.424301, 222.222198, 222.222107, 222.22229, 222.222275, 217.910782, 190.374039, 175.223801, 162.185867, 160.176132, 158.230667, 158.715775, 160.000031, 160.000092, 155.910599, 162.078934, 165.840958, 159.479996, 159.999924, 160.000061, 0.0, 0.0, 175.360367, 180.754669, 0.0, 0.0, 0.0, 99.8459396, 0.0, 98.8544464, 0.0, 0.0, 367.237183, 398.369415, 294.686829, 0.0, 206.721741, 187.969055, 186.059402, 207.157364, 217.086685, 225.6315, 225.66658, 222.222229, 222.222153, 222.222229, 201.715347, 186.588364, 200.000015, 208.744125, 237.728027, 246.702408, 228.90802, 218.515717, 218.194092, 222.222244, 203.110123, 200.002045, 210.586929, 226.867966, 223.807175, 217.852127, 216.188889, 221.492676, 229.458176, 212.18959, 208.274216, 238.091766, 0.0, 235.299683, 222.143463, 192.675552, 197.367325, 200.000061, 200.0, 200.000076, 200.000015, 200.000046, 199.999969, 205.787781, 202.71167, 190.424316, 179.325241, 196.127136, 211.214844, 200.000046, 199.999939, 190.669464, 188.487762, 189.571167, 190.476212, 190.476196, 190.476151, 190.476166, 185.731552, 179.647842, 179.003265, 177.21402, 171.982346, 172.052963, 173.913025, 163.40358, 149.962082, 144.297791, 128.03923, 0.0, 185.930908, 177.939804, 176.271622, 176.826904, 173.26416, 176.211868, 176.844543, 176.769073, 176.357605, 176.611465, 0.0, 177.971558, 179.015488, 175.540298, 172.842239, 168.929352, 91.406929, 84.3987961, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 71.5270309, 0.0, 0.0, 0.0, 0.0, 188.251282, 166.300003, 158.805252, 162.834061, 152.609085, 128.555771, 168.924927, 176.642838, 162.320282, 164.279053, 167.994476, 229.410172, 237.10672, 201.937866, 210.336792, 225.757767, 233.442352, 248.607269, 257.490295, 253.848038, 250.0, 237.684967, 225.098526, 242.909286, 246.187225, 250.110443, 251.378647, 220.088318, 215.649124, 220.473785, 209.106079, 194.530228, 249.148331, 258.41452, 254.445923, 228.105667, 231.87973, 235.294144, 247.24501, 227.450882, 203.681503, 202.291779, 210.526337, 210.526245, 210.526382, 210.526382, 210.526291, 210.526321, 210.526321, 210.526337, 210.526321, 163.455566, 207.691284, 218.15036, 210.526321, 210.526352, 210.526337, 210.526321, 210.526215, 210.526352, 210.526276, 205.078979, 210.526337, 210.526337, 210.526321, 210.526276, 210.526321, 210.526291, 210.526321, 210.526291, 0.0, 0.0, 0.0, 0.0, 211.844559, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 75.2689438, 70.4582367, 67.6860657, 0.0, 0.0, 164.62059, 169.125687, 170.697876, 166.666672, 166.666595, 166.666626, 171.987518, 181.855133, 191.497467, 201.443909, 212.443527, 216.145081, 225.757462, 226.2211, 231.558228, 239.038559, 238.018173, 235.294113, 235.294098, 235.294067, 210.678528, 192.143661, 185.255829, 198.911041, 237.815475, 243.890533, 236.552689, 235.294113, 235.294113, 227.947708, 218.855209, 218.256577, 222.222198, 222.222168, 222.222244, 222.222153, 215.245483, 206.825562, 196.547379, 177.499619, 160.532898, 158.17218, 151.790695, 151.490677, 140.791046, 140.950424, 148.173828, 155.976486, 166.800262, 183.892792, 194.560577, 201.312851, 225.586517, 240.549866, 258.338257, 275.915558, 271.22464, 285.475586, 286.55835, 256.245667, 237.394089, 229.800293, 198.878815, 176.967651, 0.0, 238.511398, 266.666626, 0.0, 266.666656, 249.539093, 266.666748, 209.726547, 198.418671, 184.22908, 176.445129, 179.004303, 174.312912, 160.184128, 146.701828, 135.593262, 126.984146, 119.402969, 113.900185, 110.238632, 106.101746, 105.265862, 104.531944, 99.1180496, 101.818123, 109.699829, 111.331078, 0.0, 0.0, 0.0, 0.0, 111.897652, 115.729393, 0.0, 0.0, 0.0, 99.4258728, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 62.0956764, 0.0, 0.0, 124.397926, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 108.535141, 105.974892, 0.0, 0.0, 86.1865463, 107.262871, 114.057579, 128.824173, 110.679985, 154.621155, 0.0, 0.0, 0.0, 84.4888306, 101.660782, 96.6636353, 86.2687836, 73.2729721, 69.9571686, 92.5190811, 89.5181732, 61.0346832, 61.9158859, 62.5, 0.0, 0.0, 0.0, 90.0022202, 178.988464, 205.682785, 231.581802, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 87.6857529, 106.73381, 106.878113, 0.0, 0.0, 0.0, 75.8305511, 0.0, 0.0, 0.0, 76.8611145, 95.3771133, 103.907906, 86.9923782, 0.0, 92.9519119, 0.0, 0.0, 0.0, 0.0, 87.2165527, 84.8820877, 0.0, 0.0, 0.0, 75.4453888, 80.3005142, 0.0, 0.0, 0.0, 93.9616318, 81.2396164, 100.553398, 108.765953, 0.0, 117.754288, 0.0, 0.0, 0.0, 97.8514252, 0.0, 79.4888535, 87.1135712, 95.8142014, 0.0, 0.0, 138.732895, 131.174362, 119.18898, 103.744919, 102.812286, 0.0, 0.0, 106.256172, 72.5558395, 0.0, 0.0, 0.0, 0.0, 101.160339, 0.0, 113.518089, 94.981369, 85.8227005, 68.6204529, 0.0, 73.2027588, 91.7411804, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 321.442841, 196.302216, 204.128891, 212.762726, 225.783051, 226.65976, 222.222168, 222.222244, 215.761459, 208.040131, 206.690125, 187.982971, 154.370621, 154.409195, 193.771011, 218.210068, 228.31546, 226.120224, 227.731003, 228.890701, 204.854889, 211.53064, 218.576492, 225.888931, 224.959518, 237.326797, 238.851959, 235.294113, 235.294174, 244.836197, 235.294037, 223.861313, 235.29422, 235.294144, 235.294098, 235.294113, 235.294006, 235.294174, 212.361343, 204.437515, 208.616898, 202.793655, 195.424072, 176.514938, 190.476196, 224.879013, 258.269806, 254.814163, 260.674103, 271.988159, 272.823334, 258.055786, 238.288406, 215.452499, 195.443176, 176.313492, 156.215668, 136.600967, 123.737434, 123.828377, 129.033188, 135.15863, 127.279701, 117.676659, 119.444153, 128.774918, 131.792374, 130.320999, 126.554581, 127.517647, 129.242752, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 102.409454, 91.9602509, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 107.986, 94.8079147, 92.6115417, 100.333321, 123.193703, 129.460907, 138.204697, 0.0, 0.0, 91.653183, 91.0362701, 94.1753235, 88.5019226, 95.2038345, 268.162689, 250.48407, 254.287445, 0.0, 0.0, 0.0, 0.0, 73.7061539, 0.0, 0.0, 0.0, 0.0, 227.64769, 208.480469, 198.048859, 0.0, 0.0, 235.081451, 0.0, 0.0, 0.0, 0.0, 122.515289, 110.496628, 101.274811, 104.226692, 101.839813, 104.041351, 130.953415, 146.850708, 0.0, 99.3893967, 98.3506851, 108.607155, 155.553085, 151.398056, 192.131042, 173.827698, 0.0, 137.06012, 151.078369, 178.036072, 202.263412, 218.952393, 230.827087, 234.406158, 238.537949, 237.933975, 227.154465, 215.145248, 217.999603, 211.682755, 229.250076, 217.783157, 214.24115, 232.510071, 231.553696, 236.864014, 234.793335, 233.328247, 235.294067, 232.996262, 235.294189, 235.294067, 221.855576, 218.903381, 222.222229, 222.222198, 222.222229, 210.439316, 220.167908, 225.022537, 230.940704, 239.825806, 239.441864, 258.196564, 245.986206, 225.822479, 223.216019, 216.145264, 237.925858, 234.152557, 220.608917, 251.447632, 227.770554, 204.789902, 204.517609, 210.526398, 221.949173, 225.730988, 237.608521, 237.45549, 235.294098, 227.930054, 218.626602, 216.995575, 210.94072, 196.799271, 195.326706, 191.108566, 188.404724, 198.607727, 202.446457, 200.225479, 168.909698, 0.0, 0.0, 0.0, 114.442879, 117.647072, 0.0, 221.525101, 204.753494, 210.526321, 224.09993, 240.789688, 239.951462, 235.294113, 229.805054, 216.295334, 222.222229, 222.222168, 220.0401, 222.33931, 211.997131, 193.161667, 181.674072, 205.003922, 233.3526, 249.960938, 236.436676, 0.0, 0.0, 253.94957, 221.164001, 218.435364, 222.222229, 217.924072, 208.858185, 198.398239, 169.838242, 151.772507, 214.807816, 233.93161, 222.222168, 192.109238, 172.360611, 159.230408, 186.457077, 205.403488, 217.459335, 228.580841, 225.774643, 236.601593, 239.716873, 222.190231, 212.599472, 212.070755, 204.671799, 207.214493, 241.307373, 242.222061, 235.294113, 235.294144, 235.294067, 235.294144, 235.294113, 235.294067, 222.021866, 203.48439, 186.850494, 196.484268, 241.752396, 244.081589, 235.294174, 235.294067, 235.294174, 217.522003, 217.876953, 0.0, 0.0, 0.0, 0.0, 256.785461, 235.294098, 235.294144, 235.294113, 221.794662, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 199.055725, 191.719818, 223.4599, 244.938278, 268.400391, 275.342285, 291.122162, 291.705963, 271.349091, 252.952499, 231.804169, 0.0, 0.0, 256.273682, 266.666565, 266.666656, 276.835846, 227.091705, 194.483887, 177.082718, 171.125214, 173.938034, 160.687912, 144.810684, 131.101181, 119.427353, 116.945976, 115.260979, 113.891273, 121.179718, 124.4272, 115.348503, 0.0, 0.0, 0.0, 92.0027618, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 68.1642151, 67.6439972, 82.6921387, 90.2988586, 96.6288071, 84.2688675, 0.0, 0.0, 0.0, 0.0, 106.489571, 0.0, 0.0, 0.0, 0.0, 96.7772217, 95.303215, 90.0015335, 92.4090881, 91.263649, 0.0, 0.0, 0.0, 66.548111, 68.5286942, 67.9504776, 67.3791885, 67.7966232, 67.796608, 66.6564865, 66.2555389, 66.6666565, 67.7671432, 68.1601868, 68.5487366, 204.511551, 189.680267, 186.894592, 201.501144, 204.002869, 200.000076, 231.170883, 192.816376, 195.568176, 190.379395, 188.616714, 190.476135, 197.149673, 202.386215, 209.53511, 213.849243, 212.486206, 216.280258, 225.371048, 225.251617, 236.396439, 239.544647, 235.294144, 235.294113, 235.294174, 235.294113, 235.294067, 227.616684, 218.873795, 217.663177, 216.21312, 207.397858, 182.656815, 168.385834, 245.855515, 227.287796, 232.950134, 244.063049, 230.716217, 221.338943, 234.87146, 195.42775, 181.252686, 170.365891, 180.192856, 184.518661, 191.677078, 199.482071, 202.561005, 201.987518, 199.999985, 211.105133, 214.426163, 210.526337, 210.526382, 210.526321, 210.526245, 210.526245, 203.526962, 196.96669, 197.243179, 199.999939, 200.0, 195.821259, 182.632004, 182.072021, 213.294296, 208.67276, 236.666183, 216.849396, 185.097107, 173.399612, 179.590668, 176.582108, 171.895004, 172.035049, 173.91304, 173.91301, 173.913025, 173.91304, 166.206909, 164.397949, 166.666702, 166.666672, 166.666718, 166.666626, 166.666672, 166.666672, 166.666702, 166.666656, 159.39447, 154.272507, 155.805923, 160.091629, 163.257462, 158.344391, 160.000046, 160.0, 153.871063, 148.495529, 154.916946, 165.495544, 0.0, 158.097519, 172.683319, 157.046677, 323.428436, 164.706482, 323.311951, 300.063416, 307.692322, 328.480377, 155.907684, 0.0, 181.84343, 164.924301, 164.078583, 170.644073, 176.098053, 168.415329, 159.250336, 165.988129, 174.010269, 175.754272, 174.536346, 173.913086, 181.419296, 176.156952, 171.663071, 178.155045, 189.504181, 195.063583, 203.866409, 201.905685, 205.940994, 213.680344, 212.539383, 210.52623, 210.526321, 210.526321, 222.709244, 227.121277, 222.22229, 222.22229, 222.22229, 222.222229, 216.904251, 207.273926, 204.556976, 219.516647, 212.257172, 210.624557, 221.362656, 220.649109, 223.961716, 140.91478, 150.231155, 145.791367, 142.264969, 143.769958, 138.509491, 0.0, 210.526276, 210.526337, 210.526352, 210.526352, 210.526352, 0.0, 0.0, 223.05159, 222.648422, 222.22229, 230.999893, 239.027802, 258.632416, 270.305756, 281.76416, 250.0, 216.109741, 229.503754, 229.60141, 288.937286, 254.29068, 229.450821, 206.737961, 201.165985, 195.051315, 168.298645, 148.052795, 145.245956, 194.313339, 179.846146, 161.488434, 159.281708, 158.174744, 158.437424, 153.977203, 155.05719, 161.725143, 165.005814, 164.815598, 162.798462, 155.018692, 170.773788, 156.468903, 164.952713, 163.620193, 154.455338, 151.071503, 152.170639, 153.846161, 153.846115, 157.639847, 161.552536, 168.501602, 176.29567, 184.72963, 200.057587, 224.825211, 239.860428, 252.04805, 268.574432, 274.005646, 282.328979, 291.446381, 295.416931, 285.714264, 296.619019, 285.714294, 290.117462, 262.980042, 241.605255, 229.732483, 214.261337, 211.752045, 200.906067, 184.953094, 171.762283, 154.34082, 134.734482, 136.380569, 108.220451, 97.7876892, 0.0, 136.509766, 0.0, 0.0, 0.0, 208.641068, 176.649261, 159.70256, 146.166153, 142.222763, 132.293152, 127.359932, 127.026833, 127.869453, 129.032227, 107.801895, 117.639046, 129.993454, 144.842728, 156.341385, 156.043793, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 117.340546, 117.05307, 116.713638, 121.521545, 131.357483, 139.033203, 149.431168, 149.150711, 147.863464, 148.987045, 148.148163, 141.39183, 143.413818, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 128.667328, 146.306488, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 189.7043, 168.499054, 75.6319504, 83.7892761, 67.5125732, 0.0, 0.0, 129.975754, 111.262291, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 256.535309, 199.382599, 186.776978, 187.381561, 187.826645, 172.494446] \ No newline at end of file diff --git a/crates/bunsen/testdata/ten/probs.json b/crates/bunsen/testdata/ten/probs.json deleted file mode 100644 index d2ed52a4..00000000 --- a/crates/bunsen/testdata/ten/probs.json +++ /dev/null @@ -1 +0,0 @@ -[0.4347309172153473, 0.47515830397605896, 0.634301483631134, 0.6157386302947998, 0.7731263637542725, 0.8397694230079651, 0.8741436004638672, 0.9150571227073669, 0.9330025315284729, 0.9532800316810608, 0.9520593285560608, 0.9621211290359497, 0.9335916042327881, 0.9243623614311218, 0.9124754071235657, 0.8904513120651245, 0.869672417640686, 0.8829265236854553, 0.883919358253479, 0.9570224285125732, 0.9783459305763245, 0.9830892086029053, 0.9688560962677002, 0.9650677442550659, 0.9709174036979675, 0.9770057797431946, 0.9697279930114746, 0.9678077101707458, 0.9574220180511475, 0.9611145853996277, 0.9485361576080322, 0.909536600112915, 0.9056535363197327, 0.8970350623130798, 0.8600016832351685, 0.8563224077224731, 0.8153627514839172, 0.8024997115135193, 0.9393901824951172, 0.9777950048446655, 0.9819766283035278, 0.9733149409294128, 0.9720635414123535, 0.976760745048523, 0.9661584496498108, 0.9546088576316833, 0.9612016677856445, 0.9391518831253052, 0.9605535268783569, 0.9526790976524353, 0.9448208808898926, 0.949067234992981, 0.9626138806343079, 0.9625979661941528, 0.9542801380157471, 0.9522096514701843, 0.9646919369697571, 0.9717250466346741, 0.9773962497711182, 0.9644774198532104, 0.9570311903953552, 0.9549010396003723, 0.960566520690918, 0.9662436842918396, 0.9406200051307678, 0.9035511016845703, 0.932823896408081, 0.9143092036247253, 0.8685616850852966, 0.921394407749176, 0.9546607732772827, 0.9783619046211243, 0.9808034896850586, 0.977644681930542, 0.9786246418952942, 0.954021692276001, 0.8828718662261963, 0.8827206492424011, 0.8272019624710083, 0.7671042680740356, 0.7655988335609436, 0.6769703030586243, 0.8022854328155518, 0.8225851058959961, 0.7398791909217834, 0.9334598779678345, 0.949239194393158, 0.943031370639801, 0.9526827931404114, 0.9242148995399475, 0.9249481558799744, 0.9149012565612793, 0.8590354323387146, 0.7698906660079956, 0.8230780363082886, 0.9196460247039795, 0.9573173522949219, 0.9784214496612549, 0.9834638237953186, 0.9729050397872925, 0.9678264856338501, 0.9789609909057617, 0.9786746501922607, 0.942025899887085, 0.9108068943023682, 0.9076728224754333, 0.8669669032096863, 0.8663465976715088, 0.9437330365180969, 0.9492568969726562, 0.9586055874824524, 0.9790080785751343, 0.980204164981842, 0.974969208240509, 0.9686200618743896, 0.9659610986709595, 0.9395571947097778, 0.8826916217803955, 0.816008985042572, 0.7397842407226562, 0.7083166241645813, 0.6331366300582886, 0.5488724708557129, 0.4987609088420868, 0.39504045248031616, 0.39982303977012634, 0.35311222076416016, 0.3860498368740082, 0.3538299798965454, 0.3256303369998932, 0.29082849621772766, 0.2697599232196808, 0.25341832637786865, 0.2420981228351593, 0.2706129252910614, 0.218914195895195, 0.24458591639995575, 0.203888937830925, 0.21200372278690338, 0.2156609743833542, 0.19472208619117737, 0.23447343707084656, 0.1788518726825714, 0.29481902718544006, 0.21172955632209778, 0.19600749015808105, 0.24762196838855743, 0.20637696981430054, 0.18914814293384552, 0.19076231122016907, 0.17794065177440643, 0.19929318130016327, 0.2162361443042755, 0.18277156352996826, 0.2435605674982071, 0.1803799867630005, 0.2086007297039032, 0.2180902510881424, 0.2069670557975769, 0.1924772411584854, 0.21305695176124573, 0.17524589598178864, 0.18373239040374756, 0.172151118516922, 0.17528359591960907, 0.17028357088565826, 0.17970043420791626, 0.16447331011295319, 0.18017786741256714, 0.6150534152984619, 0.6084359288215637, 0.7713062763214111, 0.8726150393486023, 0.8927411437034607, 0.8995258212089539, 0.9228832125663757, 0.9706785678863525, 0.961256742477417, 0.9655203819274902, 0.9773051738739014, 0.9658750891685486, 0.9699926376342773, 0.9781372547149658, 0.9687091708183289, 0.9639894366264343, 0.9740104079246521, 0.9702774286270142, 0.9740222692489624, 0.9780114889144897, 0.9796692728996277, 0.9649354219436646, 0.9745440483093262, 0.9834263324737549, 0.9712008237838745, 0.9663116931915283, 0.9636574387550354, 0.9510175585746765, 0.9673471450805664, 0.9686323404312134, 0.9428260326385498, 0.9518412947654724, 0.9332392811775208, 0.9107797145843506, 0.872241199016571, 0.8411733508110046, 0.9619585871696472, 0.9770688414573669, 0.9845253825187683, 0.9727087020874023, 0.9800360798835754, 0.9861992597579956, 0.9890336990356445, 0.990841269493103, 0.9896613359451294, 0.9826291799545288, 0.975860059261322, 0.960662841796875, 0.8991693258285522, 0.8682814836502075, 0.8032066226005554, 0.7483699321746826, 0.8094578385353088, 0.76982182264328, 0.7623757719993591, 0.6966939568519592, 0.8169906139373779, 0.8630185723304749, 0.9585803747177124, 0.9627302885055542, 0.9610162377357483, 0.9435511231422424, 0.9358833432197571, 0.9310974478721619, 0.9364021420478821, 0.9330949783325195, 0.9551905989646912, 0.9166330695152283, 0.9056131839752197, 0.9233688116073608, 0.913018524646759, 0.894991934299469, 0.8021287322044373, 0.7574690580368042, 0.7481708526611328, 0.6354675889015198, 0.5355169773101807, 0.6928509473800659, 0.8587009310722351, 0.9441896080970764, 0.9715344309806824, 0.981023371219635, 0.9783995747566223, 0.975753903388977, 0.9738327264785767, 0.9680567979812622, 0.9677005410194397, 0.9679335951805115, 0.9553970694541931, 0.9581424593925476, 0.917259931564331, 0.8438509106636047, 0.7852317690849304, 0.757296085357666, 0.7427322268486023, 0.7223671674728394, 0.7093342542648315, 0.8827294707298279, 0.9491256475448608, 0.9658385515213013, 0.9769567847251892, 0.9860978126525879, 0.9860532879829407, 0.9797438383102417, 0.9822437167167664, 0.9855857491493225, 0.9396021366119385, 0.9108883738517761, 0.9184199571609497, 0.9227825999259949, 0.9197393655776978, 0.8888539671897888, 0.8960263729095459, 0.8803038597106934, 0.8348855972290039, 0.7726873159408569, 0.6649012565612793, 0.6081145405769348, 0.5431713461875916, 0.4930979609489441, 0.45554637908935547, 0.44084933400154114, 0.4104765057563782, 0.3933025300502777, 0.3923809826374054, 0.34522414207458496, 0.3456442058086395, 0.34217992424964905, 0.34546011686325073, 0.31917616724967957, 0.3143640160560608, 0.33036908507347107, 0.3052777349948883, 0.29686257243156433, 0.2915765047073364, 0.27525243163108826, 0.2662118077278137, 0.25369030237197876, 0.27407756447792053, 0.29197484254837036, 0.2989124655723572, 0.32209521532058716, 0.31506696343421936, 0.3057541847229004, 0.7253406643867493, 0.8467276692390442, 0.9488617777824402, 0.9785683751106262, 0.9852808117866516, 0.9834408760070801, 0.967033863067627, 0.9642782211303711, 0.9765268564224243, 0.975679337978363, 0.9748920798301697, 0.9798395037651062, 0.9739287495613098, 0.9705600738525391, 0.9729214906692505, 0.9580682516098022, 0.9608242511749268, 0.9808278679847717, 0.9800997376441956, 0.9768680334091187, 0.9708252549171448, 0.9708322286605835, 0.9771461486816406, 0.976373553276062, 0.9691992998123169, 0.9429720044136047, 0.9728500843048096, 0.963934063911438, 0.9656205177307129, 0.969840943813324, 0.96141517162323, 0.9695647358894348, 0.9777058959007263, 0.9797638654708862, 0.9742518067359924, 0.9666478633880615, 0.9635240435600281, 0.9339616298675537, 0.9108095765113831, 0.9195385575294495, 0.9060268402099609, 0.83519047498703, 0.8357665538787842, 0.9509608149528503, 0.9738761782646179, 0.9921995997428894, 0.9904487729072571, 0.9903098940849304, 0.9874173402786255, 0.9890243411064148, 0.9874640703201294, 0.9810232520103455, 0.9853094220161438, 0.9823784232139587, 0.9496814012527466, 0.8808132410049438, 0.8093429803848267, 0.8042737245559692, 0.8102610111236572, 0.7823472619056702, 0.8408312201499939, 0.9574429988861084, 0.9481812715530396, 0.9772754311561584, 0.969714879989624, 0.9659664034843445, 0.9658356308937073, 0.9692407250404358, 0.9808257222175598, 0.9671186208724976, 0.9222717881202698, 0.9206124544143677, 0.8714179396629333, 0.8067312836647034, 0.8816224932670593, 0.9626299738883972, 0.9790492057800293, 0.9776833057403564, 0.9798688292503357, 0.9757375717163086, 0.9812191128730774, 0.9817124009132385, 0.9740628600120544, 0.9687978029251099, 0.9682782292366028, 0.9696394801139832, 0.94125896692276, 0.8727837204933167, 0.9324745535850525, 0.9374993443489075, 0.9296034574508667, 0.910161554813385, 0.8956136703491211, 0.8808828592300415, 0.971560537815094, 0.9772690534591675, 0.9769104719161987, 0.9818488359451294, 0.9836852550506592, 0.97450190782547, 0.9735636711120605, 0.9693701863288879, 0.9717127680778503, 0.9760149121284485, 0.9693787097930908, 0.953919529914856, 0.9006743431091309, 0.9041758179664612, 0.8815745711326599, 0.849861741065979, 0.7863995432853699, 0.7108714580535889, 0.6677485704421997, 0.6063154935836792, 0.5420957803726196, 0.4594666659832001, 0.41416922211647034, 0.37292030453681946, 0.3589310646057129, 0.33008623123168945, 0.3210504651069641, 0.3118075728416443, 0.29261982440948486, 0.2863694131374359, 0.27159976959228516, 0.2586139440536499, 0.25962722301483154, 0.24915668368339539, 0.25802984833717346, 0.24018420279026031, 0.24982696771621704, 0.23950278759002686, 0.22644446790218353, 0.2975410521030426, 0.23789481818675995, 0.3013440668582916, 0.23704679310321808, 0.22423957288265228, 0.2572096884250641, 0.21666015684604645, 0.2189324051141739, 0.21812564134597778, 0.2740340530872345, 0.3120449483394623, 0.3735952079296112, 0.4156877100467682, 0.4009109139442444, 0.3902879059314728, 0.3891994059085846, 0.34108754992485046, 0.38543346524238586, 0.3594039976596832, 0.32017460465431213, 0.30460721254348755, 0.3153355121612549, 0.3042779266834259, 0.2803873121738434, 0.3102017343044281, 0.3480941951274872, 0.31539419293403625, 0.33460453152656555, 0.5327432751655579, 0.5296030044555664, 0.554072380065918, 0.5655971169471741, 0.5137251615524292, 0.5298445224761963, 0.535720944404602, 0.48923760652542114, 0.47748368978500366, 0.46416351199150085, 0.46762219071388245, 0.5659537315368652, 0.5312755107879639, 0.472050279378891, 0.46444767713546753, 0.43604013323783875, 0.43990838527679443, 0.40535208582878113, 0.43954282999038696, 0.41597387194633484, 0.4253772497177124, 0.40484747290611267, 0.47539493441581726, 0.42470183968544006, 0.5196078419685364, 0.4084310233592987, 0.3863700330257416, 0.4223930537700653, 0.39112967252731323, 0.43321165442466736, 0.3572658896446228, 0.360352486371994, 0.5768749117851257, 0.5610027313232422, 0.48428797721862793, 0.46386125683784485, 0.4125010371208191, 0.4033864438533783, 0.384443074464798, 0.3502306342124939, 0.3483252227306366, 0.33205732703208923, 0.3176964819431305, 0.30222323536872864, 0.28739479184150696, 0.28371530771255493, 0.29283514618873596, 0.3112122714519501, 0.28486692905426025, 0.3083345293998718, 0.3137710988521576, 0.3202096223831177, 0.27638736367225647, 0.2998162806034088, 0.3003966510295868, 0.30444711446762085, 0.3167206346988678, 0.27859461307525635, 0.2830662131309509, 0.29528340697288513, 0.2904144525527954, 0.2785608172416687, 0.29669684171676636, 0.3516319692134857, 0.31360986828804016, 0.26005783677101135, 0.251934289932251, 0.24472570419311523, 0.2734731435775757, 0.28681543469429016, 0.2670074701309204, 0.33691462874412537, 0.29404884576797485, 0.27510973811149597, 0.33560171723365784, 0.27015843987464905, 0.2762143909931183, 0.27164313197135925, 0.2571170926094055, 0.25090426206588745, 0.26062700152397156, 0.23600874841213226, 0.2603212296962738, 0.26076358556747437, 0.24640421569347382, 0.22755619883537292, 0.21270214021205902, 0.22442276775836945, 0.21905404329299927, 0.24852395057678223, 0.20648665726184845, 0.22007688879966736, 0.28033512830734253, 0.2269134521484375, 0.20382599532604218, 0.20358531177043915, 0.3284345865249634, 0.35166770219802856, 0.23699967563152313, 0.23441307246685028, 0.2483457773923874, 0.21966961026191711, 0.24109789729118347, 0.2020830512046814, 0.1955588459968567, 0.18979895114898682, 0.1811036914587021, 0.20080362260341644, 0.17867255210876465, 0.19813165068626404, 0.41937923431396484, 0.760829508304596, 0.8739201426506042, 0.9576482772827148, 0.9887987971305847, 0.9924631118774414, 0.9920421242713928, 0.9898576736450195, 0.9888455271720886, 0.9844822883605957, 0.9650546312332153, 0.9772464036941528, 0.983053982257843, 0.9790481328964233, 0.9658358693122864, 0.9605967402458191, 0.9405859112739563, 0.9600124359130859, 0.9189451336860657, 0.9272586107254028, 0.9022501707077026, 0.8974308967590332, 0.8650203943252563, 0.7950756549835205, 0.8021166324615479, 0.9240617156028748, 0.9573239088058472, 0.9886536002159119, 0.945398211479187, 0.9202943444252014, 0.9252008199691772, 0.9326017498970032, 0.9146451950073242, 0.8889470100402832, 0.8629505038261414, 0.8650996088981628, 0.9391250014305115, 0.9683542847633362, 0.9869001507759094, 0.9833021759986877, 0.9831716418266296, 0.9815961718559265, 0.9850084781646729, 0.9809901714324951, 0.9708157181739807, 0.9702393412590027, 0.9673976898193359, 0.9566164612770081, 0.9449154734611511, 0.9284470081329346, 0.9256223440170288, 0.84034264087677, 0.7351484298706055, 0.6966939568519592, 0.6645194888114929, 0.6309051513671875, 0.5604258179664612, 0.5502214431762695, 0.48374640941619873, 0.4244653284549713, 0.46472737193107605, 0.43451008200645447, 0.39615464210510254, 0.4269546568393707, 0.38785314559936523, 0.5584068894386292, 0.5270147919654846, 0.5310192108154297, 0.5497889518737793, 0.6632466912269592, 0.6673207879066467, 0.6697757244110107, 0.6829806566238403, 0.7183830142021179, 0.7385655045509338, 0.8303871750831604, 0.8379261493682861, 0.9261586666107178, 0.9594857096672058, 0.9686163663864136, 0.9651381373405457, 0.9559793472290039, 0.9565877914428711, 0.9600542187690735, 0.9746034741401672, 0.9047738313674927, 0.8624377846717834, 0.8436534404754639, 0.8310922384262085, 0.8633124232292175, 0.894628643989563, 0.8925532102584839, 0.8796141147613525, 0.8592367172241211, 0.8013467192649841, 0.8284722566604614, 0.7886117696762085, 0.9265817999839783, 0.9668485522270203, 0.9386457800865173, 0.9044296145439148, 0.834973931312561, 0.7304445505142212, 0.663960874080658, 0.6388273239135742, 0.8063559532165527, 0.8645942211151123, 0.9494290351867676, 0.9676709771156311, 0.9785812497138977, 0.9753198623657227, 0.9735521078109741, 0.9723594188690186, 0.9690086245536804, 0.9645776748657227, 0.9646881818771362, 0.9588437080383301, 0.944889485836029, 0.9292600750923157, 0.9443470239639282, 0.8768323659896851, 0.817201554775238, 0.746437132358551, 0.6963678002357483, 0.6452497243881226, 0.6137204766273499, 0.5956531167030334, 0.5741050839424133, 0.5573492646217346, 0.5601413249969482, 0.5038692355155945, 0.5345808267593384, 0.4502868354320526, 0.49571019411087036, 0.7843158841133118, 0.9093626141548157, 0.9556125998497009, 0.9573540687561035, 0.9593991041183472, 0.9593213200569153, 0.9644976258277893, 0.9598654508590698, 0.9540367722511292, 0.951960027217865, 0.9413352012634277, 0.9593043327331543, 0.9660422801971436, 0.9713550806045532, 0.9455697536468506, 0.970523476600647, 0.9551326036453247, 0.9549623727798462, 0.9535315632820129, 0.9054816365242004, 0.8748160004615784, 0.8008553385734558, 0.7480955719947815, 0.7925148010253906, 0.8149203658103943, 0.7615514993667603, 0.6841320395469666, 0.908970296382904, 0.9595618844032288, 0.9668670296669006, 0.9554417729377747, 0.9747529625892639, 0.9733940958976746, 0.9774980545043945, 0.9642102718353271, 0.9661735892295837, 0.9482677578926086, 0.9231400489807129, 0.8154504299163818, 0.771166980266571, 0.7933038473129272, 0.681037425994873, 0.6025776267051697, 0.5040963292121887, 0.43556466698646545, 0.4213205575942993, 0.44335827231407166, 0.41685110330581665, 0.7554832696914673, 0.8322114944458008, 0.8893023133277893, 0.9433383345603943, 0.9669613242149353, 0.9512847661972046, 0.9736010432243347, 0.9663941860198975, 0.9465352296829224, 0.9523863196372986, 0.9420344829559326, 0.9452541470527649, 0.9181493520736694, 0.9248393177986145, 0.9188230037689209, 0.9102655053138733, 0.9029735326766968, 0.9224616289138794, 0.954002857208252, 0.964686930179596, 0.9495260715484619, 0.9392251968383789, 0.9489132761955261, 0.9529579877853394, 0.9148277044296265, 0.9324617981910706, 0.9145174622535706, 0.8894158005714417, 0.8832728862762451, 0.8971477746963501, 0.9159551858901978, 0.9393796920776367, 0.959900975227356, 0.9677600264549255, 0.9607122540473938, 0.9090737700462341, 0.7976622581481934, 0.6887193322181702, 0.6180357336997986, 0.5102595686912537, 0.4660508632659912, 0.4089835584163666, 0.3606475293636322, 0.6982128620147705, 0.896109938621521, 0.9452812075614929, 0.9570749402046204, 0.9746448993682861, 0.9691120982170105, 0.9791237115859985, 0.9556480646133423, 0.9630535840988159, 0.9631954431533813, 0.9652307629585266, 0.9573624730110168, 0.9459826350212097, 0.8789263963699341, 0.7973383069038391, 0.7766985297203064, 0.7084320783615112, 0.6614581942558289, 0.5981994271278381, 0.558088481426239, 0.49716633558273315, 0.44093674421310425, 0.41412103176116943, 0.36525246500968933, 0.334041565656662, 0.31828445196151733, 0.3032197058200836, 0.2918481230735779, 0.3122580051422119, 0.3063274919986725, 0.29086270928382874, 0.28878796100616455, 0.3062109649181366, 0.3079194724559784, 0.32021835446357727, 0.31384050846099854, 0.31165438890457153, 0.29319390654563904, 0.5754246115684509, 0.7885477542877197, 0.8697357177734375, 0.9515659809112549, 0.9708242416381836, 0.9720312356948853, 0.9562342762947083, 0.9409674406051636, 0.929382860660553, 0.9339426159858704, 0.9262422323226929, 0.9432744979858398, 0.9680089950561523, 0.9781562089920044, 0.9814023375511169, 0.9656717777252197, 0.968864381313324, 0.9668260216712952, 0.9715549945831299, 0.9736868143081665, 0.9767255187034607, 0.9753783941268921, 0.9709507822990417, 0.9744423627853394, 0.9705496430397034, 0.9763745665550232, 0.9548593163490295, 0.937550961971283, 0.8758043050765991, 0.8637571930885315, 0.8690196871757507, 0.8058924674987793, 0.831350564956665, 0.8255152106285095, 0.8856863975524902, 0.894844114780426, 0.8849827647209167, 0.8801731467247009, 0.8546658158302307, 0.8081912994384766, 0.7748565077781677, 0.7182729840278625, 0.6884744763374329, 0.5890471935272217, 0.5274632573127747, 0.5261973142623901, 0.49515458941459656, 0.45490923523902893, 0.42426618933677673, 0.4096580147743225, 0.379944384098053, 0.3402133882045746, 0.30239707231521606, 0.34636780619621277, 0.8054733872413635, 0.9199417233467102, 0.9733191132545471, 0.9758120179176331, 0.9764957427978516, 0.9757338166236877, 0.9824437499046326, 0.9850704669952393, 0.9816884398460388, 0.9808535575866699, 0.9796701669692993, 0.9738053679466248, 0.9702284932136536, 0.9756419062614441, 0.956666886806488, 0.962721049785614, 0.951688826084137, 0.9735523462295532, 0.9683524966239929, 0.9672962427139282, 0.9462940096855164, 0.9446654319763184, 0.9452629089355469, 0.9548702836036682, 0.9460601210594177, 0.9441578984260559, 0.9422258734703064, 0.9462825059890747, 0.9277600646018982, 0.902238130569458, 0.9101614952087402, 0.9295827746391296, 0.9031249284744263, 0.8762879371643066, 0.8321371078491211, 0.8139147162437439, 0.7989272475242615, 0.756671667098999, 0.663919985294342, 0.6087402701377869, 0.5838429927825928, 0.4837989807128906, 0.44950199127197266, 0.4807884693145752, 0.450351357460022, 0.47038641571998596, 0.43298017978668213, 0.4127671718597412, 0.3998953402042389, 0.38644400238990784, 0.3786303699016571, 0.392229825258255, 0.3609742820262909, 0.39138272404670715, 0.4040311574935913, 0.387505441904068, 0.6081894040107727, 0.5917055010795593, 0.6039740443229675, 0.7543684840202332, 0.8196699619293213, 0.8625162839889526, 0.9055361747741699, 0.9285416007041931, 0.9413475394248962, 0.951726496219635, 0.9532330632209778, 0.9518670439720154, 0.9489439725875854, 0.9446156024932861, 0.9435141682624817, 0.9457388520240784, 0.9438509941101074, 0.9459414482116699, 0.9497382640838623, 0.9569255113601685, 0.9477983713150024, 0.9349594116210938, 0.9315558075904846, 0.9368135333061218, 0.8880116939544678, 0.8566408753395081, 0.7931568026542664, 0.7832033634185791, 0.721686601638794, 0.6486073136329651, 0.6261914372444153, 0.5816399455070496, 0.5338131785392761, 0.5128796100616455, 0.5027530789375305, 0.48301976919174194, 0.46879518032073975, 0.4146742522716522, 0.4744032919406891, 0.43155404925346375, 0.4022071659564972, 0.3852686285972595, 0.36209285259246826, 0.3699539303779602, 0.3550279140472412, 0.30694320797920227, 0.29676175117492676, 0.28260666131973267, 0.2514928877353668, 0.24595054984092712, 0.256268709897995, 0.22148093581199646, 0.26170727610588074, 0.22535942494869232, 0.22009685635566711, 0.25929680466651917, 0.4017038643360138, 0.4879840612411499, 0.35133352875709534, 0.5279508829116821, 0.6099032163619995, 0.43580275774002075, 0.47840696573257446, 0.4824923276901245, 0.7708032727241516, 0.894352912902832, 0.976507306098938, 0.9817859530448914, 0.9761261940002441, 0.9908754825592041, 0.9888250231742859, 0.9759239554405212, 0.9626786112785339, 0.9595414400100708, 0.9586009979248047, 0.903904914855957, 0.8371107578277588, 0.8499054908752441, 0.7912514209747314, 0.7355625033378601, 0.7230972647666931, 0.7651104927062988, 0.811451256275177, 0.6999116539955139, 0.8266658186912537, 0.8942216038703918, 0.9707410335540771, 0.983735203742981, 0.9794971942901611, 0.9784498810768127, 0.9758514165878296, 0.9718335866928101, 0.9771875739097595, 0.9618597626686096, 0.9150376915931702, 0.9105156660079956, 0.8854820728302002, 0.8403854370117188, 0.9213917255401611, 0.9055948853492737, 0.9509115815162659, 0.981383740901947, 0.952172040939331, 0.9632627964019775, 0.9713901877403259, 0.9757516384124756, 0.9755414724349976, 0.9800272583961487, 0.9736874103546143, 0.9661734700202942, 0.9561760425567627, 0.9678887724876404, 0.9640821814537048, 0.9634374976158142, 0.9698652625083923, 0.9615628719329834, 0.9568464756011963, 0.9530805349349976, 0.954140841960907, 0.9484478831291199, 0.9482684135437012, 0.9555251598358154, 0.9149298071861267, 0.8570690751075745, 0.8368924856185913, 0.8499436378479004, 0.8054186701774597, 0.7913954257965088, 0.8021271228790283, 0.7090511322021484, 0.7549440860748291, 0.7141533493995667, 0.8850876092910767, 0.9496605396270752, 0.979692816734314, 0.9856413006782532, 0.9849748015403748, 0.9816131591796875, 0.9859160780906677, 0.9842705130577087, 0.9770047664642334, 0.9651378989219666, 0.9619680047035217, 0.9600962996482849, 0.9192236661911011, 0.9156500697135925, 0.8894267678260803, 0.8407412767410278, 0.7913156747817993, 0.7977457642555237, 0.7509345412254333, 0.7085087895393372, 0.6478537917137146, 0.5634317398071289, 0.6446895599365234, 0.7357609868049622, 0.7588710784912109, 0.6155433058738708, 0.5312288999557495, 0.47677379846572876, 0.42929714918136597, 0.3691733181476593, 0.3561420440673828, 0.3404930830001831, 0.3215193748474121, 0.30332061648368835, 0.2813814580440521, 0.2850731611251831, 0.2584543526172638, 0.2973208427429199, 0.37051647901535034, 0.3640283942222595, 0.3398119807243347, 0.4106110632419586, 0.32725587487220764, 0.31648173928260803, 0.29269489645957947, 0.2771168351173401, 0.2766619324684143, 0.267632395029068, 0.2746061086654663, 0.293012410402298, 0.2606816291809082, 0.24588289856910706, 0.24500423669815063, 0.2470301389694214, 0.2370148003101349, 0.2393234819173813, 0.22355514764785767, 0.23121851682662964, 0.22540654242038727, 0.2886947989463806, 0.4843694865703583, 0.5148196220397949, 0.6567721366882324, 0.8319687843322754, 0.8824970126152039, 0.8943357467651367, 0.9315172433853149, 0.9392838478088379, 0.9463707804679871, 0.9609060287475586, 0.9640381932258606, 0.9531676173210144, 0.9480997323989868, 0.9442018866539001, 0.9513987898826599, 0.9547904133796692, 0.9536683559417725, 0.9476613402366638, 0.9555532336235046, 0.9579781889915466, 0.9561097621917725, 0.9280408024787903, 0.9027942419052124, 0.8789730668067932, 0.8535560369491577, 0.7907541394233704, 0.7715292572975159, 0.7266207933425903, 0.6783638000488281, 0.6164119243621826, 0.5760602355003357, 0.5267441272735596, 0.4674623906612396, 0.4302937686443329, 0.3841308355331421, 0.3551434874534607, 0.34056350588798523, 0.34045344591140747, 0.65069180727005, 0.5641891360282898, 0.7894467115402222, 0.839697539806366, 0.9270361065864563, 0.9569458961486816, 0.9684730768203735, 0.9617319703102112, 0.9562239050865173, 0.949955403804779, 0.9489307999610901, 0.9578976631164551, 0.9599618315696716, 0.9593009352684021, 0.9610852003097534, 0.9542064070701599, 0.947124183177948, 0.9540011882781982, 0.9590019583702087, 0.9603127241134644, 0.9531154036521912, 0.9378238916397095, 0.9401199817657471, 0.9450852870941162, 0.9466704726219177, 0.9433326125144958, 0.9351113438606262, 0.925359845161438, 0.9124582409858704, 0.890873372554779, 0.8826355338096619, 0.8400772213935852, 0.8015239834785461, 0.7472202181816101, 0.6397705674171448, 0.5485677719116211, 0.4840475618839264, 0.41285181045532227, 0.3819662928581238, 0.35628291964530945, 0.3318576514720917, 0.3120812773704529, 0.28458020091056824, 0.2723526954650879, 0.28067901730537415, 0.25520721077919006, 0.27378106117248535, 0.24977299571037292, 0.30000633001327515, 0.2660488188266754, 0.24874551594257355, 0.24038589000701904, 0.22439837455749512, 0.24119237065315247, 0.2498476803302765, 0.25326868891716003, 0.2526177763938904, 0.223526269197464, 0.23267248272895813, 0.2859538495540619, 0.2429407238960266, 0.2230018973350525, 0.22720922529697418, 0.21932634711265564, 0.21358467638492584, 0.21085453033447266, 0.28468087315559387, 0.19922097027301788, 0.22235320508480072, 0.214102104306221, 0.2125415951013565, 0.20601128041744232, 0.20472398400306702, 0.1996159702539444, 0.21810202300548553, 0.2120342254638672, 0.21587608754634857, 0.2119036167860031, 0.20152564346790314, 0.208140030503273, 0.235897034406662, 0.19597390294075012, 0.2344246357679367, 0.21867989003658295, 0.20585492253303528, 0.2486845701932907, 0.5654891729354858, 0.7457510828971863, 0.6966585516929626, 0.8807029724121094, 0.9474034905433655, 0.9775091409683228, 0.9858426451683044, 0.9845607280731201, 0.9817948341369629, 0.9780964255332947, 0.9709057211875916, 0.971498429775238, 0.9691793918609619, 0.9690424203872681, 0.9595292806625366, 0.9775590300559998, 0.9758819341659546, 0.9735586643218994, 0.972734808921814, 0.9807701110839844, 0.9741594791412354, 0.9628534913063049, 0.9441680312156677, 0.9518596529960632, 0.8964146375656128, 0.8780890703201294, 0.8394854068756104, 0.8801535367965698, 0.9356369972229004, 0.9599472880363464, 0.9704251289367676, 0.9623143672943115, 0.9506623148918152, 0.9520403146743774, 0.9655352830886841, 0.9662638306617737, 0.9291477799415588, 0.8534581661224365, 0.8474740386009216, 0.8528721332550049, 0.8495866656303406, 0.8551170825958252, 0.8598807454109192, 0.8623019456863403, 0.8254008889198303, 0.8199454545974731, 0.7953333258628845, 0.7531125545501709, 0.8441919684410095, 0.7814847230911255, 0.77562016248703, 0.8817114233970642, 0.9482693672180176, 0.9839527010917664, 0.9815022349357605, 0.9791259765625, 0.9780464768409729, 0.9750108122825623, 0.9759568572044373, 0.9471477270126343, 0.955076277256012, 0.9076077342033386, 0.9010933041572571, 0.8651494979858398, 0.9199130535125732, 0.9441470503807068, 0.9678016901016235, 0.9737535119056702, 0.9694380164146423, 0.9695680737495422, 0.9686051607131958, 0.9717308878898621, 0.9723715782165527, 0.9712432026863098, 0.9669153094291687, 0.9697525501251221, 0.969572901725769, 0.9674976468086243, 0.9673702120780945, 0.9663604497909546, 0.9646022915840149, 0.9674457311630249, 0.9650730490684509, 0.9184421896934509, 0.8501889109611511, 0.7025635242462158, 0.6737613677978516, 0.6543558239936829, 0.6714280843734741, 0.6645691394805908, 0.8772815465927124, 0.8919380903244019, 0.9475818872451782, 0.9672839045524597, 0.944229781627655, 0.9461450576782227, 0.9509994983673096, 0.9195000529289246, 0.8762838840484619, 0.8515474796295166, 0.7490358352661133, 0.7841236591339111, 0.7371806502342224, 0.9158537983894348, 0.9475558996200562, 0.979775071144104, 0.9825014472007751, 0.9818648099899292, 0.9822370409965515, 0.9799847602844238, 0.980108916759491, 0.9765616655349731, 0.9767187833786011, 0.9744155406951904, 0.9781453609466553, 0.9700219035148621, 0.9685354828834534, 0.9619933366775513, 0.9657567143440247, 0.9598094820976257, 0.9433161616325378, 0.9254295229911804, 0.9454783201217651, 0.9025640487670898, 0.9019063115119934, 0.949966311454773, 0.9617822170257568, 0.9772088527679443, 0.9771534204483032, 0.920791745185852, 0.8757771849632263, 0.8489853739738464, 0.8223941922187805, 0.767111599445343, 0.7005158066749573, 0.6925103664398193, 0.6377076506614685, 0.80690997838974, 0.9436255097389221, 0.9873411059379578, 0.9787455797195435, 0.9817558526992798, 0.976398766040802, 0.9760749936103821, 0.9778754711151123, 0.9760943055152893, 0.9622342586517334, 0.9495000839233398, 0.9405749440193176, 0.9505040645599365, 0.9610510468482971, 0.9735432863235474, 0.9760265946388245, 0.9786695241928101, 0.9756529927253723, 0.9750246405601501, 0.9601575136184692, 0.9380545616149902, 0.8366378545761108, 0.8196806907653809, 0.8412862420082092, 0.8936781287193298, 0.8553050756454468, 0.7695577144622803, 0.7129757404327393, 0.6746921539306641, 0.5841575860977173, 0.8079014420509338, 0.9070843458175659, 0.9688367247581482, 0.9755218625068665, 0.9877628087997437, 0.9843193888664246, 0.9794520139694214, 0.9699208736419678, 0.9624909162521362, 0.9657617211341858, 0.969264566898346, 0.9499032497406006, 0.8780487179756165, 0.8505847454071045, 0.7940227389335632, 0.7239900231361389, 0.6623162627220154, 0.7512370944023132, 0.6417914032936096, 0.613503098487854, 0.7399544715881348, 0.938641369342804, 0.9749290347099304, 0.9604828953742981, 0.9718331098556519, 0.9712147116661072, 0.9742062091827393, 0.8504905700683594, 0.7871770858764648, 0.7270814776420593, 0.6818640828132629, 0.8645983338356018, 0.9012147784233093, 0.9448687434196472, 0.9548655152320862, 0.9718582630157471, 0.9678483605384827, 0.9666253924369812, 0.9558316469192505, 0.8736246228218079, 0.826874852180481, 0.806049108505249, 0.8270283937454224, 0.9386371970176697, 0.9594258069992065, 0.9644824266433716, 0.9741545915603638, 0.9739651679992676, 0.9750508069992065, 0.9774351119995117, 0.9679669737815857, 0.9586553573608398, 0.950574517250061, 0.964322566986084, 0.9511865377426147, 0.9561848640441895, 0.955624520778656, 0.9676769971847534, 0.9794358015060425, 0.9717652201652527, 0.981234610080719, 0.9798306822776794, 0.9767065048217773, 0.9687745571136475, 0.9447720050811768, 0.9290333390235901, 0.9132704138755798, 0.8729478716850281, 0.9050441384315491, 0.9305678009986877, 0.9590732455253601, 0.9688008427619934, 0.9744150042533875, 0.9655559062957764, 0.9733201265335083, 0.9663428664207458, 0.9726772904396057, 0.9665431976318359, 0.9651381373405457, 0.9665298461914062, 0.901101291179657, 0.8864167928695679, 0.8570606708526611, 0.8631668090820312, 0.8395199775695801, 0.902178943157196, 0.9312687516212463, 0.9614580273628235, 0.9745517373085022, 0.9764038920402527, 0.9763975143432617, 0.9653806686401367, 0.9699762463569641, 0.9725295305252075, 0.9727484583854675, 0.9713118076324463, 0.9672238826751709, 0.9709082245826721, 0.9732436537742615, 0.9805749654769897, 0.9707453846931458, 0.9362950325012207, 0.9413785338401794, 0.9093613028526306, 0.8536832928657532, 0.8755443692207336, 0.9080640077590942, 0.9048090577125549, 0.9512402415275574, 0.9813825488090515, 0.9869520664215088, 0.9862980842590332, 0.984754204750061, 0.9828376770019531, 0.9881224036216736, 0.9832320213317871, 0.9801401495933533, 0.9816099405288696, 0.9788538217544556, 0.9779219627380371, 0.9744958877563477, 0.9681451916694641, 0.9677433967590332, 0.9689415097236633, 0.9616340398788452, 0.9623388051986694, 0.9713554382324219, 0.9759774208068848, 0.9725437164306641, 0.9712498188018799, 0.968161940574646, 0.9692177772521973, 0.9088467359542847, 0.9237954020500183, 0.9265376925468445, 0.9070208668708801, 0.8562467098236084, 0.8534302115440369, 0.9205986261367798, 0.944075345993042, 0.943327009677887, 0.9650123119354248, 0.9656237363815308, 0.9750347137451172, 0.9634724855422974, 0.9742736220359802, 0.9651108980178833, 0.9703237414360046, 0.9632161259651184, 0.969866931438446, 0.9618404507637024, 0.972919225692749, 0.9737610816955566, 0.9699034690856934, 0.9690722227096558, 0.9654086828231812, 0.9702638387680054, 0.9646649956703186, 0.9609599113464355, 0.955845296382904, 0.960192859172821, 0.967018723487854, 0.9712843298912048, 0.9662788510322571, 0.9688383340835571, 0.9815520644187927, 0.9812901616096497, 0.980661153793335, 0.9587464928627014, 0.9789935350418091, 0.960519552230835, 0.9507593512535095, 0.9505518674850464, 0.9680989980697632, 0.9661222100257874, 0.9504343867301941, 0.8569257259368896, 0.8014504313468933, 0.8508244156837463, 0.868436872959137, 0.834105908870697, 0.8208452463150024, 0.7896125316619873, 0.9049859642982483, 0.9312783479690552, 0.9596607685089111, 0.9490830302238464, 0.9654217958450317, 0.9555656313896179, 0.9658587574958801, 0.9558175206184387, 0.9516378045082092, 0.8836703896522522, 0.7923056483268738, 0.7269179821014404, 0.6652235984802246, 0.5952589511871338, 0.5827327370643616, 0.49459418654441833, 0.4640483856201172, 0.42722171545028687, 0.40542906522750854, 0.40385520458221436, 0.3386964201927185, 0.3162359297275543, 0.3174704313278198, 0.2994628846645355, 0.31555554270744324, 0.29467999935150146, 0.33847370743751526, 0.3651933968067169, 0.4222211241722107, 0.6518768668174744, 0.8104949593544006, 0.9231330752372742, 0.9720202684402466, 0.979941189289093, 0.9827271103858948, 0.9623492360115051, 0.9733553528785706, 0.962984025478363, 0.9316075444221497, 0.9336385726928711, 0.9043137431144714, 0.9202242493629456, 0.8923919200897217, 0.8783496618270874, 0.9541491270065308, 0.9649885892868042, 0.9857655763626099, 0.988461971282959, 0.9650126695632935, 0.9711479544639587, 0.961085855960846, 0.902938187122345, 0.8815451264381409, 0.8915153741836548, 0.867794394493103, 0.9589200019836426, 0.972090482711792, 0.9665794968605042, 0.9791914820671082, 0.9544004201889038, 0.9652278423309326, 0.9475774765014648, 0.8908705115318298, 0.8132067322731018, 0.7828447818756104, 0.8673164248466492, 0.9465125799179077, 0.9751171469688416, 0.985869288444519, 0.9871063232421875, 0.9874526858329773, 0.981147050857544, 0.9797859787940979, 0.9741155505180359, 0.9634208083152771, 0.9679197072982788, 0.8939864039421082, 0.9217003583908081, 0.8873468637466431, 0.9422335028648376, 0.9744216799736023, 0.9833105802536011, 0.9827516674995422, 0.9449757933616638, 0.9572578072547913, 0.9517872929573059, 0.945343017578125, 0.9189321398735046, 0.8513001799583435, 0.7120230197906494, 0.683588981628418, 0.9030085206031799, 0.9417439699172974, 0.954758882522583, 0.9614033102989197, 0.981878936290741, 0.9661928415298462, 0.9772137403488159, 0.9823452830314636, 0.9897038340568542, 0.9871904253959656, 0.9868554472923279, 0.9834923148155212, 0.982012927532196, 0.9815783500671387, 0.976733922958374, 0.9765952825546265, 0.9556712508201599, 0.9450042247772217, 0.9173257350921631, 0.9604183435440063, 0.9726066589355469, 0.9817456007003784, 0.9811348915100098, 0.9847933053970337, 0.9891281723976135, 0.9837679266929626, 0.9807921051979065, 0.9794419407844543, 0.9751639366149902, 0.9818578958511353, 0.9575890302658081, 0.9542904496192932, 0.9745383858680725, 0.9796496629714966, 0.9791041612625122, 0.9826846718788147, 0.9736207127571106, 0.9718464016914368, 0.9620027542114258, 0.9653453826904297, 0.9649872183799744, 0.9704560041427612, 0.9745473265647888, 0.9741292595863342, 0.9712803959846497, 0.972149133682251, 0.9703741669654846, 0.9706548452377319, 0.9721274971961975, 0.9746415019035339, 0.9750351905822754, 0.9743875861167908, 0.974513590335846, 0.97264164686203, 0.9732493162155151, 0.9656878709793091, 0.9559805393218994, 0.9405809640884399, 0.8689736127853394, 0.8107065558433533, 0.752322793006897, 0.7157682776451111, 0.7187077403068542, 0.6579494476318359, 0.6205576062202454, 0.5484887957572937, 0.5208185315132141, 0.49717870354652405, 0.5020574927330017, 0.4883727729320526, 0.47168296575546265, 0.440326988697052, 0.4165874123573303, 0.419736385345459, 0.4197153151035309, 0.4025854170322418, 0.39954641461372375, 0.40380993485450745, 0.416507363319397, 0.3904968798160553, 0.8165537714958191, 0.8478125333786011, 0.9232754111289978, 0.9570276737213135, 0.9233292937278748, 0.9084837436676025, 0.8560179471969604, 0.7998550534248352, 0.7685621380805969, 0.7710633873939514, 0.7292231917381287, 0.9294908046722412, 0.9196001291275024, 0.9615587592124939, 0.9789626002311707, 0.9631757736206055, 0.9654574990272522, 0.9690120220184326, 0.9717644453048706, 0.969978392124176, 0.9762982130050659, 0.9767867922782898, 0.9807395935058594, 0.9771293997764587, 0.9755941033363342, 0.9715452194213867, 0.9726875424385071, 0.9524410367012024, 0.9620274305343628, 0.9524422287940979, 0.9386889338493347, 0.921347975730896, 0.9195610284805298, 0.8895316123962402, 0.8743607401847839, 0.8372955322265625, 0.7911867499351501, 0.8077003955841064, 0.8137556314468384, 0.7802161574363708, 0.8487710356712341, 0.910834014415741, 0.9295873045921326, 0.9567962884902954, 0.9674583673477173, 0.9462165236473083, 0.9496059417724609, 0.9512524008750916, 0.9568655490875244, 0.9625552892684937, 0.9818775653839111, 0.9866182208061218, 0.9825409054756165, 0.9751945734024048, 0.9551237225532532, 0.9549460411071777, 0.9623160362243652, 0.9553215503692627, 0.9405341148376465, 0.9473423957824707, 0.942558228969574, 0.9085822105407715, 0.9246655106544495, 0.8945214152336121, 0.8268392086029053, 0.8050501942634583, 0.8216559886932373, 0.8648381233215332, 0.9457864165306091, 0.9847263693809509, 0.9877855777740479, 0.9885293841362, 0.9854318499565125, 0.9813781976699829, 0.9836981296539307, 0.985080897808075, 0.9803099036216736, 0.9765126705169678, 0.9792542457580566, 0.9733161330223083, 0.9706294536590576, 0.6648507714271545, 0.6592761278152466, 0.7244044542312622, 0.7203340530395508, 0.7516839504241943, 0.7285681366920471, 0.8298481702804565, 0.915993869304657, 0.9598581194877625, 0.9611147046089172, 0.9589844346046448, 0.9680719971656799, 0.9721459150314331, 0.9595434665679932, 0.9526958465576172, 0.9577984809875488, 0.9570990800857544, 0.9655317068099976, 0.9586706757545471, 0.9521898627281189, 0.9304940104484558, 0.9199660420417786, 0.9074868559837341, 0.8788831233978271, 0.8644260168075562, 0.9558330774307251, 0.9589148163795471, 0.9655898213386536, 0.9736241102218628, 0.9726818799972534, 0.9754600524902344, 0.9701703786849976, 0.9655485153198242, 0.9615176916122437, 0.9379848837852478, 0.8614794611930847, 0.8067842125892639, 0.7310707569122314, 0.6199994087219238, 0.5997992753982544, 0.6105345487594604, 0.546142578125, 0.5044264197349548, 0.5096054673194885, 0.45400846004486084, 0.4419284462928772, 0.4207126498222351, 0.43653953075408936, 0.36741700768470764, 0.3802265226840973, 0.39380866289138794, 0.38843852281570435, 0.3524465560913086, 0.5315241813659668, 0.6046318411827087, 0.7821096777915955, 0.8647176623344421, 0.9143548011779785, 0.930306077003479, 0.9458413124084473, 0.9475984573364258, 0.9244115948677063, 0.9587003588676453, 0.9586514830589294, 0.8628623485565186, 0.8129808306694031, 0.884538471698761, 0.9221017956733704, 0.8908369541168213, 0.8780956864356995, 0.9635571241378784, 0.9745341539382935, 0.9836492538452148, 0.9688693284988403, 0.9737623333930969, 0.9613674879074097, 0.9654874801635742, 0.9527843594551086, 0.9461551904678345, 0.9274216890335083, 0.9335674047470093, 0.9579134583473206, 0.9741708040237427, 0.9822516441345215, 0.9816407561302185, 0.9794955849647522, 0.9796096682548523, 0.9783158898353577, 0.9777513742446899, 0.9773926138877869, 0.9728090167045593, 0.9627601504325867, 0.9407639503479004, 0.9733110666275024, 0.9753702878952026, 0.9734526872634888, 0.9657219052314758, 0.9728239178657532, 0.9758027195930481, 0.980387270450592, 0.9607051014900208, 0.9223342537879944, 0.9508378505706787, 0.9252031445503235, 0.9508153200149536, 0.970060408115387, 0.9729869365692139, 0.908656895160675, 0.9252480864524841, 0.9146966338157654, 0.8790549635887146, 0.905273973941803, 0.8991640210151672, 0.8174188137054443, 0.791660726070404, 0.76542729139328, 0.8703635931015015, 0.9294608235359192, 0.9622548818588257, 0.9709638357162476, 0.9803378582000732, 0.9801304340362549, 0.9704450964927673, 0.9485501050949097, 0.9519245028495789, 0.9434482455253601, 0.9186373949050903, 0.9447261691093445, 0.9311696887016296, 0.897223949432373, 0.9353144764900208, 0.9445376396179199, 0.9678842425346375, 0.9241800904273987, 0.8913542628288269, 0.9144954085350037, 0.9006198048591614, 0.921698272228241, 0.9494302272796631, 0.9638233780860901, 0.953683614730835, 0.9553409218788147, 0.9618688225746155, 0.9575957655906677, 0.9525513052940369, 0.9438641667366028, 0.9272187352180481, 0.9219503998756409, 0.9163036942481995, 0.9237087965011597, 0.8771845698356628, 0.8461354374885559, 0.8493492603302002, 0.8098078370094299, 0.7428613901138306, 0.6895901560783386, 0.6381773948669434, 0.5735471844673157, 0.4756903052330017, 0.4471265971660614, 0.40992113947868347, 0.3747453987598419, 0.3621343970298767, 0.3406643569469452, 0.3219118118286133, 0.3239901065826416, 0.30553585290908813, 0.28713440895080566, 0.26260077953338623, 0.2495788335800171, 0.36926400661468506, 0.66435706615448, 0.7471330165863037, 0.8131622076034546, 0.8821017146110535, 0.9080814123153687, 0.9568327069282532, 0.9334079027175903, 0.948250412940979, 0.9661505818367004, 0.9676405191421509, 0.9428774118423462, 0.9419678449630737, 0.9474524855613708, 0.9422173500061035, 0.94012850522995, 0.9522485733032227, 0.9668121933937073, 0.9637511968612671, 0.9637921452522278, 0.9617382287979126, 0.9501404166221619, 0.9587835073471069, 0.9496629238128662, 0.9316422939300537, 0.9161521792411804, 0.8908224701881409, 0.8310384154319763, 0.8503373861312866, 0.8648136854171753, 0.8253213167190552, 0.8199498653411865, 0.7699125409126282, 0.7141855955123901, 0.6930603384971619, 0.6557822823524475, 0.6337140202522278, 0.7633876204490662, 0.7854065895080566, 0.9276068806648254, 0.9610463976860046, 0.9711002707481384, 0.966808557510376, 0.9693686366081238, 0.9598360657691956, 0.9564975500106812, 0.9554885029792786, 0.9598326086997986, 0.9637487530708313, 0.961932897567749, 0.9574767351150513, 0.9514248371124268, 0.9512600898742676, 0.9504267573356628, 0.9507203698158264, 0.9425586462020874, 0.9402831196784973, 0.9430176615715027, 0.9613277912139893, 0.9585182070732117, 0.9530366063117981, 0.9400612115859985, 0.9507986903190613, 0.9530974626541138, 0.9499576687812805, 0.9251615405082703, 0.8158518075942993, 0.7456303238868713, 0.7309276461601257, 0.7526067495346069, 0.7350371479988098, 0.7409915328025818, 0.8680034875869751, 0.8786419034004211, 0.931411623954773, 0.949889063835144, 0.9557179808616638, 0.9561349749565125, 0.9474422335624695, 0.9516090750694275, 0.9541062712669373, 0.9437803030014038, 0.9484035968780518, 0.9383028745651245, 0.9362013936042786, 0.9516807198524475, 0.9487447142601013, 0.9353539943695068, 0.9337794780731201, 0.936615526676178, 0.9403994083404541, 0.8972999453544617, 0.8342923521995544, 0.7808993458747864, 0.8050814867019653, 0.8436107635498047, 0.9031067490577698, 0.9570356607437134, 0.9298087358474731, 0.8970509767532349, 0.9390662908554077, 0.9207864999771118, 0.9038916230201721, 0.9375726580619812, 0.9354654550552368, 0.962529718875885, 0.9705693125724792, 0.9669255614280701, 0.9722554087638855, 0.970442533493042, 0.9685345888137817, 0.9667460322380066, 0.9664993286132812, 0.9663022756576538, 0.9401913285255432, 0.9451084733009338, 0.9178813099861145, 0.8656411170959473, 0.7702390551567078, 0.6890288591384888, 0.6523339748382568, 0.581917405128479, 0.4841526448726654, 0.4312840700149536, 0.40748631954193115, 0.5948838591575623, 0.6720612049102783, 0.6805526614189148, 0.7178061604499817, 0.7629356384277344, 0.8098915219306946, 0.8431330323219299, 0.7615322470664978, 0.7291755676269531, 0.6701950430870056, 0.679032564163208, 0.8156265616416931, 0.9290739893913269, 0.9858623147010803, 0.986814022064209, 0.9749429225921631, 0.9687748551368713, 0.9639660716056824, 0.969515323638916, 0.9752041697502136, 0.9775274991989136, 0.9674063920974731, 0.9639527797698975, 0.9660309553146362, 0.9659349322319031, 0.9692496061325073, 0.9557057619094849, 0.9518802165985107, 0.942139208316803, 0.9412243962287903, 0.9274985790252686, 0.922083854675293, 0.9009718894958496, 0.8731514811515808, 0.8263649344444275, 0.7729319930076599, 0.8096332550048828, 0.8348467946052551, 0.8067536354064941, 0.8123453855514526, 0.7982338666915894, 0.7529315948486328, 0.641084611415863, 0.5806806683540344, 0.5051531791687012, 0.41880789399147034, 0.3852623999118805, 0.3792881667613983, 0.3644458055496216, 0.3770821988582611, 0.3899378776550293, 0.3490631878376007, 0.36238816380500793, 0.33365869522094727, 0.36897820234298706, 0.3360327184200287, 0.3203619122505188, 0.32916781306266785, 0.32114410400390625, 0.3281060457229614, 0.3585468530654907, 0.3814878463745117, 0.4041312336921692, 0.4178156554698944, 0.39220625162124634, 0.3326468765735626, 0.41287973523139954, 0.5920257568359375, 0.7664608955383301, 0.878863513469696, 0.9563500285148621, 0.9738453030586243, 0.9802879095077515, 0.9832388162612915, 0.9830867052078247, 0.9616643786430359, 0.9633840918540955, 0.9727665781974792, 0.9726579785346985, 0.980541467666626, 0.9835432767868042, 0.9750708937644958, 0.9490838646888733, 0.9381515979766846, 0.9570898413658142, 0.9482967257499695, 0.9155230522155762, 0.9230781197547913, 0.8646079897880554, 0.8295158743858337, 0.8344897627830505, 0.7891899943351746, 0.9501407146453857, 0.9748153686523438, 0.9774437546730042, 0.9501933455467224, 0.9387313723564148, 0.8767077326774597, 0.8902820944786072, 0.8950128555297852, 0.9743836522102356, 0.9885324239730835, 0.9901190996170044, 0.9898243546485901, 0.9851030111312866, 0.9751997590065002, 0.9771501421928406, 0.9563408493995667, 0.8985996842384338, 0.8658072352409363, 0.8592076897621155, 0.8519335389137268, 0.8920494914054871, 0.8606423735618591, 0.961201012134552, 0.9720712304115295, 0.9726864099502563, 0.9790465831756592, 0.9754706025123596, 0.9807217121124268, 0.9584640860557556, 0.946379542350769, 0.9291703104972839, 0.9237011075019836, 0.8975062966346741, 0.8339776992797852, 0.7792274355888367, 0.6798990368843079, 0.6309837698936462, 0.6586589813232422, 0.5640984177589417, 0.5263160467147827, 0.6680205464363098, 0.8195554614067078, 0.9127960205078125, 0.9568942785263062, 0.9671033620834351, 0.9777352809906006, 0.9763681888580322, 0.976425051689148, 0.9708117842674255, 0.9439218640327454, 0.9373526573181152, 0.9490092396736145, 0.9512718319892883, 0.9656789898872375, 0.9308155179023743, 0.8767697811126709, 0.8480103015899658, 0.8052466511726379, 0.7390987277030945, 0.8388835191726685, 0.8789787888526917, 0.9546384811401367, 0.9600632190704346, 0.9689100384712219, 0.9703843593597412, 0.9698735475540161, 0.971527636051178, 0.9711363911628723, 0.9774157404899597, 0.9731462001800537, 0.9717611074447632, 0.9611141085624695, 0.9268242120742798, 0.9256731867790222, 0.8845226168632507, 0.9147124886512756, 0.9143177270889282, 0.9152815937995911, 0.8917317390441895, 0.8465257287025452, 0.8252117037773132, 0.781197190284729, 0.724669337272644, 0.6635237336158752, 0.6229680180549622, 0.537986695766449, 0.44687533378601074, 0.4048595130443573, 0.3732193410396576, 0.39400872588157654, 0.3686250150203705, 0.328748881816864, 0.3234812915325165, 0.32393190264701843, 0.3141918480396271, 0.2631748616695404, 0.2740667164325714, 0.25286850333213806, 0.30158478021621704, 0.266790509223938, 0.2914336919784546, 0.26092493534088135, 0.2585377097129822, 0.24078857898712158, 0.27553844451904297, 0.24365973472595215, 0.23676009476184845, 0.2504320740699768, 0.2508661150932312, 0.2612222135066986, 0.26134809851646423, 0.7214850783348083, 0.7954569458961487, 0.8567533493041992, 0.9189543724060059, 0.9354320764541626, 0.9569189548492432, 0.9730129241943359, 0.9746481776237488, 0.9684818387031555, 0.962405264377594, 0.9550866484642029, 0.9532449245452881, 0.9559159874916077, 0.9568641781806946, 0.955157995223999, 0.9479323625564575, 0.9503206014633179, 0.947820782661438, 0.9495266079902649, 0.9562627077102661, 0.957944929599762, 0.9515560865402222, 0.9477932453155518, 0.9442406296730042, 0.9446254968643188, 0.9441259503364563, 0.9390265941619873, 0.8862606883049011, 0.7989927530288696, 0.8524237275123596, 0.8337173461914062, 0.745220959186554, 0.7635445594787598, 0.7230520248413086, 0.7851918339729309, 0.7929466366767883, 0.8827900290489197, 0.9409583806991577, 0.9695888161659241, 0.9798508286476135, 0.9770094752311707, 0.976477324962616, 0.9717038869857788, 0.951370120048523, 0.9085182547569275, 0.802880048751831, 0.8578030467033386, 0.8839621543884277, 0.842708170413971, 0.8121179342269897, 0.8630914688110352, 0.7980328798294067, 0.7709130644798279, 0.7767056822776794, 0.7503010034561157, 0.7257140278816223, 0.7197538614273071, 0.6881259679794312, 0.64284747838974, 0.6111069917678833, 0.6088583469390869, 0.5529464483261108, 0.533830463886261, 0.4909309446811676, 0.447075217962265, 0.42501458525657654, 0.4216526448726654, 0.40775594115257263, 0.39343422651290894, 0.34263792634010315, 0.33453983068466187, 0.31338027119636536, 0.31141456961631775, 0.3012884259223938, 0.26825952529907227, 0.2643945515155792, 0.2686876654624939, 0.2670772671699524, 0.24508452415466309, 0.3259686231613159, 0.2506600618362427, 0.2837313413619995, 0.33220863342285156, 0.2863152027130127, 0.23767036199569702, 0.23972585797309875, 0.22300714254379272, 0.2545608580112457, 0.254420667886734, 0.21668513119220734, 0.22034499049186707, 0.23892736434936523, 0.22095079720020294, 0.24497246742248535, 0.29737722873687744, 0.22263440489768982, 0.24000447988510132, 0.2165110558271408, 0.24611830711364746, 0.4434596598148346, 0.32118645310401917, 0.26326099038124084, 0.3177723288536072, 0.22768308222293854, 0.26283878087997437, 0.2803887128829956, 0.2654942274093628, 0.22933469712734222, 0.21837612986564636, 0.2044493556022644, 0.22867156565189362, 0.1968264877796173, 0.20264005661010742, 0.19783717393875122, 0.4410948157310486, 0.4053645730018616, 0.27696436643600464, 0.22341755032539368, 0.28032028675079346, 0.2187264859676361, 0.3401680886745453, 0.4831465482711792, 0.37549737095832825, 0.6871476769447327, 0.8149428963661194, 0.922805666923523, 0.9676086902618408, 0.9660021662712097, 0.9761768579483032, 0.9626352787017822, 0.9629302024841309, 0.9360528588294983, 0.8142060041427612, 0.7913415431976318, 0.8405918478965759, 0.7685621976852417, 0.8747352957725525, 0.9212195873260498, 0.9271408319473267, 0.8872449398040771, 0.8912235498428345, 0.8969898819923401, 0.9537410140037537, 0.9727126955986023, 0.9651150107383728, 0.9739949107170105, 0.9720847010612488, 0.9702333211898804, 0.9609987139701843, 0.9516850709915161, 0.9630250930786133, 0.960908830165863, 0.9708285331726074, 0.9730241894721985, 0.9716897010803223, 0.9750030040740967, 0.9387317299842834, 0.965476930141449, 0.9529119729995728, 0.9570401310920715, 0.9609758257865906, 0.967645525932312, 0.9602756500244141, 0.9290027022361755, 0.880181074142456, 0.8674073219299316, 0.908400297164917, 0.8986555337905884, 0.8698582649230957, 0.8317967057228088, 0.9693467020988464, 0.9842929244041443, 0.9857866764068604, 0.9851921796798706, 0.9741432666778564, 0.9807296395301819, 0.974787712097168, 0.9534935355186462, 0.9809457659721375, 0.9482606053352356, 0.9409705996513367, 0.938757061958313, 0.8857810497283936, 0.8303777575492859, 0.7623159885406494, 0.7004286646842957, 0.6142296195030212, 0.5368038415908813, 0.8841937780380249, 0.9581726789474487, 0.974294900894165, 0.9726197719573975, 0.9713501334190369, 0.9728485941886902, 0.9657554626464844, 0.9651772975921631, 0.9252489805221558, 0.9301113486289978, 0.9447241425514221, 0.9279177784919739, 0.9016997218132019, 0.8983352780342102, 0.8809592723846436, 0.8970319032669067, 0.9516566395759583, 0.968431293964386, 0.9842188954353333, 0.9776363968849182, 0.9775102734565735, 0.971100389957428, 0.9660816788673401, 0.9741097092628479, 0.9805284142494202, 0.9764071702957153, 0.9526178240776062, 0.958143413066864, 0.9374309182167053, 0.920763373374939, 0.8717261552810669, 0.8522964119911194, 0.8492449522018433, 0.809053361415863, 0.7733982801437378, 0.9272019863128662, 0.9378477334976196, 0.947329044342041, 0.9818869829177856, 0.9795291423797607, 0.986076295375824, 0.9777942299842834, 0.972396731376648, 0.9537582397460938, 0.9529072046279907, 0.8364185690879822, 0.7695951461791992, 0.7747868299484253, 0.7326703667640686, 0.7401015758514404, 0.6383869647979736, 0.5951937437057495, 0.5824648141860962, 0.5408241152763367, 0.4707247018814087, 0.4232078790664673, 0.3862702548503876, 0.3701564371585846, 0.3656787574291229, 0.37766900658607483, 0.4029025733470917, 0.43126100301742554, 0.4044428765773773, 0.704838216304779, 0.508928120136261, 0.8359355330467224, 0.849555253982544, 0.9420745968818665, 0.9639768004417419, 0.9590085744857788, 0.9728397130966187, 0.9856522083282471, 0.9877474308013916, 0.9852437973022461, 0.9837303161621094, 0.9566075205802917, 0.9544271230697632, 0.9645535945892334, 0.9731407165527344, 0.9512416124343872, 0.9519820809364319, 0.9752547740936279, 0.9836553335189819, 0.9869821667671204, 0.9878173470497131, 0.9822005033493042, 0.9707859754562378, 0.952094316482544, 0.9564095735549927, 0.9585437178611755, 0.9534828066825867, 0.9289135932922363, 0.8959243893623352, 0.8963378071784973, 0.8169806003570557, 0.7772862911224365, 0.7309890985488892, 0.6866658329963684, 0.6273927688598633, 0.8664839863777161, 0.9275774359703064, 0.9802353382110596, 0.9821348190307617, 0.9835471510887146, 0.9850804209709167, 0.9862150549888611, 0.984574019908905, 0.9837536215782166, 0.9816379547119141, 0.9831597208976746, 0.9804098010063171, 0.973334550857544, 0.9321109652519226, 0.8624599575996399, 0.8660039901733398, 0.8566469550132751, 0.8721725344657898, 0.8582370281219482, 0.9057832360267639, 0.9112524390220642, 0.9625996351242065, 0.9704756140708923, 0.9731695055961609, 0.9664431810379028, 0.9675651788711548, 0.9682259559631348, 0.9673032760620117, 0.9650393128395081, 0.9680815935134888, 0.9678042531013489, 0.9627382755279541, 0.9522377252578735, 0.902705192565918, 0.8592835664749146, 0.8159361481666565, 0.807440996170044, 0.814155638217926, 0.751592218875885, 0.6792372465133667, 0.6016720533370972, 0.5448048114776611, 0.5747032165527344, 0.5443814396858215, 0.5016879439353943, 0.4921482503414154, 0.42750850319862366, 0.40614891052246094, 0.39559829235076904, 0.37027934193611145, 0.4158032536506653, 0.37112361192703247, 0.37285640835762024, 0.35944199562072754, 0.3374050259590149, 0.3400120735168457, 0.3102942705154419, 0.27221882343292236, 0.3048272728919983, 0.2938934564590454, 0.2911960780620575, 0.2574363648891449, 0.28271427750587463, 0.2926904857158661, 0.2907300889492035, 0.2889770269393921, 0.30589842796325684, 0.5906850695610046, 0.6813890933990479, 0.8143640756607056, 0.9014596939086914, 0.9464881420135498, 0.947046160697937, 0.9232462644577026, 0.9115266799926758, 0.8838421702384949, 0.8380899429321289, 0.8222535252571106, 0.7547191381454468, 0.6895163059234619, 0.9188401103019714, 0.9683378338813782, 0.9861061573028564, 0.9917703866958618, 0.9885778427124023, 0.983307957649231, 0.9791897535324097, 0.9841392636299133, 0.9800077080726624, 0.9271294474601746, 0.9276115298271179, 0.9358231425285339, 0.8838834166526794, 0.8678010106086731, 0.8935697674751282, 0.9035952687263489, 0.9603519439697266, 0.9746977090835571, 0.9831016659736633, 0.9544111490249634, 0.9615272283554077, 0.9501550197601318, 0.9404358863830566, 0.9434657692909241, 0.9214474558830261, 0.9354188442230225, 0.8879818320274353, 0.9637526273727417, 0.9717724323272705, 0.9870882034301758, 0.9885347485542297, 0.9899460673332214, 0.9841392636299133, 0.974464476108551, 0.976469099521637, 0.9712762236595154, 0.9312928318977356, 0.958807110786438, 0.9565520882606506, 0.9352819323539734, 0.894624650478363, 0.7543086409568787, 0.7198749780654907, 0.6910592317581177, 0.681401789188385, 0.6167152523994446, 0.577948808670044, 0.5392956733703613, 0.5011746883392334, 0.4997584819793701, 0.46576637029647827, 0.434741348028183, 0.4153071343898773, 0.4218631982803345, 0.40894919633865356, 0.37290653586387634, 0.35429880023002625, 0.337309330701828, 0.3423137664794922, 0.3122822344303131, 0.3036866784095764, 0.3206649720668793, 0.2882578670978546, 0.2749808132648468, 0.29896092414855957, 0.2972623109817505, 0.2821738123893738, 0.28431621193885803, 0.25837188959121704, 0.28291386365890503, 0.2753778100013733, 0.272758424282074, 0.26376351714134216, 0.23964641988277435, 0.28426623344421387, 0.4342532753944397, 0.5834724307060242, 0.6238303184509277, 0.8071704506874084, 0.8285002112388611, 0.9092614650726318, 0.95701664686203, 0.9789976477622986, 0.9796978235244751, 0.983355700969696, 0.9778574705123901, 0.9663776159286499, 0.9643132090568542, 0.9734472632408142, 0.9765772223472595, 0.9747332334518433, 0.9628205895423889, 0.9640008211135864, 0.9671496152877808, 0.972252607345581, 0.9758526682853699, 0.9731517434120178, 0.9620950818061829, 0.9749693274497986, 0.9723446369171143, 0.9686697125434875, 0.946017861366272, 0.9466298818588257, 0.9766765236854553, 0.9830090403556824, 0.9817909002304077, 0.9740340113639832, 0.9655282497406006, 0.9661206007003784, 0.9736230969429016, 0.9654633402824402, 0.9619333148002625, 0.9589837789535522, 0.9563320279121399, 0.9536311626434326, 0.9471473097801208, 0.9132620692253113, 0.800035297870636, 0.800710916519165, 0.7743441462516785, 0.8213777542114258, 0.8078840970993042, 0.8413349390029907, 0.846535861492157, 0.8850516676902771, 0.8919388055801392, 0.8950411677360535, 0.8906539082527161, 0.8886547088623047, 0.875676155090332, 0.9250879883766174, 0.9519292712211609, 0.9242890477180481, 0.9364089369773865, 0.958101212978363, 0.9574153423309326, 0.9566835761070251, 0.9073790907859802, 0.9096469879150391, 0.8654682636260986, 0.7889764308929443, 0.7063426971435547, 0.6891340613365173, 0.6341444849967957, 0.5886331796646118, 0.5272114276885986, 0.44510984420776367, 0.4420534670352936, 0.4102003276348114, 0.8683489561080933, 0.9460129141807556, 0.944242537021637, 0.9600932002067566, 0.975648045539856, 0.9540386199951172, 0.9419835209846497, 0.9223236441612244, 0.9113845825195312, 0.8943091034889221, 0.8527902960777283, 0.7860000729560852, 0.7493616342544556, 0.7223779559135437, 0.6588051319122314, 0.550440788269043, 0.5047625303268433, 0.4651917815208435, 0.4254971444606781, 0.36056196689605713, 0.4519222676753998, 0.4470372796058655, 0.4977191388607025, 0.7088656425476074, 0.7284537553787231, 0.6368567943572998, 0.6592244505882263, 0.6703067421913147, 0.6665939688682556, 0.6834341287612915, 0.6677055358886719, 0.6138311624526978, 0.6087766289710999, 0.5766423940658569, 0.5253371596336365, 0.499644935131073, 0.459176242351532, 0.3997170627117157, 0.38358500599861145, 0.35156524181365967, 0.28898149728775024, 0.29517480731010437, 0.29152682423591614, 0.2637845277786255, 0.27852872014045715, 0.2483769655227661, 0.2318117320537567, 0.24124020338058472, 0.22054432332515717, 0.20866043865680695, 0.21006052196025848, 0.2019113451242447, 0.20770683884620667, 0.20369839668273926, 0.1935015469789505, 0.2004743218421936, 0.1817328929901123, 0.18502277135849, 0.1882632076740265, 0.18530821800231934, 0.19262617826461792, 0.18093429505825043, 0.18003658950328827, 0.18284910917282104, 0.17228396236896515, 0.1756897121667862, 0.1807820349931717, 0.19652071595191956, 0.1936233937740326, 0.20659485459327698, 0.20085981488227844, 0.18241503834724426, 0.19700123369693756, 0.18388192355632782, 0.18091510236263275, 0.20085962116718292, 0.1749596744775772, 0.2854946255683899, 0.4062231183052063, 0.45744240283966064, 0.4775230288505554, 0.4495212733745575, 0.4534164071083069, 0.41418561339378357, 0.37901440262794495, 0.3694520592689514, 0.36075347661972046, 0.3335374891757965, 0.3101147711277008, 0.31019535660743713, 0.28874218463897705, 0.34500423073768616, 0.2992706000804901, 0.29625600576400757, 0.31201714277267456, 0.28464964032173157, 0.2911951243877411, 0.3134045898914337, 0.285616934299469, 0.3085631728172302, 0.288631796836853, 0.29069983959198, 0.3113158643245697, 0.3232225775718689, 0.3120618760585785, 0.289497047662735, 0.2886534035205841, 0.30468258261680603, 0.28729724884033203, 0.2824070155620575, 0.26170840859413147, 0.2615751028060913, 0.2807716727256775, 0.25888824462890625, 0.2481217086315155, 0.2337927222251892, 0.3195692300796509, 0.2291799634695053, 0.2519465684890747, 0.2380359023809433, 0.21383172273635864, 0.2444087564945221, 0.2524086833000183, 0.21854393184185028, 0.21281428635120392, 0.23246783018112183, 0.20957939326763153, 0.20674221217632294, 0.21562781929969788, 0.2167467623949051, 0.2066829353570938, 0.20244692265987396, 0.20471830666065216, 0.20712600648403168, 0.20770300924777985, 0.20753438770771027, 0.19187016785144806, 0.20920206606388092, 0.20878292620182037, 0.1910688430070877, 0.2249932736158371, 0.24125976860523224, 0.1912861317396164, 0.22575697302818298, 0.1880142092704773, 0.20851440727710724, 0.20348533987998962, 0.19749507308006287, 0.20812512934207916, 0.19159157574176788, 0.1829102337360382, 0.18527868390083313, 0.18727745115756989, 0.19747960567474365, 0.1964724063873291, 0.18928980827331543, 0.18824495375156403, 0.18218128383159637, 0.18520772457122803, 0.1848960518836975, 0.20283769071102142, 0.1800927072763443, 0.18677258491516113, 0.1890747994184494, 0.1926821768283844, 0.36132150888442993, 0.6617596745491028, 0.7555304765701294, 0.9071340560913086, 0.9741666913032532, 0.9851512312889099, 0.9860104322433472, 0.9877912998199463, 0.9897733330726624, 0.9846851825714111, 0.9772458672523499, 0.9389025568962097, 0.9578419327735901, 0.92616868019104, 0.9118571281433105, 0.9458492994308472, 0.9395955801010132, 0.9839985370635986, 0.984558641910553, 0.9890781044960022, 0.984012246131897, 0.921596884727478, 0.9299415946006775, 0.9490970969200134, 0.9459429383277893, 0.9761364459991455, 0.9890563488006592, 0.9830954670906067, 0.9715518355369568, 0.9794676899909973, 0.9749501943588257, 0.9729570746421814, 0.9649297595024109, 0.9190958738327026, 0.8236211538314819, 0.8618544936180115, 0.9129891395568848, 0.879963755607605, 0.8964515328407288, 0.9278501868247986, 0.9791492819786072, 0.9844847917556763, 0.9850512742996216, 0.9601104855537415, 0.8377509713172913, 0.838976263999939, 0.9126794338226318, 0.9338228106498718, 0.9779173135757446, 0.9619337916374207, 0.9672325849533081, 0.9696993231773376, 0.969215989112854, 0.9662302136421204, 0.9612326622009277, 0.9591563940048218, 0.9350970387458801, 0.9138139486312866, 0.8254182934761047, 0.7922468781471252, 0.7332922220230103, 0.6897578835487366, 0.6595843434333801, 0.6357510089874268, 0.5709734559059143, 0.5260926485061646, 0.48765134811401367, 0.42991724610328674, 0.43216314911842346, 0.41332101821899414, 0.39908716082572937, 0.4089154005050659, 0.3803839087486267, 0.3790559470653534, 0.3869144916534424, 0.3787834644317627, 0.32864463329315186, 0.2892213761806488, 0.2662641406059265, 0.25502291321754456, 0.2230558544397354, 0.21873922646045685, 0.2012266367673874, 0.1951669603586197, 0.18360979855060577, 0.1856134831905365, 0.1818149983882904, 0.175020232796669, 0.18819858133792877, 0.17682752013206482, 0.19496667385101318, 0.19431407749652863, 0.19105255603790283, 0.20585349202156067, 0.18038664758205414, 0.15892212092876434, 0.15801787376403809, 0.18600480258464813, 0.16375291347503662, 0.16721877455711365, 0.18832437694072723, 0.26378554105758667, 0.27774959802627563, 0.2664008140563965, 0.25703805685043335, 0.23917315900325775, 0.21700477600097656, 0.201768696308136, 0.19070830941200256, 0.20024962723255157, 0.18731538951396942, 0.221390962600708, 0.19677041471004486, 0.18596036732196808, 0.185990571975708, 0.2066163420677185, 0.21082617342472076, 0.2183820903301239, 0.20468290150165558, 0.17997273802757263, 0.21265341341495514, 0.17747944593429565, 0.17405059933662415, 0.20220786333084106, 0.23872879147529602, 0.2203090935945511, 0.23968005180358887, 0.22701725363731384, 0.20491015911102295, 0.18493729829788208, 0.18677881360054016, 0.16304953396320343, 0.18546997010707855, 0.19894391298294067, 0.17619839310646057, 0.16904844343662262, 0.1789398193359375, 0.17455537617206573, 0.18725788593292236, 0.3044498562812805, 0.3843730390071869, 0.6881811022758484, 0.7624069452285767, 0.9015044569969177, 0.965838611125946, 0.9773274660110474, 0.9784532189369202, 0.9750648736953735, 0.9776723980903625, 0.9736093878746033, 0.9507076740264893, 0.9215500354766846, 0.9488147497177124, 0.9373583197593689, 0.8866245746612549, 0.8753513693809509, 0.8440921306610107, 0.8774397969245911, 0.9394524693489075, 0.9817055463790894, 0.9908407926559448, 0.9848233461380005, 0.9786943197250366, 0.9381905794143677, 0.9747987389564514, 0.9753978848457336, 0.972145676612854, 0.9776487946510315, 0.9753544330596924, 0.9746996164321899, 0.9743952751159668, 0.9728531241416931, 0.973010778427124, 0.9636825323104858, 0.9603598713874817, 0.9566046595573425, 0.9472607970237732, 0.9410886168479919, 0.9563134908676147, 0.881130039691925, 0.7769193649291992, 0.7433766722679138, 0.6759594678878784, 0.6078551411628723, 0.6392875909805298, 0.8591815829277039, 0.9477549195289612, 0.972206175327301, 0.9692753553390503, 0.966719925403595, 0.9858019948005676, 0.9673375487327576, 0.9587280750274658, 0.9603727459907532, 0.9693436622619629, 0.9518328905105591, 0.963399350643158, 0.970277726650238, 0.9694392085075378, 0.9331344366073608, 0.9503824710845947, 0.9211647510528564, 0.922248363494873, 0.9687680602073669, 0.9708076119422913, 0.9121578931808472, 0.8690866827964783, 0.8044062256813049, 0.73822021484375, 0.6416484713554382, 0.6301640868186951, 0.7629987597465515, 0.9147188067436218, 0.9825875163078308, 0.9891046285629272, 0.9918362498283386, 0.986438512802124, 0.9837716221809387, 0.984968900680542, 0.9690755605697632, 0.8553826212882996, 0.8649516105651855, 0.8609230518341064, 0.7671629786491394, 0.8322076201438904, 0.8889507055282593, 0.9124882221221924, 0.8962318301200867, 0.8908610939979553, 0.9218405485153198, 0.8836647868156433, 0.8569751977920532, 0.8685552477836609, 0.9582383036613464, 0.985215425491333, 0.9868647456169128, 0.987060546875, 0.9864813089370728, 0.9835545420646667, 0.959435760974884, 0.9034438729286194, 0.9054786562919617, 0.8664246201515198, 0.8084311485290527, 0.7371411323547363, 0.6572487354278564, 0.8141127228736877, 0.7599896192550659, 0.8126422166824341, 0.8535579442977905, 0.9362062215805054, 0.9736765623092651, 0.9792742133140564, 0.9887164831161499, 0.9886166453361511, 0.9875591397285461, 0.9798886179924011, 0.9741994142532349, 0.8978836536407471, 0.8988127112388611, 0.9256767630577087, 0.9198449850082397, 0.9676507711410522, 0.9818732738494873, 0.9845556616783142, 0.9823158383369446, 0.9825797080993652, 0.9754623174667358, 0.9736607074737549, 0.967795193195343, 0.9346528053283691, 0.9327009916305542, 0.8215488791465759, 0.751298189163208, 0.7434749007225037, 0.7785705327987671, 0.7172878384590149, 0.6505532264709473, 0.5980473756790161, 0.5769753456115723, 0.557792067527771, 0.5598916411399841, 0.560016930103302, 0.5458559393882751, 0.5510355830192566, 0.5135630965232849, 0.49369075894355774, 0.47213056683540344, 0.4593587815761566, 0.4120241701602936, 0.3535548448562622, 0.34331873059272766, 0.2963814437389374, 0.27979549765586853, 0.4498523771762848, 0.7073937654495239, 0.8622961044311523, 0.958376944065094, 0.977377712726593, 0.9640089273452759, 0.9479284882545471, 0.9767295122146606, 0.9791898727416992, 0.9695515036582947, 0.9542648196220398, 0.9325030446052551, 0.8614891171455383, 0.8187787532806396, 0.744773805141449, 0.7104255557060242, 0.6941980719566345, 0.6825876832008362, 0.6143574118614197, 0.860858678817749, 0.9391726851463318, 0.960252046585083, 0.9687045812606812, 0.9689019918441772, 0.9182224869728088, 0.8982653617858887, 0.8648737072944641, 0.8377068042755127, 0.7844932675361633, 0.7346802353858948, 0.655940055847168, 0.6112263798713684, 0.4872048795223236, 0.410190612077713, 0.36625659465789795, 0.4482797086238861, 0.43242305517196655, 0.34422338008880615, 0.31711217761039734, 0.3294646441936493, 0.3235763609409332, 0.26721668243408203, 0.23860876262187958, 0.2881908416748047, 0.24202078580856323, 0.25220754742622375, 0.23587371408939362, 0.2457229197025299, 0.23394064605236053, 0.28155583143234253, 0.22862261533737183, 0.23175296187400818, 0.23017051815986633, 0.22278322279453278, 0.22662319242954254, 0.24684759974479675, 0.2475064992904663, 0.253010094165802, 0.44888919591903687, 0.5987407565116882, 0.7010502815246582, 0.7258607149124146, 0.7367603778839111, 0.7627899050712585, 0.8017959594726562, 0.9385445713996887, 0.9686279892921448, 0.9844472408294678, 0.9841667413711548, 0.9913075566291809, 0.991511881351471, 0.9869712591171265, 0.9857017993927002, 0.9816812872886658, 0.9763243198394775, 0.9760580658912659, 0.9752585291862488, 0.9757323265075684, 0.9720690846443176, 0.9623642563819885, 0.956746518611908, 0.9546378254890442, 0.9144420027732849, 0.8193592429161072, 0.8426734805107117, 0.835382878780365, 0.8510352969169617, 0.8799707293510437, 0.9098752737045288, 0.9443946480751038, 0.957588791847229, 0.9661513566970825, 0.968643069267273, 0.9664379358291626, 0.974346399307251, 0.9756508469581604, 0.9710869193077087, 0.964931309223175, 0.9665907621383667, 0.9520571827888489, 0.9428350329399109, 0.9506629705429077, 0.9540398120880127, 0.9577012658119202, 0.9640864729881287, 0.9679462313652039, 0.9715772867202759, 0.960472047328949, 0.9581512212753296, 0.9557822942733765, 0.9557709097862244, 0.9349383115768433, 0.9255557656288147, 0.781880259513855, 0.7051262855529785, 0.6490282416343689, 0.6694252490997314, 0.6010769605636597, 0.5832383036613464, 0.5498681664466858, 0.6715907454490662, 0.6085667610168457, 0.8090695142745972, 0.86153644323349, 0.9200897216796875, 0.9293474555015564, 0.9225799441337585, 0.9279553890228271, 0.9443126916885376, 0.9617033004760742, 0.9702193737030029, 0.9705221056938171, 0.9656608700752258, 0.958279013633728, 0.9581626057624817, 0.9595626592636108, 0.9641536474227905, 0.9658156037330627, 0.9663981199264526, 0.9601858258247375, 0.9553248286247253, 0.9579699039459229, 0.9556856155395508, 0.9458245635032654, 0.9139459133148193, 0.8224059343338013, 0.7429352402687073, 0.7084910869598389, 0.768172562122345, 0.8053545355796814, 0.8728358745574951, 0.9493033289909363, 0.9711461663246155, 0.9781266450881958, 0.9715778231620789, 0.9657459259033203, 0.9708096385002136, 0.971796452999115, 0.9691368341445923, 0.9617801308631897, 0.961505651473999, 0.9617383480072021, 0.9630878567695618, 0.9664806127548218, 0.9618074893951416, 0.9665817022323608, 0.9602543115615845, 0.962063193321228, 0.9455409049987793, 0.9553248286247253, 0.9535303711891174, 0.9633800983428955, 0.9417682886123657, 0.9121871590614319, 0.925719678401947, 0.9182716608047485, 0.9134202599525452, 0.9339506030082703, 0.896822452545166, 0.9042292237281799, 0.8774875402450562, 0.8113236427307129, 0.7745661735534668, 0.7384748458862305, 0.6915143132209778, 0.6474124193191528, 0.6004697680473328, 0.5269311666488647, 0.48106205463409424, 0.4418434500694275, 0.4258626401424408, 0.3801303207874298, 0.38836026191711426, 0.6465109586715698, 0.7277121543884277, 0.8807100057601929, 0.9528923630714417, 0.9715237021446228, 0.9787884950637817, 0.9782994985580444, 0.9472317099571228, 0.9480807185173035, 0.9679168462753296, 0.9654800295829773, 0.9735906720161438, 0.9760162830352783, 0.9785032868385315, 0.9702360033988953, 0.9646973609924316, 0.9598219990730286, 0.9674885869026184, 0.958928644657135, 0.9511757493019104, 0.9535737037658691, 0.9575962424278259, 0.951694905757904, 0.953711211681366, 0.9623319506645203, 0.9667478203773499, 0.9681734442710876, 0.9671980142593384, 0.9654464721679688, 0.9638712406158447, 0.9613516330718994, 0.9558969140052795, 0.951849639415741, 0.9295833110809326, 0.9107601046562195, 0.8279885053634644, 0.7615189552307129, 0.7291696667671204, 0.7144089341163635, 0.6870356798171997, 0.6366664171218872, 0.6086785197257996, 0.5484097599983215, 0.6024599075317383, 0.5647953152656555, 0.6268708109855652, 0.6222558617591858, 0.6144164204597473, 0.5737174153327942, 0.5631071925163269, 0.5638323426246643, 0.5275530219078064, 0.5223597288131714, 0.49573197960853577, 0.48044922947883606, 0.5018662810325623, 0.6906263828277588, 0.8448692560195923, 0.9555901288986206, 0.9699164032936096, 0.9666415452957153, 0.9512583613395691, 0.9493741989135742, 0.9768072962760925, 0.9626669883728027, 0.8879222273826599, 0.8424073457717896, 0.8648953437805176, 0.8037249445915222, 0.9519319534301758, 0.9823611974716187, 0.980509877204895, 0.9699571132659912, 0.9430741667747498, 0.910995602607727, 0.9201655983924866, 0.8731873035430908, 0.8677708506584167, 0.9346540570259094, 0.955487072467804, 0.9786285161972046, 0.9618016481399536, 0.9703051447868347, 0.9652572870254517, 0.9725096821784973, 0.970896303653717, 0.9743515849113464, 0.9657506942749023, 0.9654076099395752, 0.8989865779876709, 0.8366906046867371, 0.8022079467773438, 0.7849540114402771, 0.7879737615585327, 0.8265215754508972, 0.8174794316291809, 0.8326340317726135, 0.8049479126930237, 0.8456969261169434, 0.8477194905281067, 0.8682271242141724, 0.8519377708435059, 0.893263041973114, 0.9313308596611023, 0.9479019641876221, 0.9538602828979492, 0.9471583366394043, 0.9324725866317749, 0.9404780268669128, 0.9553551077842712, 0.9651987552642822, 0.9703190326690674, 0.9621837735176086, 0.9308456778526306, 0.9351192712783813, 0.9679429531097412, 0.941135048866272, 0.9413736462593079, 0.9509373307228088, 0.9567047357559204, 0.9508969783782959, 0.9736602902412415, 0.9696687459945679, 0.9717634320259094, 0.9601696133613586, 0.9163809418678284, 0.8943618535995483, 0.8297036290168762, 0.7656586766242981, 0.7113914489746094, 0.6677206754684448, 0.649086594581604, 0.5233674049377441, 0.5507117509841919, 0.5050966739654541, 0.4521808624267578, 0.8177496194839478, 0.8631386756896973, 0.9498757123947144, 0.96186363697052, 0.9524709582328796, 0.9191524386405945, 0.9025450944900513, 0.8642423152923584, 0.8458425402641296, 0.8608609437942505, 0.8721507787704468, 0.8308330178260803, 0.8091669082641602, 0.7854751944541931, 0.6718984842300415, 0.663938045501709, 0.5849210023880005, 0.538189709186554, 0.4467821419239044, 0.42984071373939514, 0.8664225935935974, 0.8486839532852173, 0.8701136112213135, 0.8989191055297852, 0.8831714391708374, 0.8428999185562134, 0.7893679738044739, 0.79267817735672, 0.7748993635177612, 0.7468448877334595, 0.7282264232635498, 0.7171571850776672, 0.7300220727920532, 0.791730523109436, 0.8228363990783691, 0.8438977003097534, 0.8310835361480713, 0.8040673732757568, 0.7908160090446472, 0.7116125822067261, 0.703666627407074, 0.5884689092636108, 0.4782119691371918, 0.3710731863975525, 0.33014973998069763, 0.38310009241104126, 0.3241308033466339, 0.337555468082428, 0.3461582660675049, 0.34238892793655396, 0.33610621094703674, 0.32361018657684326, 0.3201064467430115, 0.34275394678115845, 0.29729360342025757, 0.3037031292915344, 0.2702466547489166, 0.27629080414772034, 0.2790328860282898, 0.2968750298023224, 0.29255011677742004, 0.26694533228874207, 0.2644471228122711, 0.26549115777015686, 0.26918289065361023, 0.27970069646835327, 0.29006749391555786, 0.7540911436080933, 0.8275115489959717, 0.9387770891189575, 0.947758674621582, 0.9326669573783875, 0.9246583580970764] \ No newline at end of file From 166c2859a201c12863ab985716fe2900b2fab38e Mon Sep 17 00:00:00 2001 From: Crutcher Dunnavant Date: Mon, 24 Aug 2026 23:14:39 -0700 Subject: [PATCH 32/32] test(pitch): cover the public seam and retire two unreachable branches Measured coverage rather than guessing at gaps, and the shape of the result was not what I expected. The stage internals were already at 97-99%; the hole was `source.rs` at 74.6% regions and 70% functions -- the module's *public entry point*. `PitchSourceConfig` and `PitchSourceKind` had no tests at all. They had only ever been exercised through ten_vad's driver, so deleting that kit left the seam a caller actually uses as the least-tested code in the module. Nine tests for it, each running the same assertion across all four selectable variants, because interchangeability is the entire point of the seam: every kind reports a finite non-negative plausible pitch; `forward` is the one-step case of `forward_sequence`; state carries and `reset` rewinds it; batch rows are independent; the config round-trips through serde; and `try_init_source` reports an invalid tensor geometry. One of those needed a guard of its own. The reset test proves nothing against a source with no state to rewind, so a companion test asserts that the two stateful kinds really do give a different answer with history than without -- otherwise `reset` would pass vacuously on all three. Two validate branches turned out to be unreachable rather than untested. `PitchCorrelate`'s period-range check compares two *constants* (64 against 16), and `PitchExcitation`'s history check reduces to `65 <= 0`. Neither depends on anything a caller can vary, so both become `const _: () = assert!(...)`, the idiom already used for `SHARPEN_IS_WRITE_SAFE`. That moves the invariant to compile time and removes a runtime branch that could never fire -- and an unfireable branch is indistinguishable from a bug in waiting. The rest is accessors and `Default` impls, which are worth covering because an accessor forwarding to the wrong sub-config field is a real bug class that nothing else would notice. `TensorPitch`'s test asserts `hop_size != n_bins` for exactly that reason. Coverage: source.rs 74.6% -> 98.6% regions, 70% -> 97% functions; lpc.rs and estimator.rs both improved; every file now above 97%. 121 pitch tests pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU --- .../bunsen/src/kits/speech/pitch/estimator.rs | 38 +++ crates/bunsen/src/kits/speech/pitch/lpc.rs | 11 + crates/bunsen/src/kits/speech/pitch/source.rs | 246 +++++++++++++++++- .../src/kits/speech/pitch/tensor/antialias.rs | 16 ++ .../src/kits/speech/pitch/tensor/correlate.rs | 20 +- .../kits/speech/pitch/tensor/excitation.rs | 16 +- .../src/kits/speech/pitch/tensor/source.rs | 20 ++ 7 files changed, 353 insertions(+), 14 deletions(-) diff --git a/crates/bunsen/src/kits/speech/pitch/estimator.rs b/crates/bunsen/src/kits/speech/pitch/estimator.rs index 89314412..f9a7c53d 100644 --- a/crates/bunsen/src/kits/speech/pitch/estimator.rs +++ b/crates/bunsen/src/kits/speech/pitch/estimator.rs @@ -774,6 +774,11 @@ impl PitchSourceInit for HostPitchEstimator { #[cfg(test)] mod tests { use super::*; + use crate::support::testing::CpuBackend; + + /// Constructing a `HostPitch` is setup plumbing with no numerics in it, so + /// it does not want a device lock. + type B = CpuBackend; const N_BINS: usize = FFT_SIZE / 2 + 1; @@ -898,6 +903,39 @@ mod tests { assert_eq!(est.slots(), 6); } + #[test] + fn test_default_matches_new() { + // `Default` lets the estimator sit in a struct field without a + // builder; it must not diverge from the real constructor. + let a = HostPitchEstimator::default(); + let b = HostPitchEstimator::new(); + assert_eq!(a.hop_size(), b.hop_size()); + assert_eq!(a.n_bins(), b.n_bins()); + assert_eq!(a.max_period, b.max_period); + assert_eq!(a.min_period, b.min_period); + assert_eq!(a.n_feat, b.n_feat); + } + + #[test] + fn test_estimator_is_directly_usable_as_a_source_init() { + // `HostPitchEstimator` implements `PitchSourceInit` itself, so a + // caller with a configured estimator can hand it straight to a + // pipeline instead of wrapping it in `HostPitchInit` by hand. The two + // routes must agree. + let device = Default::default(); + + let direct: HostPitch = + PitchSourceInit::::init_source(&HostPitchEstimator::new(), 2, &device); + let wrapped: HostPitch = PitchSourceInit::::init_source( + &HostPitchInit(HostPitchEstimator::new()), + 2, + &device, + ); + + assert_eq!(direct.batch_size(), wrapped.batch_size()); + assert_eq!(direct.batch_size(), 2); + } + #[test] fn test_silence_is_unvoiced() { let pitches = run(&vec![0.0f32; HOP_SIZE * 20]); diff --git a/crates/bunsen/src/kits/speech/pitch/lpc.rs b/crates/bunsen/src/kits/speech/pitch/lpc.rs index 50588049..95099b83 100644 --- a/crates/bunsen/src/kits/speech/pitch/lpc.rs +++ b/crates/bunsen/src/kits/speech/pitch/lpc.rs @@ -306,6 +306,17 @@ mod tests { const FFT: usize = 1024; const NBINS: usize = FFT / 2 + 1; + #[test] + fn test_dct_default_matches_new() { + // `Default` exists so the table can be a struct field without a + // builder; it must not diverge from the real constructor. + let a = DctTable::default(); + let b = DctTable::new(); + let probe: [f32; NB_BANDS] = core::array::from_fn(|i| (i as f32 * 0.7).cos()); + assert_eq!(a.dct(&probe), b.dct(&probe)); + assert_eq!(a.idct(&probe), b.idct(&probe)); + } + #[test] fn test_dct_round_trips_through_idct() { let dct = DctTable::new(); diff --git a/crates/bunsen/src/kits/speech/pitch/source.rs b/crates/bunsen/src/kits/speech/pitch/source.rs index a4d01004..5fabadfc 100644 --- a/crates/bunsen/src/kits/speech/pitch/source.rs +++ b/crates/bunsen/src/kits/speech/pitch/source.rs @@ -189,10 +189,18 @@ impl PitchSourceInit for ZeroPitch { #[cfg(test)] mod tests { - use burn::tensor::Tensor; + use burn::tensor::{ + Tensor, + TensorData, + Tolerance, + }; use super::*; - use crate::support::testing::PerformanceBackend; + use crate::{ + errors::WithOkOrPanic, + prelude::*, + support::testing::PerformanceBackend, + }; type B = PerformanceBackend; @@ -292,6 +300,240 @@ mod tests { } } + // ---- PitchSourceConfig / PitchSourceKind ---------------------------- + // + // The module's public entry point: a caller picks a variant by config and + // drives whatever it produces through the `PitchSource` seam. Every test + // below runs the *same* assertion across all three variants, because the + // whole point of the seam is that they are interchangeable. + + const HOP: usize = 256; + const BINS: usize = 513; + + /// Every variant a caller can select. + fn all_configs() -> Vec<(&'static str, PitchSourceConfig)> { + vec![ + ("zero", PitchSourceConfig::Zero), + ("host", PitchSourceConfig::Host), + ( + "tensor", + PitchSourceConfig::Tensor(TensorPitchConfig::new()), + ), + ( + "tensor/recurrence", + PitchSourceConfig::Tensor(TensorPitchConfig::reference()), + ), + ] + } + + /// A voiced-ish hop and a plausible spectrum for it. + fn frame( + batch: usize, + seed: f32, + device: &::Device, + ) -> (Tensor, Tensor) { + let raw: Vec = (0..batch * HOP) + .map(|i| 4000.0 * (((i % HOP) as f32 + seed) * 0.08).sin()) + .collect(); + let power: Vec = (0..batch * BINS) + .map(|i| 1e5 * (-((i % BINS) as f32) / 60.0).exp() + 10.0) + .collect(); + ( + Tensor::from_data(TensorData::new(raw, [batch, HOP]), device), + Tensor::from_data(TensorData::new(power, [batch, BINS]), device), + ) + } + + #[test] + fn test_default_config_is_the_device_estimator() { + assert!(matches!( + PitchSourceConfig::default(), + PitchSourceConfig::Tensor(_), + )); + } + + #[test] + fn test_each_config_builds_its_own_kind() { + let device = Default::default(); + + assert!(matches!( + PitchSourceConfig::Zero.init_source(1, &device), + PitchSourceKind::::Zero(_), + )); + assert!(matches!( + PitchSourceConfig::Host.init_source(1, &device), + PitchSourceKind::::Host(_), + )); + assert!(matches!( + PitchSourceConfig::Tensor(TensorPitchConfig::new()).init_source(1, &device), + PitchSourceKind::::Tensor(_), + )); + } + + #[test] + fn test_try_init_reports_an_invalid_tensor_config() { + // The only variant with geometry to get wrong, and the only reason + // `try_init_source` is fallible at all. + let device = Default::default(); + let bad = PitchSourceConfig::Tensor(TensorPitchConfig::new().with_chunk_steps(Some(0))); + + assert!(matches!( + PitchSourceInit::::try_init_source(&bad, 1, &device), + Err(BunsenError::Invalid(_)), + )); + } + + #[test] + fn test_every_kind_reports_a_plausible_pitch() { + // The seam's actual contract: `[batch, hop] + [batch, bins]` in, + // `[batch, 1]` of non-negative hertz out, whichever variant is behind + // it. + let device = Default::default(); + let (raw, power) = frame(2, 0.0, &device); + + for (name, cfg) in all_configs() { + let mut src: PitchSourceKind = cfg.init_source(2, &device); + let out = src.forward(raw.clone(), power.clone()); + + assert_eq!(out.dims(), [2, 1], "{name}"); + for (i, hz) in out + .to_data_as::() + .to_vec_as::() + .ok_or_panic() + .iter() + .enumerate() + { + assert!(hz.is_finite(), "{name} row {i}: {hz} is not finite"); + assert!(*hz >= 0.0, "{name} row {i}: {hz} is negative"); + assert!(*hz < 1000.0, "{name} row {i}: {hz} Hz is implausible"); + } + } + } + + #[test] + fn test_every_kind_agrees_with_itself_across_the_two_entry_points() { + // `forward` must be the one-step case of `forward_sequence`, or a + // caller that batches gets a different answer than one that does not. + let device = Default::default(); + let (raw, power) = frame(1, 3.0, &device); + + for (name, cfg) in all_configs() { + let mut stepwise: PitchSourceKind = cfg.init_source(1, &device); + let single = stepwise.forward(raw.clone(), power.clone()); + + let mut batched: PitchSourceKind = cfg.init_source(1, &device); + let seq = batched.forward_sequence( + raw.clone().reshape([1, 1, HOP]), + power.clone().reshape([1, 1, BINS]), + ); + + assert_eq!(seq.dims(), [1, 1, 1], "{name}"); + single.clone().to_data_as::().assert_approx_eq::( + &seq.reshape([1, 1]).to_data_as::(), + Tolerance::permissive(), + ); + } + } + + #[test] + fn test_every_kind_carries_state_and_resets_it() { + // Two hops differ from one hop repeated only if state carried; and + // after `reset` the first hop must reproduce exactly. + let device = Default::default(); + let (a, pa) = frame(1, 0.0, &device); + let (b, pb) = frame(1, 11.0, &device); + + for (name, cfg) in all_configs() { + let mut src: PitchSourceKind = cfg.init_source(1, &device); + + let first = src.forward(a.clone(), pa.clone()); + src.forward(b.clone(), pb.clone()); + + PitchSource::::reset(&mut src); + let again = src.forward(a.clone(), pa.clone()); + + first + .clone() + .to_data_as::() + .assert_approx_eq::(&again.to_data_as::(), Tolerance::permissive()); + let _ = name; + } + } + + #[test] + fn test_stateful_kinds_actually_carry_state() { + // Guard the guard for the reset test above: it proves nothing unless + // the source has state to rewind. `Zero` legitimately has none. + let device = Default::default(); + let (a, pa) = frame(1, 0.0, &device); + let (b, pb) = frame(1, 11.0, &device); + + for (name, cfg) in [ + ("host", PitchSourceConfig::Host), + ( + "tensor", + PitchSourceConfig::Tensor(TensorPitchConfig::new()), + ), + ] { + let mut fresh: PitchSourceKind = cfg.init_source(1, &device); + let cold = fresh.forward(b.clone(), pb.clone()); + + let mut warmed: PitchSourceKind = cfg.init_source(1, &device); + warmed.forward(a.clone(), pa.clone()); + let warm = warmed.forward(b.clone(), pb.clone()); + + let cold_v = cold.to_data_as::().to_vec_as::().ok_or_panic(); + let warm_v = warm.to_data_as::().to_vec_as::().ok_or_panic(); + assert_ne!( + cold_v, warm_v, + "{name}: history should change the estimate, so `reset` has something to undo", + ); + } + } + + #[test] + fn test_kinds_batch_rows_independently() { + // Row 1 must see its own signal, not row 0's. + let device = Default::default(); + let (solo, solo_p) = frame(1, 0.0, &device); + let (other, other_p) = frame(1, 17.0, &device); + + let paired = Tensor::cat(vec![solo.clone(), other], 0); + let paired_p = Tensor::cat(vec![solo_p.clone(), other_p], 0); + + for (name, cfg) in all_configs() { + let mut alone: PitchSourceKind = cfg.init_source(1, &device); + let mut together: PitchSourceKind = cfg.init_source(2, &device); + + let a = alone.forward(solo.clone(), solo_p.clone()); + let t = together.forward(paired.clone(), paired_p.clone()); + + let a_v = a.to_data_as::().to_vec_as::().ok_or_panic(); + let t_v = t.to_data_as::().to_vec_as::().ok_or_panic(); + assert!( + (a_v[0] - t_v[0]).abs() < 1e-3, + "{name}: row 0 alone {} vs batched {}", + a_v[0], + t_v[0], + ); + } + } + + #[test] + fn test_config_round_trips_through_serde() { + // `PitchSourceConfig` exists so the choice can live in a serialized + // config rather than only in code; that is worth checking. + for (name, cfg) in all_configs() { + let json = serde_json::to_string(&cfg).expect("serialize"); + let back: PitchSourceConfig = serde_json::from_str(&json).expect("deserialize"); + assert_eq!( + core::mem::discriminant(&cfg), + core::mem::discriminant(&back), + "{name} changed variant through serde", + ); + } + } + #[test] fn test_scalar_seam_is_usable() { let mut pitch = CountingPitch::default(); diff --git a/crates/bunsen/src/kits/speech/pitch/tensor/antialias.rs b/crates/bunsen/src/kits/speech/pitch/tensor/antialias.rs index 19790515..20e124f5 100644 --- a/crates/bunsen/src/kits/speech/pitch/tensor/antialias.rs +++ b/crates/bunsen/src/kits/speech/pitch/tensor/antialias.rs @@ -473,6 +473,22 @@ mod tests { assert!(PitchAntiAliasConfig::Recurrence.validate(HOP).is_ok()); } + #[test] + fn test_the_recurrence_tier_has_no_toeplitz_matrix() { + // The two tiers are asked the same questions and only one of them has + // a kernel to answer with. A caller switching tiers must not get a + // silently-wrong empty matrix from the FIR path, nor a panic from the + // recurrence path. + let recurrence = PitchAntiAliasConfig::Recurrence; + assert!(recurrence.to_vec_toeplitz(HOP).is_empty()); + assert_eq!(recurrence.carry_len(), 0); + assert_eq!(recurrence.window_len(HOP), HOP); + + let fir = PitchAntiAliasConfig::default(); + assert!(!fir.to_vec_toeplitz(HOP).is_empty()); + assert!(fir.carry_len() > 0); + } + #[test] fn test_carry_and_window_derive_from_one_constant() { // The carry is `taps - 1`, not `window - hop`; those differ by the diff --git a/crates/bunsen/src/kits/speech/pitch/tensor/correlate.rs b/crates/bunsen/src/kits/speech/pitch/tensor/correlate.rs index 30728d20..06737ec9 100644 --- a/crates/bunsen/src/kits/speech/pitch/tensor/correlate.rs +++ b/crates/bunsen/src/kits/speech/pitch/tensor/correlate.rs @@ -92,6 +92,19 @@ pub struct PitchCorrelateConfig { pub hop_size: usize, } +/// The period range has to leave something for the octave suppression to +/// sharpen over. +/// +/// Both bounds are constants rather than config, so this is a property of the +/// coefficients and not of any caller's geometry -- which makes it a +/// compile-time check rather than a runtime one. A runtime branch here could +/// never fire, and an unfireable branch is indistinguishable from a bug in +/// waiting. +const _: () = assert!( + MAX_PERIOD_16KHZ / PROC_RESAMPLE_RATE > SUBS_PER_HOP * (MIN_PERIOD_16KHZ / PROC_RESAMPLE_RATE), + "the period range leaves nothing to sharpen", +); + impl PitchCorrelateConfig { /// The longest candidate period, in samples at the correlation rate. pub fn max_period(&self) -> usize { @@ -131,13 +144,6 @@ impl PitchCorrelateConfig { self.hop_size, ))); } - if self.max_period() <= SUBS_PER_HOP * self.min_period() { - return Err(BunsenError::Invalid(format!( - "PitchCorrelate period range ({}..{}) leaves nothing to sharpen", - self.min_period(), - self.max_period(), - ))); - } Ok(()) } diff --git a/crates/bunsen/src/kits/speech/pitch/tensor/excitation.rs b/crates/bunsen/src/kits/speech/pitch/tensor/excitation.rs index ebd8c0bd..7c4b9c06 100644 --- a/crates/bunsen/src/kits/speech/pitch/tensor/excitation.rs +++ b/crates/bunsen/src/kits/speech/pitch/tensor/excitation.rs @@ -95,6 +95,17 @@ pub struct PitchExcitationConfig { pub anti_alias: PitchAntiAliasConfig, } +/// The excitation history has to outlast one hop's contribution to it. +/// +/// `exc_len` exceeds `exc_stride` by `MAX_PERIOD_16KHZ / PROC_RESAMPLE_RATE + +/// 1` for *any* hop size, so this holds by construction rather than by +/// validation -- the difference does not depend on the one thing a caller can +/// vary. +const _: () = assert!( + MAX_PERIOD_16KHZ / PROC_RESAMPLE_RATE > 0, + "the excitation history must outlast one hop", +); + impl PitchExcitationConfig { /// The raw FIFO length. pub fn fifo_len(&self) -> usize { @@ -129,11 +140,6 @@ impl PitchExcitationConfig { self.hop_size, XCORR_TRAINING_OFFSET, ))); } - if self.exc_len() <= self.exc_stride() { - return Err(BunsenError::Invalid( - "PitchExcitation excitation history must exceed one hop".to_string(), - )); - } self.anti_alias.validate(self.hop_size) } diff --git a/crates/bunsen/src/kits/speech/pitch/tensor/source.rs b/crates/bunsen/src/kits/speech/pitch/tensor/source.rs index 881a30ab..5d221e60 100644 --- a/crates/bunsen/src/kits/speech/pitch/tensor/source.rs +++ b/crates/bunsen/src/kits/speech/pitch/tensor/source.rs @@ -457,6 +457,26 @@ mod tests { assert!(TensorPitchConfig::reference().validate().is_ok()); } + #[test] + fn test_meta_accessors_report_the_configured_geometry() { + // Accessors that forward to a sub-config are exactly where a field + // gets crossed with its neighbour, and nothing else would notice. + let device = Default::default(); + let cfg = TensorPitchConfig::new(); + let coef: TensorPitch = cfg.init(&device); + + assert_eq!(coef.hop_size(), cfg.hop_size()); + assert_eq!(coef.n_bins(), cfg.n_bins()); + assert_ne!( + coef.hop_size(), + coef.n_bins(), + "the two must not be the same field" + ); + + let ctx = coef.init_state(3, &device); + assert_eq!(ctx.batch_size(), 3); + } + #[test] fn test_reference_tier_selects_the_recurrence() { assert_eq!(