mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-31 14:06:56 -06:00
685 lines
28 KiB
Rust
685 lines
28 KiB
Rust
//! `soundcraft-cli`: SoundCraft without a window.
|
|
//!
|
|
//! ```text
|
|
//! soundcraft-cli info FILE audio file or .scraft summary
|
|
//! soundcraft-cli convert IN OUT [--bit-depth 16|24|32f] [--rate HZ] [--normalize] [--dither | --no-dither]
|
|
//! dithers only when the output loses resolution, unless told
|
|
//! soundcraft-cli run [--in S.scraft | --demo] [--cmd ID[=JSON]]… [--bounce OUT] [--start T --end T] [--save S.scraft] [--inspect]
|
|
//! soundcraft-cli script FILE [--in S | --demo] [--save S] [--bounce OUT [--start T --end T]] (one `ID JSON` per line;
|
|
//! without --start/--end, --bounce covers the whole session)
|
|
//! soundcraft-cli commands [FILTER] list commands
|
|
//! soundcraft-cli describe ID one command's details
|
|
//! soundcraft-cli app [--port P] METHOD [JSON] call a running app's control channel
|
|
//! soundcraft-cli mcp [--connect PORT] [--demo] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
|
//! MCP server on stdio. The roots confine every file the
|
|
//! agent's commands read (open, import, presets) and write
|
|
//! (save, bounce, export) to those folders, links followed;
|
|
//! each flag may be given more than once, a path inside any
|
|
//! of its folders being allowed, and a kind left out grants
|
|
//! none of its access. They run the in-process server, so
|
|
//! they conflict with --connect.
|
|
//! soundcraft-cli parity [--write PATH] parity report (markdown)
|
|
//! soundcraft-cli plugins list built-in plugins
|
|
//! ```
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
|
|
mod automation_args;
|
|
|
|
use serde_json::{Value, json};
|
|
use soundcraft_automation::{Backend, Headless, Remote, mcp::Server};
|
|
use soundcraft_engine::Engine;
|
|
use soundcraft_engine::file_access::{self, AutomationRoots};
|
|
use std::io::Write;
|
|
use std::process::ExitCode;
|
|
|
|
/// Print without panicking on a closed pipe. A write error must not skip later work
|
|
/// such as `--bounce` or `--save`, and must not by itself report success.
|
|
macro_rules! outln {
|
|
($($t:tt)*) => {{
|
|
let mut o = std::io::stdout().lock();
|
|
let _ = writeln!(o, $($t)*);
|
|
}};
|
|
}
|
|
|
|
fn arg_value(args: &[String], flag: &str) -> Option<String> {
|
|
args.iter().enumerate().find_map(|(i, a)| {
|
|
if a == flag { args.get(i + 1).cloned() } else { a.strip_prefix(flag).and_then(|rest| rest.strip_prefix('=')).map(str::to_owned) }
|
|
})
|
|
}
|
|
|
|
/// Every option a subcommand accepts, with whether it carries a value.
|
|
type Options = [(&'static str, bool)];
|
|
|
|
/// A misspelled option, or one whose value is missing, reads as absent through `arg_value`, so the
|
|
/// subcommand would run without what was asked for and still report success. Reject both instead.
|
|
fn check_options(args: &[String], allowed: &Options) -> Result<(), String> {
|
|
let mut i = 0;
|
|
while i < args.len() {
|
|
let Some(name) = args[i].strip_prefix("--") else {
|
|
i += 1;
|
|
continue;
|
|
};
|
|
let (name, inline) = name.split_once('=').map_or((name, None), |(n, v)| (n, Some(v)));
|
|
let flag = format!("--{name}");
|
|
let Some(&(_, takes_value)) = allowed.iter().find(|(f, _)| f == &flag) else {
|
|
return Err(format!("unknown option {flag}"));
|
|
};
|
|
match (takes_value, inline) {
|
|
(false, Some(_)) => return Err(format!("{flag} takes no value")),
|
|
(true, None) => {
|
|
// A following option is the next flag, not this one's value.
|
|
if args.get(i + 1).is_none_or(|v| v.starts_with("--")) {
|
|
return Err(format!("{flag} needs a value"));
|
|
}
|
|
i += 1;
|
|
}
|
|
_ => {}
|
|
}
|
|
i += 1;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `--in`, `--demo`/`--sample`, `--bounce`, `--start`, `--end`, `--save` and `--inspect`, read by
|
|
/// `load_engine` and `finish` and so shared by `run` and `script`.
|
|
const ENGINE_OPTIONS: &Options = &[
|
|
("--in", true),
|
|
("--demo", false),
|
|
("--sample", false),
|
|
("--bounce", true),
|
|
("--start", true),
|
|
("--end", true),
|
|
("--save", true),
|
|
("--inspect", false),
|
|
];
|
|
|
|
fn fail(msg: impl std::fmt::Display) -> ExitCode {
|
|
eprintln!("soundcraft-cli: {msg}");
|
|
ExitCode::FAILURE
|
|
}
|
|
|
|
/// The usage block of the module doc comment, up to its closing code fence.
|
|
fn help_text() -> String {
|
|
include_str!("main.rs")
|
|
.lines()
|
|
.skip(3)
|
|
.take_while(|l| l.trim_end() != "//! ```")
|
|
.map(|l| l.trim_start_matches("//! "))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
fn main() -> ExitCode {
|
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
let Some(cmd) = args.first().cloned() else {
|
|
eprintln!("usage: soundcraft-cli <info|convert|run|script|commands|describe|app|mcp|parity|plugins> … (see --help)");
|
|
return ExitCode::FAILURE;
|
|
};
|
|
let rest: Vec<String> = args.iter().skip(1).cloned().collect();
|
|
let code = match cmd.as_str() {
|
|
"--version" | "-V" | "version" => {
|
|
outln!("SoundCraft CLI {}", env!("CARGO_PKG_VERSION"));
|
|
ExitCode::SUCCESS
|
|
}
|
|
"--help" | "-h" | "help" => {
|
|
outln!("{}", help_text());
|
|
ExitCode::SUCCESS
|
|
}
|
|
"info" => info(&rest),
|
|
"convert" => convert(&rest),
|
|
"run" => run(&rest),
|
|
"script" => script(&rest),
|
|
"commands" => commands(&rest),
|
|
"describe" => describe(&rest),
|
|
"app" => app(&rest),
|
|
"mcp" => mcp(&rest),
|
|
"parity" => parity(&rest),
|
|
"plugins" => {
|
|
for p in soundcraft_dsp::plugins() {
|
|
outln!("{:<20} {:<22} {:?}{}", p.id, p.name, p.category, if p.is_instrument { " (instrument)" } else { "" });
|
|
}
|
|
ExitCode::SUCCESS
|
|
}
|
|
other => fail(format!("unknown subcommand `{other}`")),
|
|
};
|
|
// The subcommand's engine and its plugin instances are gone by now.
|
|
soundcraft_engine::shutdown_plugin_hosts();
|
|
code
|
|
}
|
|
|
|
fn info(args: &[String]) -> ExitCode {
|
|
let Some(path) = args.first() else { return fail("info FILE") };
|
|
if path.ends_with(".scraft") {
|
|
let mut e = Engine::default();
|
|
match soundcraft_engine::io::open_session(&mut e, path) {
|
|
Ok(missing) => warn_missing(&missing),
|
|
Err(err) => return fail(err),
|
|
}
|
|
outln!("{}", soundcraft_engine::inspect::session_text(&e));
|
|
return ExitCode::SUCCESS;
|
|
}
|
|
let bytes = match std::fs::read(path) {
|
|
Ok(b) => b,
|
|
Err(e) => return fail(format!("{path}: {e}")),
|
|
};
|
|
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str);
|
|
match soundcraft_audio_io::probe(&bytes, ext) {
|
|
Ok(i) => {
|
|
outln!("{}", serde_json::to_string_pretty(&json!({"format": i.format, "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf})).unwrap_or_default());
|
|
ExitCode::SUCCESS
|
|
}
|
|
Err(e) => fail(format!("{path}: {e}")),
|
|
}
|
|
}
|
|
|
|
fn convert(args: &[String]) -> ExitCode {
|
|
let (Some(input), Some(output)) = (args.first(), args.get(1)) else { return fail("convert IN OUT") };
|
|
const OPTIONS: &Options = &[("--rate", true), ("--bit-depth", true), ("--normalize", false), ("--dither", false), ("--no-dither", false)];
|
|
if let Err(e) = check_options(&args[2..], OPTIONS) {
|
|
return fail(e);
|
|
}
|
|
let rate = match parse_rate(arg_value(args, "--rate").as_deref()) {
|
|
Ok(r) => r,
|
|
Err(e) => return fail(e),
|
|
};
|
|
let bit_depth = match parse_bit_depth(arg_value(args, "--bit-depth").as_deref()) {
|
|
Ok(b) => b,
|
|
Err(e) => return fail(e),
|
|
};
|
|
let dither_flag = match parse_dither(args) {
|
|
Ok(d) => d,
|
|
Err(e) => return fail(e),
|
|
};
|
|
let bytes = match std::fs::read(input) {
|
|
Ok(b) => b,
|
|
Err(e) => return fail(format!("{input}: {e}")),
|
|
};
|
|
let ext = std::path::Path::new(input).extension().and_then(std::ffi::OsStr::to_str);
|
|
let (info, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) {
|
|
Ok(x) => x,
|
|
Err(e) => return fail(format!("{input}: {e}")),
|
|
};
|
|
if let Some(rate) = rate {
|
|
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
|
|
buf.sample_rate = rate;
|
|
}
|
|
let normalize = args.iter().any(|a| a == "--normalize");
|
|
if normalize {
|
|
soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false);
|
|
}
|
|
let processed = rate.is_some_and(|r| r != info.sample_rate) || normalize;
|
|
let dither = dither_flag.unwrap_or_else(|| auto_dither(info.sample_format, bit_depth, processed));
|
|
let out_ext = std::path::Path::new(output).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase();
|
|
let Some(format) = soundcraft_audio_io::encode_format_for(&out_ext) else {
|
|
return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS));
|
|
};
|
|
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf: None };
|
|
match soundcraft_audio_io::encode(&buf, &opts).map_err(|e| e.to_string()).and_then(|b| std::fs::write(output, b).map_err(|e| e.to_string())) {
|
|
Ok(()) => {
|
|
outln!("wrote {output} ({} ch, {} Hz, {:.2} s)", buf.num_channels(), buf.sample_rate, buf.duration_secs());
|
|
ExitCode::SUCCESS
|
|
}
|
|
Err(e) => fail(e),
|
|
}
|
|
}
|
|
|
|
fn parse_rate(v: Option<&str>) -> Result<Option<u32>, String> {
|
|
match v {
|
|
None => Ok(None),
|
|
Some(r) => match r.parse::<u32>() {
|
|
Ok(hz) if hz > 0 => Ok(Some(hz)),
|
|
_ => Err(format!("--rate: expected a sample rate in Hz, got `{r}`")),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn parse_bit_depth(v: Option<&str>) -> Result<soundcraft_audio_io::BitDepth, String> {
|
|
match v {
|
|
None | Some("24") => Ok(soundcraft_audio_io::BitDepth::Int24),
|
|
Some("16") => Ok(soundcraft_audio_io::BitDepth::Int16),
|
|
Some("32") | Some("32f") => Ok(soundcraft_audio_io::BitDepth::Float32),
|
|
Some(b) => Err(format!("--bit-depth: expected 16, 24 or 32f, got `{b}`")),
|
|
}
|
|
}
|
|
|
|
/// `--dither` / `--no-dither`: `Some` when the caller chose, `None` to decide from the depths.
|
|
fn parse_dither(args: &[String]) -> Result<Option<bool>, String> {
|
|
match (args.iter().any(|a| a == "--dither"), args.iter().any(|a| a == "--no-dither")) {
|
|
(true, true) => Err("--dither and --no-dither: choose one".into()),
|
|
(true, false) => Ok(Some(true)),
|
|
(false, true) => Ok(Some(false)),
|
|
(false, false) => Ok(None),
|
|
}
|
|
}
|
|
|
|
/// Whether `convert` dithers when not told: only when writing integer samples that cannot hold the
|
|
/// source exactly, i.e. the output has fewer bits than the source, the source is float or lossy, or
|
|
/// resampling/normalizing produced new sample values. A 16-bit file written as 16 or 24-bit, or
|
|
/// anything written as 32-bit float, is copied sample for sample (digital silence stays silent).
|
|
fn auto_dither(source: soundcraft_audio_io::SampleFormat, target: soundcraft_audio_io::BitDepth, processed: bool) -> bool {
|
|
use soundcraft_audio_io::{BitDepth, SampleFormat};
|
|
let target_bits = match target {
|
|
BitDepth::Float32 => return false,
|
|
BitDepth::Int16 => 16,
|
|
BitDepth::Int24 => 24,
|
|
BitDepth::Int32 => 32,
|
|
};
|
|
if processed {
|
|
return true;
|
|
}
|
|
match source {
|
|
SampleFormat::Int8 => target_bits < 8,
|
|
SampleFormat::Int16 => target_bits < 16,
|
|
SampleFormat::Int24 => target_bits < 24,
|
|
// Decoded through f32, which holds 24 bits: a 32-bit integer source has already lost
|
|
// resolution, so treat it like float.
|
|
SampleFormat::Int32 | SampleFormat::Float32 | SampleFormat::Float64 | SampleFormat::Compressed => true,
|
|
}
|
|
}
|
|
|
|
/// One stderr line per media file a session refers to but could not load (the exit code is unchanged).
|
|
fn missing_warnings(missing: &[String]) -> Vec<String> {
|
|
missing.iter().map(|m| format!("warning: missing media: {m}")).collect()
|
|
}
|
|
|
|
fn warn_missing(missing: &[String]) {
|
|
for line in missing_warnings(missing) {
|
|
eprintln!("{line}");
|
|
}
|
|
}
|
|
|
|
fn load_engine(args: &[String]) -> Result<Engine, String> {
|
|
if let Some(p) = arg_value(args, "--in") {
|
|
let mut e = Engine::default();
|
|
let missing = soundcraft_engine::io::open_session(&mut e, &p).map_err(|e| e.to_string())?;
|
|
warn_missing(&missing);
|
|
Ok(e)
|
|
} else if args.iter().any(|a| a == "--demo" || a == "--sample") {
|
|
Ok(soundcraft_engine::demo::demo_engine())
|
|
} else {
|
|
Ok(Engine::default())
|
|
}
|
|
}
|
|
|
|
fn finish(e: &mut Engine, args: &[String]) -> ExitCode {
|
|
if let Some(out) = arg_value(args, "--bounce") {
|
|
let mut p = json!({"path": out});
|
|
if let Some(s) = arg_value(args, "--start") {
|
|
p["start"] = time_arg(&s);
|
|
}
|
|
if let Some(s) = arg_value(args, "--end") {
|
|
p["end"] = time_arg(&s);
|
|
}
|
|
// No range given: bounce the whole session, not whatever edit selection the commands left.
|
|
if p.get("start").is_none() && p.get("end").is_none() {
|
|
p["start"] = json!(0);
|
|
p["end"] = json!(e.session().content_end().max(1));
|
|
}
|
|
match e.execute("file.bounce_mix", &p) {
|
|
Ok(v) => outln!("{v}"),
|
|
Err(err) => return fail(err),
|
|
}
|
|
}
|
|
if let Some(path) = arg_value(args, "--save") {
|
|
match e.execute("session.save", &json!({"path": path, "replace": true})) {
|
|
Ok(v) => outln!("{v}"),
|
|
Err(err) => return fail(err),
|
|
}
|
|
}
|
|
if args.iter().any(|a| a == "--inspect") {
|
|
outln!("{}", serde_json::to_string_pretty(&soundcraft_engine::inspect::session(e, false)).unwrap_or_default());
|
|
}
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
/// `12.5` → seconds; anything else is passed through as a time string.
|
|
fn time_arg(s: &str) -> Value {
|
|
s.parse::<f64>().map_or_else(|_| json!(s), |f| json!({"seconds": f}))
|
|
}
|
|
|
|
/// `ID` or `ID=JSON` → command id and params; malformed JSON is an error, not `{}`.
|
|
fn parse_cmd_spec(spec: &str) -> Result<(String, Value), String> {
|
|
match spec.split_once('=') {
|
|
Some((id, p)) => serde_json::from_str::<Value>(p).map(|v| (id.to_string(), v)).map_err(|err| format!("{id}: bad JSON: {err}")),
|
|
None => Ok((spec.to_string(), json!({}))),
|
|
}
|
|
}
|
|
|
|
fn run(args: &[String]) -> ExitCode {
|
|
let options: Vec<(&str, bool)> = ENGINE_OPTIONS.iter().copied().chain([("--cmd", true)]).collect();
|
|
if let Err(e) = check_options(args, &options) {
|
|
return fail(e);
|
|
}
|
|
let mut e = match load_engine(args) {
|
|
Ok(e) => e,
|
|
Err(err) => return fail(err),
|
|
};
|
|
let mut it = args.iter();
|
|
while let Some(a) = it.next() {
|
|
let spec = match a.strip_prefix("--cmd=") {
|
|
Some(inline) => inline,
|
|
None if a == "--cmd" => match it.next() {
|
|
Some(next) => next,
|
|
None => continue,
|
|
},
|
|
None => continue,
|
|
};
|
|
let (id, params) = match parse_cmd_spec(spec) {
|
|
Ok(v) => v,
|
|
Err(err) => return fail(err),
|
|
};
|
|
match e.execute(&id, ¶ms) {
|
|
Ok(v) => outln!("{id}: {v}"),
|
|
Err(err) => return fail(format!("{id}: {err}")),
|
|
}
|
|
}
|
|
finish(&mut e, args)
|
|
}
|
|
|
|
fn script(args: &[String]) -> ExitCode {
|
|
let Some(path) = args.first() else { return fail("script FILE") };
|
|
let options: Vec<(&str, bool)> = ENGINE_OPTIONS.iter().copied().chain([("--keep-going", false)]).collect();
|
|
if let Err(e) = check_options(&args[1..], &options) {
|
|
return fail(e);
|
|
}
|
|
let text = match std::fs::read_to_string(path) {
|
|
Ok(t) => t,
|
|
Err(e) => return fail(format!("{path}: {e}")),
|
|
};
|
|
let mut e = match load_engine(args) {
|
|
Ok(e) => e,
|
|
Err(err) => return fail(err),
|
|
};
|
|
let keep = args.iter().any(|a| a == "--keep-going");
|
|
for (n, line) in text.lines().enumerate() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
let (id, p) = line.split_once(char::is_whitespace).unwrap_or((line, "{}"));
|
|
let params = match serde_json::from_str::<Value>(p.trim()) {
|
|
Ok(v) => v,
|
|
Err(err) => return fail(format!("{path}:{}: bad JSON: {err}", n + 1)),
|
|
};
|
|
match e.execute(id, ¶ms) {
|
|
Ok(v) => outln!("{id}: {v}"),
|
|
Err(err) => {
|
|
eprintln!("{path}:{}: {id}: {err}", n + 1);
|
|
if !keep {
|
|
return ExitCode::FAILURE;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
finish(&mut e, args)
|
|
}
|
|
|
|
fn commands(args: &[String]) -> ExitCode {
|
|
let filter = args.first().cloned().unwrap_or_default();
|
|
let e = Engine::default();
|
|
for c in soundcraft_engine::command_specs() {
|
|
if !filter.is_empty() && !c.id.contains(&filter) && !c.label.to_ascii_lowercase().contains(&filter.to_ascii_lowercase()) {
|
|
continue;
|
|
}
|
|
let i = c.info(&e);
|
|
outln!("{:<36} {:<34} {}", i.id, i.label, i.menu.join(" > "));
|
|
}
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
fn describe(args: &[String]) -> ExitCode {
|
|
let Some(id) = args.first() else { return fail("describe ID") };
|
|
match soundcraft_engine::find_command(id) {
|
|
Some(c) => {
|
|
outln!("{}", serde_json::to_string_pretty(&c.info(&Engine::default())).unwrap_or_default());
|
|
ExitCode::SUCCESS
|
|
}
|
|
None => fail(format!("unknown command `{id}`")),
|
|
}
|
|
}
|
|
|
|
/// Control-channel methods. Every other dotted id is an engine command.
|
|
fn is_control_method(method: &str) -> bool {
|
|
matches!(
|
|
method,
|
|
"engine.execute"
|
|
| "command"
|
|
| "engine.commands"
|
|
| "engine.parity"
|
|
| "session.inspect"
|
|
| "document.inspect"
|
|
| "ui.inspect"
|
|
| "ui.menu.list"
|
|
| "ui.menu.invoke"
|
|
| "ui.set"
|
|
| "ui.move"
|
|
| "ui.click"
|
|
| "ui.drag"
|
|
| "ui.key"
|
|
| "ui.text"
|
|
| "ui.screenshot"
|
|
| "app.quit"
|
|
)
|
|
}
|
|
|
|
/// A command id is shorthand for `engine.execute`. Documented control methods stay methods.
|
|
fn app_request(method: &str, params: Value) -> (String, Value) {
|
|
if is_control_method(method) || !method.contains('.') {
|
|
(method.to_string(), params)
|
|
} else {
|
|
("engine.execute".to_string(), json!({"command": method, "params": params}))
|
|
}
|
|
}
|
|
|
|
fn app(args: &[String]) -> ExitCode {
|
|
let port = arg_value(args, "--port").or_else(|| std::env::var("SOUNDCRAFT_CONTROL_PORT").ok()).unwrap_or_else(|| "7979".into());
|
|
let positional: Vec<&String> = args
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(i, a)| !(a.starts_with("--") || (*i > 0 && args.get(i - 1).is_some_and(|p| p == "--port"))))
|
|
.map(|(_, a)| a)
|
|
.collect();
|
|
let Some(method) = positional.first() else { return fail("app [--port P] METHOD [JSON]") };
|
|
let params = match positional.get(1) {
|
|
Some(p) => match serde_json::from_str::<Value>(p) {
|
|
Ok(v) => v,
|
|
Err(err) => return fail(format!("{method}: bad JSON: {err}")),
|
|
},
|
|
None => json!({}),
|
|
};
|
|
let mut r = Remote::new(&port);
|
|
// A bare command id is shorthand for engine.execute. Control-channel methods stay methods.
|
|
let (m, p) = app_request(method, params);
|
|
match r.call(&m, p) {
|
|
Ok(v) => {
|
|
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
|
ExitCode::SUCCESS
|
|
}
|
|
Err(e) => fail(e),
|
|
}
|
|
}
|
|
|
|
/// What `mcp` was asked for.
|
|
struct McpArgs {
|
|
connect: Option<String>,
|
|
demo: bool,
|
|
roots: Option<std::sync::Arc<AutomationRoots>>,
|
|
}
|
|
|
|
/// Parse the `mcp` options up front: a malformed or misspelled root flag must not leave the
|
|
/// server running unconfined.
|
|
fn parse_mcp_args(args: &[String]) -> Result<McpArgs, String> {
|
|
let mut connect = None;
|
|
let mut demo = false;
|
|
let mut roots_args = automation_args::AutomationArgs::default();
|
|
let mut it = args.iter().cloned();
|
|
while let Some(a) = it.next() {
|
|
if roots_args.consume(&a, &mut it)? {
|
|
continue;
|
|
}
|
|
match a.as_str() {
|
|
"--connect" => connect = Some(it.next().ok_or("--connect needs a port or host:port")?),
|
|
"--demo" => demo = true,
|
|
other if other.starts_with("--automation-") => return Err(format!("unknown mcp option `{other}`")),
|
|
_ => {}
|
|
}
|
|
}
|
|
let read: Vec<&std::path::Path> = roots_args.read_roots.iter().map(std::path::PathBuf::as_path).collect();
|
|
let write: Vec<&std::path::Path> = roots_args.write_roots.iter().map(std::path::PathBuf::as_path).collect();
|
|
let roots = AutomationRoots::new(&read, &write)?;
|
|
if roots.is_some() && connect.is_some() {
|
|
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
|
|
so drop --connect (or use --connect without the roots)"
|
|
.into());
|
|
}
|
|
Ok(McpArgs { connect, demo, roots })
|
|
}
|
|
|
|
fn mcp(args: &[String]) -> ExitCode {
|
|
let McpArgs { connect, demo, roots } = match parse_mcp_args(args) {
|
|
Ok(a) => a,
|
|
Err(err) => return fail(err),
|
|
};
|
|
let stdin = std::io::stdin();
|
|
let stdout = std::io::stdout();
|
|
let r = if let Some(port) = connect {
|
|
let mut s = Server::new(Remote::new(&port));
|
|
s.serve(stdin.lock(), stdout.lock())
|
|
} else {
|
|
// `--demo` is the operator's own choice, made before the roots come into force.
|
|
let e = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
|
|
let mut h = Headless::new(e);
|
|
if let Some(roots) = roots {
|
|
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
|
file_access::confine_process(roots.clone());
|
|
eprintln!("soundcraft-cli: files confined: {}", roots.describe());
|
|
h = h.with_automation_roots(Some(roots));
|
|
}
|
|
let mut s = Server::new(h);
|
|
s.serve(stdin.lock(), stdout.lock())
|
|
};
|
|
match r {
|
|
Ok(()) => ExitCode::SUCCESS,
|
|
Err(e) => fail(e),
|
|
}
|
|
}
|
|
|
|
fn parity(args: &[String]) -> ExitCode {
|
|
let p = soundcraft_engine::catalog::parity_json();
|
|
let mut md = String::new();
|
|
md.push_str("# Parity report\n\nGenerated by `soundcraft-cli parity --write docs/parity-checklist.md` (also `cargo xtask parity`).\n");
|
|
md.push_str("It compares the incumbent's menu tree (feature names observed black-box, `crates/engine/catalog/menus.txt`) with SoundCraft's command registry.\n");
|
|
md.push_str("UI-layer commands (windows, AudioSuite) are counted by the app's own report (`engine.parity` over the control channel).\n\n");
|
|
md.push_str(&format!(
|
|
"**Menu items implemented by the engine: {} / {} ({}%)**\n\n| Menu | Implemented | Total |\n|---|---:|---:|\n",
|
|
p["implemented"], p["total"], p["percent"]
|
|
));
|
|
for m in p["per_menu"].as_array().cloned().unwrap_or_default() {
|
|
md.push_str(&format!("| {} | {} | {} |\n", m["menu"].as_str().unwrap_or(""), m["implemented"], m["total"]));
|
|
}
|
|
md.push_str("\n## Not yet implemented\n\n");
|
|
for m in p["missing"].as_array().cloned().unwrap_or_default() {
|
|
md.push_str(&format!("- {}\n", m.as_str().unwrap_or("")));
|
|
}
|
|
if let Some(path) = arg_value(args, "--write") {
|
|
if let Err(e) = std::fs::write(&path, &md) {
|
|
return fail(format!("{path}: {e}"));
|
|
}
|
|
outln!("wrote {path}: {}% ({}/{})", p["percent"], p["implemented"], p["total"]);
|
|
} else {
|
|
outln!("{md}");
|
|
}
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn command_ids_are_execute_shorthand_and_control_methods_stay_methods() {
|
|
let (m, p) = app_request("engine.history", json!({}));
|
|
assert_eq!(m, "engine.execute");
|
|
assert_eq!(p["command"], "engine.history");
|
|
let (m, p) = app_request("ui.theme", json!({"mode": "light"}));
|
|
assert_eq!(m, "engine.execute");
|
|
assert_eq!(p["params"]["mode"], "light");
|
|
let (m, _) = app_request("session.new", json!({"name": "Session A"}));
|
|
assert_eq!(m, "engine.execute");
|
|
let (m, _) = app_request("track.new", json!({}));
|
|
assert_eq!(m, "engine.execute");
|
|
for method in ["session.inspect", "engine.commands", "engine.parity", "ui.screenshot", "app.quit"] {
|
|
assert_eq!(app_request(method, json!({})).0, method);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn convert_rejects_invalid_rate_and_bit_depth() {
|
|
assert_eq!(parse_rate(None), Ok(None));
|
|
assert_eq!(parse_rate(Some("44100")), Ok(Some(44_100)));
|
|
assert!(parse_rate(Some("4410x")).is_err());
|
|
assert!(parse_rate(Some("0")).is_err());
|
|
assert!(matches!(parse_bit_depth(None), Ok(soundcraft_audio_io::BitDepth::Int24)));
|
|
assert!(matches!(parse_bit_depth(Some("16")), Ok(soundcraft_audio_io::BitDepth::Int16)));
|
|
assert!(matches!(parse_bit_depth(Some("32f")), Ok(soundcraft_audio_io::BitDepth::Float32)));
|
|
assert!(parse_bit_depth(Some("8")).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn convert_dither_flags() {
|
|
let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
|
assert_eq!(parse_dither(&args(&["in.wav", "out.wav"])), Ok(None));
|
|
assert_eq!(parse_dither(&args(&["in.wav", "out.wav", "--dither"])), Ok(Some(true)));
|
|
assert_eq!(parse_dither(&args(&["in.wav", "out.wav", "--no-dither"])), Ok(Some(false)));
|
|
assert!(parse_dither(&args(&["in.wav", "out.wav", "--dither", "--no-dither"])).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn convert_dithers_only_when_resolution_is_lost() {
|
|
use soundcraft_audio_io::{BitDepth, SampleFormat};
|
|
// Same depth or wider: the samples fit exactly.
|
|
assert!(!auto_dither(SampleFormat::Int16, BitDepth::Int16, false));
|
|
assert!(!auto_dither(SampleFormat::Int16, BitDepth::Int24, false));
|
|
assert!(!auto_dither(SampleFormat::Int24, BitDepth::Int24, false));
|
|
assert!(!auto_dither(SampleFormat::Int8, BitDepth::Int16, false));
|
|
// Float output never dithers.
|
|
assert!(!auto_dither(SampleFormat::Float32, BitDepth::Float32, true));
|
|
assert!(!auto_dither(SampleFormat::Int24, BitDepth::Float32, false));
|
|
// Fewer bits, a float or lossy source, or processed samples: dither.
|
|
assert!(auto_dither(SampleFormat::Int24, BitDepth::Int16, false));
|
|
assert!(auto_dither(SampleFormat::Float32, BitDepth::Int24, false));
|
|
assert!(auto_dither(SampleFormat::Compressed, BitDepth::Int16, false));
|
|
assert!(auto_dither(SampleFormat::Int32, BitDepth::Int24, false));
|
|
assert!(auto_dither(SampleFormat::Int16, BitDepth::Int16, true));
|
|
}
|
|
|
|
#[test]
|
|
fn missing_media_gets_one_warning_each() {
|
|
assert!(missing_warnings(&[]).is_empty());
|
|
let w = missing_warnings(&["a.wav".to_string(), "b.wav: decode failed".to_string()]);
|
|
assert_eq!(w, vec!["warning: missing media: a.wav", "warning: missing media: b.wav: decode failed"]);
|
|
}
|
|
|
|
#[test]
|
|
fn cmd_spec_parses_id_and_json() {
|
|
assert_eq!(parse_cmd_spec("a.b").unwrap(), ("a.b".to_string(), json!({})));
|
|
assert_eq!(parse_cmd_spec("a.b={\"x\":1}").unwrap(), ("a.b".to_string(), json!({"x": 1})));
|
|
}
|
|
|
|
#[test]
|
|
fn cmd_spec_rejects_truncated_json() {
|
|
let err = parse_cmd_spec("a.b={\"x\":").unwrap_err();
|
|
assert!(err.starts_with("a.b: bad JSON"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn help_text_is_only_the_usage_block() {
|
|
let help = help_text();
|
|
assert!(help.starts_with("soundcraft-cli info FILE"));
|
|
assert!(help.ends_with("soundcraft-cli plugins list built-in plugins"));
|
|
assert!(help.contains("--automation-read-root DIR") && help.contains("--automation-write-root DIR"));
|
|
assert!(!help.contains("```"));
|
|
assert!(!help.contains("#![") && !help.contains("use serde_json"));
|
|
}
|
|
}
|