Skip to main content

tono_core/render/
tracks.rs

1//! The `tracks` mixer render: per-track evaluation onto the stereo bus with
2//! equal-power panning, per-track RNG streams (schema v2), automation lanes,
3//! per-layer contribution stats, and the master chain.
4
5use super::effects::reverb;
6use super::output::{make_loop_buffer, normalize_output, normalize_output_v4};
7#[cfg(feature = "sampler")]
8use super::seq::{SeqVoice, sampler_seq_stereo};
9use super::{Signal, apply_processor, render_node};
10use crate::dsl::{AutoLane, AutoTarget, Node, Playback, SeqWave, SoundDoc};
11use crate::dsp::{Rng, layer_stream_key, peak_limit};
12
13/// Equal-power channel gains for a `pan`/`gain` pair — one formula for the
14/// constant fast path and the per-sample automated path, so they can never
15/// drift (identical f32 op order, byte-identical output).
16fn pan_gains(pan: f32, gain: f32) -> (f32, f32) {
17    let theta = (pan + 1.0) * std::f32::consts::FRAC_PI_4;
18    (theta.cos() * gain, theta.sin() * gain)
19}
20
21/// Derive a track's independent RNG stream from the document seed (schema
22/// v2). `stream` is the track's FNV stream key (or `MASTER_STREAM`), not a
23/// track index. SplitMix64 finalizer over a golden-gamma offset, so streams
24/// never correlate with each other or with the v1 threaded stream.
25fn track_stream_seed(seed: u64, stream: u64) -> u64 {
26    crate::dsp::splitmix_mix(
27        seed ^ stream
28            .wrapping_add(1)
29            .wrapping_mul(crate::dsp::GOLDEN_GAMMA),
30    )
31}
32
33/// The master bus's stream key (validate rejects a layer id hashing to it).
34const MASTER_STREAM: u64 = u64::MAX;
35
36/// True when a track renders in native stereo (a sampler seq) — a cheap shape
37/// test; the actual rendering happens in [`track_native_stereo`].
38fn is_native_stereo(node: &Node) -> bool {
39    matches!(
40        node,
41        Node::Seq {
42            wave: SeqWave::Sampler,
43            ..
44        }
45    )
46}
47
48/// Post-fader, pre-master snapshot of one layer's contribution to the stereo
49/// bus — the balance numbers an agent mixes by. "Pre-master" matters: a master
50/// compressor / reverb reshapes the bus AFTER these are measured. Energy and
51/// peak are measured per channel (pan-invariant: hard-panned and centered
52/// layers of equal power read equal).
53#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
54pub struct LayerStats {
55    /// The layer's stable id.
56    pub id: String,
57    /// Peak of the layer's loudest bus channel in dBFS (−180 ⇒ silent/muted).
58    pub peak_dbfs: f32,
59    /// RMS of the layer's bus contribution over the WHOLE document timeline
60    /// (per-channel energy, both channels), dBFS — comparable across layers
61    /// regardless of their `at` placement.
62    pub rms_dbfs: f32,
63    /// Share of the summed pre-master layer energy, 0..100.
64    pub energy_pct: f32,
65    /// True when the layer is muted (it contributes nothing).
66    pub mute: bool,
67}
68
69/// A finished mixer render: the stereo bus plus per-layer contribution stats
70/// captured from the same pass (free — no extra render).
71#[derive(Debug, PartialEq)]
72pub struct TracksRender {
73    /// The left channel of the mastered stereo bus.
74    pub left: Signal,
75    /// The right channel of the mastered stereo bus.
76    pub right: Signal,
77    /// Per-layer contribution stats captured from the same pass.
78    pub layers: Vec<LayerStats>,
79}
80
81/// Per-sample values for a track-automation `target`, or `None` if no lane
82/// controls it (then the static value applies — the byte-identical fast path).
83fn lane_for(
84    automation: &[AutoLane],
85    target: AutoTarget,
86    n: usize,
87    sr: u32,
88    default: f32,
89) -> Option<Vec<f32>> {
90    let lane = automation.iter().find(|l| l.target == target)?;
91    if lane.points.is_empty() {
92        return Some(vec![default; n]);
93    }
94    let mut pts = lane.points.clone();
95    pts.sort_by(|a, b| a.t.partial_cmp(&b.t).unwrap_or(std::cmp::Ordering::Equal));
96    // Linear interpolation over the sorted breakpoints, holding flat past
97    // either end. The sample time is strictly increasing, so a persistent
98    // segment cursor replaces a from-zero scan per sample (O(n + p), not
99    // O(n·p)). Strict `>` in the advance keeps the exact segment the scan
100    // would pick — a sample landing on a breakpoint interpolates in the
101    // earlier segment, so the floats (and the rendered bytes) are unchanged.
102    let mut idx = 0;
103    Some(
104        (0..n)
105            .map(|i| {
106                let t = i as f32 / sr as f32;
107                if t <= pts[0].t {
108                    return pts[0].v;
109                }
110                let last = &pts[pts.len() - 1];
111                if t >= last.t {
112                    return last.v;
113                }
114                while t > pts[idx + 1].t {
115                    idx += 1;
116                }
117                let (w0, w1) = (&pts[idx], &pts[idx + 1]);
118                let span = (w1.t - w0.t).max(1e-9);
119                w0.v + (w1.v - w0.v) * ((t - w0.t) / span)
120            })
121            .collect(),
122    )
123}
124
125/// Render a `tracks` document to a finished stereo pair: each track is
126/// rendered mono and equal-power panned onto the bus (sampler tracks keep
127/// their native stereo), the master chain runs per channel (the reverb with
128/// decorrelated tails), then loop/normalize apply jointly.
129///
130/// RNG model: schema v2 documents give every track (and the master bus) its
131/// own deterministic stream, so editing, muting, or removing one track never
132/// changes the noise content of its siblings. v1 documents keep the original
133/// single stream threaded through the track list in order — their audio stays
134/// byte-identical across upgrades.
135pub fn render_tracks(doc: &SoundDoc) -> Option<TracksRender> {
136    let Node::Tracks { tracks, master } = &doc.root else {
137        return None;
138    };
139    let sr = doc.sample_rate;
140    // validate() caps duration at 600 s; the clamp guards direct render calls
141    // on unvalidated docs from an unbounded allocation (1e12 s ⇒ OOM abort).
142    let n = ((doc.duration.clamp(0.0, 600.0) * sr as f32).ceil() as usize).max(1);
143    let per_track_streams = doc.effective_version() >= 2;
144    let engine = doc.effective_engine();
145    let mut rng = Rng::new(doc.seed);
146    let (mut left, mut right) = (vec![0.0f32; n], vec![0.0f32; n]);
147    let mut layers = Vec::with_capacity(tracks.len());
148    let mut energies = Vec::with_capacity(tracks.len());
149    for (ti, t) in tracks.iter().enumerate() {
150        let layer_id = t.id.clone().unwrap_or_else(|| format!("layer_{ti}"));
151        // v2 streams are keyed by the stable layer id. The fallback hashes the
152        // exact id `ensure_track_ids` will backfill, so a document's noise is
153        // identical before and after the backfill pass.
154        let stream = layer_stream_key(&layer_id);
155        if t.mute {
156            // Muted layers stay off the bus. v1's single stream must still
157            // advance exactly as if the track had rendered, or muting one
158            // layer would change every later layer's noise. (Cheap shape test:
159            // native-stereo sampler tracks never touch the shared stream.)
160            if !per_track_streams && !is_native_stereo(&t.node) {
161                let _ = render_node(
162                    &t.node,
163                    n,
164                    sr,
165                    &mut rng,
166                    engine,
167                    track_stream_seed(doc.seed, stream),
168                );
169            }
170            layers.push(LayerStats {
171                id: layer_id,
172                peak_dbfs: -180.0,
173                rms_dbfs: -180.0,
174                energy_pct: 0.0,
175                mute: true,
176            });
177            energies.push(0.0f64);
178            continue;
179        }
180        // The layer lands `at` seconds into the song: render full-length, then
181        // shift right and truncate (never shortening the render keeps RNG
182        // consumption — and therefore v1 sibling content — offset-invariant).
183        let off = ((t.at.max(0.0) * sr as f32).round() as usize).min(n);
184        // Equal-power pan/gain. With no automation this is constant (the proven
185        // fast path, byte-identical); with automation it varies per bus sample.
186        // The closure returns the same constant value when unautomated, so the
187        // arithmetic on existing documents is unchanged.
188        let (glc, grc) = pan_gains(t.pan.clamp(-1.0, 1.0), t.gain);
189        let gain_lane = lane_for(&t.automation, AutoTarget::Gain, n, sr, t.gain);
190        let pan_lane = lane_for(&t.automation, AutoTarget::Pan, n, sr, t.pan);
191        let gl_gr = |pos: usize| -> (f32, f32) {
192            match (&gain_lane, &pan_lane) {
193                (None, None) => (glc, grc),
194                (g, p) => {
195                    let gain = g.as_ref().map_or(t.gain, |a| a[pos]);
196                    let pan = p.as_ref().map_or(t.pan, |a| a[pos]).clamp(-1.0, 1.0);
197                    pan_gains(pan, gain)
198                }
199            }
200        };
201        // Contribution stats accumulate over what actually lands on the bus
202        // (post fader/pan/offset, pre master). Per-channel energy keeps them
203        // pan-invariant: gl² + gr² = gain² for any pan.
204        let (mut tpeak, mut tsum) = (0.0f32, 0.0f64);
205        if let Some((l, r)) = track_native_stereo(&t.node, n, sr) {
206            // A sampler track keeps its recorded stereo image; pan biases it.
207            for i in 0..n - off {
208                let (gl, gr) = gl_gr(i + off);
209                let (la, ra) = (
210                    l[i] * gl * std::f32::consts::SQRT_2,
211                    r[i] * gr * std::f32::consts::SQRT_2,
212                );
213                left[i + off] += la;
214                right[i + off] += ra;
215                tpeak = tpeak.max(la.abs()).max(ra.abs());
216                tsum += (la * la + ra * ra) as f64;
217            }
218        } else {
219            let base = track_stream_seed(doc.seed, stream);
220            let mono = if per_track_streams {
221                let mut trng = Rng::new(base);
222                render_node(&t.node, n, sr, &mut trng, engine, base)
223            } else {
224                render_node(&t.node, n, sr, &mut rng, engine, base)
225            };
226            for (i, x) in mono.into_iter().take(n - off).enumerate() {
227                let (gl, gr) = gl_gr(i + off);
228                let (la, ra) = (x * gl, x * gr);
229                left[i + off] += la;
230                right[i + off] += ra;
231                tpeak = tpeak.max(la.abs()).max(ra.abs());
232                tsum += (la * la + ra * ra) as f64;
233            }
234        }
235        // RMS over the whole timeline (both channels), so layers compare
236        // fairly regardless of where `at` placed them.
237        let rms = ((tsum / (2 * n) as f64) as f32).sqrt();
238        layers.push(LayerStats {
239            id: layer_id,
240            peak_dbfs: crate::dsp::dbfs(tpeak),
241            rms_dbfs: crate::dsp::dbfs(rms),
242            energy_pct: 0.0, // filled below once the total is known
243            mute: false,
244        });
245        energies.push(tsum);
246    }
247    let total: f64 = energies.iter().sum();
248    if total > 0.0 {
249        for (l, e) in layers.iter_mut().zip(&energies) {
250            l.energy_pct = ((e / total) * 100.0) as f32;
251        }
252    }
253    if per_track_streams {
254        rng = Rng::new(track_stream_seed(doc.seed, MASTER_STREAM));
255    }
256    // Master bus: run each processor on both channels with identical state
257    // seeds (the rng is cloned so e.g. a duck trigger fires identically), and
258    // give the reverb the classic Freeverb stereo spread for a wide tail.
259    for m in master {
260        if let Node::Reverb { room, mix } = m {
261            left = reverb(&left, *room, *mix, sr, 0);
262            right = reverb(&right, *room, *mix, sr, 23);
263        } else {
264            let mpath = track_stream_seed(doc.seed, MASTER_STREAM);
265            let mut rl = rng.clone();
266            left = apply_processor(m, &left, sr, &mut rl, engine, mpath);
267            right = apply_processor(m, &right, sr, &mut rng, engine, mpath);
268        }
269    }
270    if let Playback::Loop {
271        start_secs,
272        end_secs,
273        crossfade_secs,
274    } = doc.playback
275    {
276        left = make_loop_buffer(&left, sr, start_secs, end_secs, crossfade_secs);
277        right = make_loop_buffer(&right, sr, start_secs, end_secs, crossfade_secs);
278    }
279    if let Some(nz) = &doc.normalize {
280        if engine >= 4 {
281            // One shared gain over the stereo program — the authored balance
282            // is sacred. Engine ≤ 3 docs keep the original per-channel stage
283            // bit-for-bit (it gain-matched L and R independently, collapsing
284            // any asymmetric mix toward center).
285            normalize_output_v4(&mut [&mut left, &mut right], nz, sr);
286        } else {
287            normalize_output(&mut left, nz);
288            normalize_output(&mut right, nz);
289        }
290    }
291    peak_limit(&mut [&mut left, &mut right]);
292    Some(TracksRender {
293        left,
294        right,
295        layers,
296    })
297}
298
299/// A track whose node is directly a sampler seq renders in native stereo.
300#[cfg(feature = "sampler")]
301pub(super) fn track_native_stereo(node: &Node, n: usize, sr: u32) -> Option<(Signal, Signal)> {
302    // Engine 0: unused by the sampler (external synth, engine-independent).
303    let (voice, bpm, steps_per_beat, notes) = SeqVoice::from_node(node, 0)?;
304    if voice.wave != SeqWave::Sampler {
305        return None;
306    }
307    let step_dur = sr as f32 * 60.0 / bpm / steps_per_beat.max(1) as f32;
308    sampler_seq_stereo(&voice, notes, step_dur, n, sr)
309}
310
311/// Without the `sampler` feature there is no native-stereo SoundFont path.
312#[cfg(not(feature = "sampler"))]
313pub(super) fn track_native_stereo(_node: &Node, _n: usize, _sr: u32) -> Option<(Signal, Signal)> {
314    None
315}