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}