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/// Interleave a mono buffer up to `channels` (the same sample on every channel).
295fn interleave(mono: &[f32], channels: u16) -> Vec<f32> {
296 let ch = channels.max(1) as usize;
297 let mut out = Vec::with_capacity(mono.len() * ch);
298 for &s in mono {
299 for _ in 0..ch {
300 out.push(s);
301 }
302 }
303 out
304}
305
306/// Whole frames in `secs` at `sample_rate` (non-negative).
307fn frame_count(secs: f32, sample_rate: u32) -> usize {
308 (secs.max(0.0) * sample_rate as f32).round() as usize
309}
310
311/// splitmix64 — a tiny seeded PRNG so noise/click generation stays deterministic
312/// without a dependency (mirrors the render side's `SeededRng`).
313struct SplitMix(u64);
314
315impl SplitMix {
316 fn new(seed: u64) -> Self {
317 Self(seed)
318 }
319
320 fn next_u64(&mut self) -> u64 {
321 self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
322 let mut z = self.0;
323 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
324 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
325 z ^ (z >> 31)
326 }
327
328 fn next_f32(&mut self) -> f32 {
329 (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use crate::dsp::{Analyzer, HOP_SIZE};
337
338 fn fmt() -> AudioFormat {
339 AudioFormat {
340 sample_rate: 48_000,
341 channels: 2,
342 }
343 }
344
345 /// Run PCM through the real analyzer, returning the latest frame after the
346 /// whole buffer.
347 fn analyze_all(pcm: &[f32]) -> crate::dsp::AnalysisFrame {
348 let mut an = Analyzer::new(fmt()).expect("valid format");
349 an.push_interleaved(pcm);
350 an.take_frame()
351 }
352
353 #[test]
354 fn bass_sine_lands_in_the_bass_band() {
355 let frame = analyze_all(&bass_sine(60.0, 1.0, fmt()));
356 assert!(
357 frame.bass > frame.treb,
358 "60 Hz: bass {} should exceed treb {}",
359 frame.bass,
360 frame.treb
361 );
362 assert!(frame.bass > 0.05, "60 Hz sine should light the bass band");
363 }
364
365 #[test]
366 fn treble_tone_lands_in_the_treble_band() {
367 let frame = analyze_all(&treble_tone(12_000.0, 1.0, fmt()));
368 assert!(
369 frame.treb > frame.bass,
370 "12 kHz: treb {} should exceed bass {}",
371 frame.treb,
372 frame.bass
373 );
374 }
375
376 /// Min / mean / max of each band across a clip's hops, skipping the hops
377 /// before the analyzer's window fills (they read zero for every generator and
378 /// would drag every minimum to 0).
379 fn band_ranges(pcm: &[f32], warmup: usize) -> [(f32, f32, f32); 3] {
380 let format = fmt();
381 let mut an = Analyzer::new(format).expect("valid format");
382 let hop = HOP_SIZE * format.channels as usize;
383 let mut bands = [Vec::new(), Vec::new(), Vec::new()];
384 for (i, chunk) in pcm.chunks(hop).enumerate() {
385 an.push_interleaved(chunk);
386 let f = an.take_frame();
387 // Past the analyzer's own warm-up (derived — see `WARMUP_HOPS`) plus
388 // whatever settling the caller asked for on top.
389 if i < crate::dsp::WARMUP_HOPS + warmup {
390 continue;
391 }
392 // The **raw** levels, deliberately. These measurements are claims
393 // about the *generator* — does this PCM have dynamics — and ADR-0049's
394 // normalization exists precisely to flatten absolute dynamics away, so
395 // reading the normalized values here would measure the AGC's crest
396 // factor instead of the signal's.
397 bands[0].push(f.bass_raw);
398 bands[1].push(f.mid_raw);
399 bands[2].push(f.treb_raw);
400 }
401 std::array::from_fn(|i| {
402 let v = &bands[i];
403 let min = v.iter().copied().fold(f32::INFINITY, f32::min);
404 let max = v.iter().copied().fold(f32::NEG_INFINITY, f32::max);
405 let mean = v.iter().sum::<f32>() / v.len().max(1) as f32;
406 (min, mean, max)
407 })
408 }
409
410 /// The property Plan 0037 Phase 3 exists for: this generator has **real
411 /// dynamics**, where every other kind here is flat. No honest absolute
412 /// threshold exists yet, so the claim is relative — `max / mean` materially
413 /// above 1 in every band, against `bass:60`'s exactly 1.000 and `chord`'s
414 /// 1.017 — and it is asserted against a steady kind measured the same way in
415 /// the same run rather than against a remembered number.
416 #[test]
417 fn dynamic_groove_has_dynamics_where_the_steady_kinds_have_none() {
418 let format = fmt();
419 let groove = band_ranges(&dynamic_groove(110.0, 4.0, format), 4);
420 // The liveliest existing kind, measured in the same run rather than
421 // quoted from memory: seeded noise, whose `max / mean` reads 1.77
422 // in bass where `bass:60` is exactly 1.000 and `chord` 1.017.
423 let liveliest = band_ranges(&noise(7, 4.0, 0.8, format), 4);
424 let names = ["bass", "mid", "treb"];
425
426 for (i, (min, mean, max)) in groove.iter().copied().enumerate() {
427 let crest = max / mean.max(f32::EPSILON);
428 let (_, noise_mean, noise_max) = liveliest[i];
429 let noise_crest = noise_max / noise_mean.max(f32::EPSILON);
430 println!(
431 "{:<5} min {min:.4} mean {mean:.4} max {max:.4} max/mean {crest:.2} \
432 (noise:7 mean {noise_mean:.4}, max/mean {noise_crest:.2})",
433 names[i]
434 );
435 // Energy in every band at all — a groove that only lit bass would
436 // exercise a third of the DSP, and the `spectrum` scenes read the
437 // whole array. The floor is deliberately low: what a band *should*
438 // read is exactly the open question Phase 4 measures, so this asserts
439 // "audible, not silence" rather than a level nobody has evidence for.
440 assert!(
441 mean > 0.004,
442 "{} is effectively silent (mean {mean:.4})",
443 names[i]
444 );
445 assert!(
446 crest > 2.0,
447 "{} has no dynamics: max/mean {crest:.2} (min {min:.4} mean \
448 {mean:.4} max {max:.4})",
449 names[i]
450 );
451 assert!(
452 crest > noise_crest,
453 "{} is no livelier than seeded noise, the liveliest kind that \
454 already existed: {crest:.2} against {noise_crest:.2}",
455 names[i]
456 );
457 }
458 }
459
460 /// Determinism (NFR section 6): the same arguments give the same samples, so
461 /// a filmstrip of this is reproducible. The seeded hat is the only thing that
462 /// could have broken it.
463 #[test]
464 fn dynamic_groove_is_a_pure_function_of_its_arguments() {
465 let format = fmt();
466 let a = dynamic_groove(110.0, 1.0, format);
467 let b = dynamic_groove(110.0, 1.0, format);
468 assert_eq!(a, b, "two calls with identical arguments differ");
469 assert!(a.iter().all(|s| s.is_finite()), "NaN/inf into the analyzer");
470 assert!(
471 a.iter().all(|s| s.abs() <= 0.9001),
472 "peak normalization did not hold the 0.9 headroom"
473 );
474 // ...and the BPM is a real argument, not decoration.
475 assert_ne!(a, dynamic_groove(90.0, 1.0, format), "the BPM does nothing");
476 }
477
478 #[test]
479 fn click_track_produces_periodic_onsets() {
480 let format = fmt();
481 let pcm = click_track(120.0, 3.0, format); // 120 BPM => 0.5 s apart
482 let mut an = Analyzer::new(format).expect("valid format");
483 let hop = HOP_SIZE * format.channels as usize;
484 let secs_per_frame = HOP_SIZE as f32 / format.sample_rate as f32;
485
486 let mut beat_secs = Vec::new();
487 for (frame, chunk) in pcm.chunks(hop).enumerate() {
488 an.push_interleaved(chunk);
489 if an.take_frame().beat {
490 beat_secs.push(frame as f32 * secs_per_frame);
491 }
492 }
493
494 // ~6 beats over 3 s (allow warm-up to swallow the first, and slack).
495 assert!(
496 (4..=7).contains(&beat_secs.len()),
497 "expected ~6 beats over 3 s, got {}: {beat_secs:?}",
498 beat_secs.len()
499 );
500 // Consecutive beats sit near 0.5 s apart.
501 for pair in beat_secs.windows(2) {
502 let gap = pair[1] - pair[0];
503 assert!(
504 (0.35..=0.65).contains(&gap),
505 "beat gap {gap:.3}s should be ~0.5s (beats {beat_secs:?})"
506 );
507 }
508 }
509}