collective

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

diff --git a/crates/engine/src/cmd/setup_more.rs b/crates/engine/src/cmd/setup_more.rs--- a/crates/engine/src/cmd/setup_more.rs+++ b/crates/engine/src/cmd/setup_more.rs@@ -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()));diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs--- a/crates/ui-egui/src/menus.rs+++ b/crates/ui-egui/src/menus.rs@@ -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})diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs--- a/crates/ui-egui/src/windows.rs+++ b/crates/ui-egui/src/windows.rs@@ -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;diff --git a/crates/ui-egui/tests/it/audio_device.rs b/crates/ui-egui/tests/it/audio_device.rs--- a/crates/ui-egui/tests/it/audio_device.rs+++ b/crates/ui-egui/tests/it/audio_device.rs@@ -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");+}diff --git a/crates/ui-egui/tests/it/command_reachability.rs b/crates/ui-egui/tests/it/command_reachability.rs--- a/crates/ui-egui/tests/it/command_reachability.rs+++ b/crates/ui-egui/tests/it/command_reachability.rs@@ -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();diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -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 |diff --git a/docs/roadmap.md b/docs/roadmap.md--- a/docs/roadmap.md+++ b/docs/roadmap.md@@ -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) |