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}