Files
soundcraft/apps/soundcraft-cli/src/main.rs
T

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, &params) {
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, &params) {
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"));
}
}