Skip to main content

rlx_core/
signal.rs

1//! Pure, deterministic PCM signal synthesis for the capture / visual-QA path
2//! (Plan 0013). These generate synthetic *signals* by math over a sample clock —
3//! they are **not** an audio *source* (no WASAPI, no file, no OS), so they live
4//! in the source-agnostic core and can be fed straight through the real
5//! [`Analyzer`](crate::dsp::Analyzer) to exercise the actual DSP.
6//!
7//! Every generator is a pure function of its arguments — no wall clock, seeded
8//! randomness only (NFR section 6). Output is interleaved f32 frames matching
9//! the given [`AudioFormat`], the same shape a frontend pushes into the intake.
10
11use std::f32::consts::TAU;
12
13use crate::audio::AudioFormat;
14
15/// A pure sine at `freq_hz` and `amplitude` for `secs`, interleaved to
16/// `format.channels`.
17pub fn sine(freq_hz: f32, secs: f32, amplitude: f32, format: AudioFormat) -> Vec<f32> {
18    let sr = format.sample_rate as f32;
19    let n = frame_count(secs, format.sample_rate);
20    let mono: Vec<f32> = (0..n)
21        .map(|i| amplitude * (TAU * freq_hz * i as f32 / sr).sin())
22        .collect();
23    interleave(&mono, format.channels)
24}
25
26/// A strong low-frequency sine (bass band). Thin wrapper over [`sine`].
27pub fn bass_sine(freq_hz: f32, secs: f32, format: AudioFormat) -> Vec<f32> {
28    sine(freq_hz, secs, 0.9, format)
29}
30
31/// A strong high-frequency sine (treble band). Thin wrapper over [`sine`].
32pub fn treble_tone(freq_hz: f32, secs: f32, format: AudioFormat) -> Vec<f32> {
33    sine(freq_hz, secs, 0.9, format)
34}
35
36/// Seeded white noise in `[-amplitude, amplitude]`, deterministic per `seed`.
37pub fn noise(seed: u64, secs: f32, amplitude: f32, format: AudioFormat) -> Vec<f32> {
38    let n = frame_count(secs, format.sample_rate);
39    let mut rng = SplitMix::new(seed);
40    let mono: Vec<f32> = (0..n)
41        .map(|_| (rng.next_f32() * 2.0 - 1.0) * amplitude)
42        .collect();
43    interleave(&mono, format.channels)
44}
45
46/// A sum of sines at `freqs` for `secs`, scaled so the peak stays within ±0.9.
47pub fn chord(freqs: &[f32], secs: f32, format: AudioFormat) -> Vec<f32> {
48    let sr = format.sample_rate as f32;
49    let n = frame_count(secs, format.sample_rate);
50    let scale = if freqs.is_empty() {
51        0.0
52    } else {
53        0.9 / freqs.len() as f32
54    };
55    let mono: Vec<f32> = (0..n)
56        .map(|i| {
57            let t = i as f32 / sr;
58            freqs.iter().map(|f| (TAU * f * t).sin()).sum::<f32>() * scale
59        })
60        .collect();
61    interleave(&mono, format.channels)
62}
63
64/// A metronome click track at `bpm` for `secs`: a short decaying broadband burst
65/// on each beat, silence between. Fed through the real analyzer it produces an
66/// onset (and a beat flag) on each click, ~`60/bpm` seconds apart.
67pub fn click_track(bpm: f32, secs: f32, format: AudioFormat) -> Vec<f32> {
68    let sr = format.sample_rate as f32;
69    let n = frame_count(secs, format.sample_rate);
70    let period = ((60.0 / bpm.max(1.0)) * sr).round() as usize;
71    let click_len = ((0.012 * sr).round() as usize).max(1); // ~12 ms
72    let mut rng = SplitMix::new(0x1234_5678_9ABC_DEF0);
73    let mut mono = vec![0.0f32; n];
74    let mut start = 0usize;
75    while start < n {
76        stamp_click(&mut mono, start, click_len, 0.95, &mut rng);
77        start += period.max(1);
78    }
79    interleave(&mono, format.channels)
80}
81
82/// A click track at `bpm` carrying an extra click on every **off-beat**, at
83/// `offbeat` times the on-beat amplitude — the double-time trap (Plan 0095
84/// Phase 1).
85///
86/// At `offbeat = 0` this is [`click_track`]'s pattern; at `offbeat = 1` it is
87/// literally a click train at twice `bpm` and there is no ground truth left to
88/// find. In between, the notated tempo is `bpm` while the onset envelope's
89/// strongest short-lag periodicity sits at half the beat period, which is the
90/// arrangement that invites a tempo estimator to read the octave above.
91pub fn offbeat_click_track(bpm: f32, secs: f32, offbeat: f32, format: AudioFormat) -> Vec<f32> {
92    alternating_clicks(
93        30.0 / bpm.max(1.0),
94        offbeat,
95        0x0095_0FFB_EA70_C11C,
96        secs,
97        format,
98    )
99}
100
101/// A sparse half-time feel at `bpm`: full clicks on beats 1 and 3, `weak` times
102/// that on beats 2 and 4 — the half-time trap (Plan 0095 Phase 1).
103///
104/// The beat grid is still fully populated, so the notated tempo is `bpm`; what
105/// changes is that the accent pattern repeats every *two* beats, so the
106/// envelope's strongest periodicity is at twice the beat period. At `weak = 1`
107/// this is [`click_track`]; at `weak = 0` it stops being half-time material and
108/// simply becomes a click train at `bpm / 2`, which is why the probe sweeps the
109/// middle rather than the ends.
110pub fn halftime_click_track(bpm: f32, secs: f32, weak: f32, format: AudioFormat) -> Vec<f32> {
111    alternating_clicks(
112        60.0 / bpm.max(1.0),
113        weak,
114        0x0095_4A1F_7146_B3D2,
115        secs,
116        format,
117    )
118}
119
120/// Clicks every `interval` seconds, alternating full amplitude with `weak`
121/// times it. Click positions are computed from the index rather than
122/// accumulated, so a non-integer interval cannot drift the grid across a long
123/// clip.
124fn alternating_clicks(
125    interval: f32,
126    weak: f32,
127    seed: u64,
128    secs: f32,
129    format: AudioFormat,
130) -> Vec<f32> {
131    let sr = format.sample_rate as f32;
132    let n = frame_count(secs, format.sample_rate);
133    let click_len = ((0.012 * sr).round() as usize).max(1); // ~12 ms
134    let weak = weak.clamp(0.0, 1.0);
135    let mut rng = SplitMix::new(seed);
136    let mut mono = vec![0.0f32; n];
137    let mut k = 0usize;
138    loop {
139        let start = (k as f32 * interval.max(1e-4) * sr).round() as usize;
140        if start >= n {
141            break;
142        }
143        let amp = if k.is_multiple_of(2) {
144            0.95
145        } else {
146            0.95 * weak
147        };
148        stamp_click(&mut mono, start, click_len, amp, &mut rng);
149        k += 1;
150    }
151    interleave(&mono, format.channels)
152}
153
154/// Stamp one exponentially decaying broadband click of `amplitude` into `mono`
155/// at `start`, truncated at the end of the buffer.
156///
157/// Draws from `rng` once per **written** sample, so a caller's noise sequence
158/// depends only on how many click samples it has stamped so far — which is what
159/// keeps [`click_track`] bit-identical across this extraction.
160fn stamp_click(
161    mono: &mut [f32],
162    start: usize,
163    click_len: usize,
164    amplitude: f32,
165    rng: &mut SplitMix,
166) {
167    for i in 0..click_len {
168        let idx = start + i;
169        if idx >= mono.len() {
170            break;
171        }
172        let env = (-(i as f32) / click_len as f32 * 6.0).exp();
173        let sample = (rng.next_f32() * 2.0 - 1.0) * env * amplitude;
174        if let Some(slot) = mono.get_mut(idx) {
175            *slot = sample;
176        }
177    }
178}
179
180/// An envelope-shaped, beat-gridded signal with **dynamics** at `bpm` for
181/// `secs` — the one generator here that rises and falls (Plan 0037, ADR-0039).
182///
183/// Every other generator is a steady tone or steady noise: measured through the
184/// band report, `bass:60` gives min/mean/max 0.187 / 0.187 / 0.187, zero
185/// variance, and `chord` 0.058 / 0.059 / 0.060. A filmstrip of those exercises
186/// the DSP with material that never changes, which is not what any preset is
187/// authored against.
188///
189/// Three layers on a beat grid, each landing in a different band, plus a
190/// **phrase envelope**: an 8-beat cycle that builds over six beats and rests for
191/// two. The rest is what produces real dynamics — without a near-silent stretch
192/// the running mean climbs to meet the peak and `max / mean` collapses toward 1.
193///
194/// - **kick**, every beat: a pitch-dropping low sine, ~105 Hz down to ~45 — the
195///   bass band, and the transient the onset detector fires on.
196/// - **hat**, on each off-beat: a very short broadband tick — the treble band.
197/// - **pad**, continuous: a three-note chord around 220-330 Hz that swells across
198///   each beat — the mid band.
199///
200/// **It exercises dynamics; it is not evidence about real loopback levels.**
201/// Nothing synthesized here can be — only a measurement of real material through
202/// `--audio` speaks to that (`docs/capturing.md`).
203///
204/// A pure function of its arguments like every generator here: no wall clock,
205/// and the hat's noise comes from a fixed seed pulled once per sample, so the
206/// sequence is identical on every run and every machine (NFR section 6).
207pub fn dynamic_groove(bpm: f32, secs: f32, format: AudioFormat) -> Vec<f32> {
208    let sr = format.sample_rate as f32;
209    let n = frame_count(secs, format.sample_rate);
210    let beat_secs = 60.0 / bpm.max(1.0);
211    let beat_samples = ((beat_secs * sr).round() as usize).max(1);
212    let mut rng = SplitMix::new(0x5EED_0037_D17A_71C5);
213    // The kick's phase is integrated rather than evaluated at `t`, because its
214    // frequency changes within the beat — `sin(TAU * f(t) * t)` would sweep the
215    // wrong way.
216    let mut kick_phase = 0.0f32;
217    let mut prev_white = 0.0f32;
218    let mut mono = vec![0.0f32; n];
219
220    for (i, slot) in mono.iter_mut().enumerate() {
221        let beat = i / beat_samples;
222        let within = (i % beat_samples) as f32 / beat_samples as f32;
223        let since = within * beat_secs;
224
225        // The phrase: six beats building, two resting. The build is geometric
226        // rather than linear because the crest factor is the whole point — a
227        // ramp that spends half its beats near the top has a mean close to its
228        // maximum, which is the flatness every other generator here suffers
229        // from. `0.04` rather than zero so the rest is quiet rather than digital
230        // silence, which is what music does and what keeps the onset detector's
231        // floor honest.
232        let phrase = match beat % 8 {
233            6 | 7 => 0.04,
234            b => 0.18 * 1.4f32.powi(b as i32),
235        };
236
237        if i % beat_samples == 0 {
238            kick_phase = 0.0;
239        }
240        let kick_hz = 45.0 + 60.0 * (-since * 45.0).exp();
241        kick_phase += TAU * kick_hz / sr;
242        let kick = kick_phase.sin() * (-since * 26.0).exp() * 0.45;
243
244        // Pulled every sample, not only inside a tick, so the noise sequence does
245        // not depend on where the eighth-note grid lands. Differenced against the
246        // previous sample, which is a one-tap high-pass: flat white noise spends
247        // most of its amplitude below 4 kHz where the kick and pad already live,
248        // so an un-brightened tick costs peak headroom to light a band it barely
249        // reaches. A hat is a bright sound; this makes it one.
250        let white = rng.next_f32() * 2.0 - 1.0;
251        let tick = white - prev_white;
252        prev_white = white;
253        // Hats on every eighth, the off-beat louder. A single 6 ms tick per beat
254        // was measurable but pointless: at a ~1 % duty cycle the treble band's
255        // mean over a hop reads 0.0002, which is silence with a good crest
256        // factor. 90 ms of decay twice a beat is what puts real energy up there.
257        let eighth = beat_secs * 0.5;
258        let hat_t = since - eighth * (since / eighth).floor();
259        let hat = tick * (-hat_t * 16.0).exp() * if within >= 0.5 { 2.6 } else { 1.6 };
260
261        // Two voices a fifth apart, each with five harmonics at 1/k. The
262        // harmonics are the point: the mid band is ~250 Hz-4 kHz and its scalar
263        // is a MEAN over that whole span, so a bare three-note chord at 220-330
264        // lands mostly in bass and reads as a trickle in mid. The stack spreads
265        // energy from 165 Hz to 1.65 kHz, which is where a mix's body sits.
266        let t = i as f32 / sr;
267        // The per-harmonic phase offset is not decoration: with every partial
268        // starting at zero they all align once per period and the pad's crest
269        // factor sets the whole signal's peak, so the normalization below pulls
270        // the kick and hats down with it. Detuned phases cost nothing and buy
271        // back most of the headroom.
272        let mut voices = 0.0f32;
273        for f0 in [165.0f32, 247.5] {
274            for k in 1..=5 {
275                let kf = k as f32;
276                voices += (TAU * f0 * kf * t + kf * 1.7).sin() / kf;
277            }
278        }
279        let pad = voices * 0.4 * (0.35 + 0.65 * (1.0 - (-since * 6.0).exp()));
280
281        // Soft-clipped rather than peak-normalized to the 0.9 headroom the other
282        // generators use. Dividing by the loudest sample would make the three
283        // layers a zero-sum game — every increase in the hats pulls the kick and
284        // pad down by the same factor, so no setting lights all three bands. A
285        // tanh saturator bounds the peak while leaving the average alone, which
286        // is what a mix bus does; the phrase multiplies BEFORE it, so the rest
287        // stays in the curve's linear region and the dynamics survive.
288        *slot = ((kick + hat + pad) * phrase * 1.2).tanh() * 0.9;
289    }
290
291    interleave(&mono, format.channels)
292}
293
294/// Broadband seeded noise panned to `p`, `-1` hard left to `+1` hard right —
295/// the stimulus a stereo quantity can be measured against (ADR-0215).
296///
297/// **One waveform at two gains**, `L = (1 - p) / (1 + |p|)` and
298/// `R = (1 + p) / (1 + |p|)`, so the per-channel RMS ratio is algebraically `p`
299/// and the channels are perfectly correlated: `balance` reads `p` and `spread`
300/// reads `0`, both as exact properties of the construction rather than as
301/// fitted numbers. Neither gain exceeds 1, so the peak stays inside the
302/// generator family's ±0.9 headroom.
303///
304/// **Broadband on purpose**: the source is seeded noise rather than a chord,
305/// because a pan applied to everything has to read as a pan in *every* band,
306/// and a sum of three sines below 350 Hz leaves the treble band empty.
307pub fn pan(p: f32, secs: f32, format: AudioFormat) -> Vec<f32> {
308    let p = p.clamp(-1.0, 1.0);
309    let n = frame_count(secs, format.sample_rate);
310    let mut rng = SplitMix::new(0x0215_9A17_C0DE_5EED);
311    let denom = 1.0 + p.abs();
312    let (gl, gr) = ((1.0 - p) / denom, (1.0 + p) / denom);
313    let mono: Vec<f32> = (0..n).map(|_| (rng.next_f32() * 2.0 - 1.0) * 0.9).collect();
314    let left: Vec<f32> = mono.iter().map(|s| s * gl).collect();
315    let right: Vec<f32> = mono.iter().map(|s| s * gr).collect();
316    interleave_pair(&left, &right, format.channels)
317}
318
319/// Independent seeded noise per channel — the decorrelated stimulus, where
320/// [`pan`] is the correlated one.
321///
322/// The two streams come from two seeds drawn from `seed`, so the pair is a pure
323/// function of one argument (NFR section 6) and the channels share no samples:
324/// `spread` reads near `0.5` and `balance` near `0`, both to the accuracy a
325/// finite hop allows rather than exactly.
326pub fn wide(seed: u64, secs: f32, format: AudioFormat) -> Vec<f32> {
327    let n = frame_count(secs, format.sample_rate);
328    let mut derive = SplitMix::new(seed);
329    let mut lrng = SplitMix::new(derive.next_u64());
330    let mut rrng = SplitMix::new(derive.next_u64());
331    let left: Vec<f32> = (0..n)
332        .map(|_| (lrng.next_f32() * 2.0 - 1.0) * 0.9)
333        .collect();
334    let right: Vec<f32> = (0..n)
335        .map(|_| (rrng.next_f32() * 2.0 - 1.0) * 0.9)
336        .collect();
337    interleave_pair(&left, &right, format.channels)
338}
339
340/// A centred bass sine under a treble tone panned to `p` — the case a whole-mix
341/// scalar cannot express (ADR-0215).
342///
343/// The 80 Hz layer is identical in both channels, so `bass_balance` reads `0`;
344/// the 8 kHz layer carries [`pan`]'s gains, so `treb_balance` reads `p`. The
345/// whole-mix `balance` lands strictly between them, at a value set by the
346/// relative energy of the two layers rather than by either position.
347///
348/// **The treble layer is the louder of the two** (0.6 against 0.3, summing
349/// inside the ±0.9 headroom), and that is arithmetic rather than taste: a band's
350/// value is a *mean over its linear bins*, and the treble band spans ~600 of
351/// them against the bass band's ~10. An equal-amplitude tone up there would
352/// average down to about the silence floor and the band would read as having no
353/// position at all.
354pub fn split(p: f32, secs: f32, format: AudioFormat) -> Vec<f32> {
355    let p = p.clamp(-1.0, 1.0);
356    let sr = format.sample_rate as f32;
357    let n = frame_count(secs, format.sample_rate);
358    let denom = 1.0 + p.abs();
359    let (gl, gr) = ((1.0 - p) / denom, (1.0 + p) / denom);
360    let mut left = Vec::with_capacity(n);
361    let mut right = Vec::with_capacity(n);
362    for i in 0..n {
363        let t = i as f32 / sr;
364        let low = 0.3 * (TAU * 80.0 * t).sin();
365        let high = 0.6 * (TAU * 8_000.0 * t).sin();
366        left.push(low + high * gl);
367        right.push(low + high * gr);
368    }
369    interleave_pair(&left, &right, format.channels)
370}
371
372/// Interleave a left/right pair up to `channels`: channel 0 is `left`, channel
373/// 1 is `right`, and any further channel carries their average.
374///
375/// Channels 0 and 1 are the only two the analyzer's stereo field and
376/// `waveform_pair` read (ADR-0199), so the rest exist here only to keep a
377/// higher channel count from carrying silence.
378fn interleave_pair(left: &[f32], right: &[f32], channels: u16) -> Vec<f32> {
379    let ch = channels.max(1) as usize;
380    let n = left.len().min(right.len());
381    let mut out = Vec::with_capacity(n * ch);
382    for (l, r) in left.iter().zip(right).take(n) {
383        for c in 0..ch {
384            out.push(match c {
385                0 => *l,
386                1 => *r,
387                _ => (l + r) * 0.5,
388            });
389        }
390    }
391    out
392}
393
394/// Interleave a mono buffer up to `channels` (the same sample on every channel).
395fn interleave(mono: &[f32], channels: u16) -> Vec<f32> {
396    let ch = channels.max(1) as usize;
397    let mut out = Vec::with_capacity(mono.len() * ch);
398    for &s in mono {
399        for _ in 0..ch {
400            out.push(s);
401        }
402    }
403    out
404}
405
406/// Whole frames in `secs` at `sample_rate` (non-negative).
407fn frame_count(secs: f32, sample_rate: u32) -> usize {
408    (secs.max(0.0) * sample_rate as f32).round() as usize
409}
410
411/// splitmix64 — a tiny seeded PRNG so noise/click generation stays deterministic
412/// without a dependency (mirrors the render side's `SeededRng`).
413struct SplitMix(u64);
414
415impl SplitMix {
416    fn new(seed: u64) -> Self {
417        Self(seed)
418    }
419
420    fn next_u64(&mut self) -> u64 {
421        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
422        let mut z = self.0;
423        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
424        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
425        z ^ (z >> 31)
426    }
427
428    fn next_f32(&mut self) -> f32 {
429        (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use crate::dsp::{Analyzer, HOP_SIZE};
437
438    fn fmt() -> AudioFormat {
439        AudioFormat {
440            sample_rate: 48_000,
441            channels: 2,
442        }
443    }
444
445    /// Run PCM through the real analyzer, returning the latest frame after the
446    /// whole buffer.
447    fn analyze_all(pcm: &[f32]) -> crate::dsp::AnalysisFrame {
448        let mut an = Analyzer::new(fmt()).expect("valid format");
449        an.push_interleaved(pcm);
450        an.take_frame()
451    }
452
453    #[test]
454    fn bass_sine_lands_in_the_bass_band() {
455        let frame = analyze_all(&bass_sine(60.0, 1.0, fmt()));
456        assert!(
457            frame.bass > frame.treb,
458            "60 Hz: bass {} should exceed treb {}",
459            frame.bass,
460            frame.treb
461        );
462        assert!(frame.bass > 0.05, "60 Hz sine should light the bass band");
463    }
464
465    #[test]
466    fn treble_tone_lands_in_the_treble_band() {
467        let frame = analyze_all(&treble_tone(12_000.0, 1.0, fmt()));
468        assert!(
469            frame.treb > frame.bass,
470            "12 kHz: treb {} should exceed bass {}",
471            frame.treb,
472            frame.bass
473        );
474    }
475
476    /// Min / mean / max of each band across a clip's hops, skipping the hops
477    /// before the analyzer's window fills (they read zero for every generator and
478    /// would drag every minimum to 0).
479    fn band_ranges(pcm: &[f32], warmup: usize) -> [(f32, f32, f32); 3] {
480        let format = fmt();
481        let mut an = Analyzer::new(format).expect("valid format");
482        let hop = HOP_SIZE * format.channels as usize;
483        let mut bands = [Vec::new(), Vec::new(), Vec::new()];
484        for (i, chunk) in pcm.chunks(hop).enumerate() {
485            an.push_interleaved(chunk);
486            let f = an.take_frame();
487            // Past the analyzer's own warm-up (derived — see `WARMUP_HOPS`) plus
488            // whatever settling the caller asked for on top.
489            if i < crate::dsp::WARMUP_HOPS + warmup {
490                continue;
491            }
492            // The **raw** levels, deliberately. These measurements are claims
493            // about the *generator* — does this PCM have dynamics — and ADR-0049's
494            // normalization exists precisely to flatten absolute dynamics away, so
495            // reading the normalized values here would measure the AGC's crest
496            // factor instead of the signal's.
497            bands[0].push(f.bass_raw);
498            bands[1].push(f.mid_raw);
499            bands[2].push(f.treb_raw);
500        }
501        std::array::from_fn(|i| {
502            let v = &bands[i];
503            let min = v.iter().copied().fold(f32::INFINITY, f32::min);
504            let max = v.iter().copied().fold(f32::NEG_INFINITY, f32::max);
505            let mean = v.iter().sum::<f32>() / v.len().max(1) as f32;
506            (min, mean, max)
507        })
508    }
509
510    /// The property Plan 0037 Phase 3 exists for: this generator has **real
511    /// dynamics**, where every other kind here is flat. No honest absolute
512    /// threshold exists yet, so the claim is relative — `max / mean` materially
513    /// above 1 in every band, against `bass:60`'s exactly 1.000 and `chord`'s
514    /// 1.017 — and it is asserted against a steady kind measured the same way in
515    /// the same run rather than against a remembered number.
516    #[test]
517    fn dynamic_groove_has_dynamics_where_the_steady_kinds_have_none() {
518        let format = fmt();
519        let groove = band_ranges(&dynamic_groove(110.0, 4.0, format), 4);
520        // The liveliest existing kind, measured in the same run rather than
521        // quoted from memory: seeded noise, whose `max / mean` reads 1.77
522        // in bass where `bass:60` is exactly 1.000 and `chord` 1.017.
523        let liveliest = band_ranges(&noise(7, 4.0, 0.8, format), 4);
524        let names = ["bass", "mid", "treb"];
525
526        for (i, (min, mean, max)) in groove.iter().copied().enumerate() {
527            let crest = max / mean.max(f32::EPSILON);
528            let (_, noise_mean, noise_max) = liveliest[i];
529            let noise_crest = noise_max / noise_mean.max(f32::EPSILON);
530            println!(
531                "{:<5} min {min:.4} mean {mean:.4} max {max:.4}  max/mean {crest:.2}  \
532                 (noise:7 mean {noise_mean:.4}, max/mean {noise_crest:.2})",
533                names[i]
534            );
535            // Energy in every band at all — a groove that only lit bass would
536            // exercise a third of the DSP, and the `spectrum` scenes read the
537            // whole array. The floor is deliberately low: what a band *should*
538            // read is exactly the open question Phase 4 measures, so this asserts
539            // "audible, not silence" rather than a level nobody has evidence for.
540            assert!(
541                mean > 0.004,
542                "{} is effectively silent (mean {mean:.4})",
543                names[i]
544            );
545            assert!(
546                crest > 2.0,
547                "{} has no dynamics: max/mean {crest:.2} (min {min:.4} mean \
548                 {mean:.4} max {max:.4})",
549                names[i]
550            );
551            assert!(
552                crest > noise_crest,
553                "{} is no livelier than seeded noise, the liveliest kind that \
554                 already existed: {crest:.2} against {noise_crest:.2}",
555                names[i]
556            );
557        }
558    }
559
560    /// Determinism (NFR section 6): the same arguments give the same samples, so
561    /// a filmstrip of this is reproducible. The seeded hat is the only thing that
562    /// could have broken it.
563    #[test]
564    fn dynamic_groove_is_a_pure_function_of_its_arguments() {
565        let format = fmt();
566        let a = dynamic_groove(110.0, 1.0, format);
567        let b = dynamic_groove(110.0, 1.0, format);
568        assert_eq!(a, b, "two calls with identical arguments differ");
569        assert!(a.iter().all(|s| s.is_finite()), "NaN/inf into the analyzer");
570        assert!(
571            a.iter().all(|s| s.abs() <= 0.9001),
572            "peak normalization did not hold the 0.9 headroom"
573        );
574        // ...and the BPM is a real argument, not decoration.
575        assert_ne!(a, dynamic_groove(90.0, 1.0, format), "the BPM does nothing");
576    }
577
578    /// The stereo generators are the first ones whose two channels differ, so
579    /// the properties worth pinning are the ones every kind before them got for
580    /// free: purity, headroom, and that the channels are what they claim.
581    #[test]
582    fn the_stereo_generators_are_pure_and_differ_between_channels() {
583        let format = fmt();
584        for pcm in [pan(-0.8, 0.5, format), wide(1, 0.5, format)] {
585            assert!(
586                pcm.iter().all(|s| s.is_finite()),
587                "NaN/inf into the analyzer"
588            );
589            assert!(
590                pcm.iter().all(|s| s.abs() <= 0.9001),
591                "the 0.9 headroom the generator family keeps"
592            );
593            let channels: Vec<Vec<f32>> = (0..2)
594                .map(|c| pcm.iter().skip(c).step_by(2).copied().collect())
595                .collect();
596            assert_ne!(
597                channels.first(),
598                channels.get(1),
599                "a stereo generator whose channels agree is a mono one"
600            );
601        }
602        assert_eq!(pan(-0.8, 0.5, format), pan(-0.8, 0.5, format));
603        assert_eq!(wide(1, 0.5, format), wide(1, 0.5, format));
604        assert_ne!(
605            pan(-0.8, 0.5, format),
606            pan(0.8, 0.5, format),
607            "the position does nothing"
608        );
609        assert_ne!(
610            wide(1, 0.5, format),
611            wide(2, 0.5, format),
612            "the seed does nothing"
613        );
614        // A centred pan is the one position whose channels do agree, and it is
615        // the boundary the analyzer reads as no stereo field at all.
616        let centred = pan(0.0, 0.5, format);
617        let (left, right): (Vec<f32>, Vec<f32>) = centred
618            .chunks_exact(2)
619            .filter_map(|f| match f {
620                [l, r] => Some((*l, *r)),
621                _ => None,
622            })
623            .unzip();
624        assert_eq!(left, right, "pan:0 is a mono signal in both channels");
625    }
626
627    #[test]
628    fn click_track_produces_periodic_onsets() {
629        let format = fmt();
630        let pcm = click_track(120.0, 3.0, format); // 120 BPM => 0.5 s apart
631        let mut an = Analyzer::new(format).expect("valid format");
632        let hop = HOP_SIZE * format.channels as usize;
633        let secs_per_frame = HOP_SIZE as f32 / format.sample_rate as f32;
634
635        let mut beat_secs = Vec::new();
636        for (frame, chunk) in pcm.chunks(hop).enumerate() {
637            an.push_interleaved(chunk);
638            if an.take_frame().beat {
639                beat_secs.push(frame as f32 * secs_per_frame);
640            }
641        }
642
643        // ~6 beats over 3 s (allow warm-up to swallow the first, and slack).
644        assert!(
645            (4..=7).contains(&beat_secs.len()),
646            "expected ~6 beats over 3 s, got {}: {beat_secs:?}",
647            beat_secs.len()
648        );
649        // Consecutive beats sit near 0.5 s apart.
650        for pair in beat_secs.windows(2) {
651            let gap = pair[1] - pair[0];
652            assert!(
653                (0.35..=0.65).contains(&gap),
654                "beat gap {gap:.3}s should be ~0.5s (beats {beat_secs:?})"
655            );
656        }
657    }
658}