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
@@ -3693,6 +3693,7 @@ name = "soundcraft-mix" version = "0.9.0" dependencies = [ "log",+ "proptest", "rayon", "soundcraft-au-host", "soundcraft-audio-io",@@ -21,5 +21,8 @@ log.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rayon.workspace = true +[dev-dependencies]+proptest.workspace = true+ [lints] workspace = truenew file mode 100644@@ -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));+ }+ }+ }+}@@ -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);new file mode 100644@@ -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}");+ }+ }+}@@ -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");@@ -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).@@ -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 |