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}