Browse / github.com/storytold/soundcraft / patch
Playback: render each device callback in one block (up to 8192 frames)
Playback: render each device callback in one block (up to 8192 frames) Playback mixed in blocks of at most 512 frames whatever the device buffer, so with a 1024, 2048 or 4096-frame buffer plugins saw each callback split into 512-frame pieces and were prepared for 512. Plugins whose processing depends on their block size (FFT or lookahead sizes, CPU per callback) behaved differently than in other hosts. Each Player now sizes its block when it opens: the device buffer × the session/device rate ratio, between 512 and 8192. The mix engine, its buffers and the third-party plugins Side prepares all use that size; a buffer change already reopens the stream, so plugins
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- forwardaudio (imported from pull request #1034, open)
- Base
aa74bffac1aebfeedee0a3b60729ad8ceefc2195- Size
- +260 −38 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
276a7f8f6c1629886fc5776b
How it went
No other operator has reported using it yet.
Diff
raw · crates/playback/src/lib.rs crates/ui-egui/src/windows.rs docs/architecture.md docs/gaps.md docs/hardware-parity.md docs/roadmap.md
@@ -36,7 +36,36 @@ use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering}; use std::sync::mpsc::{Receiver, Sender, SyncSender, channel, sync_channel}; use std::sync::{Arc, Mutex, PoisonError}; +/// The largest block the mix renders when the device buffer is unknown or smaller, and the+/// smallest one plugins are prepared for: what playback always used before it followed the+/// device buffer. const BLOCK: usize = 512;+/// The largest block playback renders in one go (8192, the largest device buffer offered).+pub const MAX_BLOCK: usize = 8192;++/// True when the session has automation to play: then playback keeps to [`BLOCK`]-frame blocks.+/// The mix reads volume, trim, VCA, send and plugin-parameter automation once per block (at its+/// ends, or its start), so larger blocks would coarsen automation from about 10 ms to as much as+/// 170 ms. Any lane with points counts, whatever the track's automation mode: a few blocks too+/// many cost nothing audible, a dip that vanishes does.+fn plays_automation(s: &Session) -> bool {+ s.tracks.iter().any(|t| t.automation.iter().any(|l| !l.points.is_empty()))+}++/// The largest block playback renders for a device buffer of `buffer` frames (`None`: the device+/// default, size unknown), so a callback renders in one block, plugins see the buffer the user+/// chose, and they are prepared for exactly that. A session faster than the device renders more+/// frames than it plays, hence the rate ratio. Never below [`BLOCK`] or above [`MAX_BLOCK`];+/// anything larger (an unknown default, a callback larger than asked for, varispeed above 1x) is+/// rendered in several blocks, and so is a session with automation ([`plays_automation`]). Some+/// drivers call back at their own period whatever buffer was asked for (WASAPI shared mode, about+/// 10 ms): plugins are then prepared for the chosen buffer and see the device's callbacks.+pub fn block_for(buffer: Option<u32>, device_rate: f64, session_rate: f64) -> usize {+ let Some(frames) = buffer else { return BLOCK };+ let ratio = if device_rate > 0.0 && session_rate > device_rate { session_rate / device_rate } else { 1.0 };+ let n = (f64::from(frames) * ratio).ceil();+ if n.is_finite() { (n as usize).clamp(BLOCK, MAX_BLOCK) } else { MAX_BLOCK }+} /// Room for replaced snapshots on their way back; `update_session` empties it before each send. const OLD_SESSIONS: usize = 16;@@ -171,10 +200,14 @@ struct AudioState { /// 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>>>,+ /// The largest block it renders and its plugins were prepared for ([`block_for`]).+ block: usize,+ /// The session has automation ([`plays_automation`]): blocks stay at [`BLOCK`] frames.+ automated: bool, } -fn new_engine(sr: f32) -> MixEngine {- let mut m = MixEngine::new(sr, BLOCK);+fn new_engine(sr: f32, block: usize) -> MixEngine {+ let mut m = MixEngine::new(sr, block); m.set_external_instances(true); m.metronome = true; m@@ -182,6 +215,20 @@ fn new_engine(sr: f32) -> MixEngine { impl AudioState { fn new(rx: Receiver<Cmd>, shared: Arc<Shared>, session: Arc<Session>, device_rate: f64, garbage: Sender<Vec<Box<dyn Plugin>>>) -> Self {+ Self::with_block(rx, shared, session, device_rate, garbage, BLOCK)+ }++ /// Like [`AudioState::new`], rendering blocks of up to `block` frames.+ fn with_block(+ rx: Receiver<Cmd>,+ shared: Arc<Shared>,+ session: Arc<Session>,+ device_rate: f64,+ garbage: Sender<Vec<Box<dyn Plugin>>>,+ block: usize,+ ) -> Self {+ let block = block.clamp(BLOCK, MAX_BLOCK);+ let automated = plays_automation(&session); let sr = session.sample_rate.as_f64() as f32; let nch = main_channels(&session); AudioState {@@ -190,18 +237,20 @@ impl AudioState { fold: fold_gains(&session), rx, shared,- mix: new_engine(sr),+ mix: new_engine(sr, block), session, pos: 0, playing: false, end: None, looped: None,- pending: vec![vec![0.0; BLOCK]; nch],+ pending: vec![vec![0.0; block]; nch], pending_len: 0, read: 0.0, device_rate, input: None, recording: false,+ block,+ automated, } } @@ -214,18 +263,23 @@ impl AudioState { Cmd::Session(s, 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));+ let mut old = std::mem::replace(&mut self.mix, new_engine(s.sample_rate.as_f64() as f32, self.block)); let _ = self.garbage.send(old.drain_plugins()); } self.mix.adopt(instances);+ let automated = plays_automation(&s);+ if automated && !self.automated {+ self.rebase_pending();+ } // The main format changed (a document edit, not steady state): resize. let nch = main_channels(&s); if self.pending.len() != nch {- self.pending = vec![vec![0.0; BLOCK]; nch];+ self.pending = vec![vec![0.0; self.block]; nch]; self.pending_len = 0; self.read = 0.0; } self.fold = fold_gains(&s);+ self.automated = automated; let old = std::mem::replace(&mut self.session, s); if let Some(tx) = &self.old_sessions { let _ = tx.try_send(old);@@ -271,16 +325,33 @@ impl AudioState { } } + /// Drops the frames rendered but not yet heard and moves the render position back to the+ /// first of them, so the next block renders from the audible position: nothing is skipped or+ /// played twice. A session that gains automation calls it, so its 512-frame blocks start now+ /// rather than after up to a whole long block rendered without the automation.+ fn rebase_pending(&mut self) {+ let heard = (self.read.max(0.0).floor() as usize).min(self.pending_len);+ if self.playing {+ let ahead = i64::try_from(self.pending_len - heard).unwrap_or(0);+ self.pos = self.pos.saturating_sub(ahead).max(0);+ self.shared.position.store(self.pos, Ordering::Relaxed);+ }+ self.read -= heard as f64;+ self.pending_len = 0;+ }+ /// Render the next block at the session rate into `pending`. fn render_next(&mut self, quantum: usize) {- let mut n = quantum.clamp(1, BLOCK);+ // Automation is read once per block: keep the blocks it was always read in.+ let largest = if self.automated { self.block.min(BLOCK) } else { self.block };+ let mut n = quantum.clamp(1, largest); if let Some(l) = self.looped && self.pos >= l.end { self.pos = l.start; } if let Some(l) = self.looped {- n = n.min(usize::try_from((l.end - self.pos).max(1)).unwrap_or(BLOCK));+ n = n.min(usize::try_from((l.end - self.pos).max(1)).unwrap_or(self.block)); } else if let Some(e) = self.end { if self.pos >= e { self.playing = false;@@ -288,20 +359,20 @@ impl AudioState { self.shared.playing.store(false, Ordering::Relaxed); n = 0; } else {- n = n.min(usize::try_from(e - self.pos).unwrap_or(BLOCK));+ n = n.min(usize::try_from(e - self.pos).unwrap_or(self.block)); } } if n == 0 { for c in &mut self.pending { c.iter_mut().for_each(|x| *x = 0.0); }- self.pending_len = BLOCK;+ self.pending_len = self.block; return; } if let Some(ring) = &self.input { let ch = ring.channels.load(Ordering::Relaxed).max(1); if self.mix.input.len() != ch {- self.mix.input = vec![vec![0.0; BLOCK]; ch];+ self.mix.input = vec![vec![0.0; self.block]; ch]; } ring.pop_into(&mut self.mix.input, n, self.session.sample_rate.hz()); }@@ -343,7 +414,7 @@ impl AudioState { let speed = f64::from(self.shared.speed.load(Ordering::Relaxed)) / 1000.0; let step = self.session.sample_rate.as_f64() / self.device_rate.max(1.0) * speed.clamp(0.1, 4.0); let callback_frames = out.len() / channels;- let quantum = ((callback_frames as f64 * step).ceil() as usize).clamp(1, BLOCK);+ let quantum = ((callback_frames as f64 * step).ceil() as usize).clamp(1, self.block); for frame in out.chunks_mut(channels) { while self.read >= self.pending_len as f64 { self.read -= self.pending_len as f64;@@ -362,7 +433,7 @@ impl AudioState { let sample = |c: usize| { let ch = self.pending.get(c); let a = ch.and_then(|v| v.get(i0)).copied().unwrap_or(0.0);- // `pending` is BLOCK long; only `pending_len` frames are valid. Hold the last one at the edge.+ // `pending` holds `block` frames; only `pending_len` are valid. Hold the last one at the edge. let b = if i0 + 1 < self.pending_len { ch.and_then(|v| v.get(i0 + 1)).copied().unwrap_or(a) } else { a }; a * (1.0 - fr) + b * fr };@@ -409,6 +480,8 @@ struct Side { shadow: HashMap<(TrackId, usize), (String, usize, bool)>, /// Session sample rate the instances were prepared for. rate: f32,+ /// The largest block they were prepared for (the audio thread's [`block_for`]).+ block: usize, /// Editor handles of the live instances. editors: HashMap<(TrackId, usize), Box<dyn PluginEditor>>, /// State readers of the live instances.@@ -421,8 +494,22 @@ struct Side { } impl Side {+ #[cfg(test)] fn new(rate: f32, garbage: Receiver<Vec<Box<dyn Plugin>>>) -> Side {- Side { shadow: HashMap::new(), rate, editors: HashMap::new(), states: BTreeMap::new(), refused: HashSet::new(), garbage, old_sessions: None }+ Side::with_block(rate, BLOCK, garbage)+ }++ fn with_block(rate: f32, block: usize, garbage: Receiver<Vec<Box<dyn Plugin>>>) -> Side {+ Side {+ shadow: HashMap::new(),+ rate,+ block: block.clamp(BLOCK, MAX_BLOCK),+ editors: HashMap::new(),+ states: BTreeMap::new(),+ refused: HashSet::new(),+ garbage,+ old_sessions: None,+ } } /// Drops retired instances (closing their editors first, inside the plugin's drop) and@@ -474,6 +561,8 @@ pub struct Player { pub device_channels: u16, /// The device buffer in frames when one was chosen (`None`: the device default). pub device_buffer: Option<u32>,+ /// The largest block playback renders and plugins are prepared for ([`block_for`]).+ pub block: usize, /// True when no audio device could be opened (silent clock). pub silent: bool, }@@ -521,7 +610,11 @@ impl Player { let want = session.sample_rate.hz(); let want_ch = u16::try_from(main_channels(&session)).unwrap_or(2); let side_rate = session.sample_rate.as_f64() as f32;- let new_side = |garbage: Receiver<Vec<Box<dyn Plugin>>>| Mutex::new(Side::new(side_rate, garbage));+ let new_side = |garbage: Receiver<Vec<Box<dyn Plugin>>>, block: usize| Mutex::new(Side::with_block(side_rate, block, garbage));+ let fixed = |b: cpal::BufferSize| match b {+ cpal::BufferSize::Fixed(n) => Some(n),+ cpal::BufferSize::Default => None,+ }; match open_device(want, want_ch, cfg) { Ok((device, config, format, name)) => { // The chosen buffer size first; if the device refuses it, its default.@@ -536,27 +629,25 @@ impl Player { let channels = usize::from(config.channels); let (tx, rx) = channel(); let (gtx, grx) = channel();- let state = AudioState::new(rx, Arc::clone(&shared), Arc::clone(&session), f64::from(rate), gtx);+ let block = block_for(fixed(config.buffer_size), f64::from(rate), session.sample_rate.as_f64());+ let state = AudioState::with_block(rx, Arc::clone(&shared), Arc::clone(&session), f64::from(rate), gtx, block); let fault = Arc::new(Fault::default()); let stream = match format { cpal::SampleFormat::I32 => build_output::<i32>(&device, config, state, channels, &fault, &shared), cpal::SampleFormat::I16 => build_output::<i16>(&device, config, state, channels, &fault, &shared), _ => build_output::<f32>(&device, config, state, channels, &fault, &shared), }?;- Ok(Opened { stream, tx, grx, fault })+ Ok(Opened { stream, block, tx, grx, fault }) }, |opened| start_stream(&opened.stream), ); match opened {- Some((config, Opened { stream, tx, grx, fault })) => {+ Some((config, Opened { stream, block, tx, grx, fault })) => { let rate = config.sample_rate; let channels = usize::from(config.channels);- let buffer = match config.buffer_size {- cpal::BufferSize::Fixed(n) => Some(n),- cpal::BufferSize::Default => None,- };+ let buffer = fixed(config.buffer_size); log::info!(- "audio device: {name} @ {rate} Hz, {channels} ch, {format}, buffer {}",+ "audio device: {name} @ {rate} Hz, {channels} ch, {format}, buffer {}, blocks up to {block}", buffer.map_or("default".to_string(), |n| n.to_string()) ); Player {@@ -565,12 +656,13 @@ impl Player { #[cfg(not(target_arch = "wasm32"))] watch: Mutex::new(None), tx,- side: new_side(grx),+ side: new_side(grx, block), _stream: Some(stream), device_name: name, device_rate: rate, device_channels: config.channels, device_buffer: buffer,+ block, silent: false, } }@@ -578,7 +670,7 @@ impl Player { log::warn!("no usable audio stream; using a silent clock"); let (tx, rx) = channel(); let (gtx, grx) = channel();- Player::silent_clock(shared, tx, rx, want, new_side(grx), gtx)+ Player::silent_clock(shared, tx, rx, want, new_side(grx, BLOCK), gtx) } } }@@ -586,7 +678,7 @@ impl Player { log::warn!("no audio output: {e}; using a silent clock"); let (tx, rx) = channel(); let (gtx, grx) = channel();- Player::silent_clock_with(shared, tx, rx, Arc::clone(&session), new_side(grx), gtx)+ Player::silent_clock_with(shared, tx, rx, Arc::clone(&session), new_side(grx, BLOCK), gtx) } } }@@ -648,6 +740,7 @@ impl Player { device_rate: rate, device_channels: 0, device_buffer: None,+ block: BLOCK, silent: true, } }@@ -671,6 +764,7 @@ impl Player { device_rate: session.sample_rate.hz(), device_channels: 0, device_buffer: None,+ block: BLOCK, silent: true, } }@@ -875,7 +969,7 @@ impl Side { continue; } self.forget(key);- let made = soundcraft_mix::create_instance(&spec.id, spec.state.as_deref(), sr, BLOCK, spec.ch);+ let made = soundcraft_mix::create_instance(&spec.id, spec.state.as_deref(), sr, self.block, spec.ch); log::debug!("plugin instance for {:?} slot {}: {} ({} ch, created: {})", spec.track, spec.slot, spec.id, spec.ch, made.is_some()); self.shadow.insert(key, (spec.id.clone(), spec.ch, made.is_some())); if let Some(soundcraft_mix::NewInstance { mut plugin, refused_state }) = made {@@ -926,6 +1020,8 @@ fn start_stream(stream: &impl cpal::traits::StreamTrait) -> bool { /// A built output stream and the channels and fault flag its callback shares with the player. struct Opened { stream: cpal::Stream,+ /// The largest block its audio state renders ([`block_for`]).+ block: usize, tx: Sender<Cmd>, grx: Receiver<Vec<Box<dyn Plugin>>>, fault: Arc<Fault>,@@ -1233,6 +1329,119 @@ mod tests { st } + #[test]+ fn the_block_follows_the_device_buffer() {+ assert_eq!(block_for(None, 48_000.0, 48_000.0), BLOCK, "unknown default buffer: as before");+ assert_eq!(block_for(Some(64), 48_000.0, 48_000.0), BLOCK, "small buffers: as before");+ assert_eq!(block_for(Some(512), 48_000.0, 48_000.0), 512);+ assert_eq!(block_for(Some(1024), 48_000.0, 48_000.0), 1024);+ assert_eq!(block_for(Some(4096), 96_000.0, 96_000.0), 4096);+ assert_eq!(block_for(Some(2048), 44_100.0, 48_000.0), 2230, "a faster session renders more frames per callback (2229.03, rounded up)");+ assert_eq!(block_for(Some(2048), 96_000.0, 48_000.0), 2048, "a slower session never shrinks it");+ assert_eq!(block_for(Some(1 << 20), 48_000.0, 48_000.0), MAX_BLOCK);+ assert_eq!(block_for(Some(1024), 0.0, 48_000.0), 1024);+ assert_eq!(block_for(Some(u32::MAX), 1.0, f64::MAX), MAX_BLOCK);+ }++ /// Records the size of every block it processes.+ struct Probe(Box<dyn Plugin>, Arc<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);+ }+ }++ #[test]+ fn plugins_see_the_device_buffer_in_one_block() {+ use soundcraft_model::{ChannelFormat, Insert};+ // (largest block, device callback frames, automation, what the plugin sees per callback)+ for (block, callback, automated, want) in [+ (BLOCK, 2048, false, vec![512, 512, 512, 512]),+ (2048, 2048, false, vec![2048]),+ (2048, 1000, false, vec![1000]),+ (BLOCK, 128, false, vec![128]),+ (2048, 2048, true, vec![512, 512, 512, 512]),+ ] {+ let st = audio_output_state(ChannelFormat::Stereo, vec![vec![0.5; 8192]; 2]);+ let mut session = (*st.session).clone();+ let track = session.tracks[0].id;+ session.track_mut(track).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.probe"));+ if automated {+ // Automation is read once per block: a session with any keeps 512-frame blocks.+ let mut lane = soundcraft_model::AutomationLane::new(soundcraft_model::AutoParam::Volume);+ lane.set_point(0, 0.0);+ session.track_mut(track).unwrap().automation.push(lane);+ }+ let session = Arc::new(session);+ let (tx, rx) = channel();+ let mut st = AudioState::with_block(rx, Arc::clone(&st.shared), Arc::clone(&session), 48_000.0, channel().0, block);+ let seen = Arc::new(Mutex::new(Vec::new()));+ let mut gain = soundcraft_dsp::create("gain").unwrap();+ gain.prepare(48_000.0, block, 2);+ let probe = PreparedInstance { track, slot: 0, id: "clap:test.probe".into(), ch: 2, plugin: Box::new(Probe(gain, Arc::clone(&seen))) };+ tx.send(Cmd::Session(session, vec![probe])).unwrap();+ tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap();+ assert_eq!(st.mix.max_block(), block, "the engine is sized for the block");+ let mut out = vec![0.0f32; 2 * callback];+ st.fill(&mut out, 2);+ assert_eq!(*seen.lock().unwrap(), want, "block {block}, callback {callback}, automation {automated}");+ assert!(out.iter().any(|x| x.abs() > 0.1), "block {block}, callback {callback}: it sounds");+ }+ }++ #[test]+ fn gaining_automation_drops_the_long_block_not_yet_heard() {+ use soundcraft_model::ChannelFormat;+ // A ramp tells which session frame each output frame came from.+ let ramp: Vec<f32> = (0..8192).map(|i| i as f32 / 8192.0).collect();+ let st = audio_output_state(ChannelFormat::Stereo, vec![ramp.clone(), ramp.clone()]);+ let (tx, rx) = channel();+ let mut st = AudioState::with_block(rx, Arc::clone(&st.shared), Arc::clone(&st.session), 48_000.0, channel().0, 4096);+ tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap();+ st.drain();+ // One long block rendered, 1000 of its frames heard so far.+ st.render_next(4096);+ assert_eq!((st.pos, st.pending_len), (4096, 4096));+ st.read = 1000.0;+ let mut session = (*st.session).clone();+ let track = session.tracks[0].id;+ let mut lane = soundcraft_model::AutomationLane::new(soundcraft_model::AutoParam::Volume);+ lane.set_point(0, 0.0);+ session.track_mut(track).unwrap().automation.push(lane);+ tx.send(Cmd::Session(Arc::new(session), Vec::new())).unwrap();+ let mut out = vec![0.0f32; 2 * 600];+ st.fill(&mut out, 2);+ // The rest of the long block was dropped and rendered again in 512-frame blocks from the+ // audible position: frame 1000 is next, nothing skipped or repeated.+ assert_eq!(st.pos, 1000 + 2 * 512, "two fresh 512-frame blocks");+ assert_eq!(st.pending_len, 512);+ for (k, f) in out.chunks(2).enumerate() {+ let want = ramp[1000 + k];+ assert!((f[0] - want).abs() < 1e-6 && (f[1] - want).abs() < 1e-6, "frame {k}: {f:?}, want {want}");+ }+ // A session that already had automation keeps what is pending.+ let (len, pos) = (st.pending_len, st.pos);+ tx.send(Cmd::Session(Arc::clone(&st.session), Vec::new())).unwrap();+ st.drain();+ assert_eq!((st.pending_len, st.pos), (len, pos));+ }+ #[test] fn mono_device_averages_stereo_before_clipping() { use soundcraft_model::ChannelFormat;@@ -543,7 +543,14 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) { ui.label(format!("{sr} Hz{note}")); ui.end_row(); });- ui.label(egui::RichText::new("The mix runs in 512-sample blocks; the buffer size sets the device's own latency.").small().color(t.text_dim));+ let blocks = app.player.as_ref().map_or(512, |pl| pl.block);+ ui.label(+ egui::RichText::new(format!(+ "The mix renders each callback in blocks of up to {blocks} samples (512 while automation plays); the buffer size sets the device's latency."+ ))+ .small()+ .color(t.text_dim),+ ); let mut dc = app.engine.session().edit.delay_compensation; if crate::widgets::checkbox(ui, &mut dc, "Delay compensation").changed() { let _ = app.run("options.delay_compensation", json!({"value": dc}));@@ -596,7 +603,7 @@ pub fn audio_device_command(app: &mut SoundApp, p: &serde_json::Value) -> Result app.apply_audio_config(cfg)?; } let device = app.player.as_ref().map(|pl| {- json!({"name": pl.device_name, "sample_rate": pl.device_rate, "channels": pl.device_channels, "buffer_size": pl.device_buffer, "silent": pl.silent})+ json!({"name": pl.device_name, "sample_rate": pl.device_rate, "channels": pl.device_channels, "buffer_size": pl.device_buffer, "block": pl.block, "silent": pl.silent}) }); let mut out = json!({"config": app.ui.audio, "device": device}); if p.get("list").and_then(Value::as_bool).unwrap_or(false) {@@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (delay compensation includes the master faders' inserts (#351); previously minor: bounces stream to disk) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (playback blocks follow the device buffer, up to 8192 samples; previously trivial: delay compensation includes the master faders' inserts (#351); previously minor: bounces stream to disk) · **Target:** Avid Pro Tools Ultimate 2026.4.1 SoundCraft is a Cargo workspace of small crates with enforced layering (`cargo xtask layers`). Nothing below the UI knows about egui, so the interface can be replaced.@@ -53,7 +53,8 @@ bounces offline (`RangeRender`, streamed to the file block by block) and realtim snapshots through a channel). Device I/O today is the system's *default* output and *default* input device, opened as two-separate cpal streams; the mix renders 512-sample blocks and resamples to the device rate when+separate cpal streams; the mix renders each device callback in one block of up to 8192 samples (512 while automation+plays, since automation is read once per block) and resamples to the device rate when they differ. There is no device or buffer-size selection, no ASIO, and no MIDI device layer yet (see [`hardware-parity.md`](hardware-parity.md)). Sources are decoded whole into the `SourcePool` and recordings are captured in memory until stop.@@ -78,6 +79,7 @@ app as an MCP server. See `control-protocol.md` and `mcp.md`. | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | Playback blocks follow the device buffer, up to 8192 samples | | 2026-10-13 | trivial | delay compensation includes the master faders' inserts (#351) | | 2026-10-10 | minor | Added video, vst3-host and au-host to the layer diagram; documented device I/O limits and in-memory media | | 2026-10-05 | major | First architecture overview |@@ -1,6 +1,6 @@ # Where SoundCraft falls short of Pro Tools -> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** trivial (G-04: Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-24 · **Change:** minor (G-01: playback blocks follow the device buffer; automation still per block; previously trivial: G-04: Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker**@@ -49,9 +49,10 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t - **Missing:** routing to physical channels (I/O Setup), one duplex stream on one clock (input and output are still separate cpal streams), latency reporting and record-offset compensation, ASIO in official builds (owner decision: Steinberg's SDK is proprietary or GPLv3), JACK, and- verification on macOS and Linux. The mix still runs in fixed 512-sample blocks.+ verification on macOS and Linux. Playback blocks follow the device buffer (up to 8192 samples), but+ automation is read once per block, so sessions with automation keep 512-sample blocks. `setup.playback_engine` still stores `device`/`buffer_size` in the session, unused.-- **Evidence:** code above; Playback Engine window says "Mix block size: 512 samples"; issue #42+- **Evidence:** code above; the Playback Engine window shows the block size; issue #42 (no input device selection, forced monitoring feedback with virtual routing). - **Impact:** blocks tracking with any multichannel interface; latency makes overdubs hard. - **Estimate:** ≈ 25–40 h (device enumeration and selection, buffer size, duplex stream with one@@ -221,6 +222,7 @@ missing. Out of scope unless the owner decides otherwise. | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | G-01: playback blocks follow the device buffer; automation still per block | | 2026-10-19 | trivial | G-04: Float32 encoding retains finite headroom (#791); integer clipping and non-finite handling retained | | 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged | | 2026-10-16 | trivial | G-04: master volume automation block boundaries fixed and regression-tested; estimates unchanged |@@ -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-24 · **Change:** minor (playback mixes in blocks of the device buffer, up to 8192; previously trivial: bounces of a bus fed by an aux compensate its latency (#246); previously minor: audio device, rate and buffer choice; ASIO feature) · **Target:** Avid Pro Tools Ultimate 2026.4.1 A DAW is judged by how it drives audio interfaces, MIDI gear, control surfaces and sync. This is SoundCraft's weakest dimension. Ours is measured from `crates/playback` (cpal) and the absence of@@ -18,7 +18,7 @@ Legend: ✅ works · 🟡 partial · ❌ missing. | Driver API | Core Audio / ASIO | 🟡 Core Audio via cpal | 🟡 WASAPI shared via cpal; ASIO with the opt-in `asio` build feature (verified with an SSL 2+) | 🟡 ALSA/Pulse via cpal; no JACK | 🟡 cpal (ALSA layer) | 🟡 Web Audio | | Choose playback device | ✅ Playback Engine | 🟡 Setup › Playback Engine (not yet verified on macOS) | ✅ Setup › Playback Engine (WASAPI, ASIO) | 🟡 (not yet verified) | 🟡 (not yet verified) | ❌ | | Choose input device | ✅ | 🟡 Setup › Playback Engine (not yet verified on macOS) | ✅ (WASAPI, ASIO) | 🟡 (not yet verified) | ❌ no recording | ❌ |-| Buffer size (32–2048) | ✅ | 🟡 device buffer when the driver allows it (not yet verified); mix in 512-sample blocks | 🟡 device buffer 16–8192 (verified 128 on ASIO, 256 on WASAPI); mix in 512-sample blocks | 🟡 (not yet verified) | 🟡 | ❌ |+| Buffer size (32–2048) | ✅ | 🟡 device buffer when the driver allows it (not yet verified); mix in blocks of the device buffer (512–8192; 512 while automation plays) | 🟡 device buffer 16–8192 (verified 128 on ASIO, 256 on WASAPI); mix in blocks of the device buffer on ASIO (512–8192; 512 while automation plays); WASAPI shared calls back at its own period (about 10 ms) whatever the buffer | 🟡 (not yet verified) | 🟡 | ❌ | | Duplex stream on one clock | ✅ | ❌ separate input/output streams | ❌ | ❌ | ❌ | ❌ | | Hardware I/O mapping (I/O Setup to physical channels) | ✅ | 🟡 multichannel output in order; no per-channel map | 🟡 | 🟡 | 🟡 | ❌ | | Low-latency monitoring | ✅ | ❌ (`options.low_latency_monitoring` is a stored flag) | ❌ | ❌ | ❌ | ❌ |@@ -72,6 +72,7 @@ Legend: ✅ works · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | Playback renders each device callback in one block of up to 8192 frames (was split into 512-sample blocks), and plugins are prepared for that size; sessions with automation keep 512-sample blocks; a buffer change reopens the stream and prepares them anew | | 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 |@@ -1,6 +1,6 @@ # SoundCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (M8 batch limit validation) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (playback blocks follow the device buffer; previously trivial: M8 batch limit validation) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Forward-looking plan. Status and numbers: [`../ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the ranked work list: [`gaps.md`](gaps.md).@@ -23,7 +23,7 @@ main platform). Any **no** keeps SoundCraft **pre-alpha**, and those rows are th | Workflow | Works end to end? | Evidence | Hours to pass | |---|---|---|---:|-| Record audio from a chosen interface at a chosen buffer size, monitor, punch, keep the takes | **no** | Default output and input devices only. Fixed 512-sample block. Separate input and output streams. `setup.playback_engine` params ignored by playback. Takes held in RAM until stop (PR #84). No input device choice (#42). | 35–55 |+| Record audio from a chosen interface at a chosen buffer size, monitor, punch, keep the takes | **no** | Default output and input devices only. Blocks follow the device buffer up to 8192 samples (512 while automation plays). Separate input and output streams. `setup.playback_engine` params ignored by playback. Takes held in RAM until stop (PR #84). No input device choice (#42). | 35–55 | | Record MIDI from a controller and play it back through an instrument | **no** | Playback of drawn and imported MIDI works. No MIDI device layer at all, so nothing can be recorded and instruments can't be played live (G-02). | 20–35 | | Edit: select, trim, fade, comp playlists, Slip/Shuffle/Grid | yes (rough) | Every Edit menu command; property-tested with undo; open bugs #3, #5, #140 don't block the workflow | — | | Mix with built-in and third-party plugins, sends, busses, automation | partial (passes on CLAP/AU) | 26 built-in processors; CLAP and AU host on the signed macOS build, but its missing entitlement blocks third-party VST3 (#153). No AU plugin windows yet; VST3 windows on macOS only | 1–2 (entitlement) |@@ -89,6 +89,7 @@ There is no `cargo xtask scorecard` in this repo; the measured number is `cargo | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | Playback blocks follow the device buffer | | 2026-10-14 | trivial | M8: reject oversized MCP batches before execution (#460) | | 2026-10-10 | minor | Added the alpha gate (3 of 6 core workflows fail → pre-alpha) | | 2026-10-10 | major | Created from ROADMAP.md's milestones and alpha list; added M12–M17 and the beta plan with estimates |