collective

Browse / github.com/storytold/soundcraft / patch

Mix: realtime headroom on large sessions (strips stay on the audio thread, clips by index)

Mix: realtime headroom on large sessions (strips stay on the audio thread, clips by index) Three changes to realtime rendering that matter on large sessions. Found on a real one: an edited drum mix with 42 tracks, 63,424 clips and four VST3 drum instruments, at 48 kHz / 128 samples through ASIO (Audient iD14). It glitched constantly; with these changes (and #805 for the UI side) it plays cleanly. ## 1. Strips stay on the audio thread unless a block is heavy `MixEngine::render` handed every block's strips to the global rayon pool, and the calling thread blocked until they were done. From the audio callback, `par_iter_mut` goes through `in_worker_cold`, so a profile of the rende

Status
open
Score
0.00
Adopters
0 other operators
Author
mrrobbiec (imported from pull request #804, open)
Base
25d52c646aea780c8076ff966076f994ad256221
Size
+642 −56 in 8 files
Review
pending
Shared
2026-10-28
Id
8601214f5aa2071c906729a8

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock crates/mix/Cargo.toml crates/mix/src/clip_index.rs crates/mix/src/lib.rs crates/mix/src/schedule.rs crates/playback/src/lib.rs docs/architecture.md docs/hardware-parity.md

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -3693,6 +3693,7 @@ name = "soundcraft-mix" version = "0.9.0" dependencies = [  "log",+ "proptest",  "rayon",  "soundcraft-au-host",  "soundcraft-audio-io",diff --git a/crates/mix/Cargo.toml b/crates/mix/Cargo.toml--- a/crates/mix/Cargo.toml+++ b/crates/mix/Cargo.toml@@ -21,5 +21,8 @@ log.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rayon.workspace = true +[dev-dependencies]+proptest.workspace = true+ [lints] workspace = truediff --git a/crates/mix/src/clip_index.rs b/crates/mix/src/clip_index.rsnew file mode 100644--- /dev/null+++ b/crates/mix/src/clip_index.rs@@ -0,0 +1,183 @@+//! Finding the clips under a block without walking every clip of a track.+//!+//! An edited drum take can hold tens of thousands of clips. Walking all of them for every block+//! costs the audio thread more than playing the few it finds, and drags megabytes of clip data+//! through the CPU caches ahead of the plugins. A [`ClipIndex`] answers "which clips overlap this+//! range" with two binary searches instead.+//!+//! It is built off the audio thread, from the same snapshot the audio thread will render, and it+//! only ever narrows the search: every candidate still goes through the same overlap test, in the+//! clips' own order, so a render with the index is bit-identical to one without. A track the index+//! does not describe (clips not sorted by start, or a different snapshot) is walked as before.++use soundcraft_model::{Clip, Session, Track, TrackId};+use soundcraft_time::{Range, Samples};++/// Where each track's clips lie in time, for one session snapshot. See the module docs.+#[derive(Debug, Default)]+pub struct ClipIndex {+    /// Sorted by track id.+    tracks: Vec<TrackClips>,+}++#[derive(Debug)]+struct TrackClips {+    track: TrackId,+    /// The clip list this describes: its address and length in the snapshot it was built from.+    /// A snapshot is immutable while it is shared, so a match means the same clips.+    clips_at: usize,+    len: usize,+    /// Clip starts, in clip order (which is sorted by start, or the track is not indexed).+    starts: Vec<Samples>,+    /// `max_end[i]`: the latest end among clips `0..=i`. Never decreases.+    max_end: Vec<Samples>,+}++impl ClipIndex {+    /// An index that describes no track: every lookup walks the whole track.+    pub const EMPTY: ClipIndex = ClipIndex { tracks: Vec::new() };++    /// Indexes the active playlist of every track of `s` whose clips are sorted by start.+    /// Allocates (two `Samples` per clip): build it off the audio thread.+    pub fn build(s: &Session) -> ClipIndex {+        let mut tracks: Vec<TrackClips> = s.tracks.iter().filter_map(TrackClips::build).collect();+        tracks.sort_unstable_by_key(|t| t.track.0);+        ClipIndex { tracks }+    }++    /// The clips of `t` that overlap `range`, in clip order: exactly those of+    /// `t.clips().iter().filter(|c| c.range().overlaps(&range))`.+    pub fn overlapping<'a>(&self, t: &'a Track, range: Range) -> impl Iterator<Item = &'a Clip> + 'a {+        let clips = t.clips();+        let span = self.span(t, clips, range).unwrap_or(0..clips.len());+        clips.get(span).unwrap_or_default().iter().filter(move |c| c.range().overlaps(&range))+    }++    /// The positions in `clips` that can overlap `range`, when this index describes them.+    fn span(&self, t: &Track, clips: &[Clip], range: Range) -> Option<std::ops::Range<usize>> {+        let at = self.tracks.binary_search_by_key(&t.id.0, |x| x.track.0).ok()?;+        let x = self.tracks.get(at)?;+        if x.len != clips.len() || x.clips_at != clips.as_ptr() as usize {+            return None;+        }+        // Clips from `hi` on start at or after the range's end; clips before `lo` (and so every+        // clip up to them) end at or before its start. Neither can overlap it.+        let hi = x.starts.partition_point(|&s| s < range.end);+        let lo = x.max_end.partition_point(|&e| e <= range.start).min(hi);+        Some(lo..hi)+    }++    /// Clips described, over all tracks (for tests and diagnostics).+    pub fn len(&self) -> usize {+        self.tracks.iter().map(|t| t.len).sum()+    }++    pub fn is_empty(&self) -> bool {+        self.len() == 0+    }+}++impl TrackClips {+    fn build(t: &Track) -> Option<TrackClips> {+        let clips = t.clips();+        if clips.is_empty() || !clips.windows(2).all(|w| matches!(w, [a, b] if a.start <= b.start)) {+            return None;+        }+        let starts = clips.iter().map(|c| c.start).collect();+        let mut latest = Samples::MIN;+        let max_end = clips+            .iter()+            .map(|c| {+                latest = latest.max(c.end());+                latest+            })+            .collect();+        Some(TrackClips { track: t.id, clips_at: clips.as_ptr() as usize, len: clips.len(), starts, max_end })+    }+}++#[cfg(test)]+mod tests {+    use super::*;+    use proptest::prelude::*;+    use soundcraft_model::{ChannelFormat, ClipId, SourceId, TrackKind};++    fn session_with(spans: &[(Samples, Samples)]) -> Session {+        let mut s = Session::default();+        let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);+        let pl = s.track_mut(t).and_then(|t| t.playlist_mut());+        if let Some(pl) = pl {+            for (i, &(start, length)) in spans.iter().enumerate() {+                pl.clips.push(Clip::audio(ClipId(i as u64 + 1), "c", SourceId(1), 0, start, length));+            }+        }+        s+    }++    fn naive(t: &Track, r: Range) -> Vec<u64> {+        t.clips().iter().filter(|c| c.range().overlaps(&r)).map(|c| c.id.0).collect()+    }++    fn indexed(ix: &ClipIndex, t: &Track, r: Range) -> Vec<u64> {+        ix.overlapping(t, r).map(|c| c.id.0).collect()+    }++    #[test]+    fn finds_the_clips_under_a_block() {+        let s = session_with(&[(0, 100), (100, 100), (150, 10), (300, 50)]);+        let ix = ClipIndex::build(&s);+        let t = &s.tracks[0];+        assert_eq!(ix.len(), 4);+        assert_eq!(indexed(&ix, t, Range::new(0, 64)), vec![1]);+        assert_eq!(indexed(&ix, t, Range::new(99, 101)), vec![1, 2]);+        assert_eq!(indexed(&ix, t, Range::new(140, 170)), vec![2, 3]);+        assert_eq!(indexed(&ix, t, Range::new(200, 300)), Vec::<u64>::new());+        assert_eq!(indexed(&ix, t, Range::new(349, 1000)), vec![4]);+    }++    #[test]+    fn a_long_clip_is_found_from_far_inside_it() {+        // The running maximum of the ends keeps a long early clip in reach of every later block.+        let s = session_with(&[(0, 10_000), (10, 5), (20, 5), (30, 5)]);+        let ix = ClipIndex::build(&s);+        assert_eq!(indexed(&ix, &s.tracks[0], Range::new(5_000, 5_128)), vec![1]);+    }++    #[test]+    fn unsorted_clips_are_walked() {+        let s = session_with(&[(500, 10), (0, 10)]);+        let ix = ClipIndex::build(&s);+        assert!(ix.is_empty(), "an unsorted track is not indexed");+        assert_eq!(indexed(&ix, &s.tracks[0], Range::new(0, 600)), vec![1, 2]);+    }++    #[test]+    fn another_snapshot_is_walked() {+        // An index used with a session it was not built from never hides a clip.+        let s = session_with(&[(0, 10), (20, 10)]);+        let ix = ClipIndex::build(&s);+        let mut other = s.clone();+        if let Some(pl) = other.tracks[0].playlist_mut() {+            pl.clips.push(Clip::audio(ClipId(9), "late", SourceId(1), 0, 40, 10));+        }+        assert_eq!(indexed(&ix, &other.tracks[0], Range::new(0, 100)), vec![1, 2, 9]);+        assert_eq!(indexed(&ClipIndex::EMPTY, &s.tracks[0], Range::new(0, 100)), vec![1, 2]);+    }++    proptest! {+        #[test]+        fn matches_a_walk_of_every_clip(+            mut spans in proptest::collection::vec((0i64..5_000, 1i64..800), 0..60),+            blocks in proptest::collection::vec((-100i64..6_000, 0i64..300), 1..40),+        ) {+            spans.sort_by_key(|s| s.0);+            let s = session_with(&spans);+            let ix = ClipIndex::build(&s);+            let t = &s.tracks[0];+            for (start, len) in blocks {+                let r = Range::new(start, start + len);+                prop_assert_eq!(indexed(&ix, t, r), naive(t, r));+            }+        }+    }+}diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs--- a/crates/mix/src/lib.rs+++ b/crates/mix/src/lib.rs@@ -61,6 +61,10 @@ use std::collections::HashMap; use std::ops::Bound; use std::sync::Arc; +mod clip_index;+mod schedule;+pub use clip_index::ClipIndex;+ /// Default (stereo) main mix width. The actual width is [`main_channels`]. pub const MAIN_CHANNELS: usize = 2; @@ -368,8 +372,10 @@ pub struct MixEngine {     adopt: HashMap<(TrackId, usize), PreparedInstance>,     /// Instances `sync` took out of service, for the host to drop (see [`MixEngine::take_retired`]).     retired: Vec<Box<dyn Plugin>>,-    /// Ownership workspace reused for parallel strip processing.+    /// Ownership workspace reused for strip processing.     work: Vec<(usize, Strip)>,+    /// Shares the strips between threads when a block needs it (see `schedule`).+    runner: schedule::StripRunner<(usize, Strip)>, }  /// What `sync` needs to fill a slot.@@ -418,6 +424,7 @@ impl SlotCtx<'_> { impl MixEngine {     pub fn new(sample_rate: f32, max_block: usize) -> Self {         let max_block = max_block.clamp(16, 16_384);+        schedule::warm_up();         MixEngine {             sample_rate: if sample_rate.is_finite() && sample_rate > 0.0 { sample_rate } else { 48_000.0 },             max_block,@@ -443,6 +450,7 @@ impl MixEngine {             adopt: HashMap::new(),             retired: Vec::new(),             work: Vec::new(),+            runner: schedule::StripRunner::new(),         }     } @@ -613,7 +621,17 @@ impl MixEngine {     }      /// Render `frames` (≤ max_block) samples starting at `pos` into `out` (≥ 2 channels).+    /// Walks every clip of every track to find the ones under the block: a host rendering+    /// repeatedly passes a [`ClipIndex`] of the session to [`MixEngine::render_with`] instead.     pub fn render(&mut self, s: &Session, pos: Samples, frames: usize, out: &mut [Vec<f32>]) {+        self.render_with(s, &ClipIndex::EMPTY, pos, frames, out);+    }++    /// [`MixEngine::render`], finding each block's clips with `clips` (built from `s`, off the+    /// audio thread). The result is the same as `render` while `clips` was built from `s` as it is+    /// now: an index of another session only saves less time, but an edit that moves or resizes a+    /// clip in place (same clip list, same length) is not detected, so rebuild the index after one.+    pub fn render_with(&mut self, s: &Session, clips: &ClipIndex, pos: Samples, frames: usize, out: &mut [Vec<f32>]) {         let frames = frames.min(self.max_block);         if pos != self.last_end {             // Seek: silence held notes and ringing plugins.@@ -639,7 +657,7 @@ impl MixEngine {         let order = std::mem::take(&mut self.order);         let live = |t: &Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca);         let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);-        // Phase 1: independent strips (audio, instrument, MIDI) in parallel.+        // Phase 1: independent strips (audio, instrument, MIDI), shared between threads when heavy.         let mut work = std::mem::take(&mut self.work);         work.clear();         for &i in &order {@@ -655,9 +673,10 @@ impl MixEngine {         }         let busses = std::mem::take(&mut self.busses);         let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording };-        run_strips(&mut work, |(i, st)| {+        let period_ns = frames as f64 / f64::from(self.sample_rate) * 1e9;+        self.runner.run(&mut work, on_audio_thread(), period_ns, |(i, st)| {             if let Some(t) = s.tracks.get(*i) {-                process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo);+                process_strip(s, clips, t, st, &busses, &live_in, pos, frames, any_solo);             }         });         self.busses = busses;@@ -736,7 +755,7 @@ impl MixEngine {                 st.scratch.resize(self.max_block, 0.0);             }             let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording };-            process_strip(s, t, &mut st, &self.busses, &live_in, pos, frames, any_solo);+            process_strip(s, clips, t, &mut st, &self.busses, &live_in, pos, frames, any_solo);             let ch = st.buf.len();             st.align.set(if pdc { lmax_a.saturating_sub(st.latency) } else { 0 }, ch);             st.align.process(&mut st.buf, frames);@@ -901,6 +920,7 @@ struct LiveInput<'a> {  fn process_strip(     s: &Session,+    clips: &ClipIndex,     t: &Track,     strip: &mut Strip,     busses: &HashMap<BusId, BusBuf>,@@ -935,8 +955,8 @@ fn process_strip(         TrackKind::Instrument | TrackKind::Midi if live.monitor_only => {}         TrackKind::Audio => {             let block = Range { start: pos, end: pos.saturating_add(frames as i64) };-            for clip in t.clips() {-                if clip.muted || !clip.is_audio() || !clip.range().overlaps(&block) {+            for clip in clips.overlapping(t, block) {+                if clip.muted || !clip.is_audio() {                     continue;                 }                 match clip_fx_params(s, clip.id.0) {@@ -997,7 +1017,7 @@ fn process_strip(                 if std::mem::take(&mut strip.chase) {                     chase_controllers(s, t, pos, inst);                 }-                schedule_notes(s, t, pos, frames, inst, &mut strip.held);+                schedule_notes(s, clips, t, pos, frames, inst, &mut strip.held);                 if strip.buf.len() >= 2 {                     inst.plugin.process(&mut strip.buf, frames);                 } else if let Some(c0) = strip.buf.first_mut() {@@ -1280,24 +1300,6 @@ fn as_audio_thread(audio: bool, f: impl FnOnce()) {     f(); } -/// Process strips, in parallel on native targets when there are enough of them.-#[cfg(not(target_arch = "wasm32"))]-fn run_strips<T: Send>(work: &mut [T], f: impl Fn(&mut T) + Sync + Send) {-    use rayon::prelude::*;-    if work.len() >= 4 {-        // The workers render on behalf of this thread: on the audio thread, they are it too.-        let audio = on_audio_thread();-        work.par_iter_mut().for_each(|w| as_audio_thread(audio, || f(w)));-    } else {-        work.iter_mut().for_each(f);-    }-}--#[cfg(target_arch = "wasm32")]-fn run_strips<T: Send>(work: &mut [T], f: impl Fn(&mut T) + Sync + Send) {-    work.iter_mut().for_each(f);-}- fn strip_channels(t: &Track, main_ch: usize) -> usize {     match t.kind {         TrackKind::Master => main_ch,@@ -1481,21 +1483,18 @@ fn midi_offset(s: &Session, t: &Track) -> i64 { }  /// Send note on/offs and controllers for MIDI clips overlapping the block.-fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mut PluginSlot, held: &mut Vec<u8>) {+fn schedule_notes(s: &Session, clips: &ClipIndex, t: &Track, pos: Samples, frames: usize, inst: &mut PluginSlot, held: &mut Vec<u8>) {     let sr = s.sample_rate;     // MIDI Track Offsets: the track's notes sound `offset` samples later (earlier if negative), so     // the block looks that far back into the clips.     let offset = midi_offset(s, t);     let pos = pos.saturating_sub(offset);     let end = pos.saturating_add(frames as i64);-    for clip in t.clips() {+    for clip in clips.overlapping(t, Range { start: pos, end }) {         if clip.muted {             continue;         }         let ClipContent::Midi { sequence } = &clip.content else { continue };-        if !clip.range().overlaps(&Range { start: pos, end }) {-            continue;-        }         let base = s.tempo.samples_to_ticks(clip.start, sr);         // MIDI Real-Time Properties (non-destructive velocity/transpose/duration/delay).         let rtp = |name: &str, d: f64| Key::new(format_args!("rtp.{}.{name}", t.id.0)).map_or(d, |k| s.edit.value(k.as_str(), d));@@ -1509,12 +1508,30 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu                 rtp("delay", 0.0).clamp(-1e7, 1e7) as i64,             )         };+        // The block in clip ticks, with a tick of slack each side for rounding. Worked out once so+        // that only the notes starting or ending in the block are converted to samples: a tempo+        // conversion walks the tempo map, and doing two per note of the clip on every block (a+        // beat-tracked map has hundreds of tempos, a dense part thousands of notes) is more work+        // than the audio thread has time for at small buffers.+        let near = s.tempo.samples_to_ticks(pos, sr).saturating_sub(base).saturating_sub(1)+            ..=s.tempo.samples_to_ticks(end, sr).saturating_sub(base).saturating_add(1);+        // Notes still held at the clip's end are cut there: when that end falls in this block,+        // every note ending at or after it ends here.+        let cut_from = if clip.end() >= pos && clip.end() < end {+            s.tempo.samples_to_ticks(clip.end(), sr).saturating_sub(base).saturating_sub(1)+        } else {+            i64::MAX+        };         for n0 in &sequence.notes {             let mut n = *n0;             n.velocity = (i64::from(n.velocity) + dv).clamp(1, 127) as u8;             n.pitch = (i64::from(n.pitch) + dp).clamp(0, 127) as u8;             n.length = ((n.length as f64 * dur) as i64).max(1);             n.start = n.start.saturating_add(delay).max(0);+            let end_tick = n.start.saturating_add(n.length);+            if !near.contains(&n.start) && !near.contains(&end_tick) && end_tick < cut_from {+                continue;+            }             let on = s.tempo.tick_to_samples(base.saturating_add(n.start), sr);             let off = s.tempo.tick_to_samples(base.saturating_add(n.start).saturating_add(n.length), sr).min(clip.end());             if on >= clip.end() {@@ -1531,8 +1548,6 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu                 held.retain(|p| *p != n.pitch);             }         }-        let near = s.tempo.samples_to_ticks(pos, sr).saturating_sub(base).saturating_sub(1)-            ..=s.tempo.samples_to_ticks(end, sr).saturating_sub(base).saturating_add(1);         // Events before tick 0 are what a start trim hid: they don't play.         for c in sequence.ctrls.iter().filter(|c| c.tick >= 0) {             let tick = c.tick.saturating_add(delay).max(0);@@ -2075,6 +2090,8 @@ pub fn source_channels(s: &Session, bus: Option<BusId>) -> usize { /// [`render_range`] or [`render_bus`] of any length, a block at a time. pub struct RangeRender {     eng: MixEngine,+    /// Where the session's clips are, so that a long render does not walk them all every block.+    clips: ClipIndex,     bus: Option<BusId>,     tmp: Vec<Vec<f32>>,     start: Samples,@@ -2090,20 +2107,21 @@ impl RangeRender {         let eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);         let tmp = vec![vec![0.0f32; eng.max_block()]; nch];         let len = if nch == 0 { 0 } else { usize::try_from(range.len().max(0)).unwrap_or(0) };-        RangeRender { eng, bus, tmp, start: range.start, done: 0, total: len, latency: 0 }+        RangeRender { eng, clips: ClipIndex::build(s), bus, tmp, start: range.start, done: 0, total: len, latency: 0 }     }      fn render(&mut self, s: &Session, pos: Samples, n: usize) {         match self.bus {             Some(id) => {-                self.eng.render(s, pos, n, &mut []);+                self.eng.render_with(s, &self.clips, pos, n, &mut []);                 self.eng.copy_bus(id, n, &mut self.tmp);             }-            None => self.eng.render(s, pos, n, &mut self.tmp),+            None => self.eng.render_with(s, &self.clips, pos, n, &mut self.tmp),         }     } -    /// Renders one more block of the mix of `s` (the session given to [`RangeRender::new`]) and+    /// Renders one more block of the mix of `s` (the session given to [`RangeRender::new`],+    /// unchanged since: its clip index was built then) and     /// returns it as `(channels, n)`: the next `n` samples of the range lead each channel (none     /// while the mix's latency is still being skipped). `None` once the range is done.     pub fn next_block(&mut self, s: &Session) -> Option<(&[Vec<f32>], usize)> {@@ -2259,11 +2277,13 @@ mod audio_thread_tests {     /// (or is not) the audio thread.     fn seen_by_strips(audio: bool) -> Vec<bool> {         std::thread::spawn(move || {+            // The audio thread never makes the pool itself: a host makes it first (MixEngine::new).+            schedule::warm_up();             if audio {                 mark_audio_thread();             }             let mut work = vec![None; 64];-            run_strips(&mut work, |w| {+            schedule::StripRunner::sharing_from_the_start().run(&mut work, audio, 1e6, |w| {                 // Long enough that the pool's workers take part.                 std::thread::sleep(std::time::Duration::from_millis(1));                 *w = Some(on_audio_thread());@@ -2303,6 +2323,48 @@ mod tests {         s     } +    #[test]+    fn a_clip_index_renders_the_same_audio_as_walking_every_clip() {+        // A sliced drum edit (thousands of short clips with fades, butted, crossfaded and with gaps),+        // a long clip under it all, a track whose clips are out of order (walked, not indexed) and+        // MIDI clips: every block must come out bit for bit as without an index.+        let mut s = synth_session(-6.0);+        let noise: Vec<f32> = (0..96_000u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 8) as f32 / 8_388_608.0 - 1.0).collect();+        s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![noise] })));+        let drums = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);+        let (mut at, mut x) = (0i64, 7u64);+        let mut sliced = Vec::new();+        for _ in 0..3000 {+            x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);+            let len = 40 + ((x >> 33) % 300) as i64;+            let mut c = Clip::audio(s.new_clip_id(), "hit", SourceId(900), ((x >> 17) % 40_000) as i64, at, len);+            c.fade_in.len = ((x >> 7) % 20) as i64;+            c.fade_out.len = ((x >> 11) % 20) as i64;+            sliced.push(c);+            at += len + ((x >> 23) % 40) as i64 - 10;+        }+        s.track_mut(drums).unwrap().playlist_mut().unwrap().clips = sliced;+        let room = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);+        let long = Clip::audio(s.new_clip_id(), "room", SourceId(900), 0, 0, at);+        let late = Clip::audio(s.new_clip_id(), "late", SourceId(900), 0, at / 2, 5_000);+        s.track_mut(room).unwrap().playlist_mut().unwrap().clips = vec![late, long];+        let index = ClipIndex::build(&s);+        assert_eq!(index.len(), 3001, "the sliced and MIDI tracks are indexed, the unsorted one is not");+        let (mut walk, mut indexed) = (MixEngine::new(48_000.0, 128), MixEngine::new(48_000.0, 128));+        let (mut a, mut b) = (vec![vec![0.0f32; 128]; 2], vec![vec![0.0f32; 128]; 2]);+        let (mut pos, mut heard) = (0, 0usize);+        while pos < at + 128 {+            walk.render(&s, pos, 128, &mut a);+            indexed.render_with(&s, &index, pos, 128, &mut b);+            for (x, y) in a.iter().zip(&b) {+                assert!(x.iter().zip(y).all(|(p, q)| p.to_bits() == q.to_bits()), "block at {pos} differs");+            }+            heard += usize::from(a[0].iter().any(|v| *v != 0.0));+            pos += 128;+        }+        assert!(heard > 1000, "the comparison covers sound, not silence ({heard} blocks)");+    }+     #[test]     fn stored_instrument_params_reach_the_plugin() {         // #152: a saved instrument level of -12 dB must render quieter than -6 dB.@@ -2728,6 +2790,98 @@ mod tests {         assert_eq!(*heard.lock().unwrap(), vec![(72, 80)]);     } +    /// Records the absolute sample of every note on and off it is sent.+    struct NoteLog {+        inner: Box<dyn Plugin>,+        pos: Arc<std::sync::Mutex<i64>>,+        heard: Arc<std::sync::Mutex<Vec<(i64, bool, u8)>>>,+    }++    impl Plugin for NoteLog {+        fn info(&self) -> &'static soundcraft_dsp::PluginInfo {+            self.inner.info()+        }+        fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {+            self.inner.prepare(sr, mb, ch);+        }+        fn reset(&mut self) {+            self.inner.reset();+        }+        fn set_param(&mut self, id: &str, v: f32) -> bool {+            self.inner.set_param(id, v)+        }+        fn param(&self, id: &str) -> Option<f32> {+            self.inner.param(id)+        }+        fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {+            self.inner.process(io, frames);+        }+        fn note_on(&mut self, offset: usize, note: u8, _velocity: u8) {+            let at = *self.pos.lock().unwrap() + offset as i64;+            self.heard.lock().unwrap().push((at, true, note));+        }+        fn note_off(&mut self, offset: usize, note: u8) {+            let at = *self.pos.lock().unwrap() + offset as i64;+            self.heard.lock().unwrap().push((at, false, note));+        }+    }++    #[test]+    fn notes_land_on_the_same_samples_with_a_dense_tempo_map() {+        // A beat-tracked tempo map (a tempo on every beat) and a dense part: only the notes near each+        // block are converted now, and every on and off must still land on the exact sample a+        // conversion of every note gives, including notes cut at the clip's end.+        let mut s = Session::default();+        let q = TICKS_PER_QUARTER;+        for beat in 1..400i64 {+            s.tempo.set_tempo(beat * q, 120.0 + ((beat * 37) % 11) as f64 * 0.7 - 3.0).unwrap();+        }+        let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);+        s.track_mut(t).unwrap().instrument = Some(Insert::new("clap:test.keys"));+        let mut seq = soundcraft_midi::Sequence::default();+        let mut x: u64 = 7;+        for _ in 0..3000 {+            x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);+            let start = ((x >> 33) % (300 * q as u64)) as i64;+            let length = 1 + ((x >> 13) % (3 * q as u64)) as i64;+            let pitch = 36 + ((x >> 7) % 48) as u8;+            seq.notes.push(soundcraft_midi::Note { pitch, velocity: 100, release_velocity: 64, channel: 0, start, length });+        }+        seq.notes.sort_by_key(|n| n.start);+        // Ends mid-block and inside the notes, so some are cut.+        let clip_end = 48_000 * 140 + 77;+        let cid = s.new_clip_id();+        s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::midi(cid, "m", 0, clip_end, seq.clone()));+        let pos = Arc::new(std::sync::Mutex::new(0i64));+        let heard = Arc::new(std::sync::Mutex::new(Vec::new()));+        let log = NoteLog { inner: soundcraft_dsp::create("gain").unwrap(), pos: Arc::clone(&pos), heard: Arc::clone(&heard) };+        let mut eng = MixEngine::new(48_000.0, 128);+        eng.set_external_instances(true);+        eng.adopt(vec![PreparedInstance { track: t, slot: INSTRUMENT_SLOT, id: "clap:test.keys".into(), ch: 2, plugin: Box::new(log) }]);+        let mut out = vec![vec![0.0f32; 128]; 2];+        let mut at = 0i64;+        while at < clip_end + 128 {+            *pos.lock().unwrap() = at;+            eng.render(&s, at, 128, &mut out);+            at += 128;+        }+        let sr = s.sample_rate;+        let mut want = Vec::new();+        for n in &seq.notes {+            let on = s.tempo.tick_to_samples(n.start, sr);+            if on >= clip_end {+                continue;+            }+            want.push((on, true, n.pitch));+            want.push((s.tempo.tick_to_samples(n.start + n.length, sr).min(clip_end), false, n.pitch));+        }+        let mut got = heard.lock().unwrap().clone();+        want.sort_unstable();+        got.sort_unstable();+        assert_eq!(got.len(), want.len());+        assert_eq!(got, want);+    }+     fn render_block(eng: &mut MixEngine, s: &Session, pos: i64) -> f32 {         let mut out = vec![vec![0.0f32; 512]; 2];         eng.render(s, pos, 512, &mut out);diff --git a/crates/mix/src/schedule.rs b/crates/mix/src/schedule.rsnew file mode 100644--- /dev/null+++ b/crates/mix/src/schedule.rs@@ -0,0 +1,220 @@+//! Who processes the independent strips of a block: the rendering thread alone, or it and a pool.+//!+//! Handing a block's strips to other threads is not free on the audio thread. The callback has to+//! wake the workers and then wait for the last strip, so every block depends on how soon the+//! scheduler runs threads of ordinary priority, and how busy the rest of the machine is. Measured+//! on a 42-track session at 128 frames, sharing every block's strips through a thread pool took+//! the worst 1% of blocks past the deadline, where processing them on the callback's own thread+//! kept them under a third of it.+//!+//! So a realtime render processes its strips serially and measures what they cost. Only when they+//! take a large share of the block for a while does it share them out: then the rendering thread+//! keeps working too, claiming strips from the same counter as the workers (heaviest first), so it+//! waits only for strips a worker is already processing. Offline renders (bounces, exports) have+//! no deadline and always share their strips.+//!+//! The pool is SoundCraft's own, not the process-wide one other work (video decoding) queues on,+//! and is never created on the audio thread.++use std::sync::Mutex;+#[cfg(not(target_arch = "wasm32"))]+use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};++/// Share a realtime block's strips once their cost peaks above this share of the block's duration.+const SHARE_ABOVE: f64 = 0.5;+/// Go back to serial processing once the peak falls below this (hysteresis: no flapping).+const SERIAL_BELOW: f64 = 0.25;+/// The remembered peak halves in this much audio (ns), so one slow block does not decide alone.+const PEAK_HALF_LIFE_NS: f64 = 500_000_000.0;+/// Fewer strips than this are never shared: there is too little to gain.+#[cfg(not(target_arch = "wasm32"))]+const MIN_SHARED: usize = 4;++/// Runs a block's strips (see the module docs). Keeps what it needs between blocks, so that steady+/// state does not allocate.+#[cfg_attr(target_arch = "wasm32", allow(dead_code))]+pub(crate) struct StripRunner<T> {+    /// Each item's processing time in the last block (ns), by position in the work list.+    #[cfg(not(target_arch = "wasm32"))]+    costs: Vec<AtomicU64>,+    /// The items while they are shared: each is claimed by exactly one thread, so its lock is+    /// never contended.+    cells: Vec<Mutex<Option<T>>>,+    /// Positions in the work list, heaviest first: the order the threads claim them in.+    claim: Vec<usize>,+    /// Decaying peak of the strips' total cost, as a share of the block's duration.+    peak: f64,+    shared: bool,+}++#[cfg_attr(target_arch = "wasm32", allow(dead_code))]+impl<T: Send> StripRunner<T> {+    pub(crate) fn new() -> Self {+        StripRunner {+            #[cfg(not(target_arch = "wasm32"))]+            costs: Vec::new(),+            cells: Vec::new(),+            claim: Vec::new(),+            peak: 0.0,+            shared: false,+        }+    }++    /// A runner that shares a realtime block's strips until it has measured them.+    #[cfg(test)]+    pub(crate) fn sharing_from_the_start() -> Self {+        let mut r = Self::new();+        r.shared = true;+        r+    }++    /// Whether the last realtime blocks were heavy enough to share their strips.+    #[cfg(test)]+    pub(crate) fn sharing(&self) -> bool {+        self.shared+    }++    /// Runs `f` once on every item of `work` (in no particular order across threads). `realtime`:+    /// the block has a deadline `period_ns` long, and its strips are shared only under load.+    pub(crate) fn run(&mut self, work: &mut Vec<T>, realtime: bool, period_ns: f64, f: impl Fn(&mut T) + Sync) {+        #[cfg(target_arch = "wasm32")]+        {+            let _ = (realtime, period_ns);+            work.iter_mut().for_each(f);+        }+        #[cfg(not(target_arch = "wasm32"))]+        {+            if self.costs.len() < work.len() {+                self.costs.resize_with(work.len(), AtomicU64::default);+            }+            let share = work.len() >= MIN_SHARED && (!realtime || self.shared);+            match pool().filter(|_| share) {+                Some(pool) => self.run_shared(pool, work, &f),+                None => {+                    for (w, cost) in work.iter_mut().zip(&self.costs) {+                        timed(cost, || f(w));+                    }+                }+            }+            if realtime {+                let total: u64 = self.costs.iter().take(work.len()).map(|c| c.load(Ordering::Relaxed)).sum();+                self.observe(total as f64 / period_ns.max(1.0), period_ns);+            }+        }+    }++    /// Folds one block's load (strip time over block duration) into the peak and picks the mode.+    fn observe(&mut self, load: f64, period_ns: f64) {+        let decay = 0.5f64.powf(period_ns.max(0.0) / PEAK_HALF_LIFE_NS);+        self.peak = load.max(self.peak * decay);+        self.shared = if self.shared { self.peak > SERIAL_BELOW } else { self.peak > SHARE_ABOVE };+    }++    #[cfg(not(target_arch = "wasm32"))]+    fn run_shared(&mut self, pool: &rayon::ThreadPool, work: &mut Vec<T>, f: &(impl Fn(&mut T) + Sync)) {+        let n = work.len();+        self.claim.clear();+        self.claim.extend(0..n);+        let costs = &self.costs;+        self.claim.sort_unstable_by_key(|&k| std::cmp::Reverse(costs.get(k).map_or(0, |c| c.load(Ordering::Relaxed))));+        self.cells.clear();+        self.cells.extend(work.drain(..).map(|w| Mutex::new(Some(w))));+        let next = AtomicUsize::new(0);+        let audio = crate::on_audio_thread();+        let (cells, claim) = (&self.cells, &self.claim);+        let drain = || {+            while let Some(&k) = claim.get(next.fetch_add(1, Ordering::Relaxed)) {+                let (Some(cell), Some(cost)) = (cells.get(k), costs.get(k)) else { continue };+                let mut item = cell.lock().unwrap_or_else(std::sync::PoisonError::into_inner);+                if let Some(w) = item.as_mut() {+                    timed(cost, || f(w));+                }+            }+        };+        let helpers = pool.current_num_threads().min(n.saturating_sub(1));+        pool.in_place_scope(|scope| {+            for _ in 0..helpers {+                // Workers process strips on behalf of this thread: on the audio thread, they are it.+                scope.spawn(|_| crate::as_audio_thread(audio, drain));+            }+            drain();+        });+        work.extend(self.cells.drain(..).filter_map(|c| c.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner)));+    }+}++#[cfg(not(target_arch = "wasm32"))]+fn timed(cost: &AtomicU64, f: impl FnOnce()) {+    let t0 = std::time::Instant::now();+    f();+    cost.store(u64::try_from(t0.elapsed().as_nanos()).unwrap_or(u64::MAX), Ordering::Relaxed);+}++/// The strip pool, made on first use off the audio thread (on it, until then: none, so serial).+#[cfg(not(target_arch = "wasm32"))]+fn pool() -> Option<&'static rayon::ThreadPool> {+    static POOL: std::sync::OnceLock<Option<rayon::ThreadPool>> = std::sync::OnceLock::new();+    if let Some(p) = POOL.get() {+        return p.as_ref();+    }+    if crate::on_audio_thread() {+        return None;+    }+    POOL.get_or_init(|| {+        // The rendering thread works too: one worker fewer than there are cores.+        let n = std::thread::available_parallelism().map_or(1, std::num::NonZero::get).saturating_sub(1).clamp(1, 15);+        rayon::ThreadPoolBuilder::new()+            .num_threads(n)+            .thread_name(|i| format!("soundcraft-mix-{i}"))+            .build()+            .inspect_err(|e| log::warn!("mix: no strip pool, strips run on one thread: {e}"))+            .ok()+    })+    .as_ref()+}++/// Makes the strip pool now, so the first realtime render that needs it finds it. Call off the+/// audio thread; [`MixEngine::new`](crate::MixEngine::new) does.+pub(crate) fn warm_up() {+    #[cfg(not(target_arch = "wasm32"))]+    let _ = pool();+}++#[cfg(test)]+mod tests {+    use super::*;++    const PERIOD: f64 = 128.0 / 48_000.0 * 1e9;++    #[test]+    fn light_blocks_stay_serial_and_heavy_ones_share() {+        let mut r = StripRunner::<u32>::new();+        for _ in 0..1000 {+            r.observe(0.3, PERIOD);+        }+        assert!(!r.sharing(), "30% of the block is handled on one thread");+        r.observe(0.8, PERIOD);+        assert!(r.sharing(), "one block over the threshold shares the next ones");+        // The peak decays: it takes a while under the lower threshold to go back.+        for _ in 0..10 {+            r.observe(0.1, PERIOD);+        }+        assert!(r.sharing(), "a few light blocks do not switch back");+        for _ in 0..1000 {+            r.observe(0.1, PERIOD);+        }+        assert!(!r.sharing(), "sustained light load goes back to serial");+    }++    #[test]+    fn every_item_runs_once_either_way() {+        for (realtime, shared) in [(true, false), (true, true), (false, false)] {+            let mut r = StripRunner::new();+            r.shared = shared;+            let mut work: Vec<(usize, u32)> = (0..20).map(|i| (i, 0)).collect();+            r.run(&mut work, realtime, PERIOD, |w| w.1 += 1);+            work.sort_unstable();+            assert_eq!(work, (0..20).map(|i| (i, 1)).collect::<Vec<_>>(), "realtime {realtime} shared {shared}");+        }+    }+}diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs--- a/crates/playback/src/lib.rs+++ b/crates/playback/src/lib.rs@@ -28,7 +28,7 @@ pub mod record; pub use devices::AudioConfig;  use soundcraft_dsp::{Plugin, PluginEditor, PluginStateReader};-use soundcraft_mix::{MAX_CHANNELS, MixEngine, PreparedInstance, StripMeter, itu_stereo_fold, main_channels};+use soundcraft_mix::{ClipIndex, MAX_CHANNELS, MixEngine, PreparedInstance, StripMeter, itu_stereo_fold, main_channels}; use soundcraft_model::{Session, TrackId}; use soundcraft_time::{Range, Samples}; use std::collections::{BTreeMap, HashMap, HashSet};@@ -60,9 +60,9 @@ pub type EditorEdit = (TrackId, usize, String, f32);  enum Cmd {     /// A new snapshot plus the third-party instances it needs (created on the UI thread).-    Session(Arc<Session>, Vec<PreparedInstance>),+    Session(Snapshot, Vec<PreparedInstance>),     /// Where the callback sends the snapshots it replaces (freeing one takes thousands of frees).-    OldSessions(SyncSender<Arc<Session>>),+    OldSessions(SyncSender<Snapshot>),     Input(Arc<record::InputRing>),     Recording(bool),     Play {@@ -74,6 +74,20 @@ enum Cmd {     Locate(Samples), } +/// A session as the audio thread renders it, with the index of its clips. Both are built on the+/// UI thread: indexing a large session allocates and walks every clip.+struct Snapshot {+    session: Arc<Session>,+    clips: ClipIndex,+}++impl Snapshot {+    fn new(session: Arc<Session>) -> Snapshot {+        let clips = ClipIndex::build(&session);+        Snapshot { session, clips }+    }+}+ struct Shared {     position: AtomicI64,     playing: AtomicBool,@@ -154,6 +168,8 @@ struct AudioState {     rx: Receiver<Cmd>,     shared: Arc<Shared>,     session: Arc<Session>,+    /// Where `session`'s clips are; always replaced together with it.+    clips: ClipIndex,     mix: MixEngine,     pos: Samples,     playing: bool,@@ -170,7 +186,7 @@ struct AudioState {     recording: bool,     /// Retired plugin instances go back to the UI thread to be dropped there.     garbage: Sender<Vec<Box<dyn Plugin>>>,-    old_sessions: Option<SyncSender<Arc<Session>>>,+    old_sessions: Option<SyncSender<Snapshot>>, }  fn new_engine(sr: f32) -> MixEngine {@@ -191,6 +207,7 @@ impl AudioState {             rx,             shared,             mix: new_engine(sr),+            clips: ClipIndex::build(&session),             session,             pos: 0,             playing: false,@@ -211,7 +228,7 @@ impl AudioState {             match c {                 Cmd::Input(r) => self.input = Some(r),                 Cmd::Recording(r) => self.recording = r,-                Cmd::Session(s, instances) => {+                Cmd::Session(Snapshot { session: s, clips }, instances) => {                     changed = true;                     if (s.sample_rate.as_f64() - self.session.sample_rate.as_f64()).abs() > 0.5 {                         let mut old = std::mem::replace(&mut self.mix, new_engine(s.sample_rate.as_f64() as f32));@@ -226,7 +243,7 @@ impl AudioState {                         self.read = 0.0;                     }                     self.fold = fold_gains(&s);-                    let old = std::mem::replace(&mut self.session, s);+                    let old = Snapshot { session: std::mem::replace(&mut self.session, s), clips: std::mem::replace(&mut self.clips, clips) };                     if let Some(tx) = &self.old_sessions {                         let _ = tx.try_send(old);                     }@@ -307,7 +324,7 @@ impl AudioState {         }         self.mix.monitor_only = !self.playing;         self.mix.recording = self.recording;-        self.mix.render(&self.session, self.pos, n, &mut self.pending);+        self.mix.render_with(&self.session, &self.clips, self.pos, n, &mut self.pending);         self.send_garbage();         self.pending_len = n;         if let Ok(mut m) = self.shared.meters.try_lock() {@@ -414,7 +431,7 @@ struct Side {     /// is opened.     refused: HashSet<(TrackId, usize)>,     garbage: Receiver<Vec<Box<dyn Plugin>>>,-    old_sessions: Option<Receiver<Arc<Session>>>,+    old_sessions: Option<Receiver<Snapshot>>, }  impl Side {@@ -676,7 +693,7 @@ impl Player {     /// prepared) instances of any third-party plugin it newly needs. Call on the UI thread.     pub fn update_session(&self, s: Arc<Session>) {         let instances = self.side().prepare(&s);-        let _ = self.tx.send(Cmd::Session(s, instances));+        let _ = self.tx.send(Cmd::Session(Snapshot::new(s), instances));     }      /// Reads the state of every live third-party plugin, for saving the session.@@ -1298,24 +1315,28 @@ mod tests {         let (gtx, grx) = channel();         let mut st = AudioState::new(rx, Arc::clone(&shared), Arc::new(soundcraft_model::Session::default()), 48_000.0, gtx);         let plugin = soundcraft_dsp::create("gain").unwrap();-        tx.send(Cmd::Session(Arc::clone(&s), vec![PreparedInstance { track: t, slot: 1, id: "clap:test.fake".into(), ch: 2, plugin }])).unwrap();+        tx.send(Cmd::Session(+            Snapshot::new(Arc::clone(&s)),+            vec![PreparedInstance { track: t, slot: 1, id: "clap:test.fake".into(), ch: 2, plugin }],+        ))+        .unwrap();         let mut out = vec![0.0f32; 256];         st.fill(&mut out, 2);         assert!(grx.try_recv().is_err(), "nothing retired yet");         // A session without the insert retires the instance to the return channel.-        tx.send(Cmd::Session(Arc::new(soundcraft_model::Session::default()), Vec::new())).unwrap();+        tx.send(Cmd::Session(Snapshot::new(Arc::new(soundcraft_model::Session::default())), Vec::new())).unwrap();         st.fill(&mut out, 2);         assert_eq!(grx.try_recv().unwrap().len(), 1);         // A sample-rate change rebuilds the engine without dropping plugins on this thread.         tx.send(Cmd::Session(-            Arc::clone(&s),+            Snapshot::new(Arc::clone(&s)),             vec![PreparedInstance { track: t, slot: 1, id: "clap:test.fake".into(), ch: 2, plugin: soundcraft_dsp::create("gain").unwrap() }],         ))         .unwrap();         st.fill(&mut out, 2);         let mut s44 = (*s).clone();         s44.sample_rate = soundcraft_time::SampleRate::new(44_100).unwrap();-        tx.send(Cmd::Session(Arc::new(s44), Vec::new())).unwrap();+        tx.send(Cmd::Session(Snapshot::new(Arc::new(s44)), Vec::new())).unwrap();         st.fill(&mut out, 2);         assert_eq!(grx.try_recv().unwrap().len(), 1);     }@@ -1397,7 +1418,7 @@ mod tests {             let s = Arc::new(soundcraft_model::Session::default());             watched.push(Arc::downgrade(&s));             side.collect_garbage();-            tx.send(Cmd::Session(s, Vec::new())).unwrap();+            tx.send(Cmd::Session(Snapshot::new(s), Vec::new())).unwrap();             st.fill(&mut out, 2);             let replaced = watched.len().checked_sub(2).and_then(|i| watched.get(i));             assert!(replaced.is_none_or(|w| w.strong_count() == 1), "freed on the audio thread");diff --git a/docs/architecture.md b/docs/architecture.md--- a/docs/architecture.md+++ b/docs/architecture.md@@ -46,8 +46,11 @@ report.  `soundcraft_mix::MixEngine::render(session, pos, frames, out)` renders one block: clips (gain, fades, clip effects) → trim → inserts → pre-fader sends → fader/mute (automation, VCA, trim-automation) → post-fader sends → pan → busses → aux inputs → master faders. Independent strips-process in parallel; plugin delay compensation aligns every path, the master faders' inserts included. The same engine renders+automation) → post-fader sends → pan → busses → aux inputs → master faders. Playback and bounces+call `render_with`, which finds each block's clips through a `ClipIndex` of the snapshot (built off+the audio thread with it); plain `render` walks every clip. Independent strips run on the audio thread itself, and are shared with SoundCraft's own+worker pool only while they take a large share of the block (offline renders always share them);+plugin delay compensation aligns every path, the master faders' inserts included. The same engine renders bounces offline (`RangeRender`, streamed to the file block by block) and realtime playback (`soundcraft_playback::Player`, which owns it on the audio thread and receives new session snapshots through a channel).diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md--- 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-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+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** trivial (strips stay on the audio thread unless a block is heavy; 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@@ -60,7 +60,7 @@ Legend: ✅ works · 🟡 partial · ❌ missing.  | Feature | Pro Tools | SoundCraft | Evidence | |---|---|---|---|-| Parallel mixing across cores | ✅ | ✅ | `soundcraft-mix` renders independent strips in parallel (rayon) |+| Parallel mixing across cores | ✅ | ✅ | `soundcraft-mix` shares independent strips with its own worker pool when a block is heavy (the audio thread works too); light blocks stay on the audio thread, which never waits on idle workers | | 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` |@@ -72,6 +72,7 @@ Legend: ✅ works · 🟡 partial · ❌ missing.  | Date | Change | Summary | |---|---|---|+| 2026-10-20 | trivial | Realtime strips stay on the audio thread unless a block is heavy, then share a dedicated worker pool | | 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 |