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}