Skip to main content

rlx_core/dsp/
novelty.rs

1//! Long-window spectral novelty for experimental track-change detection
2//! (Plan 0009 Phase 4). Feeds the standalone's scene director a *nudge*: a large
3//! spectral shift (a new track / section with a different frequency
4//! distribution) raises the novelty score, and the director lets that pull scene
5//! rotation earlier — but never triggers a change on novelty alone, since
6//! beatmatched DJ blends have no hard edge (NFR section 10).
7//!
8//! The score is the distance between the current per-band spectrum and a slow
9//! exponential running mean of it, **normalized by the mean's magnitude** so it
10//! measures a change in spectral *shape*, not loudness — a pure volume swell
11//! reads ~0, a full spectral swap reads ~sqrt(2). Within a steady segment the
12//! spectrum sits on its own running mean, so novelty stays near zero; at a
13//! boundary the spectrum diverges from a mean still built on the previous
14//! segment, so novelty spikes, then decays as the mean catches up. Deliberately
15//! spectral, not tempo: a beatmatched set holds tempo across the blend, so a
16//! tempo term would miss exactly the case this must stay soft on.
17//!
18//! Pure and deterministic — a function of the spectrum sequence alone, no wall
19//! clock and no randomness (NFR section 6).
20
21// Hot-path panic-denial pragma (Plan 0002 Phase 2): runs every hop off the
22// render loop, so it must never panic on valid input.
23#![deny(
24    clippy::unwrap_used,
25    clippy::expect_used,
26    clippy::indexing_slicing,
27    clippy::panic,
28    clippy::unreachable
29)]
30
31use super::{HOP_SIZE, SPECTRUM_BINS};
32
33/// Time constant (seconds) of the running-mean window. ~2 s is long enough that
34/// per-beat spectral wobble sits on the mean (low novelty) while a whole-track
35/// change stands out against it.
36const NOVELTY_TAU: f32 = 2.0;
37
38/// Floor added to the mean magnitude in the denominator, so near-silence (a
39/// tiny mean) can't blow the ratio up to a spurious spike.
40const MEAN_EPS: f32 = 1e-3;
41
42/// Tracks the slow running mean of the spectrum and reports how far the current
43/// spectrum sits from it.
44pub struct NoveltyDetector {
45    /// Exponential running mean of the per-band spectrum.
46    mean: [f32; SPECTRUM_BINS],
47    /// Per-hop EMA coefficient, derived from the hop duration and `NOVELTY_TAU`.
48    alpha: f32,
49    /// Seeded on the first hop so the mean starts on real data, not zeros.
50    warm: bool,
51}
52
53impl NoveltyDetector {
54    /// Build a detector for a given sample rate (sets the per-hop smoothing so
55    /// the effective window is `NOVELTY_TAU` seconds regardless of rate).
56    pub fn new(sample_rate: u32) -> Self {
57        let hop_dt = HOP_SIZE as f32 / sample_rate.max(1) as f32;
58        let alpha = 1.0 - (-hop_dt / NOVELTY_TAU).exp();
59        Self {
60            mean: [0.0; SPECTRUM_BINS],
61            alpha,
62            warm: false,
63        }
64    }
65
66    /// Consume one hop's spectrum and return the novelty score (0 on the first
67    /// hop, while the mean seeds). The running mean folds in the current
68    /// spectrum *after* the measurement, so a boundary spikes before the mean
69    /// absorbs it.
70    pub fn process(&mut self, spectrum: &[f32; SPECTRUM_BINS]) -> f32 {
71        if !self.warm {
72            self.mean = *spectrum;
73            self.warm = true;
74            return 0.0;
75        }
76        let dist_sq: f32 = spectrum
77            .iter()
78            .zip(self.mean.iter())
79            .map(|(s, m)| {
80                let d = s - m;
81                d * d
82            })
83            .sum();
84        let mean_energy: f32 = self.mean.iter().map(|m| m * m).sum();
85        // Normalize by the mean's magnitude: a shape change, not a level change.
86        let novelty = dist_sq.sqrt() / (mean_energy.sqrt() + MEAN_EPS);
87
88        for (m, s) in self.mean.iter_mut().zip(spectrum.iter()) {
89            *m += (*s - *m) * self.alpha;
90        }
91        novelty
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    /// A flat-ish spectrum with all energy in one contiguous band range, so two
100    /// different ranges are clearly distinct.
101    fn spectrum(active: std::ops::Range<usize>) -> [f32; SPECTRUM_BINS] {
102        let mut s = [0.0f32; SPECTRUM_BINS];
103        for (i, v) in s.iter_mut().enumerate() {
104            if active.contains(&i) {
105                *v = 1.0;
106            }
107        }
108        s
109    }
110
111    #[test]
112    fn steady_spectrum_stays_low_and_a_change_spikes() {
113        let mut d = NoveltyDetector::new(48_000);
114        let low = spectrum(0..8);
115        let high = spectrum(48..56);
116
117        // Warm on a steady low-band segment: novelty settles near zero.
118        for _ in 0..200 {
119            d.process(&low);
120        }
121        let steady = d.process(&low);
122        assert!(steady < 0.05, "steady novelty {steady} should be ~0");
123
124        // Switch to a distinct high-band segment: the score spikes toward the
125        // ~sqrt(2) a full spectral swap produces.
126        let spike = d.process(&high);
127        assert!(
128            spike > 0.8,
129            "a spectral change should spike novelty (got {spike}, steady {steady})"
130        );
131        assert!(spike > steady * 10.0, "spike {spike} vs steady {steady}");
132
133        // Holding on the new segment lets the mean catch up, novelty decays.
134        for _ in 0..400 {
135            d.process(&high);
136        }
137        let settled = d.process(&high);
138        assert!(
139            settled < spike * 0.5,
140            "novelty should decay within the new segment (settled {settled}, spike {spike})"
141        );
142    }
143
144    #[test]
145    fn first_hop_has_no_novelty() {
146        let mut d = NoveltyDetector::new(48_000);
147        assert_eq!(d.process(&spectrum(0..8)), 0.0);
148    }
149}