mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-04 01:11:17 -06:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a72de88454 | ||
|
|
f7315a98d6 |
@@ -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
@@ -4304,7 +4304,6 @@ dependencies = [
|
||||
"craft-update",
|
||||
"eframe",
|
||||
"egui",
|
||||
"getrandom 0.3.4",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.5",
|
||||
|
||||
@@ -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"}'
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
use crate::pcm::{be_u32, be_u64};
|
||||
use crate::{AudioError, Result};
|
||||
|
||||
/// The most `stts` entries a sound track may have. Symphonia finds each packet's timestamp by
|
||||
/// scanning the table from its start, so a table of one-sample runs makes decoding quadratic in
|
||||
/// the packet count. Audio encoders write one run per packet length (a handful of entries);
|
||||
/// this bound keeps the worst case at a few hundred million steps.
|
||||
const MAX_AUDIO_STTS_ENTRIES: u32 = 1 << 14;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Level {
|
||||
Top,
|
||||
@@ -11,7 +17,11 @@ enum Level {
|
||||
Trak,
|
||||
Mdia,
|
||||
Minf,
|
||||
Stbl,
|
||||
/// `audio`: the sample entry is an audio format, so symphonia will decode its packets (whatever the
|
||||
/// handler says: it builds codec parameters from `stsd` alone).
|
||||
Stbl {
|
||||
audio: bool,
|
||||
},
|
||||
Udta,
|
||||
Meta,
|
||||
Ilst,
|
||||
@@ -55,13 +65,20 @@ fn atoms(b: &[u8], mut pos: usize, end: usize, level: Level) -> Result<bool> {
|
||||
(Level::Moov, b"udta") => atoms(b, body, inner, Level::Udta)?,
|
||||
(Level::Trak, b"mdia") => atoms(b, body, inner, Level::Mdia)?,
|
||||
(Level::Mdia, b"minf") => atoms(b, body, inner, Level::Minf)?,
|
||||
(Level::Minf, b"stbl") => atoms(b, body, inner, Level::Stbl)?,
|
||||
(Level::Minf, b"stbl") => atoms(b, body, inner, Level::Stbl { audio: entry_is_audio(b, body, inner) })?,
|
||||
(Level::Meta, b"ilst") => atoms(b, body, inner, Level::Ilst)?,
|
||||
(Level::Ilst, _) => atoms(b, body, inner, Level::Tag)?,
|
||||
(Level::Stbl, b"stts" | b"co64") => table(b, body + 4, 8, inner)?,
|
||||
(Level::Stbl, b"stsc") => table(b, body + 4, 12, inner)?,
|
||||
(Level::Stbl, b"stco") => table(b, body + 4, 4, inner)?,
|
||||
(Level::Stbl, b"stsz") => sample_sizes(b, body, inner)?,
|
||||
(Level::Stbl { audio }, b"stts") => {
|
||||
let fits = table(b, body + 4, 8, inner)?;
|
||||
if audio && be_u32(b, body + 4).is_some_and(|n| n > MAX_AUDIO_STTS_ENTRIES) {
|
||||
return Err(AudioError::Malformed("MP4 audio timing table has more runs than packets could need".into()));
|
||||
}
|
||||
fits
|
||||
}
|
||||
(Level::Stbl { .. }, b"co64") => table(b, body + 4, 8, inner)?,
|
||||
(Level::Stbl { .. }, b"stsc") => table(b, body + 4, 12, inner)?,
|
||||
(Level::Stbl { .. }, b"stco") => table(b, body + 4, 4, inner)?,
|
||||
(Level::Stbl { .. }, b"stsz") => sample_sizes(b, body, inner)?,
|
||||
(Level::Mdia, b"hdlr") | (Level::Tag, b"data" | b"name" | b"mean") if len > (b.len() - pos) as u64 => {
|
||||
return Err(malformed(&format!("`{}` atom", String::from_utf8_lossy(kind))));
|
||||
}
|
||||
@@ -75,6 +92,36 @@ fn atoms(b: &[u8], mut pos: usize, end: usize, level: Level) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The sample entry formats symphonia reads as audio (the list `stsd` is matched against in
|
||||
/// symphonia-format-isomp4 0.6); every other format, video and subtitles included, has no audio packets.
|
||||
const AUDIO_FORMATS: [&[u8; 4]; 18] = [
|
||||
b"mp4a", b"alac", b"ac-3", b"ec-3", b"fLaC", b"Opus", b".mp3", b"lpcm", b"wave", b"alaw", b"ulaw", b"raw ", b"sowt", b"twos", b"in24", b"in32",
|
||||
b"fl32", b"fl64",
|
||||
];
|
||||
|
||||
/// Whether the `stbl` atom whose children lie in `pos..end` describes audio: its `stsd` may come before
|
||||
/// or after the timing tables, so the children are walked once more for it.
|
||||
fn entry_is_audio(b: &[u8], mut pos: usize, end: usize) -> bool {
|
||||
while pos < end {
|
||||
let (Some(size), Some(kind)) = (be_u32(b, pos), b.get(pos + 4..pos + 8)) else { return false };
|
||||
let head = if size == 1 { 16 } else { 8 };
|
||||
if kind == b"stsd" {
|
||||
// Version and flags, the entry count, then the first entry's size and format.
|
||||
return b.get(pos + head + 12..pos + head + 16).is_some_and(|f| AUDIO_FORMATS.iter().any(|a| a.as_slice() == f));
|
||||
}
|
||||
let len = match size {
|
||||
0 => return false,
|
||||
1 => be_u64(b, pos + 8).unwrap_or(0),
|
||||
n => u64::from(n),
|
||||
};
|
||||
match usize::try_from(len).ok().filter(|&n| n >= head).and_then(|n| pos.checked_add(n)) {
|
||||
Some(next) => pos = next,
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// A sample table's entries, counted at `count`, must fit in the file: symphonia reserves room for
|
||||
/// all of them first. False if they run past the table's atom (`end`), where symphonia stops.
|
||||
fn table(b: &[u8], count: usize, entry: u64, end: usize) -> Result<bool> {
|
||||
|
||||
@@ -22,6 +22,11 @@ use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat
|
||||
/// takes its frame length from the file (encoders use 4096).
|
||||
const MAX_PACKET_FRAMES: u64 = 1 << 20;
|
||||
|
||||
/// The most samples (frames × channels) a packet may declare: the PCM decoder allocates that
|
||||
/// many for its output up front, so a CAF with thousands of channels and a long packet could
|
||||
/// otherwise claim gigabytes before a sample is decoded (64 MiB of f32).
|
||||
const MAX_PACKET_SAMPLES: u64 = 1 << 24;
|
||||
|
||||
/// How far [`first_marker`] looks for the first format marker: the same 1 MiB a default symphonia
|
||||
/// probe scans.
|
||||
const MAX_PREFIX_SCAN: usize = 1 << 20;
|
||||
@@ -508,7 +513,9 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
|
||||
}
|
||||
let params = audio_params(track).cloned().ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
|
||||
let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_ID_ALAC).and_then(|d| be_u32(d, 0));
|
||||
if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) {
|
||||
let ch = u64::from(info.channels);
|
||||
if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES || n.saturating_mul(ch) > MAX_PACKET_SAMPLES)
|
||||
{
|
||||
return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into()));
|
||||
}
|
||||
let mut decoder = symphonia::default::get_codecs().make_audio_decoder(¶ms, &decoder_options()).map_err(map_err)?;
|
||||
@@ -531,6 +538,13 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
|
||||
if packet.track_id != track_id {
|
||||
continue;
|
||||
}
|
||||
// The declared frames per packet are not the packet's size: a container supplies that on its
|
||||
// own, and the PCM decoder sizes its output by the bytes it is given.
|
||||
if let Some(bytes_per_sample) = params.bits_per_coded_sample.map(|b| u64::from(b / 8)).filter(|&n| n > 0)
|
||||
&& packet.data.len() as u64 / bytes_per_sample > MAX_PACKET_SAMPLES
|
||||
{
|
||||
return Err(AudioError::Malformed("a packet holds far more samples than any codec uses".into()));
|
||||
}
|
||||
let decoded = match decoder.decode(&packet) {
|
||||
Ok(d) => d,
|
||||
Err(SymError::DecodeError(m)) => {
|
||||
|
||||
@@ -321,7 +321,7 @@ fn mp4(frames: u32, entry: &[u8], swap: impl Fn(&[u8; 4], Vec<u8>) -> Vec<u8>) -
|
||||
};
|
||||
let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom");
|
||||
let offset = (ftyp.len() + moov(0).len() + 8) as u32;
|
||||
let pcm: Vec<u8> = (0..frames as i16).flat_map(|i| (i * 40).to_le_bytes()).collect();
|
||||
let pcm: Vec<u8> = (0..frames as i16).flat_map(|i| i.wrapping_mul(40).to_le_bytes()).collect();
|
||||
[ftyp, moov(offset), atom(b"mdat", &pcm)].concat()
|
||||
}
|
||||
|
||||
@@ -373,6 +373,51 @@ fn packets_longer_than_any_codec_uses_are_refused() {
|
||||
for entry in [lpcm(1 << 21), alac(1 << 21)] {
|
||||
assert!(matches!(decode(&mp4(480, &entry, |_, a| a), None), Err(AudioError::Malformed(m)) if m.contains("declares packets")));
|
||||
}
|
||||
// Frames and channels multiply into the decoder's packet buffer; an ordinary stereo stream
|
||||
// with long packets stays well inside the bound.
|
||||
let wide = |channels: u32, frames: u32| {
|
||||
let fields = [&be(&[72])[..], &48_000f64.to_be_bytes(), &be(&[channels, 0x7f00_0000, 16, 4, 2 * channels, frames])].concat();
|
||||
sample_entry(b"lpcm", 2, &fields, &[])
|
||||
};
|
||||
assert!(decode(&mp4(480, &wide(2, 1 << 13), |_, a| a), None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_audio_timing_table_with_a_run_per_sample_is_bounded() {
|
||||
let pcm = sample_entry(b"sowt", 0, &[], &[]);
|
||||
// One stts run per sample: symphonia scans the runs from the start for every packet.
|
||||
let runs = |n: u32| atom(b"stts", &[&[0; 4][..], &n.to_be_bytes(), &[1u32, 1].map(u32::to_be_bytes).concat().repeat(n as usize)[..]].concat());
|
||||
let frames = 1 << 14;
|
||||
let (_, buf) = decode(&mp4(frames, &pcm, |k, a| if k == b"stts" { runs(frames) } else { a }), None).unwrap();
|
||||
assert_eq!(buf.frames(), frames as usize, "a table at the bound still decodes");
|
||||
let f = mp4(frames + 1, &pcm, |k, a| if k == b"stts" { runs(frames + 1) } else { a });
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("timing table")), "one run past the bound is refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_audio_timing_bound_follows_the_sample_entry_not_the_handler() {
|
||||
let pcm = sample_entry(b"sowt", 0, &[], &[]);
|
||||
let runs = |n: u32| atom(b"stts", &[&[0; 4][..], &n.to_be_bytes(), &[1u32, 1].map(u32::to_be_bytes).concat().repeat(n as usize)[..]].concat());
|
||||
// an audio sample entry under a video handler is still decoded as audio by symphonia
|
||||
let video_handler = atom(b"hdlr", &[0u32, 0, u32::from_be_bytes(*b"vide"), 0, 0, 0, 0].map(u32::to_be_bytes).concat());
|
||||
let frames = (1 << 14) + 1;
|
||||
let f = mp4(frames, &pcm, |k, a| match k {
|
||||
b"stts" => runs(frames),
|
||||
b"hdlr" => video_handler.clone(),
|
||||
_ => a,
|
||||
});
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("timing table")), "the handler does not exempt an audio entry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pcm_packet_longer_than_its_declared_frames_is_refused_before_decoding() {
|
||||
let pcm = sample_entry(b"sowt", 0, &[], &[]);
|
||||
// one `stsz` sample of 2 * 2^24 + 2 bytes (just over the packet bound) in a file that holds it
|
||||
let big = 2 * (1usize << 24) + 2;
|
||||
let stsz = atom(b"stsz", &[0u32, big as u32, 1].map(u32::to_be_bytes).concat());
|
||||
let mut f = mp4(1, &pcm, |k, a| if k == b"stsz" { stsz.clone() } else { a });
|
||||
f.resize(f.len() + big, 0);
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("packet holds far more samples")));
|
||||
}
|
||||
|
||||
/// FLAC metadata blocks with field lengths exceeding their block boundary are refused as malformed.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user