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::{
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}