Browse / github.com/storytold/soundcraft / patch
setup.playback_engine / setup.hardware: apply device and buffer in the app, don't claim it headless
setup.playback_engine / setup.hardware: apply device and buffer in the app, don't claim it headless ## What `setup.playback_engine` and `setup.hardware` accepted `{device?, buffer_size?}` but only stored them as session values (`engine.device`, `engine.buffer_size`) that nothing reads. The result was misleading: `setup.playback_engine {buffer_size: 1024}` answered `buffer_size: 1024` while the device kept running at 512 frames. With nothing set, it also reported 1024. Found driving SoundCraft over MCP and confirmed with the callback load meter from #1067: 10.7 ms buffers after the command, 21.3 ms after choosing 1024 in the window. ## Fix - **In the app**, a programmatic call goes throug
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- sanderdewijs (imported from pull request #1077, open)
- Base
7dc56a4dbeebf57eacece09536726c57844fc59d- Size
- +250 −21 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
41f360639518ee1b51f91667
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd/setup_more.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/windows.rs crates/ui-egui/tests/it/audio_device.rs crates/ui-egui/tests/it/command_reachability.rs docs/gaps.md docs/roadmap.md
@@ -42,13 +42,24 @@ pub fn specs() -> Vec<CommandSpec> { always, prepare_dpe ),- cmd!(noundo "setup.hardware", "Hardware...", ["Setup"], None, "{device?, buffer_size?: 16..8192} — audio interface settings (sample rate is the session's)", always, |e, p| engine_settings(e, p)),- cmd!(noundo "setup.playback_engine", "Playback Engine...", ["Setup"], None, "{device?, buffer_size?: 16..8192, delay_compensation?: bool}", always, |e, p| {- if let Some(v) = p.get("delay_compensation").and_then(Value::as_bool) {- e.session_mut().edit.delay_compensation = v;- }- engine_settings(e, p)- }),+ cmd!(+ noundo "setup.hardware",+ "Hardware...",+ ["Setup"],+ None,+ "{device?: output name, buffer_size?: 16..=8192 or null} — output device and buffer. Applied only in the desktop app (Playback Engine window / ui.audio_device), which returns the open device. Headless stores the request and returns applied: false; buffer_size is null when unset. Any integer in range, not rounded to the window's power-of-two sizes. Sample rate is the session's",+ always,+ |e, p| engine_settings(e, p, "setup.hardware")+ ),+ cmd!(+ noundo "setup.playback_engine",+ "Playback Engine...",+ ["Setup"],+ None,+ "{device?: output name, buffer_size?: 16..=8192 or null, delay_compensation?: bool} — device and buffer as setup.hardware (applied only in the desktop app, or via ui.audio_device); delay_compensation is applied here too. Headless returns applied: false and does not invent a buffer",+ always,+ |e, p| engine_settings(e, p, "setup.playback_engine")+ ), cmd!(noundo "setup.disk_allocation", "Disk Allocation...", ["Setup"], None, "{path?, track?} — the record folder for the session, or for one track", always, disk_allocation), cmd!(noundo "setup.peripherals", "Peripherals...", ["Setup"], None, "{settings?: {name: number|bool|string}} — control surface / sync device settings", always, peripherals), cmd!(@@ -156,18 +167,69 @@ fn prepare_dpe(e: &mut Engine, p: &Value) -> Result<Value> { Ok(json!({"prepared": n})) } -fn engine_settings(e: &mut Engine, p: &Value) -> Result<Value> {+/// A `buffer_size` argument: absent, cleared (`null`), or an integer in 16..=8192.+enum BufferParam {+ Skip,+ Clear,+ Set(u64),+}++/// Same range as `ui.audio_device`. Out of range and non-numbers error; there is no clamp and no+/// power-of-two rounding (the Playback Engine window lists those sizes, the command keeps the number).+fn buffer_param(id: &str, p: &Value) -> Result<BufferParam> {+ match p.get("buffer_size") {+ None => Ok(BufferParam::Skip),+ Some(Value::Null) => Ok(BufferParam::Clear),+ Some(v) => {+ let n = v.as_u64().filter(|n| (16..=8192).contains(n)).ok_or_else(|| bad(id, "`buffer_size` must be a number in 16..=8192 or null"))?;+ Ok(BufferParam::Set(n))+ }+ }+}++/// `device` as `ui.audio_device`'s `output`: a name, or `null`/empty to clear. Absent leaves it.+fn device_param<'a>(id: &str, p: &'a Value) -> Result<Option<Option<&'a str>>> {+ match p.get("device") {+ None => Ok(None),+ Some(Value::Null) => Ok(Some(None)),+ Some(Value::String(name)) => Ok(Some((!name.is_empty()).then_some(name.as_str()))),+ Some(_) => Err(bad(id, "`device` must be a string or null")),+ }+}++/// Headless `setup.hardware` / `setup.playback_engine`. Playback never reads `engine.device` or+/// `engine.buffer_size` (only this command does); they are kept so a later call can report the+/// request. The result says `applied: false` and leaves `buffer_size` null until one was stored.+fn engine_settings(e: &mut Engine, p: &Value, id: &str) -> Result<Value> {+ let buffer = buffer_param(id, p)?;+ let device = device_param(id, p)?;+ if id == "setup.playback_engine"+ && let Some(on) = p.get("delay_compensation").and_then(Value::as_bool)+ {+ e.session_mut().edit.delay_compensation = on;+ } let s = e.session_mut();- if let Some(b) = p.get("buffer_size").and_then(Value::as_u64) {- let b = b.clamp(16, 8192).next_power_of_two().min(8192);- s.edit.values.insert("engine.buffer_size".into(), b as f64);+ match buffer {+ BufferParam::Skip => {}+ BufferParam::Clear => {+ s.edit.values.remove("engine.buffer_size");+ }+ BufferParam::Set(n) => {+ s.edit.values.insert("engine.buffer_size".into(), n as f64);+ } }- if let Some(d) = str_param(p, "device") {- set_text(&mut s.edit, "engine.device", Some(d));+ if let Some(name) = device {+ set_text(&mut s.edit, "engine.device", name); }+ // Exact integers only: a hand-edited fraction is not reported as a buffer size.+ let buffer_size = s.edit.values.get("engine.buffer_size").copied().and_then(|v| {+ let n = v as u64;+ (v.is_finite() && (n as f64) == v).then_some(n)+ }); Ok(json!({+ "applied": false, "device": text(&s.edit, "engine.device"),- "buffer_size": s.edit.value("engine.buffer_size", 1024.0),+ "buffer_size": buffer_size, "sample_rate": s.sample_rate.hz(), "bit_depth": s.bit_depth, "delay_compensation": s.edit.delay_compensation,@@ -587,7 +649,8 @@ mod tests { fn engine_and_midi_settings() { let mut e = Engine::default(); let r = e.execute("setup.playback_engine", &json!({"buffer_size": 300, "device": "Interface"})).unwrap();- assert_eq!(r["buffer_size"], 512.0);+ assert_eq!(r["applied"], false);+ assert_eq!(r["buffer_size"], 300); assert_eq!(r["device"], "Interface"); let r = e.execute("setup.midi_input_filter", &json!({"filter": ["sysex", "bogus"]})).unwrap(); assert_eq!(r["filtered"], json!(["sysex"]));@@ -597,6 +660,45 @@ mod tests { assert_eq!(r["samples"], 3200.0); } + #[test]+ fn playback_engine_does_not_pretend_the_device_changed() {+ let mut e = Engine::default();+ let r = e.execute("setup.playback_engine", &json!({})).unwrap();+ assert_eq!(r["applied"], false);+ assert!(r["buffer_size"].is_null(), "no invented default buffer: {r}");+ assert!(r["device"].is_null());+ assert_eq!(r["delay_compensation"], true, "the session default, not a device claim");++ let r = e.execute("setup.hardware", &json!({"buffer_size": 300, "device": "Interface"})).unwrap();+ assert_eq!(r["applied"], false);+ assert_eq!(r["buffer_size"], 300, "kept, not rounded to a power of two");+ assert_eq!(r["device"], "Interface");+ assert_eq!(e.session().edit.value("engine.buffer_size", -1.0), 300.0);++ let r = e.execute("setup.playback_engine", &json!({"delay_compensation": false})).unwrap();+ assert_eq!(r["applied"], false);+ assert_eq!(r["delay_compensation"], false);+ assert_eq!(r["buffer_size"], 300, "delay compensation does not clear a stored buffer");+ assert!(!e.session().edit.delay_compensation);++ let err = e.execute("setup.playback_engine", &json!({"buffer_size": 8, "delay_compensation": true, "device": "Other"})).unwrap_err();+ assert!(err.to_string().contains("16..=8192"), "{err}");+ assert!(!e.session().edit.delay_compensation, "an invalid buffer changes nothing");+ assert_eq!(e.session().edit.value("engine.buffer_size", -1.0), 300.0);+ assert_eq!(text(&e.session().edit, "engine.device").as_deref(), Some("Interface"));+ assert!(e.execute("setup.hardware", &json!({"buffer_size": "big"})).is_err());++ let err = e.execute("setup.playback_engine", &json!({"device": 42, "delay_compensation": true})).unwrap_err();+ assert!(err.to_string().contains("`device` must be a string or null"), "{err}");+ assert!(!e.session().edit.delay_compensation, "an invalid device changes nothing");+ assert_eq!(text(&e.session().edit, "engine.device").as_deref(), Some("Interface"));++ let r = e.execute("setup.hardware", &json!({"buffer_size": null, "device": ""})).unwrap();+ assert_eq!(r["applied"], false);+ assert!(r["buffer_size"].is_null());+ assert!(r["device"].is_null());+ }+ #[test] fn import_session_data_brings_tracks_and_audio() { let dir = std::env::temp_dir().join(format!("sc-import-{}", std::process::id()));@@ -518,6 +518,11 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result< if id == "ui.audio_device" { return Some(crate::windows::audio_device_command(app, p)); }+ // Menu clicks never get here: Setup › Playback Engine / Hardware open the window+ // (`MENU_WINDOWS`). A programmatic call applies the open device instead.+ if matches!(id, "setup.playback_engine" | "setup.hardware") {+ return Some(crate::windows::setup_audio_command(app, id, p));+ } let toggle = |v: &mut bool| { *v = p.get("value").and_then(Value::as_bool).unwrap_or(!*v); json!({"value": *v})@@ -516,6 +516,8 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) { number_combo(ui, "pe-rate", "Sample rate", &mut draft.sample_rate, out.map_or(&[][..], |d| &d.sample_rates), "Hz"); ui.end_row(); ui.label("Buffer size");+ // The combo lists standard power-of-two sizes. `setup.playback_engine` does not round+ // onto this list: it keeps any integer 16..=8192, the same as `ui.audio_device`. let buffers = out.map(DeviceInfo::buffer_sizes).unwrap_or_else(|| soundcraft_playback::devices::STANDARD_BUFFERS.to_vec()); number_combo(ui, "pe-buffer", "Buffer size", &mut draft.buffer_size, &buffers, "samples"); ui.end_row();@@ -622,6 +624,53 @@ pub fn audio_device_command(app: &mut SoundApp, p: &serde_json::Value) -> Result Ok(out) } +/// Programmatic `setup.playback_engine` / `setup.hardware`. `device` is the output name and+/// `buffer_size` is any integer 16..=8192 or null (not rounded to the window's power-of-two list);+/// both go through [`audio_device_command`]. Delay compensation still goes through the engine+/// command, and only after the device change has been accepted. The result is the open device:+/// its name, the buffer actually in use (`device_buffer`, null for the device default), its sample+/// rate, and delay compensation. `applied` is true only when an audio device is open.+pub fn setup_audio_command(app: &mut SoundApp, id: &str, p: &serde_json::Value) -> Result<serde_json::Value, String> {+ use serde_json::Value;+ let mut audio = serde_json::Map::new();+ if let Some(device) = p.get("device") {+ if !device.is_null() && !device.is_string() {+ return Err("`device` must be a string or null".into());+ }+ audio.insert("output".to_string(), device.clone());+ }+ if let Some(buffer) = p.get("buffer_size") {+ audio.insert("buffer_size".to_string(), buffer.clone());+ }+ if !audio.is_empty() {+ audio_device_command(app, &Value::Object(audio))?;+ }+ if id == "setup.playback_engine"+ && let Some(on) = p.get("delay_compensation").and_then(Value::as_bool)+ {+ app.engine.execute("setup.playback_engine", &json!({"delay_compensation": on})).map_err(|e| e.to_string())?;+ }+ let applied = app.player.as_ref().is_some_and(|player| !player.silent);+ let (device, buffer_size, sample_rate) = match app.player.as_ref() {+ Some(player) => {+ let buffer = match player.device_buffer {+ Some(n) => json!(n),+ None => Value::Null,+ };+ (json!(player.device_name), buffer, json!(player.device_rate))+ }+ None => (Value::Null, Value::Null, json!(app.engine.session().sample_rate.hz())),+ };+ Ok(json!({+ "applied": applied,+ "device": device,+ "buffer_size": buffer_size,+ "sample_rate": sample_rate,+ "bit_depth": app.engine.session().bit_depth,+ "delay_compensation": app.engine.session().edit.delay_compensation,+ }))+}+ /// Setup › I/O: busses and output paths. pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_io_setup;@@ -25,3 +25,64 @@ fn the_audio_device_choice_is_kept_only_when_it_is_applied() { assert_eq!(r["config"]["buffer_size"], 256); assert!(r["device"].is_object(), "{r}"); }++#[test]+fn playback_engine_applies_the_same_device_choice_as_the_audio_command() {+ let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());+ let before = app.ui.audio.clone();+ let bit_depth = app.engine.session().bit_depth;+ assert!(!app.ui.show_playback_engine);++ let r = app.run("setup.hardware", json!({})).unwrap();+ assert_eq!(r["applied"], false, "without a player there is no open device");+ assert_eq!(r["bit_depth"], json!(bit_depth));++ assert!(app.engine.session().edit.delay_compensation, "delay compensation starts on");+ let err = app.run("setup.playback_engine", json!({"buffer_size": 8, "delay_compensation": false})).unwrap_err();+ assert!(err.contains("16..=8192"), "{err}");+ assert_eq!(app.ui.audio, before, "an invalid buffer changes nothing");+ assert!(app.engine.session().edit.delay_compensation, "an invalid buffer does not apply delay compensation");++ app.engine.transport.recording = true;+ let err = app.run("setup.playback_engine", json!({"buffer_size": 256, "delay_compensation": false})).unwrap_err();+ assert!(err.contains("stop recording"), "{err}");+ assert_eq!(app.ui.audio, before);+ assert!(app.engine.session().edit.delay_compensation, "a refused device change does not apply delay compensation");+ app.engine.transport.recording = false;++ let err = app.run("setup.playback_engine", json!({"device": 42, "delay_compensation": false})).unwrap_err();+ assert!(err.contains("`device` must be a string or null"), "{err}");+ assert_eq!(app.ui.audio, before, "an invalid device changes nothing");+ assert!(app.engine.session().edit.delay_compensation, "an invalid device does not apply delay compensation");++ let r = app.run("setup.playback_engine", json!({"buffer_size": 256, "delay_compensation": false})).unwrap();+ assert_eq!(app.ui.audio.buffer_size, Some(256));+ assert!(!app.engine.session().edit.delay_compensation);+ assert!(!app.ui.show_playback_engine, "a programmatic call does not open the window");+ assert_eq!(r["delay_compensation"], false);+ let player = app.player.as_ref().unwrap();+ assert_eq!(r["applied"], !player.silent);+ assert_eq!(r["device"], json!(player.device_name));+ assert_eq!(r["buffer_size"], json!(player.device_buffer));+ assert_eq!(r["sample_rate"], player.device_rate);+ assert_eq!(r["bit_depth"], json!(bit_depth));++ // 300 is in range and is not rounded onto the window's power-of-two list.+ let mut via_window_command = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());+ via_window_command.run("ui.audio_device", json!({"buffer_size": 300, "output": "Elsewhere"})).unwrap();+ app.run("setup.hardware", json!({"buffer_size": 300, "device": "Elsewhere"})).unwrap();+ assert_eq!(app.ui.audio.buffer_size, Some(300));+ assert_eq!(app.ui.audio.buffer_size, via_window_command.ui.audio.buffer_size);+ assert_eq!(app.ui.audio.output, via_window_command.ui.audio.output);+ assert!(!app.ui.show_playback_engine);++ app.run("setup.playback_engine", json!({"buffer_size": null, "device": null})).unwrap();+ assert_eq!(app.ui.audio.buffer_size, None);+ assert_eq!(app.ui.audio.output, None);+ assert!(!app.engine.session().edit.delay_compensation, "clearing the device leaves delay compensation");++ let cfg = app.ui.audio.clone();+ soundcraft_ui_egui::menus::invoke_menu(&mut app, "setup.playback_engine", "Setup > Playback Engine...");+ assert!(app.ui.show_playback_engine, "the menu still opens the Playback Engine window");+ assert_eq!(app.ui.audio, cfg, "opening the window does not change the device");+}@@ -25,8 +25,6 @@ const KNOWN_UNREACHED: &[&str] = &[ "mix.solo_safe", "options.post_roll", "options.pre_roll",- "setup.hardware", // menu opens the Playback Engine window, which doesn't run it- "setup.playback_engine", // same "setup.reset_edit_state", "track.load_preset", "track.pin",@@ -93,6 +91,13 @@ fn every_command_has_a_ui_path() { assert!(stale.is_empty(), "now reachable, remove from KNOWN_UNREACHED: {stale:?}"); } +#[test]+fn playback_engine_menu_opens_the_window_instead_of_running_the_command() {+ let extra = menus::ui_aliases();+ assert_eq!(catalog::implemented_by("Setup > Playback Engine...", &extra).as_deref(), Some("window.playback_engine"));+ assert_eq!(catalog::implemented_by("Setup > Hardware...", &extra).as_deref(), Some("window.playback_engine"));+}+ #[test] fn new_midi_clip_is_in_the_clip_menu() { let extra = menus::ui_aliases();@@ -1,6 +1,6 @@ # Where SoundCraft falls short of Pro Tools -> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-25 · **Change:** trivial (G-04: Clip Looping crossfade and loop count; previously trivial: G-08 and G-20: built-in plugin count, the sampler shipped) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** trivial (G-01: `setup.playback_engine` applies the device buffer in the desktop app; G-04: Clip Looping crossfade and loop count) · **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**@@ -46,11 +46,16 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t device, sample rate and device buffer size (kept with the preferences, per machine; `ui.audio_device` for agents); f32 and 16/32-bit integer devices; ASIO behind the opt-in `asio` build feature (verified on Windows with an SSL 2+ at 128 samples).+- **Done (2026-10-25):** `setup.playback_engine` and `setup.hardware` apply `device` (the output+ name) and `buffer_size` in the desktop app on the same path as the Playback Engine window+ (`ui.audio_device`) and return the open device. `buffer_size` is any integer 16..=8192 and is+ not rounded onto the window's power-of-two list. Headless (CLI, no app) still stores the request+ and returns `applied: false`, with `buffer_size` null when unset; playback does not read those+ session values. - **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.- `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 (no input device selection, forced monitoring feedback with virtual routing). - **Impact:** blocks tracking with any multichannel interface; latency makes overdubs hard.@@ -226,6 +231,7 @@ missing. Out of scope unless the owner decides otherwise. | Date | Change | Summary | |---|---|---| | 2026-10-25 | trivial | G-04: Clip Looping (#1030) lacks the loop crossfade option, and Number of Loops stops at 512 while length and fill make up to 1000 loops |+| 2026-10-25 | trivial | G-01: `setup.playback_engine` and `setup.hardware` apply the device and buffer in the desktop app; headless returns `applied: false` | | 2026-10-24 | trivial | G-08: 27 processors + 3 instruments, as the plugin registry holds; G-20: the sampler shipped (#108), so it is no longer listed as missing | | 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 |@@ -1,6 +1,6 @@ # SoundCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** trivial (built-in plugin count; the sampler shipped) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** trivial (`setup.playback_engine` applies the device buffer in the desktop app) · **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** | Device, rate and buffer are chosen in the Playback Engine window and, in the desktop app, by `setup.playback_engine` (headless returns `applied: false`). The mix still runs in fixed 512-sample blocks. Separate input and output streams. Takes held in RAM until stop (PR #84). | 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) | 27 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-25 | trivial | Record-workflow evidence: `setup.playback_engine` applies the device and buffer in the desktop app; headless returns `applied: false` | | 2026-10-24 | trivial | 27 built-in processors, as the plugin registry holds; the sampler (#108) is no longer listed under plugin essentials | | 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) |