From 5cd155bb647bcb4832280747f288b94cbcbe9cf2 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:57:23 +0200 Subject: [PATCH 1/2] Mix: a bus bounce compensates the aux returns onto it (#246) render_bus dropped only the track-insert latency from a bus, but an aux that returns onto a bus delays it by the slowest aux return as well. With Delay Compensation on, a bounce of such a bus came out late (a 5 ms lookahead left 240 leading silent frames) and its last impulse fell outside the requested end. The engine now knows which busses an aux returns onto. A bounce of one drops the aux latency too, and the tracks routed straight to that bus wait for it, as the tracks routed to the main mix already do, so the two kinds of signal on the bus arrive together. Other busses and the main mix are unchanged. --- crates/mix/src/lib.rs | 40 +++++++++++-- crates/mix/tests/bus_aux_interval.rs | 87 ++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 crates/mix/tests/bus_aux_interval.rs diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index a8c8b569..505a1ab3 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -313,6 +313,10 @@ pub struct MixEngine { /// that lines direct outputs up with aux returns stays in `latency` and is removed /// only from the main mix. track_latency: usize, + /// Busses an aux returns onto, and the latency of the slowest aux return: audio written onto + /// such a bus waits for it, so a bounce of the bus drops it as well. + aux_fed: Vec, + aux_latency: usize, /// Live input for the next block (planar), set by the audio host for input monitoring. pub input: Vec>, /// Transport stopped: only monitored inputs sound (no clips). @@ -393,6 +397,8 @@ impl MixEngine { order: Vec::new(), latency: 0, track_latency: 0, + aux_fed: Vec::new(), + aux_latency: 0, input: Vec::new(), monitor_only: false, recording: false, @@ -646,6 +652,19 @@ impl MixEngine { if pdc { self.strips.iter().filter(|(id, _)| s.track(**id).is_some_and(is_aux)).map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 }; self.track_latency = lmax_t; self.latency = lmax_t + lmax_a; + // An aux that returns onto a bus delays it by the slowest aux return, so the tracks routed + // to that bus wait as well (as the ones routed to the main mix do) and the bus lines up. + self.aux_latency = lmax_a; + self.aux_fed.clear(); + if pdc { + for t in s.tracks.iter().filter(|t| is_aux(t) && live(t)) { + if let Route::Bus(b) = &t.mixer.output + && !self.aux_fed.contains(b) + { + self.aux_fed.push(*b); + } + } + } for (i, st) in work.iter_mut() { let Some(t) = s.tracks.get(*i) else { continue }; let ch = st.buf.len(); @@ -655,7 +674,12 @@ impl MixEngine { st.align_pre.set(lmax_t.saturating_sub(st.latency), ch); st.align_pre.process(&mut st.pre, frames); } - st.out_extra.set(if t.mixer.output == Route::Main { lmax_a } else { 0 }, ch); + let waits = match &t.mixer.output { + Route::Main => true, + Route::Bus(b) => self.aux_fed.contains(b), + _ => false, + }; + st.out_extra.set(if waits { lmax_a } else { 0 }, ch); } for (i, st) in work.iter_mut() { if let Some(t) = s.tracks.get(*i) { @@ -737,6 +761,12 @@ impl MixEngine { self.main_meter.measure(&self.main, frames, 0.0); } + /// The delay-compensation latency written onto `bus`: the slowest track insert, plus the + /// slowest aux return when an aux returns onto this bus. + fn bus_latency(&self, bus: BusId) -> usize { + self.track_latency + if self.aux_fed.contains(&bus) { self.aux_latency } else { 0 } + } + /// Copy one rendered block of `bus` into `out`. A missing bus stays silent. fn copy_bus(&self, bus: BusId, frames: usize, out: &mut [Vec]) { for o in out.iter_mut() { @@ -780,7 +810,7 @@ impl MixEngine { apply_matrix(src, frames, &mut dst.buf, &m, gain); } let src: &[Vec] = match &t.mixer.output { - Route::Main if strip.out_extra.len > 0 => { + Route::Main | Route::Bus(_) if strip.out_extra.len > 0 => { if strip.outbuf.len() != strip.buf.len() { strip.outbuf = vec![Vec::new(); strip.buf.len()]; } @@ -1867,8 +1897,8 @@ pub fn render_range(s: &Session, range: Range, block: usize) -> Vec> { } /// Offline render of one bus over `range` (planar, the bus format's channel count). -/// Track-insert compensation is removed, so a bus fed by tracks lines up with the timeline. -/// An unknown bus is silent. +/// The compensation delay written onto the bus is removed (track inserts, and the aux returns +/// when an aux returns onto it), so the bus lines up with the timeline. An unknown bus is silent. pub fn render_bus(s: &Session, bus: BusId, range: Range, block: usize) -> Vec> { let nch = s.bus(bus).map(|b| b.format.channels()).unwrap_or(0); if nch == 0 { @@ -1879,7 +1909,7 @@ pub fn render_bus(s: &Session, bus: BusId, range: Range, block: usize) -> Vec (Session, BusId, BusId) { + let mut s = Session::default(); + s.edit.delay_compensation = true; + let mut signal = vec![0.0; 8192]; + for at in AT { + signal[at] = 0.25; + } + s.pool.insert(SourceId(901), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48000, channels: vec![signal.clone(), signal] }))); + let a = s.add_bus("A", ChannelFormat::Stereo); + let b = s.add_bus("B", ChannelFormat::Stereo); + let source = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Source")); + let aux = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, Some("Return")); + let clip = s.new_clip_id(); + s.track_mut(source).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(clip, "impulses", SourceId(901), 0, 0, 8192)); + s.track_mut(source).unwrap().mixer.pan = vec![-1.0, 1.0]; + s.track_mut(source).unwrap().mixer.output = Route::Bus(a); + s.track_mut(aux).unwrap().mixer.pan = vec![-1.0, 1.0]; + s.track_mut(aux).unwrap().mixer.input = Route::Bus(a); + s.track_mut(aux).unwrap().mixer.output = if aux_to_main { Route::Main } else { Route::Bus(b) }; + if insert { + let mut compressor = Insert::new("compressor"); + compressor.params.insert("lookahead".into(), 5.0); + compressor.params.insert("ratio".into(), 1.0); + s.track_mut(aux).unwrap().mixer.inserts[0] = Some(compressor); + } + (s, a, b) +} + +fn positions(ch: &[f32]) -> Vec { + ch.iter().enumerate().filter(|(_, x)| x.abs() > 1e-4).map(|(i, _)| i).collect() +} + +#[test] +fn named_bus_after_aux_preserves_requested_timeline() { + let (s, a, b) = fixture(true, false); + let bus = render_bus(&s, b, Range::new(0, 8192), 1024); + let upstream = render_bus(&s, a, Range::new(0, 8192), 1024); + let (plain, _, plain_b) = fixture(false, false); + let control = render_bus(&plain, plain_b, Range::new(0, 8192), 1024); + let (main, _, _) = fixture(true, true); + let main = render_range(&main, Range::new(0, 8192), 1024); + for c in 0..2 { + // Controls: the bus before the aux, the same route without the insert, and the main mix. + assert_eq!(positions(&control[c]), AT); + assert_eq!(upstream[c], control[c]); + assert_eq!(main[c], control[c]); + // The bus after the aux: the whole interval, nothing late, nothing cut off. + assert_eq!(bus[c].len(), 8192); + assert_eq!(positions(&bus[c]), AT, "the named bus must preserve the requested interval"); + assert!(bus[c] == control[c], "the aligned samples must match the control"); + } + // A partial range lines up too. + let part = render_bus(&s, b, Range::new(2048, 4096), 1024); + assert_eq!(positions(&part[0]), vec![0]); +} + +#[test] +fn a_track_routed_to_the_same_bus_stays_aligned_with_the_aux_return() { + // A second track goes straight to bus B, where the aux (with its lookahead) also returns. + let (mut s, _, b) = fixture(true, false); + let direct = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Direct")); + let clip = s.new_clip_id(); + s.track_mut(direct).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(clip, "impulses", SourceId(901), 0, 0, 8192)); + s.track_mut(direct).unwrap().mixer.pan = vec![-1.0, 1.0]; + s.track_mut(direct).unwrap().mixer.output = Route::Bus(b); + let bus = render_bus(&s, b, Range::new(0, 8192), 1024); + for (c, ch) in bus.iter().enumerate() { + assert_eq!(positions(ch), AT, "direct and aux signals arrive together"); + for at in AT { + assert!((ch[at] - 0.5).abs() < 1e-3, "channel {c} at {at}: {}", ch[at]); + } + } +} From 3498db9e51f48a3de99505107edece43992cded2 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:57:23 +0200 Subject: [PATCH 2/2] Docs: update the progress docs for this change --- ROADMAP.md | 2 ++ docs/hardware-parity.md | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bab0d924..212c063d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **2026-10-13:** A bounce of a bus that an aux returns onto lines up with the timeline: the aux insert's lookahead is compensated like the main mix's (it left the bus late and cut the end off), and tracks routed to that bus wait for it too (#246) - **2026-10-12:** `soundcraft-cli convert` rejects an unparsable or zero `--rate` and an unknown `--bit-depth` (exit 1, nothing written) instead of converting with a default (#256) - **2026-10-12:** Timecode labels with huge fields no longer overflow: frame counts are computed in 128 bits and clamp, so `setup.current_timecode` and `session.format_time` saturate instead of failing (#248) - **2026-10-12:** `session.format_time` with the smallest bar number no longer fails with an internal error: the bar offset saturates (#249) @@ -146,6 +147,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Progress log: bus bounce compensates aux returns (#246) | | 2026-10-12 | trivial | Progress log: convert option validation (#256) | | 2026-10-12 | trivial | Progress log: timecode overflow (#248) | | 2026-10-12 | trivial | Progress log: bar offset saturates (#249) | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 2b3eadc5..5ea38679 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first hardware audit against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (bounces of a bus fed by an aux compensate its latency (#246); previously major: first hardware audit against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1 A DAW is judged by how it drives audio interfaces, MIDI gear, control surfaces and sync. This is SoundCraft's weakest dimension. Ours is measured from `crates/playback` (cpal) and the absence of @@ -61,7 +61,7 @@ Legend: ✅ works · 🟡 partial · ❌ missing. | Feature | Pro Tools | SoundCraft | Evidence | |---|---|---|---| | Parallel mixing across cores | ✅ | ✅ | `soundcraft-mix` renders independent strips in parallel (rayon) | -| Plugin delay compensation | ✅ | ✅ | | +| Plugin delay compensation | ✅ | ✅ | Bounces of a bus that an aux returns onto are compensated like the main mix (#246). | | Disk streaming of long sources | ✅ | ❌ | Whole files decoded into the `SourcePool` | | Track count (Ultimate: 2,048 voices) | ✅ | unmeasured | No benchmark exists; add `cargo xtask bench` | | Real-time audio thread discipline | ✅ | ✅ | No blocking/allocation in steady state, audio-thread-aware logger | @@ -72,4 +72,5 @@ Legend: ✅ works · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | bounces of a bus fed by an aux compensate its latency (#246) | | 2026-10-10 | major | Created: audit of audio interface, MIDI, control-surface, sync and performance support against Pro Tools 2026.4.1 |