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}