collective

Browse / github.com/storytold/soundcraft / patch

Offline renders: fixed, cycled or seeded random block sizes for plugin testing

Offline renders: fixed, cycled or seeded random block sizes for plugin testing Offline bounces always ran in 1024-sample blocks, so hosted plugins never saw odd or changing block sizes — where many plugin bugs hide. `file.bounce_mix` and `file.bounce_stems` now take an optional `block`: a size (1–16384), a list that is cycled, or `{"random": {min, max, seed?}}`, seeded so a failing run can be repeated. Plugins are prepared for the largest size. The default is unchanged (1024), and realtime playback is unaffected (blocks of at most 512). Sizes asked for are capped at ten million blocks per job (or as many as the default would take, if more; normalizing counts twice, stem

Status
open
Score
0.00
Adopters
0 other operators
Author
forwardaudio (imported from pull request #1031, open)
Base
aa74bffac1aebfeedee0a3b60729ad8ceefc2195
Size
+499 −12 in 6 files
Review
pending
Shared
2026-10-28
Id
1f6ae59d357297ca1eb6b44e

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/file.rs crates/engine/src/io.rs crates/mix/src/blocks.rs crates/mix/src/lib.rs docs/control-protocol.md docs/plugin-parity.md

diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs--- a/crates/engine/src/cmd/file.rs+++ b/crates/engine/src/cmd/file.rs@@ -87,8 +87,8 @@ pub fn specs() -> Vec<CommandSpec> {                 crate::io::import_video_bytes(e, &path, bytes, at)             }         ),-        cmd!(noundo "file.bounce_mix", "Bounce Mix...", ["File"], Some("Cmd+Alt+B"), "{path, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, source?: main|bus name, normalize?: false, dither?: true, fold_down?: stereo}", always, bounce),-        cmd!(noundo "file.bounce_stems", "Bounce Stems...", [], None, "{dir, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, dither?: true, fold_down?: stereo}", has_tracks, |e, p| {+        cmd!(noundo "file.bounce_mix", "Bounce Mix...", ["File"], Some("Cmd+Alt+B"), "{path, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, source?: main|bus name, normalize?: false, dither?: true, fold_down?: stereo, block?: n | [n, …] (cycled) | {random: {min, max, seed?}}: render block sizes 1..16384, default 1024}", always, bounce),+        cmd!(noundo "file.bounce_stems", "Bounce Stems...", [], None, "{dir, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, dither?: true, fold_down?: stereo, block?: n | [n, …] (cycled) | {random: {min, max, seed?}}: render block sizes 1..16384, default 1024}", has_tracks, |e, p| {             let dir = str_param(p, "dir").ok_or_else(|| bad("file.bounce_stems", "`dir` required"))?.to_string();             let r = range_param(e, "file.bounce_stems", p)?;             let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };@@ -103,7 +103,9 @@ pub fn specs() -> Vec<CommandSpec> {                 _ => soundcraft_audio_io::BitDepth::Int24,             };             let fold = fold_down_param(p, "file.bounce_stems")?;-            let files = crate::io::bounce_stems_with(e, &dir, r, &soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None }, fold)?;+            let sizes = block_sizes_param(p, "file.bounce_stems")?;+            let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None };+            let files = crate::io::bounce_stems_in_blocks(e, &dir, r, &opts, fold, &sizes)?;             Ok(json!({"files": files}))         }),         cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| {@@ -325,7 +327,39 @@ pub fn bounce_job(e: &Engine, p: &Value) -> Result<crate::io::Bounce> {     let normalize = bool_or(p, "normalize", false);     let fold = fold_down_param(p, "file.bounce_mix")?;     let bus = bounce_bus(e, p)?;-    crate::io::Bounce::new(e.session_arc(), std::path::Path::new(&path), r, &opts, bus, normalize, fold)+    let sizes = block_sizes_param(p, "file.bounce_mix")?;+    crate::io::check_block_work("file.bounce_mix", &sizes, r.len(), if normalize { 2 } else { 1 })?;+    Ok(crate::io::Bounce::new(e.session_arc(), std::path::Path::new(&path), r, &opts, bus, normalize, fold)?.with_block_sizes(sizes))+}++/// `block`: the render's block sizes. A number (every block that size), a list of numbers (cycled)+/// or `{"random": {min, max, seed?}}` (seeded, so a failing run can be repeated); 1024 when absent.+pub fn block_sizes_param(p: &Value, cmd: &str) -> Result<soundcraft_mix::BlockSizes> {+    use soundcraft_mix::BlockSizes;+    let size = |v: &Value| v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(cmd, "block sizes must be whole numbers"));+    let sizes = match p.get("block") {+        None | Some(Value::Null) => return Ok(BlockSizes::default()),+        Some(Value::Array(v)) => {+            if v.len() > soundcraft_mix::blocks::MAX_CYCLE {+                return Err(bad(cmd, format!("a block size list holds 1 to {} sizes", soundcraft_mix::blocks::MAX_CYCLE)));+            }+            BlockSizes::Cycle(v.iter().map(size).collect::<Result<_>>()?)+        }+        Some(Value::Object(o)) => {+            let r = o.get("random").filter(|r| r.is_object()).ok_or_else(|| bad(cmd, "`block` object must be {\"random\": {min, max, seed?}}"))?;+            let field = |k: &str| r.get(k).ok_or_else(|| bad(cmd, format!("`block.random.{k}` required"))).and_then(size);+            let seed = match r.get("seed") {+                None | Some(Value::Null) => 0,+                Some(s) => s.as_u64().ok_or_else(|| bad(cmd, "`block.random.seed` must be a whole number"))?,+            };+            BlockSizes::Random { min: field("min")?, max: field("max")?, seed }+        }+        Some(v) => BlockSizes::Fixed(size(v)?),+    };+    match sizes.problem() {+        Some(why) => Err(bad(cmd, why)),+        None => Ok(sizes),+    } }  /// Write the score of the chosen MIDI tracks as MusicXML (or SVG when `svg`).@@ -362,6 +396,90 @@ mod tests {         path.to_string_lossy().into_owned()     } +    #[test]+    fn bounces_take_block_sizes_and_sound_the_same() {+        let dir = std::env::temp_dir().join(format!("sc-bounce-blocks-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&dir);+        std::fs::create_dir_all(&dir).unwrap();+        let mut e = Engine::default();+        e.execute("file.import_audio", &json!({"path": tone_wav(&dir, "tone.wav")})).unwrap();+        e.execute("mix.insert", &json!({"track": "tone", "plugin": "maximizer"})).unwrap();+        let bounce = |e: &mut Engine, name: &str, block: Value| {+            let path = dir.join(name);+            e.execute(+                "file.bounce_mix",+                &json!({"path": path.to_string_lossy(), "start": 0, "end": 4800, "bit_depth": "32f", "dither": false, "block": block}),+            )+            .map(|_| soundcraft_audio_io::decode(&std::fs::read(&path).unwrap(), Some("wav")).unwrap().1.channels)+        };+        let reference = bounce(&mut e, "ref.wav", Value::Null).unwrap();++        // The sizes asked for reach the renderer, in both passes of a normalized bounce and for stems.+        let job = |block: Value, normalize: bool| {+            let p = json!({"path": dir.join("j.wav").to_string_lossy(), "start": 0, "end": 4800, "block": block, "normalize": normalize});+            bounce_job(&e, &p).unwrap()+        };+        use soundcraft_mix::BlockSizes;+        assert_eq!(job(Value::Null, false).block_sizes(), &BlockSizes::default());+        assert_eq!(job(json!(37), false).block_sizes(), &BlockSizes::Fixed(37));+        let mut normalized = job(json!([5, 9]), true);+        assert_eq!(normalized.block_sizes(), &BlockSizes::Cycle(vec![5, 9]));+        // Step into the second pass (its first blocks may hold no samples while the maximizer's+        // latency is skipped, so progress can stay at one half for a few steps).+        for _ in 0..100_000 {+            if normalized.progress() > 0.5 {+                break;+            }+            normalized.step(|| false).unwrap();+        }+        assert!(normalized.progress() > 0.5, "in the second pass");+        assert_eq!(normalized.block_sizes(), &BlockSizes::Cycle(vec![5, 9]), "the second pass keeps them");+        let stem = crate::io::stem_bounce(+            e.session().clone(),+            &dir.join("st.wav"),+            soundcraft_time::Range::new(0, 4800),+            &soundcraft_audio_io::EncodeOptions::default(),+            false,+            &BlockSizes::Fixed(77),+        )+        .unwrap();+        assert_eq!(stem.block_sizes(), &BlockSizes::Fixed(77));+        for block in [json!(1), json!(37), json!([1, 512, 3, 2048]), json!({"random": {"min": 1, "max": 900, "seed": 42}})] {+            assert_eq!(bounce(&mut e, "b.wav", block.clone()).unwrap(), reference, "block {block}");+        }+        for block in [json!(0), json!(16_385), json!([]), json!([1, -2]), json!("512"), json!({"random": {"min": 9, "max": 3}}), json!({"other": 1})]+        {+            assert!(bounce(&mut e, "bad.wav", block.clone()).is_err(), "block {block}");+        }+        let stems = dir.join("stems");+        let r = e.execute("file.bounce_stems", &json!({"dir": stems.to_string_lossy(), "start": 0, "end": 4800, "block": [7, 300]}));+        assert_eq!(r.map(|v| v["files"].as_array().map_or(0, Vec::len)), Ok(1));+        assert!(e.execute("file.bounce_stems", &json!({"dir": stems.to_string_lossy(), "block": 0})).is_err());+        assert!(bounce(&mut e, "s.wav", json!({"random": {"min": 2, "max": 9, "seed": null}})).is_ok(), "a null seed is the default seed");++        // Tiny blocks over a long range are refused up front instead of rendering for hours.+        let hours = |e: &mut Engine, cmd: &str, extra: Value| {+            let mut p =+                json!({"path": dir.join("long.wav").to_string_lossy(), "dir": stems.to_string_lossy(), "start": 0, "end": {"seconds": 4.0 * 3600.0}});+            p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());+            e.execute(cmd, &p).map(|_| ())+        };+        for cmd in ["file.bounce_mix", "file.bounce_stems"] {+            let err = hours(&mut e, cmd, json!({"block": 1})).unwrap_err().to_string();+            assert!(err.contains("at most"), "{cmd}: {err}");+        }+        // Default-size bounces are never refused, however many stems and hours.+        assert!(crate::io::check_block_work("file.bounce_stems", &BlockSizes::default(), 4 * 3600 * 192_000, 500).is_ok());+        assert!(+            crate::io::check_block_work("file.bounce_stems", &BlockSizes::fixed(2048), 4 * 3600 * 192_000, 500).is_ok(),+            "fewer blocks than the default"+        );+        // 4 h in 128-sample blocks is 5.4 million blocks; normalizing renders twice, over the limit.+        let err = hours(&mut e, "file.bounce_mix", json!({"block": 128, "normalize": true})).unwrap_err().to_string();+        assert!(err.contains("at most"), "{err}");+        let _ = std::fs::remove_dir_all(&dir);+    }+     /// Issue #597: the app decodes on a worker thread and hands the audio over; the command then     /// only places it. Audio decoded for another rate is ignored and the file is read again.     #[test]diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs--- a/crates/engine/src/io.rs+++ b/crates/engine/src/io.rs@@ -437,6 +437,8 @@ pub struct Bounce {     normalize: bool,     /// Measuring the peak to normalize to, the first of two passes.     measuring: bool,+    /// The block sizes it renders in (see [`Bounce::with_block_sizes`]).+    sizes: soundcraft_mix::BlockSizes,     render: soundcraft_mix::RangeRender,     block: Vec<Vec<f32>>,     done: usize,@@ -485,7 +487,8 @@ impl Bounce {             let _ = std::fs::remove_file(&part);             EngineError::Io(format!("{}: {err}", path.display()))         })?;-        let render = soundcraft_mix::RangeRender::new(&s, bus, r, 1024);+        let sizes = soundcraft_mix::BlockSizes::default();+        let render = soundcraft_mix::RangeRender::with_block_sizes(&s, bus, r, &sizes);         let meter = soundcraft_dsp::meter::LoudnessMeter::new(s.sample_rate.as_f64() as f32, channels);         Ok(Bounce {             session: s,@@ -496,6 +499,7 @@ impl Bounce {             fold,             normalize,             measuring: normalize,+            sizes,             render,             block: Vec::new(),             done: 0,@@ -506,6 +510,23 @@ impl Bounce {         })     } +    /// Renders in blocks of `sizes` instead of 1024 samples, with plugins prepared for the largest:+    /// odd and changing sizes find plugin bugs that fixed ones hide. Call before the first step.+    pub fn with_block_sizes(mut self, sizes: soundcraft_mix::BlockSizes) -> Self {+        if self.done != 0 || (self.normalize && !self.measuring) {+            log::warn!("{}: block sizes can only change before the bounce starts", self.path.display());+            return self;+        }+        self.render = soundcraft_mix::RangeRender::with_block_sizes(&self.session, self.bus, self.range, &sizes);+        self.sizes = sizes;+        self+    }++    /// The block sizes the bounce renders in now (in either pass when normalizing).+    pub fn block_sizes(&self) -> &soundcraft_mix::BlockSizes {+        self.render.block_sizes()+    }+     pub fn path(&self) -> &Path {         &self.path     }@@ -545,7 +566,7 @@ impl Bounce {                     self.peak = 0.0;                     self.done = 0;                     self.measuring = false;-                    self.render = soundcraft_mix::RangeRender::new(&self.session, self.bus, self.range, 1024);+                    self.render = soundcraft_mix::RangeRender::with_block_sizes(&self.session, self.bus, self.range, &self.sizes);                 }                 None => return self.finish().map(Some),             }@@ -670,7 +691,8 @@ fn bounce_layout(s: &Session, bus: Option<BusId>, fold_stereo: bool) -> (Channel  /// The first `n` frames of `chunk`, folded down from `fold` when set and scaled by `gain`. fn fill(block: &mut Vec<Vec<f32>>, chunk: &[Vec<f32>], n: usize, fold: Option<ChannelFormat>, gain: f32) {-    let folded = fold.map(|from| soundcraft_mix::fold_down_stereo(from, chunk));+    // Fold only the block's `n` samples: the buffers hold as many as the largest block size.+    let folded = fold.map(|from| soundcraft_mix::fold_down_stereo(from, &chunk.iter().map(|c| c[..n.min(c.len())].to_vec()).collect::<Vec<_>>()));     let src = folded.as_deref().unwrap_or(chunk);     block.resize_with(src.len(), Vec::new);     for (d, c) in block.iter_mut().zip(src) {@@ -687,6 +709,49 @@ pub fn bounce_stems(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions) -> Re  /// [`bounce_stems`] in the main format, or folded down to stereo when `fold_stereo`. pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions, fold_stereo: bool) -> Result<Vec<String>> {+    bounce_stems_in_blocks(e, dir, r, opts, fold_stereo, &soundcraft_mix::BlockSizes::default())+}++/// Refuses a job of `renders` passes over `samples` in blocks of `sizes` that would take more than+/// [`soundcraft_mix::blocks::MAX_RENDER_BLOCKS`] blocks: tiny blocks over a long range would keep+/// the engine (and an app waiting on it) busy for hours.+/// Only sizes asked for are limited: a job in the default 1024-sample blocks renders as it always+/// has, however long, and other sizes may always take as many blocks as the default would.+pub fn check_block_work(cmd: &str, sizes: &soundcraft_mix::BlockSizes, samples: i64, renders: usize) -> Result<()> {+    let samples = u64::try_from(samples.max(0)).unwrap_or(0);+    let blocks = sizes.blocks_for(samples).saturating_mul(renders as u64);+    let default = soundcraft_mix::BlockSizes::default().blocks_for(samples).saturating_mul(renders as u64);+    let max = soundcraft_mix::blocks::MAX_RENDER_BLOCKS.max(default);+    if *sizes != soundcraft_mix::BlockSizes::default() && blocks > max {+        return Err(EngineError::BadParams(+            cmd.into(),+            format!("these block sizes need about {blocks} blocks; a bounce may take at most {max}: use larger blocks or a shorter range"),+        ));+    }+    Ok(())+}++/// One stem of [`bounce_stems_in_blocks`]: the soloed session `solo` bounced to `path`.+pub(crate) fn stem_bounce(+    solo: Session,+    path: &Path,+    r: Range,+    opts: &EncodeOptions,+    fold_stereo: bool,+    sizes: &soundcraft_mix::BlockSizes,+) -> Result<Bounce> {+    Ok(Bounce::new(Arc::new(solo), path, r, opts, None, false, fold_stereo)?.with_block_sizes(sizes.clone()))+}++/// [`bounce_stems_with`], rendered in blocks of `sizes` (see [`Bounce::with_block_sizes`]).+pub fn bounce_stems_in_blocks(+    e: &Engine,+    dir: &str,+    r: Range,+    opts: &EncodeOptions,+    fold_stereo: bool,+    sizes: &soundcraft_mix::BlockSizes,+) -> Result<Vec<String>> {     let s = e.session();     if r.len() > s.sample_rate.samples(MAX_BOUNCE_SECS) {         return Err(EngineError::BadParams("file.bounce_stems".into(), "bounces are limited to 4 hours".into()));@@ -704,13 +769,14 @@ pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions,         .filter(|t| matches!(t.kind, TrackKind::Audio | TrackKind::Instrument | TrackKind::Midi) && !t.inactive && !t.mixer.mute)         .map(|t| (t.id, t.name.clone()))         .collect();+    check_block_work("file.bounce_stems", sizes, r.len(), stems.len())?;     for (id, name) in stems {         let mut solo = s.clone();         for t in &mut solo.tracks {             t.mixer.solo = t.id == id;         }         let path = Path::new(dir).join(format!("{} - {}.{ext}", sanitize_name(&s.name), sanitize_name(&name)));-        Bounce::new(Arc::new(solo), &path, r, opts, None, false, fold_stereo)?.run()?;+        stem_bounce(solo, &path, r, opts, fold_stereo, sizes)?.run()?;         out.push(path.to_string_lossy().into_owned());     }     Ok(out)diff --git a/crates/mix/src/blocks.rs b/crates/mix/src/blocks.rsnew file mode 100644--- /dev/null+++ b/crates/mix/src/blocks.rs@@ -0,0 +1,176 @@+//! How an offline render splits its range into blocks. Plugins must handle any block size from 1+//! up to the size they were prepared for, and many bugs only show at odd or changing sizes, so a+//! render can use one size, cycle through a list, or draw sizes from a seeded generator (the+//! same seed gives the same sizes, so a failure can be reproduced).++/// The largest block the mix engine processes (`MixEngine::new` clamps to it).+pub const MAX_BLOCK: usize = 16_384;+/// Most sizes a [`BlockSizes::Cycle`] may list.+pub const MAX_CYCLE: usize = 4096;+/// Most blocks one render job should take ([`BlockSizes::blocks_for`]): each block costs time+/// whatever its size (a few to tens of µs with many tracks), so 1-sample blocks over hours of+/// audio would keep the engine busy for hours. Ten million is minutes at worst.+pub const MAX_RENDER_BLOCKS: u64 = 10_000_000;++/// The block sizes of a render, each 1..=[`MAX_BLOCK`].+#[derive(Debug, Clone, PartialEq, Eq)]+pub enum BlockSizes {+    /// Every block this size (the last one may be shorter).+    Fixed(usize),+    /// The sizes in turn, from the start again after the last.+    Cycle(Vec<usize>),+    /// Sizes drawn evenly from `min..=max` by a generator seeded with `seed`.+    Random { min: usize, max: usize, seed: u64 },+}++impl BlockSizes {+    /// One size, clamped to 1..=[`MAX_BLOCK`].+    pub fn fixed(n: usize) -> Self {+        BlockSizes::Fixed(n.clamp(1, MAX_BLOCK))+    }++    /// Why these sizes can't be used (`None` when they can).+    pub fn problem(&self) -> Option<String> {+        let in_range = |n: usize| (1..=MAX_BLOCK).contains(&n);+        match self {+            BlockSizes::Fixed(n) if !in_range(*n) => Some(format!("block sizes must be 1 to {MAX_BLOCK}")),+            BlockSizes::Cycle(v) if v.is_empty() || v.len() > MAX_CYCLE => Some(format!("a block size list holds 1 to {MAX_CYCLE} sizes")),+            BlockSizes::Cycle(v) if !v.iter().all(|n| in_range(*n)) => Some(format!("block sizes must be 1 to {MAX_BLOCK}")),+            BlockSizes::Random { min, max, .. } if !in_range(*min) || !in_range(*max) || min > max => {+                Some(format!("random block sizes need 1 <= min <= max <= {MAX_BLOCK}"))+            }+            _ => None,+        }+    }++    /// The largest block these sizes produce: what plugins must be prepared for.+    pub fn max(&self) -> usize {+        let n = match self {+            BlockSizes::Fixed(n) => *n,+            BlockSizes::Cycle(v) => v.iter().copied().max().unwrap_or(1),+            BlockSizes::Random { max, .. } => *max,+        };+        n.clamp(1, MAX_BLOCK)+    }++    /// The average block size.+    pub fn mean(&self) -> f64 {+        let n = match self {+            BlockSizes::Fixed(n) => *n as f64,+            BlockSizes::Cycle(v) if !v.is_empty() => v.iter().sum::<usize>() as f64 / v.len() as f64,+            BlockSizes::Cycle(_) => 1.0,+            BlockSizes::Random { min, max, .. } => (*min as f64 + *max as f64) / 2.0,+        };+        n.clamp(1.0, MAX_BLOCK as f64)+    }++    /// About how many blocks rendering `samples` takes (on average for random sizes).+    pub fn blocks_for(&self, samples: u64) -> u64 {+        (samples as f64 / self.mean()).ceil() as u64+    }++    /// The sizes in order, without end.+    pub fn iter(&self) -> BlockIter {+        let state = match self {+            BlockSizes::Random { seed, .. } => *seed,+            _ => 0,+        };+        BlockIter { sizes: self.clone(), at: 0, state }+    }+}++impl Default for BlockSizes {+    /// 1024-sample blocks, the size offline renders have always used.+    fn default() -> Self {+        BlockSizes::Fixed(1024)+    }+}++/// An endless sequence of block sizes (see [`BlockSizes::iter`]); every size is 1..=[`MAX_BLOCK`].+#[derive(Debug, Clone)]+pub struct BlockIter {+    sizes: BlockSizes,+    at: usize,+    state: u64,+}++impl BlockIter {+    /// The sizes this sequence follows.+    pub fn sizes(&self) -> &BlockSizes {+        &self.sizes+    }++    pub fn next_size(&mut self) -> usize {+        let n = match &self.sizes {+            BlockSizes::Fixed(n) => *n,+            BlockSizes::Cycle(v) => {+                let n = v.get(self.at % v.len().max(1)).copied().unwrap_or(1);+                self.at = self.at.wrapping_add(1);+                n+            }+            BlockSizes::Random { min, max, .. } => {+                let span = max.saturating_sub(*min).saturating_add(1) as u64;+                min.saturating_add((self.splitmix() % span.max(1)) as usize)+            }+        };+        n.clamp(1, MAX_BLOCK)+    }++    /// SplitMix64: small, fast and the same on every platform.+    fn splitmix(&mut self) -> u64 {+        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);+        let mut z = self.state;+        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);+        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);+        z ^ (z >> 31)+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    fn take(b: &BlockSizes, n: usize) -> Vec<usize> {+        let mut it = b.iter();+        (0..n).map(|_| it.next_size()).collect()+    }++    #[test]+    fn fixed_and_cycled_sizes() {+        assert_eq!(take(&BlockSizes::fixed(37), 3), [37, 37, 37]);+        assert_eq!(BlockSizes::fixed(0), BlockSizes::Fixed(1));+        assert_eq!(BlockSizes::fixed(1 << 20).max(), MAX_BLOCK);+        let c = BlockSizes::Cycle(vec![1, 512, 37]);+        assert_eq!(take(&c, 7), [1, 512, 37, 1, 512, 37, 1]);+        assert_eq!(c.max(), 512);+        assert_eq!(BlockSizes::default(), BlockSizes::Fixed(1024));+        assert_eq!(c.blocks_for(55_000), 300, "mean 183.3");+        assert_eq!(BlockSizes::fixed(1).blocks_for(48_000), 48_000);+        assert_eq!(BlockSizes::Random { min: 1, max: 99, seed: 0 }.blocks_for(500), 10);+    }++    #[test]+    fn random_sizes_stay_in_range_and_repeat_per_seed() {+        let r = BlockSizes::Random { min: 1, max: 64, seed: 7 };+        let a = take(&r, 2000);+        assert!(a.iter().all(|n| (1..=64).contains(n)));+        assert!(a.contains(&1) && a.contains(&64), "both ends are reached");+        assert_eq!(a, take(&r, 2000), "the same seed gives the same sizes");+        assert_ne!(a, take(&BlockSizes::Random { min: 1, max: 64, seed: 8 }, 2000));+        assert_eq!(take(&BlockSizes::Random { min: 5, max: 5, seed: 0 }, 3), [5, 5, 5]);+        assert_eq!(r.max(), 64);+    }++    #[test]+    fn unusable_sizes_are_reported() {+        assert!(BlockSizes::Fixed(0).problem().is_some());+        assert!(BlockSizes::Fixed(MAX_BLOCK + 1).problem().is_some());+        assert!(BlockSizes::Cycle(Vec::new()).problem().is_some());+        assert!(BlockSizes::Cycle(vec![1, 0]).problem().is_some());+        assert!(BlockSizes::Cycle(vec![1; MAX_CYCLE + 1]).problem().is_some());+        assert!(BlockSizes::Random { min: 9, max: 8, seed: 0 }.problem().is_some());+        assert!(BlockSizes::Random { min: 0, max: 8, seed: 0 }.problem().is_some());+        assert_eq!(BlockSizes::Random { min: 1, max: MAX_BLOCK, seed: u64::MAX }.problem(), None);+        assert_eq!(BlockSizes::Cycle(vec![1, MAX_BLOCK]).problem(), None);+    }+}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@@ -50,6 +50,9 @@ //! [`Insert::sample`]: soundcraft_model::Insert::sample #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod blocks;+pub use blocks::{BlockIter, BlockSizes};+ use soundcraft_dsp::pan::{PanLaw, SpeakerPos, SurroundParams, gains}; use soundcraft_dsp::{Plugin, SampleSource, Transport, db_to_gain}; use soundcraft_midi::CtrlKind;@@ -2243,6 +2246,7 @@ 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,+    sizes: BlockIter,     bus: Option<BusId>,     tmp: Vec<Vec<f32>>,     start: Samples,@@ -2254,11 +2258,16 @@ pub struct RangeRender { impl RangeRender {     /// Renders the main mix over `range`, or `bus` when one is named.     pub fn new(s: &Session, bus: Option<BusId>, range: Range, block: usize) -> Self {+        Self::with_block_sizes(s, bus, range, &BlockSizes::fixed(block))+    }++    /// Like [`RangeRender::new`], in blocks of `sizes` (plugins are prepared for the largest).+    pub fn with_block_sizes(s: &Session, bus: Option<BusId>, range: Range, sizes: &BlockSizes) -> Self {         let nch = source_channels(s, bus);-        let eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);+        let eng = MixEngine::new(s.sample_rate.as_f64() as f32, sizes.max());         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, sizes: sizes.iter(), bus, tmp, start: range.start, done: 0, total: len, latency: 0 }     }      fn render(&mut self, s: &Session, pos: Samples, n: usize) {@@ -2271,6 +2280,11 @@ impl RangeRender {         }     } +    /// The block sizes it renders in.+    pub fn block_sizes(&self) -> &BlockSizes {+        self.sizes.sizes()+    }+     /// Renders one more block of the mix of `s` (the session given to [`RangeRender::new`]) 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.@@ -2278,7 +2292,7 @@ impl RangeRender {         if self.done >= self.total {             return None;         }-        let n = (self.total - self.done).min(self.eng.max_block());+        let n = (self.total - self.done).min(self.sizes.next_size()).min(self.eng.max_block());         self.render(s, self.start + self.done as i64, n);         if self.done == 0 {             // The first block tells the mix's latency: render that much more and drop the head.@@ -3360,6 +3374,93 @@ mod tests {         }     } +    #[test]+    fn offline_renders_in_changing_block_sizes_match_fixed_ones() {+        let mut s = Session::default();+        let mut imp = vec![0.0f32; 48_000];+        imp[1000] = 1.0;+        imp[1777] = -0.5;+        s.pool.insert(SourceId(901), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![imp] })));+        let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);+        let cid = s.new_clip_id();+        let tr = s.track_mut(t).unwrap();+        tr.playlist_mut().unwrap().clips.push(Clip::audio(cid, "i", SourceId(901), 0, 0, 48_000));+        tr.mixer.inserts[0] = Some(Insert::new("maximizer"));+        let collect = |sizes: &BlockSizes| {+            let mut got = Vec::new();+            let mut blocks = Vec::new();+            let mut render = RangeRender::with_block_sizes(&s, None, Range::new(900, 2900), sizes);+            while let Some((chunk, n)) = render.next_block(&s) {+                got.extend_from_slice(&chunk[0][..n]);+                blocks.push(n);+            }+            (got, blocks)+        };+        let (reference, _) = collect(&BlockSizes::fixed(512));+        assert_eq!(reference.len(), 2000);+        for sizes in [BlockSizes::Cycle(vec![1, 37, 1000, 3]), BlockSizes::Random { min: 1, max: 700, seed: 3 }, BlockSizes::fixed(1)] {+            let (got, blocks) = collect(&sizes);+            assert_eq!(got, reference, "{sizes:?}: same samples whatever the block sizes");+            assert!(blocks.iter().all(|n| *n <= sizes.max()), "{sizes:?}");+        }+        let (_, blocks) = collect(&BlockSizes::Cycle(vec![1, 37, 1000, 3]));+        assert!(blocks.contains(&1) && blocks.contains(&37) && blocks.contains(&3), "the cycle reaches the plugin: {blocks:?}");+    }++    #[test]+    fn plugins_receive_the_block_sizes_asked_for() {+        struct Probe(Box<dyn Plugin>, Arc<std::sync::Mutex<Vec<usize>>>);+        impl Plugin for Probe {+            fn info(&self) -> &'static soundcraft_dsp::PluginInfo {+                self.0.info()+            }+            fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {+                self.0.prepare(sr, mb, ch);+            }+            fn reset(&mut self) {+                self.0.reset();+            }+            fn set_param(&mut self, id: &str, v: f32) -> bool {+                self.0.set_param(id, v)+            }+            fn param(&self, id: &str) -> Option<f32> {+                self.0.param(id)+            }+            fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {+                self.1.lock().unwrap().push(frames);+                self.0.process(io, frames);+            }+        }+        let (mut s, t) = session_with_dc(0.5, 48_000);+        s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.probe"));+        for (sizes, prepared) in [(BlockSizes::Cycle(vec![1, 37, 1000, 3]), 1000), (BlockSizes::fixed(5), 16), (BlockSizes::fixed(16_384), 16_384)] {+            let seen = Arc::new(std::sync::Mutex::new(Vec::new()));+            let mut render = RangeRender::with_block_sizes(&s, None, Range::new(0, 3000), &sizes);+            assert_eq!(render.eng.max_block(), prepared, "{sizes:?}: what plugins are prepared for");+            render.eng.set_external_instances(true);+            let mut gain = soundcraft_dsp::create("gain").unwrap();+            gain.prepare(48_000.0, render.eng.max_block(), 1);+            render.eng.adopt(vec![PreparedInstance {+                track: t,+                slot: 0,+                id: "clap:test.probe".into(),+                ch: 1,+                plugin: Box::new(Probe(gain, seen.clone())),+            }]);+            while render.next_block(&s).is_some() {}+            let seen = seen.lock().unwrap().clone();+            assert!(seen.iter().all(|n| *n <= prepared), "{sizes:?}: never more than prepared");+            let mut want = sizes.iter();+            let mut left = 3000;+            for (i, n) in seen.iter().enumerate() {+                let next = want.next_size().min(left);+                assert_eq!(*n, next, "{sizes:?}: block {i} reaches the plugin unsplit");+                left -= next;+            }+            assert_eq!(left, 0, "{sizes:?}: the whole range went through the plugin");+        }+    }+     #[test]     fn a_plugins_bogus_latency_is_capped() {         struct Latent(Box<dyn Plugin>);diff --git a/docs/control-protocol.md b/docs/control-protocol.md--- a/docs/control-protocol.md+++ b/docs/control-protocol.md@@ -93,6 +93,30 @@ System to Dark, or on Linux to the desktop's portal setting when one is availabl track) brings the track to the top of the Edit window and the left of the Mix window, the same as Scroll Into View in the track name menu. A window not on screen catches up when it is next shown. +## Block sizes for plugin tests++Offline renders (`file.bounce_mix`, `file.bounce_stems`) run in 1024-sample blocks unless+`block` says otherwise: a number (every block that size), a list (cycled) or+`{"random": {"min": …, "max": …, "seed": …}}` (sizes drawn from the range by a seeded generator,+seed 0 when left out, so a failing run can be repeated). Sizes are 1 to 16384; plugins are+prepared for the largest (at least 16). Audio Units still receive at most 8192 samples per call:+the AU host slices larger blocks. A job may take at most ten million blocks (normalizing counts+twice, stems once per track), so 1-sample blocks suit short ranges.++On a session without automation, a plugin that handles any block size renders the same samples+whatever the sizes, so bouncing once with the default and once with odd sizes, then comparing the+files, catches block-size bugs. Automation is read once per block (plugin parameters) or+interpolated across each block (faders), so with automation, or for a plugin whose processing+legitimately depends on block size, compare with a tolerance instead of expecting identical files:++```sh+soundcraft-cli run --cmd 'file.import_audio={"path":"in.wav"}' --cmd 'mix.insert={"track":"in","plugin":"vst3:<class id>"}' \+  --cmd 'file.bounce_mix={"path":"ref.wav","bit_depth":"32f","dither":false}' \+  --cmd 'file.bounce_mix={"path":"odd.wav","bit_depth":"32f","dither":false,"block":{"random":{"min":1,"max":4096,"seed":7}}}'+```++Realtime playback still mixes in blocks of at most 512 samples, whatever the device buffer.+ ## Panel docking  Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Commands target the active Edit or Mix view; floating panels stay inside the current viewport. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents.diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md--- a/docs/plugin-parity.md+++ b/docs/plugin-parity.md@@ -1,6 +1,6 @@ # Plugin parity: built-in processors and plugin hosting -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (offline block sizes for plugin tests; previously minor: Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1  Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins cover the rest. This doc tracks both. Our plugins have our own names and are written from@@ -41,6 +41,7 @@ User report #152: a saved instrument level does not reach the bounce. | VST2, LV2, LADSPA | — | ❌ (#30) | Beyond Pro Tools; VST2 SDK is no longer licensable | | Scan, vendor menus, instrument picker | ✅ | ✅ | #23 | | Audio, parameters, automation, latency (PDC) | ✅ | ✅ | |+| Odd and changing block sizes (offline) | — | ✅ `block` on `file.bounce_mix` / `file.bounce_stems`: fixed, cycled or seeded random sizes 1–16384 | Realtime mixing is capped at 512-sample blocks | | Notes / MIDI to instruments | ✅ | ✅ | But no live MIDI input (G-02) | | State saved in sessions, presets | ✅ | ✅ | | | Create/destroy off the audio thread | ✅ | ✅ | |@@ -58,6 +59,7 @@ User report #152: a saved instrument level does not reach the bounce.  | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | Offline renders take fixed, cycled or seeded random block sizes (`block`), to test hosted plugins at odd sizes | | 2026-10-23 | trivial | Modulation: Ring Modulator (`ring_mod`), also used by the AudioSuite Modulation menu | | 2026-10-13 | minor | VST3 host: IMessage and IAttributeList for plugins whose halves talk through messages (Line 6 Helix Stadium Native verified) | | 2026-10-13 | trivial | Sampler ping-pong loop phase (#463) |