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 = true
diff --git a/crates/mix/src/clip_index.rs b/crates/mix/src/clip_index.rs
new 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.rs
new 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 |
