Skip to main content

prime_render/
lib.rs

1//! `prime-render` — Pure sample-level scan loop.
2//!
3//! This is the ADVANCE evaluator for the temporal assembly thesis. A rendered
4//! buffer is the result of folding a pure step function over time:
5//!
6//! ```text
7//! output[n] = f(state_n, t_n)   where   state_{n+1} = step(state_n, t_n).sample_rate
8//! ```
9//!
10//! `render` is the only place in PRIME where ADVANCE is the *explicit* design.
11//! Everything else in PRIME is LOAD + COMPUTE (single-step). Here we fold N steps.
12//!
13//! # Temporal Assembly in PRIME
14//!
15//! ```text
16//! LOAD    ← initial_state, sample_rate, num_samples, step fn
17//! COMPUTE ← fold: (state, sample_index) → (sample, next_state)
18//! APPEND  ← push each sample into the output buffer
19//! ADVANCE ← repeat for every sample — this is the scan loop
20//! ```
21//!
22//! The `step` function is always LOAD + COMPUTE only. It never calls `render`
23//! recursively, never mutates, never reads the clock. Same inputs → same output.
24
25/// Render a mono audio buffer by folding a pure step function over time.
26///
27/// This is the core ADVANCE operation for Score's temporal assembly thesis.
28/// The output is a deterministic function of `initial_state`, `sample_rate`,
29/// and `step` — no hidden state, no I/O, no side effects.
30///
31/// # Math
32///
33/// ```text
34/// dt = 1 / sample_rate
35/// output[n], state_{n+1} = step(state_n, n * dt)
36/// ```
37///
38/// # Arguments
39/// * `initial_state` — starting DSP state (e.g. oscillator phase, envelope stage)
40/// * `sample_rate`   — samples per second (e.g. 44100)
41/// * `num_samples`   — number of output samples to generate
42/// * `step`          — pure function: `(state, time_in_seconds) → (sample, next_state)`
43///
44/// # Returns
45/// `Vec<f32>` of exactly `num_samples` mono samples in [-1.0, 1.0].
46///
47/// # Edge cases
48/// * `num_samples == 0` → returns empty `Vec`
49/// * `sample_rate == 0` → `dt = infinity`; step receives `f64::INFINITY` for t > 0
50///
51/// # Example
52/// ```rust
53/// // Render 4 samples of a 440 Hz sine at 4 Hz (for clarity)
54/// use prime_render::render;
55/// use std::f64::consts::TAU;
56///
57/// let samples = render(0.0_f64, 4, 4, |phase, _t| {
58///     let sample = phase.sin() as f32;
59///     let next = (phase + TAU * 440.0 / 4.0) % TAU;
60///     (sample, next)
61/// });
62/// assert_eq!(samples.len(), 4);
63/// ```
64pub fn render<S>(
65    initial_state: S,
66    sample_rate: u32,
67    num_samples: usize,
68    step: impl Fn(S, f64) -> (f32, S),
69) -> Vec<f32> {
70    let dt = 1.0 / sample_rate as f64;
71    let (_, samples) = (0..num_samples).fold(
72        (initial_state, Vec::with_capacity(num_samples)),
73        |(state, mut buf), n| {
74            let t = n as f64 * dt;
75            let (sample, next_state) = step(state, t);
76            buf.push(sample);
77            (next_state, buf)
78        },
79    );
80    samples
81}
82
83/// Render a stereo audio buffer by folding a pure step function over time.
84///
85/// Identical to [`render`] but the step function produces a `(left, right)`
86/// sample pair per frame. Returns interleaved `[(L0, R0), (L1, R1), ...]`.
87///
88/// # Arguments
89/// * `initial_state` — starting DSP state
90/// * `sample_rate`   — samples per second
91/// * `num_samples`   — number of stereo frames to generate
92/// * `step`          — pure function: `(state, t) → ((left, right), next_state)`
93///
94/// # Returns
95/// `Vec<(f32, f32)>` of `num_samples` stereo frames.
96///
97/// # Example
98/// ```rust
99/// use prime_render::render_stereo;
100///
101/// // Constant stereo frame — left=0.5, right=-0.5
102/// let frames = render_stereo((), 44100, 3, |s, _t| ((0.5, -0.5), s));
103/// assert_eq!(frames, vec![(0.5, -0.5), (0.5, -0.5), (0.5, -0.5)]);
104/// ```
105pub fn render_stereo<S>(
106    initial_state: S,
107    sample_rate: u32,
108    num_samples: usize,
109    step: impl Fn(S, f64) -> ((f32, f32), S),
110) -> Vec<(f32, f32)> {
111    let dt = 1.0 / sample_rate as f64;
112    let (_, frames) = (0..num_samples).fold(
113        (initial_state, Vec::with_capacity(num_samples)),
114        |(state, mut buf), n| {
115            let t = n as f64 * dt;
116            let (frame, next_state) = step(state, t);
117            buf.push(frame);
118            (next_state, buf)
119        },
120    );
121    frames
122}
123
124/// Render a buffer and reduce it to a scalar — useful for envelope integration
125/// and level metering without allocating the full output.
126///
127/// Folds the same scan loop as [`render`] but accumulates into a single value
128/// `A` instead of collecting samples. The `combine` function receives the
129/// running accumulator and each new sample.
130///
131/// # Arguments
132/// * `initial_state` — starting DSP state
133/// * `initial_acc`   — starting accumulator value
134/// * `sample_rate`   — samples per second
135/// * `num_samples`   — number of samples to process
136/// * `step`          — `(state, t) → (sample, next_state)`
137/// * `combine`       — `(acc, sample) → next_acc`
138///
139/// # Returns
140/// Final accumulated value of type `A`.
141///
142/// # Example
143/// ```rust
144/// use prime_render::render_fold;
145///
146/// // Sum all samples from a constant signal of 0.1
147/// let total = render_fold((), 0.0_f32, 44100, 100, |s, _t| (0.1_f32, s), |acc, x| acc + x);
148/// assert!((total - 10.0).abs() < 1e-4);
149/// ```
150pub fn render_fold<S, A>(
151    initial_state: S,
152    initial_acc: A,
153    sample_rate: u32,
154    num_samples: usize,
155    step: impl Fn(S, f64) -> (f32, S),
156    combine: impl Fn(A, f32) -> A,
157) -> A {
158    let dt = 1.0 / sample_rate as f64;
159    let (_, acc) = (0..num_samples).fold(
160        (initial_state, initial_acc),
161        |(state, acc), n| {
162            let t = n as f64 * dt;
163            let (sample, next_state) = step(state, t);
164            (next_state, combine(acc, sample))
165        },
166    );
167    acc
168}
169
170// ── Tests ─────────────────────────────────────────────────────────────────────
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use std::f64::consts::TAU;
176
177    const EPSILON: f32 = 1e-5;
178
179    // ── render ────────────────────────────────────────────────────────────────
180
181    #[test]
182    fn render_empty() {
183        let out = render(0.0_f64, 44100, 0, |s, _t| (0.0, s));
184        assert!(out.is_empty());
185    }
186
187    #[test]
188    fn render_single_sample() {
189        let out = render(0.0_f64, 44100, 1, |s, _t| (0.5_f32, s));
190        assert_eq!(out.len(), 1);
191        assert!((out[0] - 0.5).abs() < EPSILON);
192    }
193
194    #[test]
195    fn render_constant_signal() {
196        let out = render((), 44100, 100, |s, _t| (0.25_f32, s));
197        assert_eq!(out.len(), 100);
198        assert!(out.iter().all(|&x| (x - 0.25).abs() < EPSILON));
199    }
200
201    #[test]
202    fn render_correct_length() {
203        let n = 512;
204        let out = render(0_u32, 44100, n, |s, _t| (0.0, s + 1));
205        assert_eq!(out.len(), n);
206    }
207
208    #[test]
209    fn render_threads_state_forward() {
210        // State should accumulate: output[n] should equal n as f32
211        let out = render(0_u32, 44100, 5, |count, _t| (count as f32, count + 1));
212        assert_eq!(out, vec![0.0, 1.0, 2.0, 3.0, 4.0]);
213    }
214
215    #[test]
216    fn render_time_argument_correct() {
217        // At sr=10, dt=0.1: t[0]=0.0, t[1]=0.1, t[2]=0.2 ...
218        let out = render((), 10, 4, |s, t| (t as f32, s));
219        assert!((out[0] - 0.0).abs() < EPSILON);
220        assert!((out[1] - 0.1).abs() < EPSILON);
221        assert!((out[2] - 0.2).abs() < EPSILON);
222        assert!((out[3] - 0.3).abs() < EPSILON);
223    }
224
225    #[test]
226    fn render_deterministic() {
227        let step = |phase: f64, _t: f64| {
228            let s = phase.sin() as f32;
229            let next = (phase + TAU * 440.0 / 44100.0) % TAU;
230            (s, next)
231        };
232        let a = render(0.0_f64, 44100, 64, step);
233        let b = render(0.0_f64, 44100, 64, step);
234        assert_eq!(a, b);
235    }
236
237    #[test]
238    fn render_sine_440hz_peak() {
239        // 440 Hz at 44100 sr — peak should be close to 1.0 somewhere in first period
240        let out = render(0.0_f64, 44100, 200, |phase, _t| {
241            let s = phase.sin() as f32;
242            let next = (phase + TAU * 440.0 / 44100.0) % TAU;
243            (s, next)
244        });
245        let peak = out.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
246        assert!(peak > 0.99, "peak={}", peak);
247    }
248
249    #[test]
250    fn render_no_mutation_of_input_state() {
251        let initial = 42_u32;
252        render(initial, 44100, 10, |s, _t| (0.0, s + 1));
253        // initial is Copy — verify it was not mutated (it can't be in Rust, but
254        // this confirms the API takes ownership and threads state correctly)
255        assert_eq!(initial, 42);
256    }
257
258    // ── render_stereo ─────────────────────────────────────────────────────────
259
260    #[test]
261    fn render_stereo_empty() {
262        let out = render_stereo((), 44100, 0, |s, _t| ((0.0, 0.0), s));
263        assert!(out.is_empty());
264    }
265
266    #[test]
267    fn render_stereo_constant() {
268        let out = render_stereo((), 44100, 4, |s, _t| ((0.5_f32, -0.5_f32), s));
269        assert_eq!(out.len(), 4);
270        assert!(out.iter().all(|&(l, r)| (l - 0.5).abs() < EPSILON && (r + 0.5).abs() < EPSILON));
271    }
272
273    #[test]
274    fn render_stereo_threads_state() {
275        let out = render_stereo(0_i32, 44100, 3, |n, _t| ((n as f32, -(n as f32)), n + 1));
276        assert_eq!(out, vec![(0.0, 0.0), (1.0, -1.0), (2.0, -2.0)]);
277    }
278
279    #[test]
280    fn render_stereo_deterministic() {
281        let step = |phase: f64, _t: f64| {
282            let l = phase.sin() as f32;
283            let r = (phase + std::f64::consts::FRAC_PI_2).sin() as f32;
284            let next = (phase + TAU * 440.0 / 44100.0) % TAU;
285            ((l, r), next)
286        };
287        let a = render_stereo(0.0_f64, 44100, 32, step);
288        let b = render_stereo(0.0_f64, 44100, 32, step);
289        assert_eq!(a, b);
290    }
291
292    // ── render_fold ───────────────────────────────────────────────────────────
293
294    #[test]
295    fn render_fold_sum() {
296        // 100 samples of 0.1 → sum = 10.0
297        let total = render_fold((), 0.0_f32, 44100, 100,
298            |s, _t| (0.1_f32, s),
299            |acc, x| acc + x,
300        );
301        assert!((total - 10.0).abs() < 1e-3, "total={}", total);
302    }
303
304    #[test]
305    fn render_fold_max() {
306        let step = |phase: f64, _t: f64| {
307            let s = phase.sin() as f32;
308            let next = (phase + TAU * 440.0 / 44100.0) % TAU;
309            (s, next)
310        };
311        let peak = render_fold(0.0_f64, f32::NEG_INFINITY, 44100, 200, step,
312            f32::max,
313        );
314        assert!(peak > 0.99, "peak={}", peak);
315    }
316
317    #[test]
318    fn render_fold_count() {
319        let count = render_fold((), 0_usize, 44100, 77,
320            |s, _t| (0.0, s),
321            |acc, _x| acc + 1,
322        );
323        assert_eq!(count, 77);
324    }
325
326    #[test]
327    fn render_fold_deterministic() {
328        let step = |phase: f64, _t: f64| {
329            let s = phase.sin() as f32;
330            let next = (phase + TAU / 10.0) % TAU;
331            (s, next)
332        };
333        let a = render_fold(0.0_f64, 0.0_f32, 44100, 50, step, |acc, x| acc + x);
334        let b = render_fold(0.0_f64, 0.0_f32, 44100, 50, step, |acc, x| acc + x);
335        assert!((a - b).abs() < EPSILON);
336    }
337
338    // ── render + osc integration ──────────────────────────────────────────────
339
340    #[test]
341    fn render_integrates_with_tuple_state() {
342        // Multi-field state as a tuple — (phase, amplitude)
343        let out = render((0.0_f64, 1.0_f32), 44100, 4, |(phase, amp), _t| {
344            let s = (phase.sin() as f32) * amp;
345            let next_phase = (phase + TAU * 440.0 / 44100.0) % TAU;
346            let next_amp = amp * 0.999;
347            (s, (next_phase, next_amp))
348        });
349        assert_eq!(out.len(), 4);
350        assert!(out.iter().all(|x| x.abs() <= 1.0));
351    }
352}