Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 94544a9a5f chore: drop worktree links added by mistake 2026-11-03 11:02:57 +00:00
Dylan M. Taylor 26bdcdf6fb fix(control): require a per-launch token on every control request 2026-11-03 11:02:27 +00:00
Dylan M. Taylor 17d3f00700 MIDI: index controller dedup by channel and kind (#1298)
* MIDI: index controller dedup by channel and kind

Remove Duplicate Notes rescanned every controller already kept at the
same tick for each incoming one. A session whose sequence holds one tick
with many distinct channel/controller pairs made that quadratic and
blocked editing. Track where each channel+kind sits at the current tick
in a map instead; the last event per key still wins.

* MIDI: tag dedup index entries with their tick instead of clearing per tick

Clearing the map on every tick change cost O(capacity) per tick after a
dense tick. Store the tick with each entry and treat entries from an
earlier tick as absent. Add an oracle test comparing against the
previous per-tick rescan on mixed-tick input.
2026-11-02 22:51:02 -05:00
Dylan M. Taylor 1c350101bc clap-host tests: load plugins only from a freshly created private temp directory (#1295)
* clap-host tests: scan a freshly created private directory for the garbage-file test

The test reused a PID-named directory under the shared temp dir after ignoring a
failed cleanup, then loaded every .clap candidate in it. Another local account
could pre-create that directory and plant a native library that the test runner
would load. Create the directory exclusively (mode 0700 on Unix) and fail
instead of reusing one that already exists.

* clap-host tests: create the garbage-file test directory beside the test binary, not under TMPDIR
2026-11-02 21:30:40 -05:00
14 changed files with 234 additions and 97 deletions
+2 -2
View File
@@ -53,8 +53,8 @@ User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu
Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the incumbent's menu leaves (names only). `cargo xtask parity` writes `docs/parity-checklist.md` (generated; the hand-written assessment is `docs/target-app-parity.md`); a test enforces a floor that only rises.
## Running and looking at the app
- `cargo run --release -p soundcraft -- --demo --control 0` (demo session + control channel on a free port; the port is printed).
- Drive it: JSON lines over TCP, e.g. `{"id":1,"method":"engine.execute","params":{"command":"track.new","params":{"count":2,"format":"stereo"}}}`, then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- `cargo run --release -p soundcraft -- --demo --control 0` (demo session + control channel on a free port; the port and the launch's `SOUNDCRAFT_CONTROL_TOKEN` are printed, or export the token first so both sides share it).
- Drive it: JSON lines over TCP, e.g. `{"id":1,"method":"engine.execute","params":{"command":"track.new","params":{"count":2,"format":"stereo"}},"token":"…"}`, then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"},"token":"…"}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- Offscreen UI render (no window, no focus stealing): `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png [script.jsonl]`.
- Headless: `soundcraft-cli run --demo --cmd 'mix.volume={"track":"Kick","db":-6}' --bounce out.wav`; MCP: `soundcraft-cli mcp [--connect PORT]`.
- **For UI work, look at the result** (render, read the PNG) and compare with `plan/protools/screenshots/`.
Generated
+1
View File
@@ -4304,6 +4304,7 @@ dependencies = [
"craft-update",
"eframe",
"egui",
"getrandom 0.3.4",
"log",
"muda",
"objc2 0.6.5",
+2 -1
View File
@@ -214,7 +214,8 @@ soundcraft-cli run --demo \
--cmd 'mix.insert={"track":"Bass","plugin":"eq_7band"}' \
--bounce mix.wav
# Drive the running app.
# Drive the running app (the token keeps other local accounts off the loopback port).
export SOUNDCRAFT_CONTROL_TOKEN=$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')
soundcraft --demo --control 7801 &
soundcraft-cli app --port 7801 transport.play
soundcraft-cli app --port 7801 ui.screenshot '{"path":"shot.png"}'
+2
View File
@@ -26,6 +26,8 @@ egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"
serde_json.workspace = true
log.workspace = true
chrono.workspace = true
# The per-launch control token (already in the graph through egui's ahash).
getrandom = "0.3"
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
+73 -9
View File
@@ -3,8 +3,13 @@
//!
//! Lines are bounded ([`MAX_LINE`]): a longer one is refused and the connection closed, without
//! buffering the rest. So is the number of connections served at once ([`MAX_CONNECTIONS`]).
//!
//! Every request carries the launch's secret as `"token"` (`SOUNDCRAFT_CONTROL_TOKEN`, or one made
//! here and printed after the port): the port is loopback, which any local account can reach, and
//! a request acts with this process's authority.
use serde_json::{Value, json};
use soundcraft_automation::CONTROL_TOKEN_VAR;
use soundcraft_ui_egui::ControlRequest;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
@@ -28,9 +33,20 @@ const MAX_CONNECTIONS: usize = 16;
const MAX_QUEUED_REQUESTS: usize = MAX_CONNECTIONS;
/// Bind the loopback control listener and return the request receiver. Port 0 picks a free one and the actual port is
/// printed; bind or spawn failures are logged instead of panicking.
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
/// printed; bind or spawn failures are logged instead of panicking. Requests must carry `token`; with `None` one is
/// made for this launch and printed after the port, so only the launching process learns it.
pub fn start(port: u16, token: Option<String>, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = sync_channel::<ControlRequest>(MAX_QUEUED_REQUESTS);
let (token, made) = match token {
Some(t) => (t, false),
None => match new_token() {
Ok(t) => (t, true),
Err(e) => {
log::error!("control server not started: no {CONTROL_TOKEN_VAR} and no random token: {e}");
return rx;
}
},
};
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
@@ -41,13 +57,29 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let actual = listener.local_addr().map(|a| a.port()).unwrap_or(port);
log::info!("control server listening on 127.0.0.1:{actual}");
println!("SOUNDCRAFT_CONTROL_PORT={actual}");
let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || accept_loop(listener, tx, ctx));
if made {
println!("{CONTROL_TOKEN_VAR}={token}");
}
let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || accept_loop(listener, tx, ctx, Arc::from(token)));
if let Err(e) = spawned {
log::error!("control thread failed: {e}");
}
rx
}
/// 32 random bytes as hex, from the OS.
fn new_token() -> Result<String, getrandom::Error> {
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes)?;
Ok(bytes.iter().map(|b| format!("{b:02x}")).collect())
}
/// `token` equals `expected`, compared in time that doesn't depend on where they differ.
fn token_matches(token: Option<&str>, expected: &str) -> bool {
let Some(token) = token else { return false };
token.len() == expected.len() && token.bytes().zip(expected.bytes()).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0
}
/// A connection's place among [`MAX_CONNECTIONS`], given back when the connection ends (also when
/// its thread can't be started, or panics).
struct Slot(Arc<AtomicUsize>);
@@ -60,7 +92,7 @@ impl Drop for Slot {
/// Accept until the listener closes, admitting at most [`MAX_CONNECTIONS`] peers; excess peers get one error line.
/// Each admitted connection is served on its own thread, releasing its slot on exit.
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context) {
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, token: Arc<str>) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
@@ -71,9 +103,10 @@ fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui:
let slot = Slot(Arc::clone(&open));
let tx = tx.clone();
let ctx = ctx.clone();
let token = Arc::clone(&token);
let _ = std::thread::Builder::new().name("soundcraft-control-conn".into()).spawn(move || {
let _slot = slot;
serve(stream, tx, ctx);
serve(stream, tx, ctx, &token);
});
}
}
@@ -103,8 +136,9 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
}
/// Answer newline-delimited JSON requests for one connection: bound each line, forward it to the UI thread with a 90
/// s reply deadline, and stop at EOF, invalid UTF-8 or a closed request queue. Bad JSON still gets an error reply.
fn serve(stream: TcpStream, tx: SyncSender<ControlRequest>, ctx: egui::Context) {
/// s reply deadline, and stop at EOF, invalid UTF-8, a closed request queue or a wrong `token`. Bad JSON still gets
/// an error reply.
fn serve(stream: TcpStream, tx: SyncSender<ControlRequest>, ctx: egui::Context, token: &str) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
@@ -126,6 +160,11 @@ fn serve(stream: TcpStream, tx: SyncSender<ControlRequest>, ctx: egui::Context)
let reply = match serde_json::from_str::<Value>(line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
if !token_matches(msg.get("token").and_then(Value::as_str), token) {
let error = format!("unauthorized: the request's `token` is not this app's {CONTROL_TOKEN_VAR}");
let _ = writeln!(out, "{}", json!({"ok": false, "id": id, "error": error}));
break;
}
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let (req, rrx) = ControlRequest::new(method, params);
@@ -185,12 +224,14 @@ mod tests {
assert!(state + request.to_string().len() <= MAX_LINE);
}
const TOKEN: &str = "s3cret";
/// The control server on a loopback port, with a stand-in UI that echoes each method.
fn serve_echo() -> std::net::SocketAddr {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = sync_channel::<ControlRequest>(MAX_QUEUED_REQUESTS);
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default(), Arc::from(TOKEN)));
std::thread::spawn(move || {
for req in rx {
let _ = req.reply.send(json!({"ok": true, "method": req.method}));
@@ -204,7 +245,7 @@ mod tests {
let addr = serve_echo();
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":3,\"method\":\"ping\"}\r\n\n{\"id\":4,\"method\":\"pong\"}\n").unwrap();
c.write_all(b"{\"id\":3,\"method\":\"ping\",\"token\":\"s3cret\"}\r\n\n{\"id\":4,\"method\":\"pong\",\"token\":\"s3cret\"}\n").unwrap();
let mut r = BufReader::new(c);
for (id, method) in [(3, "ping"), (4, "pong")] {
let mut line = String::new();
@@ -214,6 +255,29 @@ mod tests {
}
}
#[test]
fn a_request_without_the_token_is_refused_and_the_connection_closed() {
let addr = serve_echo();
for request in [r#"{"id":1,"method":"ping"}"#, r#"{"id":1,"method":"ping","token":"s3cre"}"#, r#"{"id":1,"method":"ping","token":"S3CRET"}"#]
{
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
writeln!(c, "{request}").unwrap();
let mut r = BufReader::new(c);
let mut line = String::new();
r.read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!((v["id"].as_i64(), v["ok"].as_bool()), (Some(1), Some(false)), "{line}");
assert!(v["error"].as_str().unwrap_or("").starts_with("unauthorized"), "{line}");
assert!(v.get("method").is_none(), "the request was not forwarded: {line}");
line.clear();
assert_eq!(r.read_line(&mut line).unwrap(), 0, "the connection is closed after a refusal");
}
assert!(token_matches(Some(TOKEN), TOKEN));
assert!(!token_matches(None, TOKEN));
assert_eq!(new_token().unwrap().len(), 64);
}
#[test]
fn extra_connections_are_refused() {
let addr = serve_echo();
+4 -3
View File
@@ -3,8 +3,9 @@
//! Usage: `soundcraft [--demo] [--control <port>] [--no-audio] [session.scraft | audio files…]`; `soundcraft --help` lists the flags and `SOUNDCRAFT_CONTROL_PORT`.
//!
//! `--control <port>` (or `SOUNDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server
//! (`0` picks a free port and prints it): `{"id":1,"method":"ui.inspect","params":{}}` →
//! `{"id":1,"ok":true,"result":…}`. See `soundcraft_ui_egui::control` for the methods.
//! (`0` picks a free port and prints it): `{"id":1,"method":"ui.inspect","params":{},"token":"…"}` →
//! `{"id":1,"ok":true,"result":…}`, where `token` is `SOUNDCRAFT_CONTROL_TOKEN` (or the one made for
//! this launch, printed after the port). See `soundcraft_ui_egui::control` for the methods.
//!
//! `log` records go to standard error and `<settings dir>/logs/soundcraft.log` (`RUST_LOG` sets the
//! levels); see [`logging`].
@@ -260,7 +261,7 @@ fn main() -> eframe::Result {
app.extra.ui_scale = prefs.ui_scale;
app.ui = prefs;
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
let rx = control_server::start(port, soundcraft_automation::control_token(), cc.egui_ctx.clone());
app = app.with_control(rx);
}
for f in &files {
+16 -3
View File
@@ -151,18 +151,31 @@ impl RemoteError {
type Connection = (TcpStream, BufReader<TcpStream>);
/// The variable carrying the control channel's secret: the app refuses every request whose
/// `token` differs from it, so another local account can't drive the app through the loopback
/// port. The app makes one per launch when the variable is unset (printed after the port line).
pub const CONTROL_TOKEN_VAR: &str = "SOUNDCRAFT_CONTROL_TOKEN";
/// [`CONTROL_TOKEN_VAR`] from the environment; a blank value counts as unset.
pub fn control_token() -> Option<String> {
std::env::var(CONTROL_TOKEN_VAR).ok().map(|t| t.trim().to_owned()).filter(|t| !t.is_empty())
}
/// A TCP client for a running app's control channel.
pub struct Remote {
/// The target as `host:port`, for connecting and for messages.
addr: String,
/// Sent with every request; without one the app refuses, naming the variable.
token: Option<String>,
conn: Option<Connection>,
next_id: u64,
}
impl Remote {
/// A client for the app at `target`; it connects on the first request.
/// A client for the app at `target`, authenticating with [`control_token`]; it connects on the
/// first request.
pub fn new(target: &ControlTarget) -> Self {
Remote { addr: target.to_string(), conn: None, next_id: 1 }
Remote { addr: target.to_string(), token: control_token(), conn: None, next_id: 1 }
}
/// The open connection, connecting first when there is none.
@@ -178,7 +191,7 @@ impl Remote {
fn roundtrip(&mut self, method: &str, params: &Value) -> Result<Value, (RemoteError, bool)> {
let id = self.next_id;
self.next_id += 1;
let line = json!({"id": id, "method": method, "params": params}).to_string();
let line = json!({"id": id, "method": method, "params": params, "token": self.token}).to_string();
let (w, r) = self.connection().map_err(|e| (e, false))?;
writeln!(w, "{line}").map_err(|e| (e.into(), false))?;
let mut buf = String::new();
+25 -2
View File
@@ -222,10 +222,33 @@ mod tests {
assert!(plugin_info("reverb").is_none());
}
/// A new directory that this test alone created, readable only by its user on Unix. Creation
/// fails if the path already exists: the test then loads native libraries from it, so it must
/// never reuse a directory someone else could have put a library in. It lives next to the test
/// binary (in the build directory), not under the temp dir: `TMPDIR`/`TEMP` can name a shared
/// directory without a sticky bit or with inheritable write access, where another user could
/// rename or populate it. Anyone who can write to the build directory can already replace the
/// test binary itself, so this parent adds no new trust.
fn fresh_private_dir(prefix: &str) -> std::path::PathBuf {
let exe = std::env::current_exe().expect("test binary path");
let parent = exe.parent().expect("test binary has a parent directory");
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |t| t.as_nanos());
let d = parent.join(format!("{prefix}-{}-{nanos}", std::process::id()));
#[cfg(unix)]
let builder = {
let mut b = std::fs::DirBuilder::new();
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
b
};
#[cfg(not(unix))]
let builder = std::fs::DirBuilder::new();
builder.create(&d).unwrap_or_else(|e| panic!("cannot create a fresh test directory {}: {e}", d.display()));
d
}
#[test]
fn garbage_files_are_errors_not_crashes() {
let d = std::env::temp_dir().join(format!("soundcraft-clap-garbage-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
let d = fresh_private_dir("soundcraft-clap-garbage");
std::fs::create_dir_all(d.join("Bundle.clap/Contents/MacOS")).unwrap();
std::fs::write(d.join("garbage.clap"), b"\x7fELF this is not a plugin \x00\x01\x02").unwrap();
std::fs::write(d.join("empty.clap"), b"").unwrap();
+7 -69
View File
@@ -20,23 +20,14 @@ pub fn specs() -> Vec<CommandSpec> {
)]
}
/// How many times longer than its input a process's output can be.
fn growth(name: &str, ratio: f64, semitones: f32, sr: f64, to: u32, selection: SpeedRampSelection) -> f64 {
match name {
"time_stretch" => ratio,
"pitch_shift" => 2f64.powf(f64::from(semitones) / 12.0),
"varispeed" => f64::from(to) / sr,
"speed_ramp" if selection == SpeedRampSelection::Extend => 1.5,
_ => 1.0,
}
}
/// The most frames a process can take in: these processes cut their output at MAX_OFFLINE_SAMPLES frames.
fn frame_limit(name: &str, ratio: f64, semitones: f32, sr: f64, to: u32, selection: SpeedRampSelection) -> usize {
let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize;
match name {
"time_stretch" | "pitch_shift" | "varispeed" | "speed_ramp" => {
(soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / growth(name, ratio, semitones, sr, to, selection)) as usize
}
"time_stretch" => longest(ratio),
"pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)),
"varispeed" => longest(f64::from(to) / sr),
"speed_ramp" => longest(if selection == SpeedRampSelection::Extend { 1.5 } else { 1.0 }),
_ => usize::MAX,
}
}
@@ -85,33 +76,14 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
let selection = if get("selection", 0.0) >= 0.5 { SpeedRampSelection::Extend } else { SpeedRampSelection::FitTo };
let fades = get("fades", 1.0) >= 0.5;
let most = frame_limit(&name, ratio, semitones, sr.as_f64(), to, selection);
// Each clip once: a clip listed again would otherwise process the result of its first pass
// (an expanding process compounding its length every time).
let mut seen = std::collections::HashSet::new();
let ids: Vec<_> = ids.into_iter().filter(|id| seen.insert(*id)).collect();
// Refuse before processing anything. Every result is kept as a new source for the life of
// the command (and undo holds it), so cap the total output across clips, not just each one.
// (pitch_shift resamples its stretched result back to the input length)
let grows = if name == "pitch_shift" { 1.0 } else { growth(&name, ratio, semitones, sr.as_f64(), to, selection).max(1.0) };
let mut budget = soundcraft_mix::MAX_RENDER_SAMPLES;
for id in &ids {
let Some((track, clip)) = e.session().find_clip(*id) else { continue };
if !clip.is_audio() {
continue;
}
let channels = e.session().track(track).map_or(1, |t| t.channels()).max(1);
crate::io::refuse_longer(e.session(), "audiosuite.process", &clip.name, "process", clip.length, most.min(crate::io::render_limit(channels)))?;
let frames = (clip.length.max(0) as f64 * grows).ceil() as usize;
budget = budget
.checked_sub(frames.saturating_mul(channels))
.ok_or_else(|| bad("audiosuite.process", "too much audio to process in one command"))?;
}
let mut done = 0;
for id in ids {
let Some((track, clip)) = e.session().find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
if !clip.is_audio() {
continue;
}
let channels = e.session().track(track).map_or(1, |t| t.channels());
crate::io::refuse_longer(e.session(), "audiosuite.process", &clip.name, "process", clip.length, most.min(crate::io::render_limit(channels)))?;
let mut audio = soundcraft_mix::render_clips(e.session(), track, clip.range());
// Render without the clip's own fades: re-render a copy without fades so they stay editable.
let mut tmp = e.session().clone();
@@ -378,40 +350,6 @@ mod tests {
assert_eq!(e.session().tracks[0].clips().len(), 1, "one clip, not one per duplicate listing");
}
#[test]
fn a_clip_listed_twice_is_processed_once() {
let (mut e, id) = gained_clip();
let out =
e.execute("audiosuite.process", &json!({"process": "time_stretch", "params": {"ratio": 4.0}, "clips": [id.0, id.0, id.0]})).unwrap();
assert_eq!(out["processed"], 1);
let c = e.session().tracks[0].clips()[0].clone();
assert!(c.length <= 64 * 4 + 64, "stretched once, not compounded: {}", c.length);
assert_eq!(e.session().sources.len(), 2, "one new source, not one per listing");
}
#[test]
fn process_refuses_a_total_budget_across_clips() {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone"));
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] };
let src = crate::io::add_source(&mut s, "tone", buf, None, FileFormat::Wav);
// Three clips each within the per-clip limit whose results together exceed the budget.
let len = (soundcraft_mix::MAX_RENDER_SAMPLES / 3 + 1) as i64;
let ids: Vec<u64> = (0..3)
.map(|k| {
let id = s.new_clip_id();
crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, k * len, len));
id.0
})
.collect();
let mut e = crate::Engine::new(s);
let before = serde_json::to_value(e.session()).unwrap();
let out = e.execute("audiosuite.process", &json!({"process": "reverse", "clips": ids}));
let ok = matches!(out, Err(crate::EngineError::BadParams(_, ref m)) if m.contains("too much audio"));
assert!(ok, "three clips over budget, but got: {out:?}");
assert_eq!(serde_json::to_value(e.session()).unwrap(), before, "refused before processing anything");
}
#[test]
fn generate_refuses_a_total_budget_across_tracks() {
let mut e = Engine::new(Session::default());
+22 -6
View File
@@ -1,4 +1,6 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::hash_map::Entry;
/// A MIDI note. Times are in model ticks ([`crate::PPQ`] per quarter note).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
@@ -108,14 +110,28 @@ impl Sequence {
self.ctrls.sort_by_key(|c| c.tick);
let mut kept: Vec<CtrlEvent> = Vec::with_capacity(self.ctrls.len());
// The latest tick and `kept` index of each channel+kind, so a same-tick repeat is found
// without rescanning the tick's events (a tick can hold many thousands of distinct keys).
// An entry from an earlier tick counts as absent; keeping the tick instead of clearing the
// map per tick keeps the work per event constant after a dense tick.
let mut last_at: HashMap<(u8, (u8, u8)), (i64, usize)> = HashMap::new();
for c in self.ctrls.drain(..) {
// Within a tick, replace an earlier event of the same channel+kind.
// Only events at the same tick (the tail of `kept`) can match.
let slot =
kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && kind_key(k.kind) == kind_key(c.kind));
match slot {
Some(slot) => *slot = c,
None => kept.push(c),
match last_at.entry((c.channel, kind_key(c.kind))) {
Entry::Occupied(mut e) => {
let (tick, idx) = *e.get();
match kept.get_mut(idx) {
Some(slot) if tick == c.tick => *slot = c,
_ => {
e.insert((c.tick, kept.len()));
kept.push(c);
}
}
}
Entry::Vacant(e) => {
e.insert((c.tick, kept.len()));
kept.push(c);
}
}
}
self.ctrls = kept;
+69
View File
@@ -222,3 +222,72 @@ fn sequence_serde_round_trip() {
let json = serde_json::to_string(&seq).unwrap();
assert_eq!(serde_json::from_str::<Sequence>(&json).unwrap(), seq);
}
#[test]
fn remove_duplicates_handles_a_large_same_tick_controller_group() {
// Every channel x controller number at one tick, twice: the second copy of each wins. A
// rescan of the tick's kept events per incoming event would make this quadratic.
let keys: Vec<(u8, u8)> = (0..=255u8).flat_map(|ch| (0..=255u8).map(move |cc| (ch, cc))).collect();
let ctrls = [0, 1]
.iter()
.flat_map(|&pass| keys.iter().map(move |&(channel, cc)| CtrlEvent { tick: 7, channel, kind: CtrlKind::Cc(cc), value: pass }))
.collect();
let mut seq = Sequence { notes: Vec::new(), ctrls };
let start = std::time::Instant::now();
assert_eq!(seq.remove_duplicates(), keys.len());
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
assert_eq!(seq.ctrls.len(), keys.len());
assert!(seq.ctrls.iter().all(|c| c.value == 1));
}
#[test]
fn remove_duplicates_matches_a_per_tick_rescan_on_mixed_ticks() {
// The straightforward per-tick rescan the indexed version replaced, kept as an oracle.
fn reference(mut ctrls: Vec<CtrlEvent>) -> Vec<CtrlEvent> {
ctrls.sort_by_key(|c| c.tick);
let key = |k: CtrlKind| match k {
CtrlKind::Cc(n) => (0, n),
CtrlKind::PitchBend => (1, 0),
CtrlKind::ChannelPressure => (2, 0),
CtrlKind::PolyPressure(p) => (3, p),
CtrlKind::Program => (4, 0),
};
let mut kept: Vec<CtrlEvent> = Vec::new();
for c in ctrls {
match kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && key(k.kind) == key(c.kind)) {
Some(slot) => *slot = c,
None => kept.push(c),
}
}
kept
}
// Few ticks, channels and kinds, so the same key recurs both within a tick (interleaved with
// other keys) and across ticks; unsorted input from a fixed xorshift stream.
let mut state = 0x9E37_79B9_7F4A_7C15u64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
let kinds = |r: u64| match r % 5 {
0 => CtrlKind::Cc((r >> 8) as u8 % 4),
1 => CtrlKind::PitchBend,
2 => CtrlKind::ChannelPressure,
3 => CtrlKind::PolyPressure((r >> 8) as u8 % 4),
_ => CtrlKind::Program,
};
for round in 0..200 {
let mut ctrls = Vec::new();
for i in 0..(next() % 64) {
let r = next();
ctrls.push(CtrlEvent { tick: (r % 6) as i64 - 2, channel: (r >> 16) as u8 % 3, kind: kinds(r >> 24), value: (round * 100 + i) as i32 });
}
let mut seq = Sequence { notes: Vec::new(), ctrls: ctrls.clone() };
let removed = seq.remove_duplicates();
let expected = reference(ctrls.clone());
assert_eq!(seq.ctrls, expected, "round {round}");
assert_eq!(removed, ctrls.len() - expected.len(), "round {round}");
}
}
+2
View File
@@ -77,6 +77,8 @@ link stays. An existing file that is not an input is replaced without asking.
|---|---|---|
| `SOUNDCRAFT_CONTROL_PORT` | `app --port` (CLI), `--control` (desktop app) | A port or `host:port`, an IPv6 host in brackets (`[::1]:7979`). The desktop app takes the port of a loopback `host:port` (`127.0.0.1`, `localhost`). Its control server listens on `127.0.0.1` only, so it ignores another host from the variable, with a log warning, and starts no control server; given to `--control`, another host is a usage error |
| `SOUNDCRAFT_CONTROL_TOKEN` | none | The secret every control request carries as `token`. The desktop app refuses requests without it; unset, it makes one per launch and prints it after the port line (`SOUNDCRAFT_CONTROL_TOKEN=…`). `app` and `mcp --connect` send the variable's value |
- A flag wins over its variable.
- An empty variable counts as unset.
- A bad value is a usage error that names the variable.
+8 -2
View File
@@ -6,7 +6,7 @@ input), and every command is reachable from:
| Surface | How |
|---|---|
| Desktop app control channel | `soundcraft --control <port>` (`0` = pick a free port; printed as `SOUNDCRAFT_CONTROL_PORT=…`) |
| Desktop app control channel | `soundcraft --control <port>` (`0` = pick a free port; printed as `SOUNDCRAFT_CONTROL_PORT=…`, then the launch's `SOUNDCRAFT_CONTROL_TOKEN=…` unless the variable was set) |
| CLI against a running app | `soundcraft-cli app --port <port> <method or command id> [JSON]` (see [cli.md](cli.md)) |
| Headless CLI | `soundcraft-cli run --demo --cmd 'mix.volume={"track":"Kick","db":-6}' --bounce out.wav` |
| MCP (headless) | `soundcraft-cli mcp [--demo]` |
@@ -29,12 +29,17 @@ input delivery or screen-reader acceptance. Native screenshots use the presented
Newline-delimited JSON over TCP on `127.0.0.1`. One request per line, one reply per line:
```json
{"id": 1, "method": "engine.execute", "params": {"command": "track.new", "params": {"count": 2, "format": "Stereo"}}}
{"id": 1, "method": "engine.execute", "params": {"command": "track.new", "params": {"count": 2, "format": "Stereo"}}, "token": "…"}
{"id": 1, "ok": true, "result": {"tracks": [12, 13]}}
```
Errors: `{"id": 1, "ok": false, "error": "bad parameters for `mix.volume`: no track named `Nope`"}`.
`token` is the launch's secret: `SOUNDCRAFT_CONTROL_TOKEN` when the app was started with it, otherwise
the one the app made and printed after the port line. Any local account can reach the loopback port,
so a request without the right token is refused (`"error": "unauthorized: …"`) and its connection
closed. `soundcraft-cli app` and `mcp --connect` read the variable.
## Methods
| Method | Params | What it does |
@@ -71,6 +76,7 @@ Scripted input (`ui.click`, `ui.drag`, `ui.scroll`, `ui.zoom`, `ui.key`, `ui.tex
## Examples
```sh
export SOUNDCRAFT_CONTROL_TOKEN=$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')
P=$(soundcraft --demo --control 0 | sed -n 's/SOUNDCRAFT_CONTROL_PORT=//p') # or read stderr
soundcraft-cli app --port $P mix.solo '{"track":"Bass"}'
soundcraft-cli app --port $P transport.play
+1
View File
@@ -8,6 +8,7 @@ Claude and other agents can make music with it. The server is the `mcp` subcomma
soundcraft-cli mcp # headless engine (no window, no audio device)
soundcraft-cli mcp --demo # headless, starting from the demo session
soundcraft-cli mcp --automation-read-root DIR --automation-write-root DIR # headless, confined to folders
export SOUNDCRAFT_CONTROL_TOKEN=… # the secret both sides share (see control-protocol.md)
soundcraft --control 7801 & # the desktop app…
soundcraft-cli mcp --connect 7801 # …driven over MCP: same tools plus screenshots and clicks
```