Skip to main content

rlx_core/dsp/
mod.rs

1//! Deterministic analysis of the PCM stream: windowed FFT spectrum plus an
2//! onset envelope and beat flag, delivered once per hop as an
3//! [`AnalysisFrame`].
4//!
5//! Everything here is a pure function of the samples fed in — no wall clock,
6//! no unseeded randomness (NFR section 6). Window and hop sizes fit the 60 ms
7//! latency budget at 48 kHz: one hop is ~10.7 ms (NFR section 3).
8//!
9//! Two FFT windows feed one band axis (ADR-0049): [`WINDOW_SIZE`] carries
10//! everything it can resolve — including all of onset, beat and tempo, so the
11//! transient path keeps its speed — and [`LOW_WINDOW_SIZE`] carries the bands
12//! below the crossover, which a 23 kHz-wide bin cannot. See [`fft::BandLayout`].
13
14// Hot-path panic-denial pragma (Plan 0002 Phase 2). Analysis runs every hop
15// off the render loop; it must never panic on valid input.
16#![deny(
17    clippy::unwrap_used,
18    clippy::expect_used,
19    clippy::indexing_slicing,
20    clippy::panic,
21    clippy::unreachable
22)]
23
24pub mod bands;
25pub mod downbeat;
26pub mod fft;
27pub mod gain;
28pub mod grid;
29pub mod novelty;
30pub mod onset;
31pub mod stereo;
32pub mod tempo;
33
34use crate::audio::{AudioFormat, FormatError};
35
36/// FFT window length in samples (~43 ms at 48 kHz).
37pub const WINDOW_SIZE: usize = 2048;
38
39/// How many time-domain samples an [`AnalysisFrame`] carries in
40/// [`waveform`](AnalysisFrame::waveform).
41///
42/// **512, which is MilkDrop's own count** (Plan 0100 Phase 4): its waveform draws
43/// 512 consecutive samples, every preset's `wave_mode` geometry is written
44/// against that resolution, and a converted preset should draw the figure its
45/// author drew. At 48 kHz it is 10.7 ms of audio against MilkDrop's 11.6 ms at
46/// 44.1 kHz — the same gesture, a fraction of a beat either way.
47///
48/// The samples are the **most recent** 512 of [`WINDOW_SIZE`], taken
49/// consecutively rather than decimated across the whole window. Decimation would
50/// alias: a 4:1 pick of every fourth sample of a 12 kHz tone at 48 kHz reads as a
51/// 3 kHz one, and a waveform display exists to show exactly that shape.
52pub const WAVE_SAMPLES: usize = 512;
53
54// The tail this is taken from has to exist. A compile-time check rather than a
55// runtime one, because the failure would be a silently shorter waveform.
56const _: () = assert!(
57    WAVE_SAMPLES <= WINDOW_SIZE,
58    "the waveform is the tail of the short analysis window and cannot be longer than it"
59);
60// The left/right pair rolls a trace-length tail forward by one hop, so a hop has
61// to fit inside a trace. Same reasoning as above: a hop longer than the trace
62// would be a slice range that does not exist rather than a shorter waveform.
63const _: () = assert!(
64    HOP_SIZE <= WAVE_SAMPLES,
65    "the waveform pair rolls by one hop and cannot roll further than its own length"
66);
67/// Second, longer FFT window feeding the bands below the crossover (~171 ms at
68/// 48 kHz). Chosen by measurement in Plan 0048 Phase 1 against one rule — 4096
69/// first, 8192 only if 4096 still leaves sub-bass bands bin-starved. Of the 20
70/// bands below the crossover, 4096 leaves **all 20** still one bin wide and 8192
71/// leaves 8, pulling the unresolved boundary down from 246 Hz to 76 Hz. See
72/// [`fft::BandLayout`] and ADR-0049; `the_long_window_was_chosen_by_measurement`
73/// pins all three candidates.
74pub const LOW_WINDOW_SIZE: usize = 8192;
75/// Samples between successive analysis hops (~10.7 ms at 48 kHz).
76pub const HOP_SIZE: usize = 512;
77/// Hops the analyzer consumes before it publishes its first frame — the time the
78/// **longer** window takes to fill (~171 ms at 48 kHz).
79///
80/// Exported so callers that sample a clip "past warm-up" derive the offset
81/// instead of restating it as a literal. Two already did, and both were silently
82/// wrong the moment [`LOW_WINDOW_SIZE`] arrived.
83pub const WARMUP_HOPS: usize = LOW_WINDOW_SIZE / HOP_SIZE;
84/// Log-frequency bands exposed to scenes.
85pub const SPECTRUM_BINS: usize = 64;
86
87/// One hop's worth of analysis.
88///
89/// **The four headline levels are normalized** (ADR-0049): `bass`, `mid`, `treb`
90/// and `onset` are each a 0..1 fraction of that signal's own slowly-decaying
91/// recent peak, so `> 0.5` means "loud for this track" rather than naming an
92/// absolute magnitude that depended on the gain staging. The absolute values
93/// remain as `*_raw` for looks that genuinely want them, and for harness
94/// continuity. `spectrum` normalizes against **one** peak shared by the whole
95/// array, so every ratio inside it — and therefore every `bin()` contrast — comes
96/// through untouched; see [`gain::BandNormalizer`] for why per-band would not.
97///
98/// `beat` flags an onset event this hop; `bpm`/`bar` come from the tempo tracker,
99/// which reads the **raw** onset — see [`gain`] for why the internal consumers
100/// are deliberately left on raw values. The bar-position trio comes from
101/// [`downbeat`], and falls back to plain counters whenever its estimate is not
102/// confident (ADR-0050).
103#[derive(Debug, Clone, Copy)]
104pub struct AnalysisFrame {
105    /// Per-band energy, the whole array normalized against **one shared** recent
106    /// peak — so every ratio inside it, and therefore every `bin()` contrast,
107    /// comes through untouched. Not a per-band normalization: that was the draft
108    /// ADR-0049 rejected, because it flattens the very shape a spectrum is for.
109    pub spectrum: [f32; SPECTRUM_BINS],
110    /// The most recent [`WAVE_SAMPLES`] of the mono signal, in **time** order —
111    /// the oscilloscope trace, not a spectrum (Plan 0100 Phase 4 / ADR-0113).
112    ///
113    /// Nothing in the engine's own vocabulary reads this: the expression grammar
114    /// is scalar and reaches the band array through `bin()` alone (ADR-0036), and
115    /// widening it to an array type is exactly what ADR-0002's purity refuses.
116    /// **It is here for the one consumer that genuinely needs a waveform** — the
117    /// warp mesh's `wave_mode` draw, which is what MilkDrop's presets use as
118    /// their light source, and which no amount of spectrum can reconstruct.
119    ///
120    /// **Levelled against its own recent peak** (ADR-0139), like every other
121    /// headline value on this struct: the whole trace is divided by one
122    /// slowly-released running peak of its magnitude, so it reads `-1..=1` at any
123    /// fader position. That is what makes the two frontends agree — the plugin
124    /// taps the decoded stream *before* the output volume, the standalone taps
125    /// loopback *after* it, and only the absolute level differs between them.
126    /// Dynamics *within* a track survive the seconds-scale release; a quiet
127    /// **track** reads like a loud one, which is the price of cancelling a volume
128    /// knob nothing else can see. The consumer still scales what it gets
129    /// (MilkDrop's `wave_scale` does exactly that).
130    ///
131    /// [`waveform_gain`](Self::waveform_gain) is the divisor, so an absolute
132    /// amplitude is one multiply away and a true oscilloscope stays reachable.
133    ///
134    /// **This is the array that made this struct big.** `AnalysisFrame` is `Copy`
135    /// and copied per frame, and 512 floats take it from ~340 bytes to ~2.4 kB —
136    /// about 100 ns of memcpy at 60 Hz, which is why it was acceptable. It is
137    /// deliberately **not** in [`Variables`](crate::preset::Variables), which
138    /// carries the band array by borrow for precisely this reason.
139    pub waveform: [f32; WAVE_SAMPLES],
140    /// The divisor [`waveform`](Self::waveform) was levelled by: `waveform[i] *
141    /// waveform_gain` is the raw amplitude the analyzer read.
142    ///
143    /// `0.0` while the tracked peak sits under [`gain::WAVE_FLOOR`], where the
144    /// trace is zeroed rather than amplified — so reconstructing from a silent
145    /// frame gives silence rather than noise.
146    pub waveform_gain: f32,
147    /// Channels 0 and 1 of the same [`WAVE_SAMPLES`] window, in time order —
148    /// the figure a two-channel `wave_mode` draws (ADR-0199).
149    ///
150    /// **Front-left then front-right**, which is the order WASAPI loopback and
151    /// foobar's `visualisation_stream` both deliver. A layout with more than two
152    /// channels reads its first two and ignores the rest; a one-channel stream
153    /// fills both slots from channel 0, so a consumer never has to ask how many
154    /// channels there are.
155    ///
156    /// **Levelled by ONE divisor tracked over both** (ADR-0139's rule, applied to
157    /// a pair), published as [`waveform_pair_gain`](Self::waveform_pair_gain).
158    /// Two independent divisors would level a hard-panned signal's silent side up
159    /// to whatever noise is in it, and the x-y figure would lose the aspect that
160    /// says where the sound is.
161    ///
162    /// [`waveform`](Self::waveform) is **not** derived from this and is not
163    /// affected by it: the mono trace keeps its own normalizer and its own
164    /// history, so every value that reached a preset before this pair existed
165    /// still does, bit for bit.
166    pub waveform_pair: [[f32; WAVE_SAMPLES]; 2],
167    /// The divisor [`waveform_pair`](Self::waveform_pair) was levelled by —
168    /// `0.0` while the tracked peak sits under [`gain::WAVE_FLOOR`], exactly as
169    /// [`waveform_gain`](Self::waveform_gain) is.
170    ///
171    /// Its own value, not a copy of `waveform_gain`: the mono trace is the
172    /// channel average and the pair is tracked over the larger magnitude, so on a
173    /// panned signal the two divisors differ.
174    pub waveform_pair_gain: f32,
175    /// Spectral-flux onset envelope, normalized against its recent peak.
176    pub onset: f32,
177    /// Whether a beat (onset event) fired this hop.
178    pub beat: bool,
179    /// Bass-band level (~20-250 Hz), normalized against its recent peak.
180    pub bass: f32,
181    /// Mid-band level (~250-4000 Hz), normalized against its recent peak.
182    pub mid: f32,
183    /// Treble-band level (~4-18 kHz), normalized against its recent peak.
184    pub treb: f32,
185    /// Raw mean magnitude in the bass band — the pre-ADR-0049 `bass`, unchanged.
186    pub bass_raw: f32,
187    /// Raw mean magnitude in the mid band — the pre-ADR-0049 `mid`, unchanged.
188    pub mid_raw: f32,
189    /// Raw mean magnitude in the treble band — the pre-ADR-0049 `treb`, unchanged.
190    pub treb_raw: f32,
191    /// Raw spectral-flux envelope — the pre-ADR-0049 `onset`, unchanged.
192    pub onset_raw: f32,
193    /// Tempo estimate in BPM (hop-clock autocorrelation; 0 until warm).
194    pub bpm: f32,
195    /// Beat phase in [0, 1): 0 on each beat, ramping to the next.
196    ///
197    /// The name is a **documented misnomer** — this is beat phase, not bar phase.
198    /// Too widely bound to rename (ADR-0050); `bar_phase` is the true quantity.
199    pub bar: f32,
200    /// Monotone count of **onset detections** since the stream started, 0 before
201    /// the first (ADR-0050 Layer 1, corrected by ADR-0109). Unconditional and
202    /// deterministic — no confidence gate. **Not a musical period:** the
203    /// detector fires 1.2x-2.3x per musical beat depending on the material and
204    /// wanders inside a single track, so no fixed multiplier converts this to
205    /// beats.
206    pub beat_index: u32,
207    /// Seconds since the last onset detection; exactly 0 on a detection hop.
208    pub time_since_beat: f32,
209    /// Which beat of the bar this is, `0..4` (ADR-0050 Layer 2). Estimated when
210    /// the downbeat tracker is confident, and the fold's own counter modulo 4
211    /// otherwise — see [`bar_index`](Self::bar_index) for what that counter is.
212    pub beat_in_bar: u32,
213    /// Bar counter, on the same gated-or-counted basis. **Monotone except across
214    /// an alignment change** — it is `(beat count - alignment) / 4`, where the
215    /// beat count is the [`grid`]'s tempo-driven one, and `beat_index` only
216    /// while the grid warms up. So the beat the estimator locks, drops back, or
217    /// moves its alignment can repeat or skip a bar. Hysteresis makes that rare
218    /// (a challenger must lead for three bars), and a repeated bar is a far
219    /// softer failure than a wrong downbeat — but `mod(bar_index, 8)` will see
220    /// it. The warmup handover is *not* a second source of it: the grid's count
221    /// carries a whole-bar offset that keeps this moving forward across it.
222    pub bar_index: u32,
223    /// Position across the bar in `[0, 1)` — the true bar phase, as against
224    /// [`bar`](Self::bar), which is beat phase under a historical name.
225    pub bar_phase: f32,
226    /// Downbeat-alignment confidence in `0..1`. **Diagnostics only** — not a
227    /// grammar variable, so authors get behavior rather than homework.
228    pub downbeat_confidence: f32,
229    /// Whether the bar trio above came from the estimator rather than the
230    /// counter fallback. **Diagnostics only**, as with the confidence.
231    pub downbeat_locked: bool,
232    /// Experimental spectral track-change novelty (Plan 0009 Phase 4): ~0 within
233    /// a steady segment, spiking at a spectral boundary. Native-API only — not
234    /// exposed across the C ABI.
235    pub novelty: f32,
236    /// Where the mix sits between channels 0 and 1, `-1` hard left to `+1` hard
237    /// right, `0` centred (ADR-0215).
238    ///
239    /// **Absolute, never levelled**, unlike the four headline levels above: it
240    /// is the plain ratio of the hop's per-channel RMS, so `0` means genuinely
241    /// centred on every track rather than "centred for this track". A running
242    /// peak cannot represent the *absence* of stereo information — it would
243    /// stretch a mono stream's noise floor into a confident wandering pan — and
244    /// position, unlike loudness, can genuinely be absent.
245    ///
246    /// Exactly `0` below [`gain::WAVE_FLOOR`], on a one-channel stream, and on
247    /// any mono-duplicated stereo stream, because that is the truth about all
248    /// three. Channels 0 and 1 only, as [`waveform_pair`](Self::waveform_pair)
249    /// is: a surround stream's other channels do not reach it.
250    ///
251    /// Published raw, per hop, with no smoother — `[smoothing]` eases any
252    /// binding that wants it, and a smoother here would be a second opinion an
253    /// author cannot turn off.
254    pub balance: f32,
255    /// How decorrelated channels 0 and 1 are over the hop: `0` identical, `0.5`
256    /// fully decorrelated, `1` polarity-inverted (ADR-0215).
257    ///
258    /// `(1 - corr) / 2` over the normalized L/R correlation, absolute on the
259    /// same terms as [`balance`](Self::balance). A 512-sample hop is about one
260    /// cycle of a low bass note, so this jitters on bass-heavy material.
261    pub spread: f32,
262    /// [`balance`](Self::balance) taken over the bass band alone — the ratio of
263    /// the two channels' energy across the same bins [`bass`](Self::bass)
264    /// summarises, since both come from one [`bands::BandSplitter`].
265    ///
266    /// The expressive half of the stereo field: a whole-mix scalar cannot say
267    /// that the hats are thrown to one side while the bass stays centred.
268    /// Exactly `0` when the band's louder channel sits below
269    /// [`gain::WAVE_FLOOR`] — a band with no energy has no position.
270    pub bass_balance: f32,
271    /// The same ratio over the mid band.
272    pub mid_balance: f32,
273    /// The same ratio over the treble band.
274    pub treb_balance: f32,
275}
276
277impl Default for AnalysisFrame {
278    fn default() -> Self {
279        Self {
280            spectrum: [0.0; SPECTRUM_BINS],
281            waveform: [0.0; WAVE_SAMPLES],
282            waveform_gain: 0.0,
283            waveform_pair: [[0.0; WAVE_SAMPLES]; 2],
284            waveform_pair_gain: 0.0,
285            onset: 0.0,
286            beat: false,
287            bass: 0.0,
288            mid: 0.0,
289            treb: 0.0,
290            bass_raw: 0.0,
291            mid_raw: 0.0,
292            treb_raw: 0.0,
293            onset_raw: 0.0,
294            bpm: 0.0,
295            bar: 0.0,
296            beat_index: 0,
297            time_since_beat: 0.0,
298            beat_in_bar: 0,
299            bar_index: 0,
300            bar_phase: 0.0,
301            downbeat_confidence: 0.0,
302            downbeat_locked: false,
303            novelty: 0.0,
304            balance: 0.0,
305            spread: 0.0,
306            bass_balance: 0.0,
307            mid_balance: 0.0,
308            treb_balance: 0.0,
309        }
310    }
311}
312
313impl AnalysisFrame {
314    /// **The one definition of "fully driven"**: every headline level and the
315    /// whole log-band array at full scale, with the beat flag set.
316    ///
317    /// Two harnesses hold a differential against this frame — `--report`'s
318    /// `drive` column and its step stimulus (ADR-0134), and the animation gate's
319    /// driven branch (ADR-0136). A second construction site would let them
320    /// measure two different stimuli while reading as the same word, which is a
321    /// disagreement no capture could show.
322    ///
323    /// Three fields are deliberately not at full scale, and each for its own
324    /// mechanism:
325    ///
326    /// - The four `*_raw` levels stay `0`. The headline four are peak-normalized
327    ///   (ADR-0049), so `1.0` is the documented top of their range; a raw
328    ///   magnitude has no top to name, and any value picked for one would be a
329    ///   gain-staging assumption. A binding reading `bass_raw` sees silence here.
330    /// - `bpm` stays `0`, the tracker's own not-yet-warm value. There is no
331    ///   "full scale" tempo.
332    /// - `bar` is a **phase** in `[0, 1)`, not a level, so it takes `0.5` — the
333    ///   middle of a beat rather than either edge, so a `bar`-driven binding
334    ///   reads a typical position instead of sitting on the wrap.
335    /// - The stereo field stays `0`. `balance` is a **position**, not a level:
336    ///   its top is "hard right", which is not what "fully driven" means, and
337    ///   `0` is the centred field most material sits near. `spread` follows it,
338    ///   since a frame claiming a centred mix and a decorrelated one at once
339    ///   describes nothing.
340    pub fn fully_driven() -> Self {
341        Self {
342            bass: 1.0,
343            mid: 1.0,
344            treb: 1.0,
345            onset: 1.0,
346            beat: true,
347            bar: 0.5,
348            // "Every band up" includes the log-band array itself: `bin()` is the
349            // grammar's only reach into the spectrum (ADR-0036), so a frame that
350            // lit the four scalars alone would leave every `bin()` binding dark
351            // and read as unreactive.
352            spectrum: [1.0; SPECTRUM_BINS],
353            ..Default::default()
354        }
355    }
356}
357
358/// Stateful per-stream analyzer: accumulates interleaved samples into mono
359/// hops, runs FFT + onset detection each completed hop, and hands the latest
360/// frame to the render side. Deterministic for a given sample sequence.
361///
362/// After construction, processing allocates nothing — safe to drive from the
363/// render loop every frame.
364pub struct Analyzer {
365    format: AudioFormat,
366    spectrum: fft::SpectrumAnalyzer,
367    onset: onset::OnsetDetector,
368    bands: bands::BandSplitter,
369    tempo: tempo::TempoTracker,
370    /// Layer 2's own beat clock (ADR-0109), driven by the tempo estimate rather
371    /// than by the transient stream. Nothing outside [`Self::push_interleaved`]
372    /// reads it and it publishes no grammar variable — it exists so the downbeat
373    /// fold has a unit that is a beat.
374    grid: grid::BarGrid,
375    /// Offset added to the grid's beat count, latched on the first hop the grid
376    /// runs. `None` until then.
377    ///
378    /// The grid's counter starts at zero when the tempo tracker warms up,
379    /// several seconds into every stream, while the fold has been counting
380    /// `beat_index` until that moment — so without this the published
381    /// [`bar_index`](AnalysisFrame::bar_index) restarts there and walks
382    /// **backwards** once per stream. Measured before it existed: back one bar
383    /// on a 120 BPM click train, three on `dynamic_groove(124)` and on a 200 BPM
384    /// train, and further on material with a denser onset stream.
385    ///
386    /// Latched **rounded up to a whole bar**, which is what makes it free. A
387    /// whole-bar shift cannot change `beat_in_bar` or which of the fold's four
388    /// buckets an accent lands in — their phase is arbitrary and `alignment`
389    /// absorbs it — and rounding *up* rather than down is what guarantees the
390    /// published bar cannot step back at the handover.
391    grid_offset: Option<u32>,
392    novelty: novelty::NoveltyDetector,
393    /// ADR-0050 Layer 2. Reads the **normalized** bass and flux, unlike the
394    /// detectors above: its accent blend weighs the two against each other, which
395    /// is only meaningful once both are on a common 0..1 scale.
396    downbeat: downbeat::DownbeatTracker,
397    /// Published-surface normalizers (ADR-0049). Deliberately *after* the
398    /// detectors above in the hop, so each of those keeps reading raw values.
399    band_gain: gain::BandNormalizer,
400    bass_gain: gain::PeakNormalizer,
401    mid_gain: gain::PeakNormalizer,
402    treb_gain: gain::PeakNormalizer,
403    onset_gain: gain::PeakNormalizer,
404    wave_gain: gain::TraceNormalizer,
405    /// The pair's own normalizer, separate from `wave_gain` so the mono trace's
406    /// running peak is untouched by this existing at all.
407    pair_gain: gain::TraceNormalizer,
408    /// The short window per channel, the two-channel counterpart of `window`,
409    /// feeding the per-band stereo field alone (ADR-0215). Heap-held: 8 KB
410    /// apiece, and `Analyzer` is moved by value.
411    ///
412    /// Only the **short** window is kept per channel. The band split reads the
413    /// short window's linear magnitudes, so a per-channel long window would be
414    /// 32 KB and an 8192-point FFT each that nothing would read.
415    window_pair: [Vec<f32>; 2],
416    /// One short FFT per channel, feeding `bands.split` per channel so the band
417    /// edges are the ones `bass`/`mid`/`treb` already use.
418    pair_spectrum: [fft::ShortSpectrum; 2],
419    window: [f32; WINDOW_SIZE],
420    /// The long window feeding the sub-crossover bands (ADR-0049). Heap-held:
421    /// 32 KB, and `Analyzer` is moved by value.
422    low_window: Vec<f32>,
423    /// Samples seen, saturating at [`LOW_WINDOW_SIZE`]. Analysis waits for the
424    /// **longer** window, so no frame is ever published from a partly-filled
425    /// one: Hann weights the newest samples near zero, so a half-full long
426    /// window reads its low bands *low* and ramps as real audio reaches the
427    /// taper's centre. That ramp is a genuine spectral transient — the novelty
428    /// detector's 2 s running mean integrates it into seconds of spurious
429    /// score, which would nudge the scene director at every stream start. The
430    /// cost is first analysis at ~171 ms instead of ~43 ms, at cold start only;
431    /// NFR section 3's beat-to-reaction budget is about steady state and does
432    /// not move.
433    filled: usize,
434    hop: [f32; HOP_SIZE],
435    /// Channels 0 and 1 of the hop being filled, beside the mono `hop`. Two
436    /// fixed arrays rather than a per-channel `Vec`: the fill loop below runs on
437    /// the render thread's analysis path and allocates nothing.
438    hop_pair: [[f32; HOP_SIZE]; 2],
439    hop_filled: usize,
440    /// The published pair's rolling tail, the two-channel counterpart of the last
441    /// [`WAVE_SAMPLES`] of `window`. Kept at trace length rather than window
442    /// length because nothing analyses it — it is published and drawn.
443    wave_pair: [[f32; WAVE_SAMPLES]; 2],
444    latest: AnalysisFrame,
445    /// Beats are sticky between `take_frame` calls so a beat can never fall
446    /// between two render frames and vanish.
447    pending_beat: bool,
448}
449
450impl Analyzer {
451    /// Build an analyzer for a validated stream format.
452    pub fn new(format: AudioFormat) -> Result<Self, FormatError> {
453        let format = format.validate()?;
454        Ok(Self {
455            format,
456            spectrum: fft::SpectrumAnalyzer::new(format.sample_rate),
457            onset: onset::OnsetDetector::new(),
458            bands: bands::BandSplitter::new(format.sample_rate),
459            tempo: tempo::TempoTracker::new(format.sample_rate),
460            grid: grid::BarGrid::new(format.sample_rate),
461            grid_offset: None,
462            novelty: novelty::NoveltyDetector::new(format.sample_rate),
463            downbeat: downbeat::DownbeatTracker::new(),
464            band_gain: gain::BandNormalizer::new(format.sample_rate),
465            bass_gain: gain::PeakNormalizer::new(format.sample_rate, gain::BAND_FLOOR),
466            mid_gain: gain::PeakNormalizer::new(format.sample_rate, gain::BAND_FLOOR),
467            treb_gain: gain::PeakNormalizer::new(format.sample_rate, gain::BAND_FLOOR),
468            onset_gain: gain::PeakNormalizer::new(format.sample_rate, gain::ONSET_FLOOR),
469            wave_gain: gain::TraceNormalizer::new(format.sample_rate),
470            pair_gain: gain::TraceNormalizer::new(format.sample_rate),
471            window_pair: [vec![0.0; WINDOW_SIZE], vec![0.0; WINDOW_SIZE]],
472            pair_spectrum: [fft::ShortSpectrum::new(), fft::ShortSpectrum::new()],
473            window: [0.0; WINDOW_SIZE],
474            low_window: vec![0.0; LOW_WINDOW_SIZE],
475            filled: 0,
476            hop: [0.0; HOP_SIZE],
477            hop_pair: [[0.0; HOP_SIZE]; 2],
478            hop_filled: 0,
479            wave_pair: [[0.0; WAVE_SAMPLES]; 2],
480            latest: AnalysisFrame::default(),
481            pending_beat: false,
482        })
483    }
484
485    /// The validated format this analyzer was created with.
486    pub fn format(&self) -> AudioFormat {
487        self.format
488    }
489
490    /// The log-frequency band a given frequency falls into — lets scenes and
491    /// tests reason about where energy should show up.
492    pub fn band_for_freq(&self, hz: f32) -> usize {
493        self.spectrum.band_for_freq(hz)
494    }
495
496    /// Feed interleaved samples (whole frames, as produced by the intake).
497    /// Runs one analysis pass per completed hop.
498    #[allow(
499        clippy::indexing_slicing,
500        reason = "hop_filled < HOP_SIZE (reset at the boundary); both window tail slices are fixed (SIZE - HOP_SIZE) ranges of buffers allocated at exactly SIZE, so all are in-bounds by construction"
501    )]
502    pub fn push_interleaved(&mut self, samples: &[f32]) {
503        let channels = self.format.channels as usize;
504        for frame in samples.chunks_exact(channels) {
505            let mono = frame.iter().sum::<f32>() / channels as f32;
506            self.hop[self.hop_filled] = mono;
507            // Front-left and front-right, in the interleaved order both intakes
508            // deliver (ADR-0199). A one-channel stream fills both slots from
509            // channel 0, so the published pair is always two traces and no
510            // consumer branches on the channel count.
511            let left = frame.first().copied().unwrap_or(mono);
512            self.hop_pair[0][self.hop_filled] = left;
513            self.hop_pair[1][self.hop_filled] = frame.get(1).copied().unwrap_or(left);
514            self.hop_filled += 1;
515            if self.hop_filled == HOP_SIZE {
516                self.hop_filled = 0;
517                self.window.copy_within(HOP_SIZE.., 0);
518                self.window[WINDOW_SIZE - HOP_SIZE..].copy_from_slice(&self.hop);
519                self.low_window.copy_within(HOP_SIZE.., 0);
520                self.low_window[LOW_WINDOW_SIZE - HOP_SIZE..].copy_from_slice(&self.hop);
521                for (tail, hop) in self.wave_pair.iter_mut().zip(&self.hop_pair) {
522                    tail.copy_within(HOP_SIZE.., 0);
523                    tail[WAVE_SAMPLES - HOP_SIZE..].copy_from_slice(hop);
524                }
525                for (win, hop) in self.window_pair.iter_mut().zip(&self.hop_pair) {
526                    win.copy_within(HOP_SIZE.., 0);
527                    win[WINDOW_SIZE - HOP_SIZE..].copy_from_slice(hop);
528                }
529                self.filled = (self.filled + HOP_SIZE).min(LOW_WINDOW_SIZE);
530                if self.filled == LOW_WINDOW_SIZE {
531                    let raw_spectrum = self.spectrum.analyze(&self.window, &self.low_window);
532                    let (onset_raw, beat) = self.onset.process(self.spectrum.magnitudes());
533                    let (bass_raw, mid_raw, treb_raw) =
534                        self.bands.split(self.spectrum.magnitudes());
535
536                    // Every consumer below this line reads RAW values on purpose
537                    // (see `gain`'s module docs): the tempo tracker
538                    // autocorrelates the onset envelope, and peak-normalizing it
539                    // would distort the periodicity it looks for, while novelty
540                    // measures spectral shape, which per-band normalization
541                    // flattens by construction.
542                    let clock = self.tempo.process(onset_raw, beat);
543                    let grid = self.grid.process(clock.bpm, onset_raw);
544                    let novelty = self.novelty.process(&raw_spectrum);
545
546                    // ...and normalization happens last, on the way out.
547                    let mut spectrum = raw_spectrum;
548                    self.band_gain.normalize(&mut spectrum);
549                    let onset = self.onset_gain.normalize(onset_raw);
550                    let bass = self.bass_gain.normalize(bass_raw);
551
552                    // The downbeat tracker sits after normalization on purpose —
553                    // it weighs bass against flux, which needs a common scale.
554                    //
555                    // What it folds over is the **grid's** beat count, not
556                    // `beat_index` (ADR-0109): the latter counts transients, at
557                    // 1.35x-2.10x per musical beat, so `beat_index % 4` spanned
558                    // well under a bar and a bar-locked accent precessed across
559                    // all four alignments. Until the grid is running — the tempo
560                    // tracker needs its envelope history filled first — the old
561                    // pair is passed, which is the counter fallback ADR-0050
562                    // specifies rather than a second code path.
563                    //
564                    // The grid's count carries a whole-bar offset latched at the
565                    // handover, so the published bar continues forward across it
566                    // rather than restarting — see `grid_offset`.
567                    let (fold_count, fold_phase) = if grid.running {
568                        let offset = *self.grid_offset.get_or_insert_with(|| {
569                            let rem = clock.beat_index % downbeat::BEATS_PER_BAR;
570                            if rem == 0 {
571                                clock.beat_index
572                            } else {
573                                // Saturating rather than `next_multiple_of`,
574                                // which panics on overflow: this module denies
575                                // panics on the hot path and does not argue
576                                // reachability with itself.
577                                clock
578                                    .beat_index
579                                    .saturating_add(downbeat::BEATS_PER_BAR - rem)
580                            }
581                        });
582                        (
583                            offset
584                                .saturating_add(grid.bar_index * downbeat::BEATS_PER_BAR)
585                                .saturating_add(grid.beat_in_bar),
586                            grid.beat_phase,
587                        )
588                    } else {
589                        (clock.beat_index, clock.bar)
590                    };
591                    let bars = self
592                        .downbeat
593                        .process(beat, fold_count, bass, onset, fold_phase);
594
595                    // The oscilloscope trace: the most recent `WAVE_SAMPLES` of
596                    // the window, consecutive (Plan 0100 Phase 4). A slice of a
597                    // buffer the analyzer already holds — no extra state, no
598                    // extra pass, and nothing here reads a clock.
599                    //
600                    // Levelled here, on the way out, for the same reason the
601                    // bands are and in the same place: the internal consumers
602                    // above have already read raw. The divisor is published
603                    // beside the trace (ADR-0139).
604                    let mut waveform = [0.0f32; WAVE_SAMPLES];
605                    if let Some(tail) = self.window.get(WINDOW_SIZE - WAVE_SAMPLES..) {
606                        waveform.copy_from_slice(tail);
607                    }
608                    let waveform_gain = self.wave_gain.normalize(&mut waveform);
609                    // The pair, levelled by its own normalizer so `waveform`'s
610                    // running peak is the one it always was.
611                    let mut waveform_pair = self.wave_pair;
612                    let [left, right] = &mut waveform_pair;
613                    let waveform_pair_gain = self.pair_gain.normalize_pair(left, right);
614
615                    // The stereo field (ADR-0215), from the hop's own two
616                    // channels. It reads nothing above this line and nothing
617                    // above this line reads it, which is what keeps every field
618                    // constructed before it a function of the channel average
619                    // alone. Unlevelled, so it is not routed through `gain`.
620                    let [hop_l, hop_r] = &self.hop_pair;
621                    let field = stereo::StereoField::measure(hop_l, hop_r);
622                    // ...and its per-band half, from one short FFT per channel
623                    // split by the **same** `BandSplitter` the mono bands come
624                    // from, so the edges cannot drift apart. Two short FFTs, not
625                    // two full spectra: the band split reads the short window's
626                    // linear magnitudes, so the long window stays single. That
627                    // is what kept the exact mechanism affordable against the
628                    // ~11 ms NFR section 3 allocates, and the alternative was a
629                    // time-domain approximation (ADR-0215 decision point 3).
630                    let [spectrum_l, spectrum_r] = &mut self.pair_spectrum;
631                    let [window_l, window_r] = &self.window_pair;
632                    let (bass_balance, mid_balance, treb_balance) = stereo::band_balance(
633                        self.bands.split(spectrum_l.analyze(window_l)),
634                        self.bands.split(spectrum_r.analyze(window_r)),
635                    );
636
637                    self.latest = AnalysisFrame {
638                        spectrum,
639                        waveform,
640                        waveform_gain,
641                        waveform_pair,
642                        waveform_pair_gain,
643                        onset,
644                        beat,
645                        bass,
646                        mid: self.mid_gain.normalize(mid_raw),
647                        treb: self.treb_gain.normalize(treb_raw),
648                        bass_raw,
649                        mid_raw,
650                        treb_raw,
651                        onset_raw,
652                        bpm: clock.bpm,
653                        bar: clock.bar,
654                        beat_index: clock.beat_index,
655                        time_since_beat: clock.time_since_beat,
656                        beat_in_bar: bars.beat_in_bar,
657                        bar_index: bars.bar_index,
658                        bar_phase: bars.bar_phase,
659                        downbeat_confidence: bars.confidence,
660                        downbeat_locked: bars.locked,
661                        novelty,
662                        balance: field.balance,
663                        spread: field.spread,
664                        bass_balance,
665                        mid_balance,
666                        treb_balance,
667                    };
668                    self.pending_beat |= beat;
669                }
670            }
671        }
672    }
673
674    /// The downbeat estimator's current decomposition — Plan 0068's instrument,
675    /// reachable from a native shell (Plan 0086 Phase 1).
676    ///
677    /// **Reading it changes nothing.** [`downbeat::DownbeatTracker::terms`] takes
678    /// `&self`, recomputes from state [`push_interleaved`](Self::push_interleaved)
679    /// already keeps, allocates nothing and reads no clock — so the estimator
680    /// behaves identically whether or not anyone is looking, and the value is the
681    /// published [`AnalysisFrame::downbeat_confidence`] bit for bit between hops.
682    ///
683    /// Diagnostics only, and **native-only** (ADR-0052): not a grammar variable,
684    /// and never on the C ABI.
685    pub fn downbeat_terms(&self) -> downbeat::DownbeatTerms {
686        self.downbeat.terms()
687    }
688
689    /// Latest analysis with any beat since the previous take. Call once per
690    /// render frame.
691    pub fn take_frame(&mut self) -> AnalysisFrame {
692        let mut frame = self.latest;
693        frame.beat = self.pending_beat;
694        self.pending_beat = false;
695        self.latest.beat = false;
696        frame
697    }
698}