Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 951a5cd08a AudioSuite: budget pitch shift at its input length 2026-11-03 11:03:34 +00:00
Dylan M. Taylor 2b5785f2e2 AudioSuite: process each listed clip once, within a total budget
A clip listed more than once in audiosuite.process was processed again
on its own result: the replacement keeps the clip id, so an expanding
process (time stretch, varispeed, pitch shift) compounded the clip's
length each time, and every intermediate result stayed in the source
pool for the rest of the command.

Deduplicate the clip ids, and before processing anything check each
clip's per-render limit and the projected output of all clips together
against MAX_RENDER_SAMPLES, the same total budget the signal generator
already uses.
2026-11-02 22:29:03 -05:00
14 changed files with 97 additions and 234 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 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`.
- `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`.
- 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,7 +4304,6 @@ dependencies = [
"craft-update",
"eframe",
"egui",
"getrandom 0.3.4",
"log",
"muda",
"objc2 0.6.5",
+1 -2
View File
@@ -214,8 +214,7 @@ soundcraft-cli run --demo \
--cmd 'mix.insert={"track":"Bass","plugin":"eq_7band"}' \
--bounce mix.wav
# 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')
# Drive the running app.
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,8 +26,6 @@ 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"] }
+9 -73
View File
@@ -3,13 +3,8 @@
//!
//! 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};
@@ -33,20 +28,9 @@ 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. 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> {
/// printed; bind or spawn failures are logged instead of panicking.
pub fn start(port: u16, 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) => {
@@ -57,29 +41,13 @@ pub fn start(port: u16, token: Option<String>, ctx: egui::Context) -> Receiver<C
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}");
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)));
let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || accept_loop(listener, tx, ctx));
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>);
@@ -92,7 +60,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, token: Arc<str>) {
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
@@ -103,10 +71,9 @@ 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, &token);
serve(stream, tx, ctx);
});
}
}
@@ -136,9 +103,8 @@ 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, 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) {
/// 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) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
@@ -160,11 +126,6 @@ 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);
@@ -224,14 +185,12 @@ 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(), Arc::from(TOKEN)));
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
std::thread::spawn(move || {
for req in rx {
let _ = req.reply.send(json!({"ok": true, "method": req.method}));
@@ -245,7 +204,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\",\"token\":\"s3cret\"}\r\n\n{\"id\":4,\"method\":\"pong\",\"token\":\"s3cret\"}\n").unwrap();
c.write_all(b"{\"id\":3,\"method\":\"ping\"}\r\n\n{\"id\":4,\"method\":\"pong\"}\n").unwrap();
let mut r = BufReader::new(c);
for (id, method) in [(3, "ping"), (4, "pong")] {
let mut line = String::new();
@@ -255,29 +214,6 @@ 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();
+3 -4
View File
@@ -3,9 +3,8 @@
//! 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":{},"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.
//! (`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.
//!
//! `log` records go to standard error and `<settings dir>/logs/soundcraft.log` (`RUST_LOG` sets the
//! levels); see [`logging`].
@@ -261,7 +260,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, soundcraft_automation::control_token(), cc.egui_ctx.clone());
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
for f in &files {
+3 -16
View File
@@ -151,31 +151,18 @@ 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`, authenticating with [`control_token`]; it connects on the
/// first request.
/// A client for the app at `target`; it connects on the first request.
pub fn new(target: &ControlTarget) -> Self {
Remote { addr: target.to_string(), token: control_token(), conn: None, next_id: 1 }
Remote { addr: target.to_string(), conn: None, next_id: 1 }
}
/// The open connection, connecting first when there is none.
@@ -191,7 +178,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, "token": self.token}).to_string();
let line = json!({"id": id, "method": method, "params": params}).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();
+2 -25
View File
@@ -222,33 +222,10 @@ 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 = fresh_private_dir("soundcraft-clap-garbage");
let d = std::env::temp_dir().join(format!("soundcraft-clap-garbage-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
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();
+69 -7
View File
@@ -20,14 +20,23 @@ 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" => 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 }),
"time_stretch" | "pitch_shift" | "varispeed" | "speed_ramp" => {
(soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / growth(name, ratio, semitones, sr, to, selection)) as usize
}
_ => usize::MAX,
}
}
@@ -76,14 +85,33 @@ 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();
@@ -350,6 +378,40 @@ 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());
+6 -22
View File
@@ -1,6 +1,4 @@
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)]
@@ -110,28 +108,14 @@ 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.
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);
}
// 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),
}
}
self.ctrls = kept;
-69
View File
@@ -222,72 +222,3 @@ 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,8 +77,6 @@ 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.
+2 -8
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=…`, then the launch's `SOUNDCRAFT_CONTROL_TOKEN=…` unless the variable was set) |
| Desktop app control channel | `soundcraft --control <port>` (`0` = pick a free port; printed as `SOUNDCRAFT_CONTROL_PORT=…`) |
| 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,17 +29,12 @@ 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"}}, "token": "…"}
{"id": 1, "method": "engine.execute", "params": {"command": "track.new", "params": {"count": 2, "format": "Stereo"}}}
{"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 |
@@ -76,7 +71,6 @@ 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,7 +8,6 @@ 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
```