Merge pull request #408 from Silvertree2010/fix/bus-bounce-aux-latency

Mix: a bus bounce compensates the aux returns onto it (#246)
This commit is contained in:
Brandon Thomas
2026-10-13 06:53:54 -04:00
committed by GitHub
4 changed files with 130 additions and 7 deletions
+2
View File
@@ -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-13:** Delay compensation covers the inserts of the master faders: a bounce of the main mix with a Maximizer on the master lines up with the same mix without it (it came out 71 samples late) (#351)
- **2026-10-12 (audio device settings):** Setup › Playback Engine chooses the audio system, output and input device, sample rate and buffer size, kept per machine with the preferences; 16/32-bit integer devices are converted; ASIO is available behind the opt-in `asio` build feature (verified with an SSL 2+ at 128 samples). Channel routing, one duplex clock and latency compensation remain (G-01). No readiness percentage change.
- **2026-10-12 (Mix in a separate window):** Window › Mix in Separate Window shows the Mix window in its own native window, so Edit and Mix can be on screen at once (e.g. one per monitor), as in Pro Tools. No readiness percentage change.
@@ -153,6 +154,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-13 | trivial | Progress log: master insert latency compensated (#351) |
| 2026-10-12 | trivial | Progress log: convert option validation (#256) |
| 2026-10-12 | trivial | Progress log: timecode overflow (#248) |
+38 -5
View File
@@ -349,6 +349,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<BusId>,
aux_latency: usize,
/// Live input for the next block (planar), set by the audio host for input monitoring.
pub input: Vec<Vec<f32>>,
/// Transport stopped: only monitored inputs sound (no clips).
@@ -429,6 +433,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,
@@ -679,6 +685,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 + lmax_m;
// 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();
@@ -688,7 +707,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) {
@@ -778,6 +802,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<f32>]) {
for o in out.iter_mut() {
@@ -823,7 +853,7 @@ impl MixEngine {
apply_matrix(src, frames, &mut dst.buf, &m, gain);
}
let src: &[Vec<f32>] = 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()];
}
@@ -2004,8 +2034,8 @@ pub fn render_range(s: &Session, range: Range, block: usize) -> Vec<Vec<f32>> {
}
/// 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<Vec<f32>> {
render_in_memory(s, Some(bus), range, block)
}
@@ -2073,7 +2103,10 @@ impl RangeRender {
self.render(s, self.start + self.done as i64, n);
if self.done == 0 {
// The first block tells the mix's latency: render that much more and drop the head.
self.latency = if self.bus.is_some() { self.eng.track_latency } else { self.eng.latency };
self.latency = match self.bus {
Some(b) => self.eng.bus_latency(b),
None => self.eng.latency,
};
self.total = self.total.saturating_add(self.latency);
}
let skip = self.latency.saturating_sub(self.done).min(n);
+87
View File
@@ -0,0 +1,87 @@
//! A bounce of a bus keeps the requested timeline interval when an aux returns onto the bus
//! (its insert's lookahead is compensated like the main mix's).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use soundcraft_audio_io::AudioBuffer;
use soundcraft_mix::{render_bus, render_range};
use soundcraft_model::{BusId, ChannelFormat, Clip, Insert, Route, Session, SourceAudio, SourceId, TrackKind};
use soundcraft_time::Range;
use std::sync::Arc;
const AT: [usize; 3] = [0, 2048, 8000];
/// Impulses at 0, 2048 and 8000 on a track routed to bus A, and an aux reading bus A that returns
/// onto bus B (or the main mix) with a unity-ratio compressor of 5 ms lookahead on it.
fn fixture(insert: bool, aux_to_main: bool) -> (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<usize> {
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]);
}
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
# Hardware parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (audio device, rate and buffer choice; ASIO feature) · **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 minor: audio device, rate and buffer choice; ASIO feature) · **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,5 +72,6 @@ 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-12 | minor | Audio device settings: system, output, input, sample rate and buffer size; opt-in ASIO |
| 2026-10-10 | major | Created: audit of audio interface, MIDI, control-surface, sync and performance support against Pro Tools 2026.4.1 |