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}