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::{
11 AutoCurve, AutoLane, AutoPoint, AutoTarget, Node, Playback, SeqWave, Sidechain, SoundDoc, Track,
12};
13use crate::dsp::{Rng, layer_stream_key, peak_limit};
14
15/// One track's raw render, kept whole until the mix pass so sidechain
16/// followers can read their source's signal regardless of declaration order.
17enum TrackRender {
18 /// Muted layers render nothing (a v1 document still advanced its stream).
19 Muted,
20 /// A mono render plus its `at` offset in samples.
21 Mono { off: usize, sig: Signal },
22 /// A native-stereo (sampler) render plus its `at` offset in samples.
23 Stereo {
24 off: usize,
25 left: Signal,
26 right: Signal,
27 },
28}
29
30/// Equal-power channel gains for a `pan`/`gain` pair — one formula for the
31/// constant fast path and the per-sample automated path, so they can never
32/// drift (identical f32 op order, byte-identical output). Shared with the
33/// streaming mixer ([`crate::streaming`]) for the same reason. `engine`
34/// dispatches the pan-law sin/cos (ADR 0001).
35pub(crate) fn pan_gains(pan: f32, gain: f32, engine: u32) -> (f32, f32) {
36 let theta = (pan + 1.0) * std::f32::consts::FRAC_PI_4;
37 (
38 crate::dsp::cos(theta, engine) * gain,
39 crate::dsp::sin(theta, engine) * gain,
40 )
41}
42
43/// Derive a track's independent RNG stream from the document seed (schema
44/// v2). `stream` is the track's FNV stream key (or `MASTER_STREAM`), not a
45/// track index. SplitMix64 finalizer over a golden-gamma offset, so streams
46/// never correlate with each other or with the v1 threaded stream. Shared
47/// with the streaming mixer, which seeds each track's graph identically.
48pub(crate) fn track_stream_seed(seed: u64, stream: u64) -> u64 {
49 crate::dsp::splitmix_mix(
50 seed ^ stream
51 .wrapping_add(1)
52 .wrapping_mul(crate::dsp::GOLDEN_GAMMA),
53 )
54}
55
56/// The master bus's stream key (validate rejects a layer id hashing to it).
57pub(crate) const MASTER_STREAM: u64 = u64::MAX;
58
59/// True when a track renders in native stereo (a sampler seq) — a cheap shape
60/// test; the actual rendering happens in [`track_native_stereo`].
61fn is_native_stereo(node: &Node) -> bool {
62 matches!(
63 node,
64 Node::Seq {
65 wave: SeqWave::Sampler,
66 ..
67 }
68 )
69}
70
71/// Post-fader, pre-master snapshot of one layer's contribution to the stereo
72/// bus — the balance numbers an author mixes by. "Pre-master" matters: a master
73/// compressor / reverb reshapes the bus AFTER these are measured. Energy and
74/// peak are measured per channel (pan-invariant: hard-panned and centered
75/// layers of equal power read equal).
76#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
77pub struct LayerStats {
78 /// The layer's stable id.
79 pub id: String,
80 /// Peak of the layer's loudest bus channel in dBFS (−180 ⇒ silent/muted).
81 pub peak_dbfs: f32,
82 /// RMS of the layer's bus contribution over the WHOLE document timeline
83 /// (per-channel energy, both channels), dBFS — comparable across layers
84 /// regardless of their `at` placement.
85 pub rms_dbfs: f32,
86 /// Share of the summed pre-master layer energy, 0..100.
87 pub energy_pct: f32,
88 /// True when the layer is muted (it contributes nothing).
89 pub mute: bool,
90}
91
92/// A finished mixer render: the stereo bus plus per-layer contribution stats
93/// captured from the same pass (free — no extra render).
94#[derive(Debug, PartialEq)]
95pub struct TracksRender {
96 /// The left channel of the mastered stereo bus.
97 pub left: Signal,
98 /// The right channel of the mastered stereo bus.
99 pub right: Signal,
100 /// Per-layer contribution stats captured from the same pass.
101 pub layers: Vec<LayerStats>,
102}
103
104/// A persistent cursor over one automation lane, producing the exact values
105/// the original whole-buffer scan produced — the ONE definition of the lane
106/// math the offline mixer (per [`lane_for`]) and the streaming renderer's
107/// block-wise evaluation share, so they can never drift. [`LaneCursor::at`]
108/// must be called with monotonically non-decreasing sample indices (the
109/// segment cursor only advances); starting mid-lane is fine — the strict-`>`
110/// advance picks the same segment a from-zero scan would.
111pub(crate) struct LaneCursor {
112 /// Breakpoints sorted by time (the lane's authored order is not trusted).
113 pts: Vec<AutoPoint>,
114 curve: AutoCurve,
115 /// The persistent segment cursor.
116 idx: usize,
117}
118
119impl LaneCursor {
120 /// The cursor for `target` in `automation`, or `None` if no lane controls
121 /// it (then the static value applies — the byte-identical fast path).
122 /// `default` is the static value an empty-points lane holds.
123 pub(crate) fn build(automation: &[AutoLane], target: AutoTarget, default: f32) -> Option<Self> {
124 let lane = automation.iter().find(|l| l.target == target)?;
125 // An empty lane holds the static value; a single breakpoint holds
126 // flat. Both collapse to one synthetic flat point so `at`'s guards
127 // are total — a NaN sample time (or a NaN point time on an
128 // unvalidated doc) would otherwise fall through both comparisons
129 // into the segment scan and index out of bounds.
130 if lane.points.len() < 2 {
131 let v = lane.points.first().map_or(default, |p| p.v);
132 return Some(LaneCursor {
133 pts: vec![AutoPoint { t: 0.0, v }],
134 curve: lane.curve,
135 idx: 0,
136 });
137 }
138 let mut pts = lane.points.clone();
139 pts.sort_by(|a, b| a.t.partial_cmp(&b.t).unwrap_or(std::cmp::Ordering::Equal));
140 Some(LaneCursor {
141 pts,
142 curve: lane.curve,
143 idx: 0,
144 })
145 }
146
147 /// The lane's value at sample `i` (`sr` the document's sample rate).
148 /// Interpolation over the sorted breakpoints per the lane's curve,
149 /// holding flat past either end. Strict `>` in the advance keeps the
150 /// exact segment the from-zero scan would pick — a sample landing on a
151 /// breakpoint interpolates in the earlier segment, so the floats (and
152 /// the rendered bytes) are unchanged. `engine` dispatches the exp curve's
153 /// powf (ADR 0001).
154 pub(crate) fn at(&mut self, i: usize, sr: u32, engine: u32) -> f32 {
155 let t = i as f32 / sr as f32;
156 // An unvalidated doc with sample_rate 0 makes frame 0 NaN (0.0/0.0),
157 // which every comparison below rejects — hold the first point
158 // instead of scanning off the end (infinite times already take the
159 // `>= last.t` branch).
160 if t.is_nan() {
161 return self.pts[0].v;
162 }
163 if t <= self.pts[0].t {
164 return self.pts[0].v;
165 }
166 let last = &self.pts[self.pts.len() - 1];
167 if t >= last.t {
168 return last.v;
169 }
170 while t > self.pts[self.idx + 1].t {
171 self.idx += 1;
172 }
173 let (w0, w1) = (&self.pts[self.idx], &self.pts[self.idx + 1]);
174 let span = (w1.t - w0.t).max(1e-9);
175 let u = (t - w0.t) / span;
176 match self.curve {
177 AutoCurve::Linear => w0.v + (w1.v - w0.v) * u,
178 // Hold w0 until the next breakpoint lands.
179 AutoCurve::Step => {
180 if u >= 1.0 {
181 w1.v
182 } else {
183 w0.v
184 }
185 }
186 // Exponential between same-sign positive endpoints; any other
187 // segment degrades to linear (deterministic).
188 AutoCurve::Exp => {
189 if w0.v > 0.0 && w1.v > 0.0 {
190 w0.v * crate::dsp::powf(w1.v / w0.v, u, engine)
191 } else {
192 w0.v + (w1.v - w0.v) * u
193 }
194 }
195 }
196 }
197}
198
199/// Per-sample values for a track-automation `target`, or `None` if no lane
200/// controls it (then the static value applies — the byte-identical fast path).
201fn lane_for(
202 automation: &[AutoLane],
203 target: AutoTarget,
204 n: usize,
205 sr: u32,
206 default: f32,
207 engine: u32,
208) -> Option<Vec<f32>> {
209 let mut cursor = LaneCursor::build(automation, target, default)?;
210 Some((0..n).map(|i| cursor.at(i, sr, engine)).collect())
211}
212
213/// The gain-reduction envelope for one follower track: the `duck` node's
214/// exact attack/release follower (same coefficients, same recurrence), so a
215/// mixer-level pump matches the node-level one. It is driven by the source
216/// track's positioned (post-`at`) mono signal scaled by the source's gain
217/// fader — pre-pan, pre-master: the source as it actually lands on the bus,
218/// so the follower ducks when the source sounds, not when its node starts.
219/// A muted source is silence, so the envelope stays fully open.
220fn duck_envelope(
221 source: &TrackRender,
222 source_track: &Track,
223 sc: &Sidechain,
224 n: usize,
225 sr: u32,
226 engine: u32,
227) -> Vec<f32> {
228 let mut sig = vec![0.0f32; n];
229 let gain_lane = lane_for(
230 &source_track.automation,
231 AutoTarget::Gain,
232 n,
233 sr,
234 source_track.gain,
235 engine,
236 );
237 let g = |pos: usize| gain_lane.as_ref().map_or(source_track.gain, |a| a[pos]);
238 match source {
239 TrackRender::Muted => {}
240 TrackRender::Mono { off, sig: mono } => {
241 for (i, x) in mono.iter().take(n - off).enumerate() {
242 sig[i + off] = x * g(i + off);
243 }
244 }
245 TrackRender::Stereo { off, left, right } => {
246 // A native-stereo (sampler) source steers the follower with its
247 // mid signal — the mono of the recorded image.
248 for i in 0..n - off {
249 sig[i + off] = 0.5 * (left[i] + right[i]) * g(i + off);
250 }
251 }
252 }
253 let srf = sr as f32;
254 let at = crate::dsp::exp(-1.0 / (sc.attack.max(1e-4) * srf), engine);
255 let rt = crate::dsp::exp(-1.0 / (sc.release.max(1e-4) * srf), engine);
256 let mut env = 0.0f32;
257 sig.into_iter()
258 .map(|t| {
259 let rect = t.abs().min(1.0);
260 let coeff = if rect > env { at } else { rt };
261 env = rect + coeff * (env - rect);
262 1.0 - sc.amount * env
263 })
264 .collect()
265}
266
267/// Render a `tracks` document to a finished stereo pair: each track is
268/// rendered mono and equal-power panned onto the bus (sampler tracks keep
269/// their native stereo), the master chain runs per channel (the reverb with
270/// decorrelated tails), then loop/normalize apply jointly.
271///
272/// RNG model: schema v2 documents give every track (and the master bus) its
273/// own deterministic stream, so editing, muting, or removing one track never
274/// changes the noise content of its siblings. v1 documents keep the original
275/// single stream threaded through the track list in order — their audio stays
276/// byte-identical across upgrades.
277pub fn render_tracks(doc: &SoundDoc) -> Option<TracksRender> {
278 render_tracks_impl(doc, false).map(|(r, _)| r)
279}
280
281/// One rendered stem: a track's positioned stereo contribution (post
282/// fader/pan/offset/duck, pre bus/master) or a bus's processed return
283/// (post inserts and return fader, pre master chain). `id` is the track's
284/// layer id, or `bus:<id>` for a bus. Stems are pre-master-chain by
285/// definition: the sum of every MASTER-routed track stem plus every bus
286/// stem reproduces the mix the master chain hears — a bus-routed track's
287/// stem is its channel output for your own processing, already included
288/// in its bus's stem.
289#[derive(Debug, Clone)]
290pub struct Stem {
291 /// The track's layer id, or `bus:<id>` for a bus stem.
292 pub id: String,
293 /// Whether this is a bus stem (a processed bus return).
294 pub is_bus: bool,
295 /// Where this track stem routes: `Some(bus id)` if its main output goes
296 /// to a bus (the stem is then already inside that bus's stem), None if
297 /// it lands on the master bus directly. Always None for bus stems.
298 pub bus: Option<String>,
299 /// The left channel.
300 pub left: Signal,
301 /// The right channel.
302 pub right: Signal,
303}
304
305/// Render a `tracks` document to per-track and per-bus stereo stems (see
306/// [`Stem`]). Muted tracks render as silent stems. `None` for a non-tracks
307/// document, exactly like [`render_tracks`].
308pub fn render_stems(doc: &SoundDoc) -> Option<Vec<Stem>> {
309 let (_, stems) = render_tracks_impl(doc, true)?;
310 Some(stems.expect("stems requested"))
311}
312
313fn render_tracks_impl(
314 doc: &SoundDoc,
315 want_stems: bool,
316) -> Option<(TracksRender, Option<Vec<Stem>>)> {
317 let Node::Tracks {
318 tracks,
319 master,
320 buses,
321 } = &doc.root
322 else {
323 return None;
324 };
325 let sr = doc.sample_rate;
326 // validate() caps duration at 600 s; the clamp guards direct render calls
327 // on unvalidated docs from an unbounded allocation (1e12 s ⇒ OOM abort).
328 let n = ((doc.duration.clamp(0.0, 600.0) * sr as f32).ceil() as usize).max(1);
329 let per_track_streams = doc.effective_version() >= 2;
330 let engine = doc.effective_engine();
331 let mut rng = Rng::new(doc.seed);
332 let (mut left, mut right) = (vec![0.0f32; n], vec![0.0f32; n]);
333 // Pass 1 — render every track's raw node output in declaration order. All
334 // RNG consumption lives here (v1's shared stream threads through the track
335 // list exactly as it always has; v2 uses id-keyed streams), so the mix
336 // pass below touches no randomness and sidechain followers can read their
337 // source's signal regardless of declaration order.
338 let mut layer_ids = Vec::with_capacity(tracks.len());
339 let mut rendered = Vec::with_capacity(tracks.len());
340 for (ti, t) in tracks.iter().enumerate() {
341 let layer_id = t.id.clone().unwrap_or_else(|| format!("layer_{ti}"));
342 // v2 streams are keyed by the stable layer id. The fallback hashes the
343 // exact id `ensure_track_ids` will backfill, so a document's noise is
344 // identical before and after the backfill pass.
345 let stream = layer_stream_key(&layer_id);
346 layer_ids.push(layer_id);
347 if t.mute {
348 // Muted layers stay off the bus. v1's single stream must still
349 // advance exactly as if the track had rendered, or muting one
350 // layer would change every later layer's noise. (Cheap shape test:
351 // native-stereo sampler tracks never touch the shared stream.)
352 if !per_track_streams && !is_native_stereo(&t.node) {
353 let _ = render_node(
354 &t.node,
355 n,
356 sr,
357 &mut rng,
358 engine,
359 track_stream_seed(doc.seed, stream),
360 );
361 }
362 rendered.push(TrackRender::Muted);
363 continue;
364 }
365 // The layer lands `at` seconds into the song: render full-length, then
366 // shift right and truncate (never shortening the render keeps RNG
367 // consumption — and therefore v1 sibling content — offset-invariant).
368 let off = ((t.at.max(0.0) * sr as f32).round() as usize).min(n);
369 if let Some((l, r)) = track_native_stereo(&t.node, n, sr) {
370 rendered.push(TrackRender::Stereo {
371 off,
372 left: l,
373 right: r,
374 });
375 } else {
376 let base = track_stream_seed(doc.seed, stream);
377 let mono = if per_track_streams {
378 let mut trng = Rng::new(base);
379 render_node(&t.node, n, sr, &mut trng, engine, base)
380 } else {
381 render_node(&t.node, n, sr, &mut rng, engine, base)
382 };
383 rendered.push(TrackRender::Mono { off, sig: mono });
384 }
385 }
386 // Pass 2 — mix: pan/gain (static or automated), the sidechain duck, and
387 // the per-layer contribution stats. Each track's contribution is built in
388 // a scratch stereo buffer, then routed: to the master bus by default, to
389 // its named `bus` when routed, plus a copy per `send`. A document without
390 // buses routes everything to master — the exact legacy mix.
391 let mut layers = Vec::with_capacity(tracks.len());
392 let mut energies = Vec::with_capacity(tracks.len());
393 let mut stems = want_stems.then(Vec::new);
394 let mut bus_bufs: Vec<(Vec<f32>, Vec<f32>)> = buses
395 .iter()
396 .map(|_| (vec![0.0f32; n], vec![0.0f32; n]))
397 .collect();
398 let bus_index = |id: &str| buses.iter().position(|b| b.id == id);
399 let (mut cl, mut cr) = (vec![0.0f32; n], vec![0.0f32; n]);
400 for (ti, t) in tracks.iter().enumerate() {
401 let layer_id = layer_ids[ti].clone();
402 if let TrackRender::Muted = &rendered[ti] {
403 if let Some(stems) = &mut stems {
404 stems.push(Stem {
405 id: layer_id.clone(),
406 is_bus: false,
407 bus: t.bus.clone(),
408 left: vec![0.0f32; n],
409 right: vec![0.0f32; n],
410 });
411 }
412 layers.push(LayerStats {
413 id: layer_id,
414 peak_dbfs: -180.0,
415 rms_dbfs: -180.0,
416 energy_pct: 0.0,
417 mute: true,
418 });
419 energies.push(0.0f64);
420 continue;
421 }
422 // Equal-power pan/gain. With no automation this is constant (the proven
423 // fast path, byte-identical); with automation it varies per bus sample.
424 // The closure returns the same constant value when unautomated, so the
425 // arithmetic on existing documents is unchanged.
426 let (glc, grc) = pan_gains(t.pan.clamp(-1.0, 1.0), t.gain, engine);
427 let gain_lane = lane_for(&t.automation, AutoTarget::Gain, n, sr, t.gain, engine);
428 let pan_lane = lane_for(&t.automation, AutoTarget::Pan, n, sr, t.pan, engine);
429 let gl_gr = |pos: usize| -> (f32, f32) {
430 match (&gain_lane, &pan_lane) {
431 (None, None) => (glc, grc),
432 (g, p) => {
433 let gain = g.as_ref().map_or(t.gain, |a| a[pos]);
434 let pan = p.as_ref().map_or(t.pan, |a| a[pos]).clamp(-1.0, 1.0);
435 pan_gains(pan, gain, engine)
436 }
437 }
438 };
439 // The duck envelope follows the SOURCE track's signal (the source
440 // itself renders untouched); this track's post-fader contribution is
441 // multiplied by it. Unvalidated documents may name a missing source —
442 // then there is no ducking (validate() rejects the document).
443 let duck = t.sidechain.as_ref().and_then(|sc| {
444 let (si, source) = tracks
445 .iter()
446 .enumerate()
447 .find(|(_, s)| s.id.as_deref() == Some(sc.source.as_str()))?;
448 Some(duck_envelope(&rendered[si], source, sc, n, sr, engine))
449 });
450 // Contribution stats accumulate over what actually lands (post
451 // fader/pan/offset/duck, pre bus/master). Per-channel energy keeps
452 // them pan-invariant: gl² + gr² = gain² for any pan.
453 let (mut tpeak, mut tsum) = (0.0f32, 0.0f64);
454 cl.fill(0.0);
455 cr.fill(0.0);
456 let off = match &rendered[ti] {
457 TrackRender::Muted => unreachable!("muted layers continue above"),
458 TrackRender::Stereo { off, .. } | TrackRender::Mono { off, .. } => *off,
459 };
460 match &rendered[ti] {
461 TrackRender::Muted => unreachable!("muted layers continue above"),
462 TrackRender::Stereo {
463 off,
464 left: l,
465 right: r,
466 } => {
467 // A sampler track keeps its recorded stereo image; pan biases it.
468 for i in 0..n - off {
469 let (gl, gr) = gl_gr(i + off);
470 let d = duck.as_ref().map_or(1.0, |v| v[i + off]);
471 let (la, ra) = (
472 l[i] * gl * std::f32::consts::SQRT_2 * d,
473 r[i] * gr * std::f32::consts::SQRT_2 * d,
474 );
475 cl[i + off] = la;
476 cr[i + off] = ra;
477 tpeak = tpeak.max(la.abs()).max(ra.abs());
478 tsum += (la * la + ra * ra) as f64;
479 }
480 }
481 TrackRender::Mono { off, sig } => {
482 for (i, x) in sig.iter().take(n - off).enumerate() {
483 let (gl, gr) = gl_gr(i + off);
484 let d = duck.as_ref().map_or(1.0, |v| v[i + off]);
485 let (la, ra) = (x * gl * d, x * gr * d);
486 cl[i + off] = la;
487 cr[i + off] = ra;
488 tpeak = tpeak.max(la.abs()).max(ra.abs());
489 tsum += (la * la + ra * ra) as f64;
490 }
491 }
492 }
493 // Route the main output: the master bus, or the track's named bus.
494 // (Only the contributed range is added — a full-range += 0.0 could
495 // flip a −0.0 sample to +0.0 in slots this track never wrote.)
496 let (dl, dr) = match t.bus.as_deref().and_then(bus_index) {
497 None => (&mut left, &mut right),
498 Some(bi) => {
499 let (bl, br) = &mut bus_bufs[bi];
500 (bl, br)
501 }
502 };
503 for i in off..n {
504 dl[i] += cl[i];
505 dr[i] += cr[i];
506 }
507 // Post-fader sends: the same contribution, scaled, into each target.
508 // (A track may send to the bus it's routed to — the sends simply add.)
509 for s in &t.sends {
510 let Some(bi) = bus_index(&s.bus) else {
511 continue; // an unvalidated doc's dangling send is ignored
512 };
513 let amount = s.amount.clamp(0.0, 1.0);
514 let (bl, br) = &mut bus_bufs[bi];
515 for i in off..n {
516 bl[i] += cl[i] * amount;
517 br[i] += cr[i] * amount;
518 }
519 }
520 // The stem is this exact contribution (pre bus/master).
521 if let Some(stems) = &mut stems {
522 stems.push(Stem {
523 id: layer_id.clone(),
524 is_bus: false,
525 bus: t.bus.clone(),
526 left: cl.clone(),
527 right: cr.clone(),
528 });
529 }
530 // RMS over the whole timeline (both channels), so layers compare
531 // fairly regardless of where `at` placed them.
532 let rms = ((tsum / (2 * n) as f64) as f32).sqrt();
533 layers.push(LayerStats {
534 id: layer_id,
535 peak_dbfs: crate::dsp::dbfs_e(tpeak, engine),
536 rms_dbfs: crate::dsp::dbfs_e(rms, engine),
537 energy_pct: 0.0, // filled below once the total is known
538 mute: false,
539 });
540 energies.push(tsum);
541 }
542 let total: f64 = energies.iter().sum();
543 if total > 0.0 {
544 for (l, e) in layers.iter_mut().zip(&energies) {
545 l.energy_pct = ((e / total) * 100.0) as f32;
546 }
547 }
548 // Buses: inserts run per bus with its own keyed stream (the same
549 // per-channel treatment as the master chain — a reverb gets the
550 // decorrelated tails), then the return fader, then onto the master bus.
551 // Bus streams are always id-keyed: no historical document has buses, so
552 // there is no shared-stream behavior to preserve.
553 for (bi, b) in buses.iter().enumerate() {
554 let (mut bl, mut br) = std::mem::take(&mut bus_bufs[bi]);
555 let bpath = track_stream_seed(doc.seed, layer_stream_key(&format!("bus:{}", b.id)));
556 let mut brng = Rng::new(bpath);
557 for fx in &b.effects {
558 if let Node::Reverb { room, mix } = fx {
559 bl = reverb(&bl, *room, *mix, sr, 0);
560 br = reverb(&br, *room, *mix, sr, 23);
561 } else {
562 let mut rl = brng.clone();
563 bl = apply_processor(fx, &bl, sr, &mut rl, engine, bpath);
564 br = apply_processor(fx, &br, sr, &mut brng, engine, bpath);
565 }
566 }
567 let gain = if b.gain.is_finite() { b.gain } else { 1.0 };
568 if let Some(stems) = &mut stems {
569 stems.push(Stem {
570 id: format!("bus:{}", b.id),
571 is_bus: true,
572 bus: None,
573 left: bl.iter().map(|x| x * gain).collect(),
574 right: br.iter().map(|x| x * gain).collect(),
575 });
576 }
577 for i in 0..n {
578 left[i] += bl[i] * gain;
579 right[i] += br[i] * gain;
580 }
581 }
582 if per_track_streams {
583 rng = Rng::new(track_stream_seed(doc.seed, MASTER_STREAM));
584 }
585 // Master bus: run each processor on both channels with identical state
586 // seeds (the rng is cloned so e.g. a duck trigger fires identically), and
587 // give the reverb the classic Freeverb stereo spread for a wide tail.
588 for m in master {
589 if let Node::Reverb { room, mix } = m {
590 left = reverb(&left, *room, *mix, sr, 0);
591 right = reverb(&right, *room, *mix, sr, 23);
592 } else {
593 let mpath = track_stream_seed(doc.seed, MASTER_STREAM);
594 let mut rl = rng.clone();
595 left = apply_processor(m, &left, sr, &mut rl, engine, mpath);
596 right = apply_processor(m, &right, sr, &mut rng, engine, mpath);
597 }
598 }
599 if let Playback::Loop {
600 start_secs,
601 end_secs,
602 crossfade_secs,
603 } = doc.playback
604 {
605 left = make_loop_buffer(&left, sr, start_secs, end_secs, crossfade_secs, engine);
606 right = make_loop_buffer(&right, sr, start_secs, end_secs, crossfade_secs, engine);
607 }
608 if let Some(nz) = &doc.normalize {
609 if engine >= 4 {
610 // One shared gain over the stereo program — the authored balance
611 // is sacred. Engine ≤ 3 docs keep the original per-channel stage
612 // bit-for-bit (it gain-matched L and R independently, collapsing
613 // any asymmetric mix toward center).
614 normalize_output_v4(&mut [&mut left, &mut right], nz, sr, engine);
615 } else {
616 normalize_output(&mut left, nz);
617 normalize_output(&mut right, nz);
618 }
619 }
620 peak_limit(&mut [&mut left, &mut right]);
621 Some((
622 TracksRender {
623 left,
624 right,
625 layers,
626 },
627 stems,
628 ))
629}
630
631/// A track whose node is directly a sampler seq renders in native stereo.
632#[cfg(feature = "sampler")]
633pub(super) fn track_native_stereo(node: &Node, n: usize, sr: u32) -> Option<(Signal, Signal)> {
634 // Engine 0: unused by the sampler (external synth, engine-independent).
635 let (voice, bpm, steps_per_beat, notes) = SeqVoice::from_node(node, 0)?;
636 if voice.wave != SeqWave::Sampler {
637 return None;
638 }
639 let step_dur = sr as f32 * 60.0 / bpm / steps_per_beat.max(1) as f32;
640 sampler_seq_stereo(&voice, notes, step_dur, n, sr)
641}
642
643/// Without the `sampler` feature there is no native-stereo SoundFont path.
644#[cfg(not(feature = "sampler"))]
645pub(super) fn track_native_stereo(_node: &Node, _n: usize, _sr: u32) -> Option<(Signal, Signal)> {
646 None
647}