Skip to main content

tono_core/render/
mod.rs

1//! Deterministic graph → samples renderer.
2//!
3//! Rendering is a pure function of `(graph, seed, sample_rate)`. Each node is
4//! evaluated into a block of `f32` samples; combinators combine those blocks.
5//! Processors transform the signal flowing through a `chain`.
6
7mod effects;
8mod kit;
9mod osc;
10mod seq;
11#[cfg(test)]
12mod tests;
13
14pub(crate) use effects::{FilterKind, biquad_coeffs, drive_antideriv, drive_curve};
15pub(crate) use osc::{osc, poly_blep};
16pub(crate) use seq::seq_to_signal;
17
18use crate::dsl::{Adsr, Node, Playback, Shape, SoundDoc, Value};
19use crate::dsp::{Rng, node_path, node_seed, peak_limit};
20use effects::{biquad, chorus, compress, drive_adaa, flanger, modal_bank, phaser, reverb};
21use osc::{
22    dust_signal, fm_signal, impact_signal, noise_signal, osc_signal, saw_signal, square_signal,
23    super_signal, tri_signal,
24};
25use std::f32::consts::TAU;
26
27/// A block of mono audio samples.
28type Signal = Vec<f32>;
29
30/// A finished render: the mono mid (what analysis and mono export consume)
31/// plus the true stereo bus when the document is a `tracks` mixer. Producing
32/// both from ONE render keeps the author/refine/export paths from paying the
33/// full synthesis cost twice. Plain documents carry no pair here — their
34/// `stereo` treatment (Haas / Wide) is applied at write time by [`stereoize`].
35pub struct RenderProduct {
36    /// Mono mid signal: `0.5 × (L + R)` for a mixer document, the render
37    /// itself otherwise.
38    pub mono: Signal,
39    /// The panned, mastered stereo bus of a `tracks` document.
40    pub stereo: Option<(Signal, Signal)>,
41    /// Per-layer contribution stats for a `tracks` document (post-fader,
42    /// pre-master), captured from the same render pass.
43    pub layers: Vec<LayerStats>,
44}
45
46mod output;
47mod tracks;
48
49pub use output::{loop_seam_db, make_loop_buffer, stereoize};
50use output::{normalize_output, normalize_output_v4};
51pub use tracks::{LayerStats, TracksRender, render_tracks};
52
53/// Render a sound document once, yielding the mono mid plus the stereo bus
54/// for mixer documents. Every consumer that needs both (analysis + the WAV on
55/// disk) should call this instead of rendering twice.
56pub fn render_product(doc: &SoundDoc) -> RenderProduct {
57    if let Some(tr) = render_tracks(doc) {
58        // Mono consumers (analysis, mono export) get the mid signal.
59        let mono = tr
60            .left
61            .iter()
62            .zip(&tr.right)
63            .map(|(a, b)| 0.5 * (a + b))
64            .collect();
65        return RenderProduct {
66            mono,
67            stereo: Some((tr.left, tr.right)),
68            layers: tr.layers,
69        };
70    }
71    RenderProduct {
72        mono: render_plain(doc),
73        stereo: None,
74        layers: Vec::new(),
75    }
76}
77
78/// Render a sound document to normalized mono samples in [-1, 1].
79pub fn render(doc: &SoundDoc) -> Signal {
80    render_product(doc).mono
81}
82
83/// Raw graph evaluation — [`render_node`] on the root with no output stage (loop
84/// / normalize / peak-limit / stereo). This is the reference the streaming
85/// renderer matches byte-for-byte (used by the streaming byte-identity tests).
86#[cfg(test)]
87pub(crate) fn render_graph(doc: &SoundDoc) -> Signal {
88    let sr = doc.sample_rate;
89    // validate() caps duration at 600 s; the clamp guards direct render calls
90    // on unvalidated docs from an unbounded allocation (1e12 s ⇒ OOM abort).
91    let n = ((doc.duration.clamp(0.0, 600.0) * sr as f32).ceil() as usize).max(1);
92    let mut rng = Rng::new(doc.seed);
93    render_node(&doc.root, n, sr, &mut rng, doc.effective_engine(), doc.seed)
94}
95
96/// The non-mixer render path: one graph, one mono buffer.
97fn render_plain(doc: &SoundDoc) -> Signal {
98    let sr = doc.sample_rate;
99    // validate() caps duration at 600 s; the clamp guards direct render calls
100    // on unvalidated docs from an unbounded allocation (1e12 s ⇒ OOM abort).
101    let n = ((doc.duration.clamp(0.0, 600.0) * sr as f32).ceil() as usize).max(1);
102    let mut rng = Rng::new(doc.seed);
103    let engine = doc.effective_engine();
104    let mut out = render_node(&doc.root, n, sr, &mut rng, engine, doc.seed);
105    // A loop is rendered as its seamless body (tail crossfaded onto the head).
106    if let Playback::Loop {
107        start_secs,
108        end_secs,
109        crossfade_secs,
110    } = doc.playback
111    {
112        out = make_loop_buffer(&out, sr, start_secs, end_secs, crossfade_secs);
113    }
114    match &doc.normalize {
115        // Loudness-matched / true-peak-limited output stage (opt-in).
116        Some(nz) if engine >= 4 => normalize_output_v4(&mut [&mut out], nz, sr),
117        Some(nz) => normalize_output(&mut out, nz),
118        // Default: a transparent sample-peak safety limit only.
119        None => peak_limit(&mut [&mut out]),
120    }
121    out
122}
123
124/// Evaluate a parameter into a per-sample buffer of length `n`. The streaming
125/// [`crate::streaming::value::Val`] is the single definition of every
126/// modulator's math — this is just a loop over it, so the offline and
127/// streaming paths can never diverge (the streaming byte-identity fuzz proves
128/// the loop reproduces the closed forms).
129fn eval_value(v: &Value, n: usize, sr: u32) -> Vec<f32> {
130    let mut val = crate::streaming::value::Val::build(v, sr, n);
131    (0..n).map(|t| val.eval(t)).collect()
132}
133
134/// Edit-stable seed for a [`Modulator::Rand`]: a hash of only the modulator's
135/// own fields, so its walk never shifts when sibling nodes are added or
136/// removed (the random stream is not threaded through graph traversal).
137pub(crate) fn rand_seed(seed: u64, from: f32, to: f32, rate: f32) -> u64 {
138    let mut h = seed ^ crate::dsp::GOLDEN_GAMMA;
139    for bits in [from.to_bits(), to.to_bits(), rate.to_bits()] {
140        h = (h ^ bits as u64).wrapping_mul(crate::dsp::FNV_PRIME);
141    }
142    h
143}
144
145/// Render a node into a signal of length `n`. `engine` is the document's
146/// DSP-kernel revision (see [`crate::dsl::ENGINE_VERSION`]); kernels that
147/// changed output across revisions branch on it so older documents stay
148/// byte-identical.
149fn render_node(node: &Node, n: usize, sr: u32, rng: &mut Rng, engine: u32, path: u64) -> Signal {
150    match node {
151        Node::Square { freq, duty } => square_signal(freq, duty, n, sr),
152        Node::Triangle { freq } => tri_signal(freq, n, sr),
153        Node::Sawtooth { freq } => saw_signal(freq, n, sr),
154        Node::Super {
155            wave,
156            freq,
157            voices,
158            detune_cents,
159        } => super_signal(*wave, freq, *voices, *detune_cents, n, sr),
160        Node::Sine { freq } => osc_signal(freq, n, sr, |p| osc(Shape::Sine, p)),
161        Node::Noise { color } => {
162            // Engine ≥ 2: each noise leaf owns a structurally-seeded stream (from
163            // its graph position), so its randomness is independent of traversal
164            // order and reproduces byte-identically in the streaming renderer.
165            if engine >= 2 {
166                let mut local = Rng::new(node_seed(path));
167                noise_signal(*color, n, &mut local)
168            } else {
169                noise_signal(*color, n, rng)
170            }
171        }
172        Node::Fm { freq, ratio, index } => fm_signal(freq, *ratio, index, n, sr),
173        // Engine ≥ 2: the seq draws its voice randomness (noise/pluck/kit/thump)
174        // from a structurally-seeded stream, so it's order-independent and the
175        // streaming renderer reproduces it byte-identically.
176        Node::Seq { .. } => {
177            if engine >= 2 {
178                let mut local = Rng::new(node_seed(path));
179                seq_to_signal(node, n, sr, &mut local, engine)
180            } else {
181                seq_to_signal(node, n, sr, rng, engine)
182            }
183        }
184        Node::Impact { hardness, velocity } => impact_signal(*hardness, *velocity, n, sr),
185        Node::Dust { density, decay } => {
186            if engine >= 2 {
187                let mut local = Rng::new(node_seed(path));
188                dust_signal(*density, *decay, n, sr, &mut local)
189            } else {
190                dust_signal(*density, *decay, n, sr, rng)
191            }
192        }
193        Node::Env { adsr: env } => adsr(env, n, sr),
194        // Validation rejects nested mixers; render defensively as a plain sum.
195        Node::Tracks { tracks, .. } => {
196            let mut acc = vec![0.0f32; n];
197            for (i, t) in tracks.iter().enumerate() {
198                let sig = render_node(&t.node, n, sr, rng, engine, node_path(path, i));
199                for (o, v) in acc.iter_mut().zip(sig) {
200                    *o += v * t.gain;
201                }
202            }
203            acc
204        }
205        Node::Mix { inputs } => {
206            let mut acc = vec![0.0f32; n];
207            for (i, input) in inputs.iter().enumerate() {
208                let s = render_node(input, n, sr, rng, engine, node_path(path, i));
209                for (o, v) in acc.iter_mut().zip(s) {
210                    *o += v;
211                }
212            }
213            acc
214        }
215        Node::Mul { inputs } => {
216            let mut acc = vec![1.0f32; n];
217            for (i, input) in inputs.iter().enumerate() {
218                let s = render_node(input, n, sr, rng, engine, node_path(path, i));
219                for (o, v) in acc.iter_mut().zip(s) {
220                    *o *= v;
221                }
222            }
223            acc
224        }
225        Node::Chain { stages } => {
226            let mut buf: Option<Signal> = None;
227            for (i, stage) in stages.iter().enumerate() {
228                let cp = node_path(path, i);
229                buf = Some(match (&buf, stage.is_processor()) {
230                    // A processor transforms the running signal.
231                    (Some(input), true) => apply_processor(stage, input, sr, rng, engine, cp),
232                    // A source/combinator as a later stage replaces the signal.
233                    (_, _) => render_node(stage, n, sr, rng, engine, cp),
234                });
235            }
236            buf.unwrap_or_else(|| vec![0.0; n])
237        }
238        // A processor rendered standalone (outside a chain) has no input ⇒ silence.
239        _ if node.is_processor() => vec![0.0; n],
240        // Every non-processor variant is matched above; this fires only if a
241        // new source is added to the DSL without a render arm.
242        _ => unreachable!("unhandled source node in render_node"),
243    }
244}
245
246/// Apply a processor node to an incoming signal. (`rng` feeds processors that
247/// render an internal side signal, e.g. `duck`'s trigger.) `engine` is the
248/// document's DSP-kernel revision; quality-changing processors branch on it so
249/// older documents stay byte-identical.
250fn apply_processor(
251    node: &Node,
252    input: &[f32],
253    sr: u32,
254    rng: &mut Rng,
255    engine: u32,
256    path: u64,
257) -> Signal {
258    match node {
259        Node::Duck {
260            trigger,
261            amount,
262            attack,
263            release,
264        } => {
265            // Render the trigger silently; its loudness envelope steers a
266            // gain dip on the chained signal — the sidechain pump.
267            let trig = render_node(trigger, input.len(), sr, rng, engine, node_path(path, 0));
268            let srf = sr as f32;
269            let at = (-1.0 / (attack.max(1e-4) * srf)).exp();
270            let rt = (-1.0 / (release.max(1e-4) * srf)).exp();
271            let mut env = 0.0f32;
272            input
273                .iter()
274                .zip(trig)
275                .map(|(&x, t)| {
276                    let rect = t.abs().min(1.0);
277                    let coeff = if rect > env { at } else { rt };
278                    env = rect + coeff * (env - rect);
279                    x * (1.0 - amount * env)
280                })
281                .collect()
282        }
283        Node::Lowpass { cutoff, q } => biquad(input, cutoff, *q, sr, FilterKind::Low),
284        Node::Highpass { cutoff, q } => biquad(input, cutoff, *q, sr, FilterKind::High),
285        Node::Bandpass { cutoff, q } => biquad(input, cutoff, *q, sr, FilterKind::Band),
286        Node::Notch { cutoff, q } => biquad(input, cutoff, *q, sr, FilterKind::Notch),
287        Node::Peak { cutoff, q, gain_db } => {
288            biquad(input, cutoff, *q, sr, FilterKind::Peak(*gain_db))
289        }
290        Node::Lowshelf { cutoff, gain_db } => {
291            biquad(input, cutoff, 0.707, sr, FilterKind::LowShelf(*gain_db))
292        }
293        Node::Highshelf { cutoff, gain_db } => {
294            biquad(input, cutoff, 0.707, sr, FilterKind::HighShelf(*gain_db))
295        }
296        Node::Gain { amount } => {
297            let g = eval_value(amount, input.len(), sr);
298            input.iter().zip(g).map(|(x, k)| x * k).collect()
299        }
300        Node::Bitcrush { bits } => {
301            // validate() bounds bits to 1..=16; the .min(31) keeps a direct
302            // render of an unvalidated doc from overflowing the shift.
303            let levels = (1u32 << (*bits as u32).min(31)) as f32;
304            let half = levels / 2.0;
305            input
306                .iter()
307                .map(|x| (x.clamp(-1.0, 1.0) * half).round() / half)
308                .collect()
309        }
310        Node::Downsample { factor } => {
311            let f = (*factor).max(1) as usize;
312            let mut out = Vec::with_capacity(input.len());
313            let mut held = 0.0;
314            for (i, &x) in input.iter().enumerate() {
315                if i % f == 0 {
316                    held = x;
317                }
318                out.push(held);
319            }
320            out
321        }
322        Node::Delay { secs, feedback } => {
323            // validate() caps secs at 30 s; the shared clamp guards direct
324            // render calls on unvalidated docs from an unbounded allocation.
325            let dn = crate::dsp::delay_line_len(*secs, sr);
326            let mut buf = vec![0.0f32; dn];
327            let mut w = 0usize;
328            let mut out = Vec::with_capacity(input.len());
329            for &x in input {
330                let delayed = buf[w];
331                let y = x + feedback * delayed;
332                buf[w] = y;
333                w = (w + 1) % dn;
334                out.push(y);
335            }
336            out
337        }
338        Node::Reverb { room, mix } => reverb(input, *room, *mix, sr, 0),
339        Node::Modal { modes, mix } => modal_bank(input, modes, *mix, sr),
340        Node::Drive { amount, shape, aa } => {
341            let a = eval_value(amount, input.len(), sr);
342            // ADAA is an engine-1 kernel: gated on the document's engine so
343            // legacy (engine-0) documents render the original aliasing curve
344            // byte-for-byte. Within engine 1 it is on unless `aa: false`.
345            let use_adaa = engine >= 1 && aa.unwrap_or(true);
346            if use_adaa {
347                drive_adaa(input, &a, *shape)
348            } else {
349                input
350                    .iter()
351                    .zip(a)
352                    .map(|(x, amt)| drive_curve(amt.max(0.0) * x, *shape))
353                    .collect()
354            }
355        }
356        Node::RingMod { freq } => {
357            let f = eval_value(freq, input.len(), sr);
358            let srf = sr as f32;
359            let mut phase = 0.0f32;
360            let mut out = Vec::with_capacity(input.len());
361            for (i, &x) in input.iter().enumerate() {
362                out.push(x * (TAU * phase).sin());
363                phase += f[i].max(0.0) / srf;
364                phase -= phase.floor();
365            }
366            out
367        }
368        Node::Chorus { rate, depth, mix } => chorus(input, *rate, *depth, *mix, sr),
369        Node::Flanger {
370            rate,
371            depth,
372            feedback,
373            mix,
374        } => flanger(input, *rate, *depth, *feedback, *mix, sr),
375        Node::Phaser {
376            rate,
377            depth,
378            feedback,
379            mix,
380        } => phaser(input, *rate, *depth, *feedback, *mix, sr),
381        Node::Compress {
382            threshold,
383            ratio,
384            attack,
385            release,
386            makeup,
387        } => compress(input, *threshold, *ratio, *attack, *release, *makeup, sr),
388        // Every processor variant is matched above; this fires only if a new
389        // processor is added to the DSL without a render arm (the same guard
390        // render_node keeps for sources) — loud, never a silent identity.
391        _ => unreachable!("unhandled processor node in apply_processor"),
392    }
393}
394
395/// ADSR envelope with an sfxr-style punch boost on the initial transient.
396fn adsr(env: &Adsr, n: usize, sr: u32) -> Signal {
397    let Adsr { a, d, s, r, punch } = *env;
398    let srf = sr as f32;
399    let rel_start = (n as f32 / srf - r).max(0.0);
400    (0..n)
401        .map(|i| crate::dsp::adsr_env(i as f32 / srf, a, d, s, r, punch, rel_start))
402        .collect()
403}