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 tempo;
32
33use crate::audio::{AudioFormat, FormatError};
34
35/// FFT window length in samples (~43 ms at 48 kHz).
36pub const WINDOW_SIZE: usize = 2048;
37
38/// How many time-domain samples an [`AnalysisFrame`] carries in
39/// [`waveform`](AnalysisFrame::waveform).
40///
41/// **512, which is MilkDrop's own count** (Plan 0100 Phase 4): its waveform draws
42/// 512 consecutive samples, every preset's `wave_mode` geometry is written
43/// against that resolution, and a converted preset should draw the figure its
44/// author drew. At 48 kHz it is 10.7 ms of audio against MilkDrop's 11.6 ms at
45/// 44.1 kHz — the same gesture, a fraction of a beat either way.
46///
47/// The samples are the **most recent** 512 of [`WINDOW_SIZE`], taken
48/// consecutively rather than decimated across the whole window. Decimation would
49/// alias: a 4:1 pick of every fourth sample of a 12 kHz tone at 48 kHz reads as a
50/// 3 kHz one, and a waveform display exists to show exactly that shape.
51pub const WAVE_SAMPLES: usize = 512;
52
53// The tail this is taken from has to exist. A compile-time check rather than a
54// runtime one, because the failure would be a silently shorter waveform.
55const _: () = assert!(
56    WAVE_SAMPLES <= WINDOW_SIZE,
57    "the waveform is the tail of the short analysis window and cannot be longer than it"
58);
59/// Second, longer FFT window feeding the bands below the crossover (~171 ms at
60/// 48 kHz). Chosen by measurement in Plan 0048 Phase 1 against one rule — 4096
61/// first, 8192 only if 4096 still leaves sub-bass bands bin-starved. Of the 20
62/// bands below the crossover, 4096 leaves **all 20** still one bin wide and 8192
63/// leaves 8, pulling the unresolved boundary down from 246 Hz to 76 Hz. See
64/// [`fft::BandLayout`] and ADR-0049; `the_long_window_was_chosen_by_measurement`
65/// pins all three candidates.
66pub const LOW_WINDOW_SIZE: usize = 8192;
67/// Samples between successive analysis hops (~10.7 ms at 48 kHz).
68pub const HOP_SIZE: usize = 512;
69/// Hops the analyzer consumes before it publishes its first frame — the time the
70/// **longer** window takes to fill (~171 ms at 48 kHz).
71///
72/// Exported so callers that sample a clip "past warm-up" derive the offset
73/// instead of restating it as a literal. Two already did, and both were silently
74/// wrong the moment [`LOW_WINDOW_SIZE`] arrived.
75pub const WARMUP_HOPS: usize = LOW_WINDOW_SIZE / HOP_SIZE;
76/// Log-frequency bands exposed to scenes.
77pub const SPECTRUM_BINS: usize = 64;
78
79/// One hop's worth of analysis.
80///
81/// **The four headline levels are normalized** (ADR-0049): `bass`, `mid`, `treb`
82/// and `onset` are each a 0..1 fraction of that signal's own slowly-decaying
83/// recent peak, so `> 0.5` means "loud for this track" rather than naming an
84/// absolute magnitude that depended on the gain staging. The absolute values
85/// remain as `*_raw` for looks that genuinely want them, and for harness
86/// continuity. `spectrum` normalizes against **one** peak shared by the whole
87/// array, so every ratio inside it — and therefore every `bin()` contrast — comes
88/// through untouched; see [`gain::BandNormalizer`] for why per-band would not.
89///
90/// `beat` flags an onset event this hop; `bpm`/`bar` come from the tempo tracker,
91/// which reads the **raw** onset — see [`gain`] for why the internal consumers
92/// are deliberately left on raw values. The bar-position trio comes from
93/// [`downbeat`], and falls back to plain counters whenever its estimate is not
94/// confident (ADR-0050).
95#[derive(Debug, Clone, Copy)]
96pub struct AnalysisFrame {
97    /// Per-band energy, the whole array normalized against **one shared** recent
98    /// peak — so every ratio inside it, and therefore every `bin()` contrast,
99    /// comes through untouched. Not a per-band normalization: that was the draft
100    /// ADR-0049 rejected, because it flattens the very shape a spectrum is for.
101    pub spectrum: [f32; SPECTRUM_BINS],
102    /// The most recent [`WAVE_SAMPLES`] of the mono signal, in **time** order —
103    /// the oscilloscope trace, not a spectrum (Plan 0100 Phase 4 / ADR-0113).
104    ///
105    /// Nothing in the engine's own vocabulary reads this: the expression grammar
106    /// is scalar and reaches the band array through `bin()` alone (ADR-0036), and
107    /// widening it to an array type is exactly what ADR-0002's purity refuses.
108    /// **It is here for the one consumer that genuinely needs a waveform** — the
109    /// warp mesh's `wave_mode` draw, which is what MilkDrop's presets use as
110    /// their light source, and which no amount of spectrum can reconstruct.
111    ///
112    /// **Levelled against its own recent peak** (ADR-0139), like every other
113    /// headline value on this struct: the whole trace is divided by one
114    /// slowly-released running peak of its magnitude, so it reads `-1..=1` at any
115    /// fader position. That is what makes the two frontends agree — the plugin
116    /// taps the decoded stream *before* the output volume, the standalone taps
117    /// loopback *after* it, and only the absolute level differs between them.
118    /// Dynamics *within* a track survive the seconds-scale release; a quiet
119    /// **track** reads like a loud one, which is the price of cancelling a volume
120    /// knob nothing else can see. The consumer still scales what it gets
121    /// (MilkDrop's `wave_scale` does exactly that).
122    ///
123    /// [`waveform_gain`](Self::waveform_gain) is the divisor, so an absolute
124    /// amplitude is one multiply away and a true oscilloscope stays reachable.
125    ///
126    /// **This is the array that made this struct big.** `AnalysisFrame` is `Copy`
127    /// and copied per frame, and 512 floats take it from ~340 bytes to ~2.4 kB —
128    /// about 100 ns of memcpy at 60 Hz, which is why it was acceptable. It is
129    /// deliberately **not** in [`Variables`](crate::preset::Variables), which
130    /// carries the band array by borrow for precisely this reason.
131    pub waveform: [f32; WAVE_SAMPLES],
132    /// The divisor [`waveform`](Self::waveform) was levelled by: `waveform[i] *
133    /// waveform_gain` is the raw amplitude the analyzer read.
134    ///
135    /// `0.0` while the tracked peak sits under [`gain::WAVE_FLOOR`], where the
136    /// trace is zeroed rather than amplified — so reconstructing from a silent
137    /// frame gives silence rather than noise.
138    pub waveform_gain: f32,
139    /// Spectral-flux onset envelope, normalized against its recent peak.
140    pub onset: f32,
141    /// Whether a beat (onset event) fired this hop.
142    pub beat: bool,
143    /// Bass-band level (~20-250 Hz), normalized against its recent peak.
144    pub bass: f32,
145    /// Mid-band level (~250-4000 Hz), normalized against its recent peak.
146    pub mid: f32,
147    /// Treble-band level (~4-18 kHz), normalized against its recent peak.
148    pub treb: f32,
149    /// Raw mean magnitude in the bass band — the pre-ADR-0049 `bass`, unchanged.
150    pub bass_raw: f32,
151    /// Raw mean magnitude in the mid band — the pre-ADR-0049 `mid`, unchanged.
152    pub mid_raw: f32,
153    /// Raw mean magnitude in the treble band — the pre-ADR-0049 `treb`, unchanged.
154    pub treb_raw: f32,
155    /// Raw spectral-flux envelope — the pre-ADR-0049 `onset`, unchanged.
156    pub onset_raw: f32,
157    /// Tempo estimate in BPM (hop-clock autocorrelation; 0 until warm).
158    pub bpm: f32,
159    /// Beat phase in [0, 1): 0 on each beat, ramping to the next.
160    ///
161    /// The name is a **documented misnomer** — this is beat phase, not bar phase.
162    /// Too widely bound to rename (ADR-0050); `bar_phase` is the true quantity.
163    pub bar: f32,
164    /// Monotone count of **onset detections** since the stream started, 0 before
165    /// the first (ADR-0050 Layer 1, corrected by ADR-0109). Unconditional and
166    /// deterministic — no confidence gate. **Not a musical period:** the
167    /// detector fires 1.2x-2.3x per musical beat depending on the material and
168    /// wanders inside a single track, so no fixed multiplier converts this to
169    /// beats.
170    pub beat_index: u32,
171    /// Seconds since the last onset detection; exactly 0 on a detection hop.
172    pub time_since_beat: f32,
173    /// Which beat of the bar this is, `0..4` (ADR-0050 Layer 2). Estimated when
174    /// the downbeat tracker is confident, and the fold's own counter modulo 4
175    /// otherwise — see [`bar_index`](Self::bar_index) for what that counter is.
176    pub beat_in_bar: u32,
177    /// Bar counter, on the same gated-or-counted basis. **Monotone except across
178    /// an alignment change** — it is `(beat count - alignment) / 4`, where the
179    /// beat count is the [`grid`]'s tempo-driven one, and `beat_index` only
180    /// while the grid warms up. So the beat the estimator locks, drops back, or
181    /// moves its alignment can repeat or skip a bar. Hysteresis makes that rare
182    /// (a challenger must lead for three bars), and a repeated bar is a far
183    /// softer failure than a wrong downbeat — but `mod(bar_index, 8)` will see
184    /// it. The warmup handover is *not* a second source of it: the grid's count
185    /// carries a whole-bar offset that keeps this moving forward across it.
186    pub bar_index: u32,
187    /// Position across the bar in `[0, 1)` — the true bar phase, as against
188    /// [`bar`](Self::bar), which is beat phase under a historical name.
189    pub bar_phase: f32,
190    /// Downbeat-alignment confidence in `0..1`. **Diagnostics only** — not a
191    /// grammar variable, so authors get behavior rather than homework.
192    pub downbeat_confidence: f32,
193    /// Whether the bar trio above came from the estimator rather than the
194    /// counter fallback. **Diagnostics only**, as with the confidence.
195    pub downbeat_locked: bool,
196    /// Experimental spectral track-change novelty (Plan 0009 Phase 4): ~0 within
197    /// a steady segment, spiking at a spectral boundary. Native-API only — not
198    /// exposed across the C ABI.
199    pub novelty: f32,
200}
201
202impl Default for AnalysisFrame {
203    fn default() -> Self {
204        Self {
205            spectrum: [0.0; SPECTRUM_BINS],
206            waveform: [0.0; WAVE_SAMPLES],
207            waveform_gain: 0.0,
208            onset: 0.0,
209            beat: false,
210            bass: 0.0,
211            mid: 0.0,
212            treb: 0.0,
213            bass_raw: 0.0,
214            mid_raw: 0.0,
215            treb_raw: 0.0,
216            onset_raw: 0.0,
217            bpm: 0.0,
218            bar: 0.0,
219            beat_index: 0,
220            time_since_beat: 0.0,
221            beat_in_bar: 0,
222            bar_index: 0,
223            bar_phase: 0.0,
224            downbeat_confidence: 0.0,
225            downbeat_locked: false,
226            novelty: 0.0,
227        }
228    }
229}
230
231impl AnalysisFrame {
232    /// **The one definition of "fully driven"**: every headline level and the
233    /// whole log-band array at full scale, with the beat flag set.
234    ///
235    /// Two harnesses hold a differential against this frame — `--report`'s
236    /// `drive` column and its step stimulus (ADR-0134), and the animation gate's
237    /// driven branch (ADR-0136). A second construction site would let them
238    /// measure two different stimuli while reading as the same word, which is a
239    /// disagreement no capture could show.
240    ///
241    /// Three fields are deliberately not at full scale, and each for its own
242    /// mechanism:
243    ///
244    /// - The four `*_raw` levels stay `0`. The headline four are peak-normalized
245    ///   (ADR-0049), so `1.0` is the documented top of their range; a raw
246    ///   magnitude has no top to name, and any value picked for one would be a
247    ///   gain-staging assumption. A binding reading `bass_raw` sees silence here.
248    /// - `bpm` stays `0`, the tracker's own not-yet-warm value. There is no
249    ///   "full scale" tempo.
250    /// - `bar` is a **phase** in `[0, 1)`, not a level, so it takes `0.5` — the
251    ///   middle of a beat rather than either edge, so a `bar`-driven binding
252    ///   reads a typical position instead of sitting on the wrap.
253    pub fn fully_driven() -> Self {
254        Self {
255            bass: 1.0,
256            mid: 1.0,
257            treb: 1.0,
258            onset: 1.0,
259            beat: true,
260            bar: 0.5,
261            // "Every band up" includes the log-band array itself: `bin()` is the
262            // grammar's only reach into the spectrum (ADR-0036), so a frame that
263            // lit the four scalars alone would leave every `bin()` binding dark
264            // and read as unreactive.
265            spectrum: [1.0; SPECTRUM_BINS],
266            ..Default::default()
267        }
268    }
269}
270
271/// Stateful per-stream analyzer: accumulates interleaved samples into mono
272/// hops, runs FFT + onset detection each completed hop, and hands the latest
273/// frame to the render side. Deterministic for a given sample sequence.
274///
275/// After construction, processing allocates nothing — safe to drive from the
276/// render loop every frame.
277pub struct Analyzer {
278    format: AudioFormat,
279    spectrum: fft::SpectrumAnalyzer,
280    onset: onset::OnsetDetector,
281    bands: bands::BandSplitter,
282    tempo: tempo::TempoTracker,
283    /// Layer 2's own beat clock (ADR-0109), driven by the tempo estimate rather
284    /// than by the transient stream. Nothing outside [`Self::push_interleaved`]
285    /// reads it and it publishes no grammar variable — it exists so the downbeat
286    /// fold has a unit that is a beat.
287    grid: grid::BarGrid,
288    /// Offset added to the grid's beat count, latched on the first hop the grid
289    /// runs. `None` until then.
290    ///
291    /// The grid's counter starts at zero when the tempo tracker warms up,
292    /// several seconds into every stream, while the fold has been counting
293    /// `beat_index` until that moment — so without this the published
294    /// [`bar_index`](AnalysisFrame::bar_index) restarts there and walks
295    /// **backwards** once per stream. Measured before it existed: back one bar
296    /// on a 120 BPM click train, three on `dynamic_groove(124)` and on a 200 BPM
297    /// train, and further on material with a denser onset stream.
298    ///
299    /// Latched **rounded up to a whole bar**, which is what makes it free. A
300    /// whole-bar shift cannot change `beat_in_bar` or which of the fold's four
301    /// buckets an accent lands in — their phase is arbitrary and `alignment`
302    /// absorbs it — and rounding *up* rather than down is what guarantees the
303    /// published bar cannot step back at the handover.
304    grid_offset: Option<u32>,
305    novelty: novelty::NoveltyDetector,
306    /// ADR-0050 Layer 2. Reads the **normalized** bass and flux, unlike the
307    /// detectors above: its accent blend weighs the two against each other, which
308    /// is only meaningful once both are on a common 0..1 scale.
309    downbeat: downbeat::DownbeatTracker,
310    /// Published-surface normalizers (ADR-0049). Deliberately *after* the
311    /// detectors above in the hop, so each of those keeps reading raw values.
312    band_gain: gain::BandNormalizer,
313    bass_gain: gain::PeakNormalizer,
314    mid_gain: gain::PeakNormalizer,
315    treb_gain: gain::PeakNormalizer,
316    onset_gain: gain::PeakNormalizer,
317    wave_gain: gain::TraceNormalizer,
318    window: [f32; WINDOW_SIZE],
319    /// The long window feeding the sub-crossover bands (ADR-0049). Heap-held:
320    /// 32 KB, and `Analyzer` is moved by value.
321    low_window: Vec<f32>,
322    /// Samples seen, saturating at [`LOW_WINDOW_SIZE`]. Analysis waits for the
323    /// **longer** window, so no frame is ever published from a partly-filled
324    /// one: Hann weights the newest samples near zero, so a half-full long
325    /// window reads its low bands *low* and ramps as real audio reaches the
326    /// taper's centre. That ramp is a genuine spectral transient — the novelty
327    /// detector's 2 s running mean integrates it into seconds of spurious
328    /// score, which would nudge the scene director at every stream start. The
329    /// cost is first analysis at ~171 ms instead of ~43 ms, at cold start only;
330    /// NFR section 3's beat-to-reaction budget is about steady state and does
331    /// not move.
332    filled: usize,
333    hop: [f32; HOP_SIZE],
334    hop_filled: usize,
335    latest: AnalysisFrame,
336    /// Beats are sticky between `take_frame` calls so a beat can never fall
337    /// between two render frames and vanish.
338    pending_beat: bool,
339}
340
341impl Analyzer {
342    /// Build an analyzer for a validated stream format.
343    pub fn new(format: AudioFormat) -> Result<Self, FormatError> {
344        let format = format.validate()?;
345        Ok(Self {
346            format,
347            spectrum: fft::SpectrumAnalyzer::new(format.sample_rate),
348            onset: onset::OnsetDetector::new(),
349            bands: bands::BandSplitter::new(format.sample_rate),
350            tempo: tempo::TempoTracker::new(format.sample_rate),
351            grid: grid::BarGrid::new(format.sample_rate),
352            grid_offset: None,
353            novelty: novelty::NoveltyDetector::new(format.sample_rate),
354            downbeat: downbeat::DownbeatTracker::new(),
355            band_gain: gain::BandNormalizer::new(format.sample_rate),
356            bass_gain: gain::PeakNormalizer::new(format.sample_rate, gain::BAND_FLOOR),
357            mid_gain: gain::PeakNormalizer::new(format.sample_rate, gain::BAND_FLOOR),
358            treb_gain: gain::PeakNormalizer::new(format.sample_rate, gain::BAND_FLOOR),
359            onset_gain: gain::PeakNormalizer::new(format.sample_rate, gain::ONSET_FLOOR),
360            wave_gain: gain::TraceNormalizer::new(format.sample_rate),
361            window: [0.0; WINDOW_SIZE],
362            low_window: vec![0.0; LOW_WINDOW_SIZE],
363            filled: 0,
364            hop: [0.0; HOP_SIZE],
365            hop_filled: 0,
366            latest: AnalysisFrame::default(),
367            pending_beat: false,
368        })
369    }
370
371    /// The validated format this analyzer was created with.
372    pub fn format(&self) -> AudioFormat {
373        self.format
374    }
375
376    /// The log-frequency band a given frequency falls into — lets scenes and
377    /// tests reason about where energy should show up.
378    pub fn band_for_freq(&self, hz: f32) -> usize {
379        self.spectrum.band_for_freq(hz)
380    }
381
382    /// Feed interleaved samples (whole frames, as produced by the intake).
383    /// Runs one analysis pass per completed hop.
384    #[allow(
385        clippy::indexing_slicing,
386        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"
387    )]
388    pub fn push_interleaved(&mut self, samples: &[f32]) {
389        let channels = self.format.channels as usize;
390        for frame in samples.chunks_exact(channels) {
391            let mono = frame.iter().sum::<f32>() / channels as f32;
392            self.hop[self.hop_filled] = mono;
393            self.hop_filled += 1;
394            if self.hop_filled == HOP_SIZE {
395                self.hop_filled = 0;
396                self.window.copy_within(HOP_SIZE.., 0);
397                self.window[WINDOW_SIZE - HOP_SIZE..].copy_from_slice(&self.hop);
398                self.low_window.copy_within(HOP_SIZE.., 0);
399                self.low_window[LOW_WINDOW_SIZE - HOP_SIZE..].copy_from_slice(&self.hop);
400                self.filled = (self.filled + HOP_SIZE).min(LOW_WINDOW_SIZE);
401                if self.filled == LOW_WINDOW_SIZE {
402                    let raw_spectrum = self.spectrum.analyze(&self.window, &self.low_window);
403                    let (onset_raw, beat) = self.onset.process(self.spectrum.magnitudes());
404                    let (bass_raw, mid_raw, treb_raw) =
405                        self.bands.split(self.spectrum.magnitudes());
406
407                    // Every consumer below this line reads RAW values on purpose
408                    // (see `gain`'s module docs): the tempo tracker
409                    // autocorrelates the onset envelope, and peak-normalizing it
410                    // would distort the periodicity it looks for, while novelty
411                    // measures spectral shape, which per-band normalization
412                    // flattens by construction.
413                    let clock = self.tempo.process(onset_raw, beat);
414                    let grid = self.grid.process(clock.bpm, onset_raw);
415                    let novelty = self.novelty.process(&raw_spectrum);
416
417                    // ...and normalization happens last, on the way out.
418                    let mut spectrum = raw_spectrum;
419                    self.band_gain.normalize(&mut spectrum);
420                    let onset = self.onset_gain.normalize(onset_raw);
421                    let bass = self.bass_gain.normalize(bass_raw);
422
423                    // The downbeat tracker sits after normalization on purpose —
424                    // it weighs bass against flux, which needs a common scale.
425                    //
426                    // What it folds over is the **grid's** beat count, not
427                    // `beat_index` (ADR-0109): the latter counts transients, at
428                    // 1.35x-2.10x per musical beat, so `beat_index % 4` spanned
429                    // well under a bar and a bar-locked accent precessed across
430                    // all four alignments. Until the grid is running — the tempo
431                    // tracker needs its envelope history filled first — the old
432                    // pair is passed, which is the counter fallback ADR-0050
433                    // specifies rather than a second code path.
434                    //
435                    // The grid's count carries a whole-bar offset latched at the
436                    // handover, so the published bar continues forward across it
437                    // rather than restarting — see `grid_offset`.
438                    let (fold_count, fold_phase) = if grid.running {
439                        let offset = *self.grid_offset.get_or_insert_with(|| {
440                            let rem = clock.beat_index % downbeat::BEATS_PER_BAR;
441                            if rem == 0 {
442                                clock.beat_index
443                            } else {
444                                // Saturating rather than `next_multiple_of`,
445                                // which panics on overflow: this module denies
446                                // panics on the hot path and does not argue
447                                // reachability with itself.
448                                clock
449                                    .beat_index
450                                    .saturating_add(downbeat::BEATS_PER_BAR - rem)
451                            }
452                        });
453                        (
454                            offset
455                                .saturating_add(grid.bar_index * downbeat::BEATS_PER_BAR)
456                                .saturating_add(grid.beat_in_bar),
457                            grid.beat_phase,
458                        )
459                    } else {
460                        (clock.beat_index, clock.bar)
461                    };
462                    let bars = self
463                        .downbeat
464                        .process(beat, fold_count, bass, onset, fold_phase);
465
466                    // The oscilloscope trace: the most recent `WAVE_SAMPLES` of
467                    // the window, consecutive (Plan 0100 Phase 4). A slice of a
468                    // buffer the analyzer already holds — no extra state, no
469                    // extra pass, and nothing here reads a clock.
470                    //
471                    // Levelled here, on the way out, for the same reason the
472                    // bands are and in the same place: the internal consumers
473                    // above have already read raw. The divisor is published
474                    // beside the trace (ADR-0139).
475                    let mut waveform = [0.0f32; WAVE_SAMPLES];
476                    if let Some(tail) = self.window.get(WINDOW_SIZE - WAVE_SAMPLES..) {
477                        waveform.copy_from_slice(tail);
478                    }
479                    let waveform_gain = self.wave_gain.normalize(&mut waveform);
480
481                    self.latest = AnalysisFrame {
482                        spectrum,
483                        waveform,
484                        waveform_gain,
485                        onset,
486                        beat,
487                        bass,
488                        mid: self.mid_gain.normalize(mid_raw),
489                        treb: self.treb_gain.normalize(treb_raw),
490                        bass_raw,
491                        mid_raw,
492                        treb_raw,
493                        onset_raw,
494                        bpm: clock.bpm,
495                        bar: clock.bar,
496                        beat_index: clock.beat_index,
497                        time_since_beat: clock.time_since_beat,
498                        beat_in_bar: bars.beat_in_bar,
499                        bar_index: bars.bar_index,
500                        bar_phase: bars.bar_phase,
501                        downbeat_confidence: bars.confidence,
502                        downbeat_locked: bars.locked,
503                        novelty,
504                    };
505                    self.pending_beat |= beat;
506                }
507            }
508        }
509    }
510
511    /// The downbeat estimator's current decomposition — Plan 0068's instrument,
512    /// reachable from a native shell (Plan 0086 Phase 1).
513    ///
514    /// **Reading it changes nothing.** [`downbeat::DownbeatTracker::terms`] takes
515    /// `&self`, recomputes from state [`push_interleaved`](Self::push_interleaved)
516    /// already keeps, allocates nothing and reads no clock — so the estimator
517    /// behaves identically whether or not anyone is looking, and the value is the
518    /// published [`AnalysisFrame::downbeat_confidence`] bit for bit between hops.
519    ///
520    /// Diagnostics only, and **native-only** (ADR-0052): not a grammar variable,
521    /// and never on the C ABI.
522    pub fn downbeat_terms(&self) -> downbeat::DownbeatTerms {
523        self.downbeat.terms()
524    }
525
526    /// Latest analysis with any beat since the previous take. Call once per
527    /// render frame.
528    pub fn take_frame(&mut self) -> AnalysisFrame {
529        let mut frame = self.latest;
530        frame.beat = self.pending_beat;
531        self.pending_beat = false;
532        self.latest.beat = false;
533        frame
534    }
535}