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/// 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}