mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-01 06:26:51 -06:00
461 lines
18 KiB
Rust
461 lines
18 KiB
Rust
//! The command line, as clap types. A flag a subcommand doesn't take is a usage error (exit 2), so
|
|
//! a typo such as `--bounces` can't be silently ignored (#549), and so is a value flag without its
|
|
//! value: a following flag is never taken as the value.
|
|
|
|
use std::borrow::Cow;
|
|
use std::ffi::OsString;
|
|
use std::num::NonZeroU32;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::OnceLock;
|
|
|
|
use clap::builder::NonEmptyStringValueParser;
|
|
use clap::error::ErrorKind;
|
|
use clap::{ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
|
|
use serde_json::{Value, json};
|
|
use soundcraft_audio_io::FileFormat;
|
|
use soundcraft_automation::ControlTarget;
|
|
use soundcraft_automation::args::{AutomationArgs, ControlClientArgs, blank_env_is_unset, control_target, long_version, name_env_vars};
|
|
|
|
/// Help text that names the extensions `list` gives, built once from
|
|
/// [`FileFormat::readable_extensions`] or [`FileFormat::writable_extensions`] so it can't drift
|
|
/// from what the code accepts.
|
|
fn with_formats(cell: &'static OnceLock<String>, before: &str, list: fn() -> Vec<&'static str>, after: &str) -> &'static str {
|
|
cell.get_or_init(|| format!("{before}({}){after}", list().join(", ")))
|
|
}
|
|
|
|
fn info_file_help() -> &'static str {
|
|
static C: OnceLock<String> = OnceLock::new();
|
|
with_formats(&C, "An audio file ", FileFormat::readable_extensions, " or a .scraft session")
|
|
}
|
|
|
|
fn read_help() -> &'static str {
|
|
static C: OnceLock<String> = OnceLock::new();
|
|
with_formats(&C, "The audio file to read ", FileFormat::readable_extensions, "")
|
|
}
|
|
|
|
fn write_help() -> &'static str {
|
|
static C: OnceLock<String> = OnceLock::new();
|
|
with_formats(&C, "The audio file to write; its extension picks the format ", FileFormat::writable_extensions, "")
|
|
}
|
|
|
|
/// soundcraft-cli: SoundCraft without a window
|
|
#[derive(Parser)]
|
|
#[command(name = "soundcraft-cli", version = long_version(), arg_required_else_help = true)]
|
|
pub(crate) struct Cli {
|
|
#[command(subcommand)]
|
|
pub(crate) command: Command,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
pub(crate) enum Command {
|
|
Info(InfoArgs),
|
|
Convert(ConvertArgs),
|
|
Run(RunArgs),
|
|
Script(ScriptArgs),
|
|
Commands(CommandsArgs),
|
|
Describe(DescribeArgs),
|
|
App(AppArgs),
|
|
Mcp(McpArgs),
|
|
Parity(ParityArgs),
|
|
/// List the built-in plugins
|
|
Plugins,
|
|
}
|
|
|
|
/// Print an audio file's format as JSON, or a .scraft session's summary
|
|
#[derive(Args)]
|
|
pub(crate) struct InfoArgs {
|
|
#[arg(help = info_file_help())]
|
|
pub(crate) file: PathBuf,
|
|
/// One line of JSON instead of pretty-printed (a .scraft summary is text and ignores it)
|
|
#[arg(long)]
|
|
pub(crate) compact: bool,
|
|
}
|
|
|
|
/// Convert an audio file: resample, change the bit depth, normalize
|
|
///
|
|
/// Dithers only when the output loses resolution, unless --dither or --no-dither says otherwise.
|
|
#[derive(Args)]
|
|
pub(crate) struct ConvertArgs {
|
|
#[arg(value_name = "IN", help = read_help())]
|
|
pub(crate) input: PathBuf,
|
|
#[arg(value_name = "OUT", help = write_help())]
|
|
pub(crate) output: PathBuf,
|
|
/// The output's bit depth
|
|
#[arg(long, value_enum, value_name = "16|24|32f", default_value = "24")]
|
|
pub(crate) bit_depth: BitDepthArg,
|
|
/// Resample to this sample rate
|
|
#[arg(long, value_name = "HZ")]
|
|
pub(crate) rate: Option<NonZeroU32>,
|
|
/// Normalize the peak to -0.1 dBFS
|
|
#[arg(long)]
|
|
pub(crate) normalize: bool,
|
|
/// Always dither
|
|
#[arg(long, conflicts_with = "no_dither")]
|
|
pub(crate) dither: bool,
|
|
/// Never dither
|
|
#[arg(long)]
|
|
pub(crate) no_dither: bool,
|
|
/// Allow an <OUT> that is the <IN> file, replacing the original
|
|
#[arg(long)]
|
|
pub(crate) in_place: bool,
|
|
}
|
|
|
|
/// Where `run` and `script` start, and what they do with the result.
|
|
#[derive(Args)]
|
|
pub(crate) struct EngineArgs {
|
|
/// Open this session first
|
|
#[arg(long = "in", value_name = "S.scraft", conflicts_with = "demo")]
|
|
pub(crate) input: Option<PathBuf>,
|
|
/// Start from the demo session
|
|
#[arg(long, visible_alias = "sample")]
|
|
pub(crate) demo: bool,
|
|
/// Bounce the mix to this file at the end; without --start and --end, the whole session
|
|
#[arg(long, value_name = "OUT")]
|
|
pub(crate) bounce: Option<PathBuf>,
|
|
/// Where the bounce starts: seconds (12.5) or a time string
|
|
#[arg(long, value_name = "T", allow_negative_numbers = true, requires = "bounce", value_parser = time_arg)]
|
|
pub(crate) start: Option<TimeArg>,
|
|
/// Where the bounce ends: seconds (12.5) or a time string
|
|
#[arg(long, value_name = "T", allow_negative_numbers = true, requires = "bounce", value_parser = time_arg)]
|
|
pub(crate) end: Option<TimeArg>,
|
|
/// Save the session to this file at the end, replacing it
|
|
#[arg(long, value_name = "S.scraft")]
|
|
pub(crate) save: Option<PathBuf>,
|
|
/// Print the session as JSON at the end
|
|
#[arg(long)]
|
|
pub(crate) inspect: bool,
|
|
/// Print the --inspect JSON on one line instead of pretty-printed
|
|
#[arg(long, requires = "inspect")]
|
|
pub(crate) compact: bool,
|
|
/// Allow a --save that is the --in session, replacing the original
|
|
#[arg(long, requires = "input")]
|
|
pub(crate) in_place: bool,
|
|
}
|
|
|
|
/// Run engine commands on a new, demo or saved session
|
|
///
|
|
/// Prints each command's JSON result on one line, then bounces, saves and inspects, in that order.
|
|
/// The --inspect JSON is pretty-printed unless --compact is given.
|
|
#[derive(Args)]
|
|
pub(crate) struct RunArgs {
|
|
/// An engine command, with its JSON parameters after `=` (default {}); repeat for several, run
|
|
/// in order
|
|
#[arg(long, value_name = "ID[=JSON]", action = ArgAction::Append, value_parser = cmd_spec)]
|
|
pub(crate) cmd: Vec<CmdSpec>,
|
|
#[command(flatten)]
|
|
pub(crate) engine: EngineArgs,
|
|
}
|
|
|
|
/// Run a file of engine commands, one `ID JSON` per line
|
|
///
|
|
/// Blank lines and lines starting with # are skipped. A failing line stops the script unless
|
|
/// --keep-going is given.
|
|
#[derive(Args)]
|
|
pub(crate) struct ScriptArgs {
|
|
/// The script to run
|
|
pub(crate) file: PathBuf,
|
|
/// Continue after command errors and finish requested output, then exit nonzero if any command failed.
|
|
/// Malformed JSON stops immediately
|
|
#[arg(long)]
|
|
pub(crate) keep_going: bool,
|
|
#[command(flatten)]
|
|
pub(crate) engine: EngineArgs,
|
|
}
|
|
|
|
/// List the engine commands
|
|
#[derive(Args)]
|
|
pub(crate) struct CommandsArgs {
|
|
/// Only the commands whose id, or label in any case, contains this text
|
|
#[arg(value_parser = NonEmptyStringValueParser::new())]
|
|
pub(crate) filter: Option<String>,
|
|
}
|
|
|
|
/// Print one engine command's details as JSON
|
|
#[derive(Args)]
|
|
pub(crate) struct DescribeArgs {
|
|
/// The command's id, such as file.bounce_mix
|
|
#[arg(value_parser = NonEmptyStringValueParser::new())]
|
|
pub(crate) id: String,
|
|
/// One line of JSON instead of pretty-printed
|
|
#[arg(long)]
|
|
pub(crate) compact: bool,
|
|
}
|
|
|
|
/// Call a running app's control channel
|
|
///
|
|
/// A dotted engine command id is shorthand for engine.execute; the control methods
|
|
/// (session.inspect, ui.screenshot, …) stay methods. Prints the reply as JSON.
|
|
#[derive(Args)]
|
|
pub(crate) struct AppArgs {
|
|
#[command(flatten)]
|
|
pub(crate) control: ControlClientArgs,
|
|
/// A control method or engine command id
|
|
#[arg(value_parser = NonEmptyStringValueParser::new())]
|
|
pub(crate) method: String,
|
|
/// Its parameters as JSON [default: {}]
|
|
#[arg(value_parser = json_value)]
|
|
pub(crate) json: Option<Value>,
|
|
/// One line of JSON instead of pretty-printed
|
|
#[arg(long)]
|
|
pub(crate) compact: bool,
|
|
}
|
|
|
|
/// Serve MCP on stdin and stdout
|
|
///
|
|
/// The automation roots confine every file the agent's commands read (open, import, presets) and
|
|
/// write (save, bounce, export) to those folders, links followed. Each root 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. The roots confine the in-process server, so they, like --demo, conflict with --connect.
|
|
#[derive(Args)]
|
|
pub(crate) struct McpArgs {
|
|
/// Serve a running app's control channel at this port or host:port instead of an in-process
|
|
/// engine; conflicts with --demo, which starts only that engine
|
|
#[arg(long, value_name = "PORT", value_parser = control_target, conflicts_with_all = ["demo", "automation_read_root", "automation_write_root"])]
|
|
pub(crate) connect: Option<ControlTarget>,
|
|
/// Start the in-process engine from the demo session
|
|
#[arg(long)]
|
|
pub(crate) demo: bool,
|
|
#[command(flatten)]
|
|
pub(crate) automation: AutomationArgs,
|
|
}
|
|
|
|
/// Print the parity report (markdown)
|
|
#[derive(Args)]
|
|
pub(crate) struct ParityArgs {
|
|
/// Write the report to this file instead
|
|
#[arg(long, value_name = "PATH")]
|
|
pub(crate) write: Option<PathBuf>,
|
|
}
|
|
|
|
impl Command {
|
|
/// The outputs clap can't check: one that is an input file, however it is spelt, or both of
|
|
/// `--bounce` and `--save` naming one file. The paths are compared before any work starts and
|
|
/// written later, so a link swapped in meanwhile goes unnoticed: this guards against mistakes,
|
|
/// it is not a security boundary.
|
|
fn check_outputs(&self) -> Result<(), OutputError> {
|
|
match self {
|
|
Self::Convert(a) if !a.in_place && same_file(&a.input, &a.output) => {
|
|
Err(OutputError::ReplacesInput { output: "<OUT>", input: "<IN> file", path: a.output.clone() })
|
|
}
|
|
Self::Run(a) => a.engine.check_outputs(None),
|
|
Self::Script(a) => a.engine.check_outputs(Some(&a.file)),
|
|
Self::Info(_)
|
|
| Self::Convert(_)
|
|
| Self::Commands(_)
|
|
| Self::Describe(_)
|
|
| Self::App(_)
|
|
| Self::Mcp(_)
|
|
| Self::Parity(_)
|
|
| Self::Plugins => Ok(()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ConvertArgs {
|
|
/// The file `convert` writes: <OUT>, or the file it links to when it replaces <IN> in place.
|
|
pub(crate) fn write_path(&self) -> Cow<'_, Path> {
|
|
in_place_target(self.in_place, Some(&self.input), &self.output)
|
|
}
|
|
}
|
|
|
|
impl EngineArgs {
|
|
/// The file --save writes: its path, or the file it links to when it replaces --in in place.
|
|
pub(crate) fn save_path(&self) -> Option<Cow<'_, Path>> {
|
|
self.save.as_deref().map(|save| in_place_target(self.in_place, self.input.as_deref(), save))
|
|
}
|
|
|
|
/// `script` is the script file `script` runs, which no flag lets an output replace. --in-place
|
|
/// lets only --save replace the --in session: a bounce would turn it into audio.
|
|
fn check_outputs(&self, script: Option<&Path>) -> Result<(), OutputError> {
|
|
for (flag, path) in [("--bounce", &self.bounce), ("--save", &self.save)] {
|
|
let Some(path) = path else { continue };
|
|
if script.is_some_and(|script| same_file(script, path)) {
|
|
return Err(OutputError::ReplacesScript { output: flag, path: path.clone() });
|
|
}
|
|
}
|
|
let replaces_input = |path: &Path| self.input.as_ref().is_some_and(|input| same_file(input, path));
|
|
if let Some(bounce) = &self.bounce
|
|
&& replaces_input(bounce)
|
|
{
|
|
return Err(OutputError::BounceReplacesInput(bounce.clone()));
|
|
}
|
|
if let Some(save) = &self.save
|
|
&& !self.in_place
|
|
&& replaces_input(save)
|
|
{
|
|
return Err(OutputError::ReplacesInput { output: "--save", input: "--in session", path: save.clone() });
|
|
}
|
|
match (&self.bounce, &self.save) {
|
|
(Some(bounce), Some(save)) if same_file(bounce, save) => Err(OutputError::SameOutputs(save.clone())),
|
|
_ => Ok(()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether `a` and `b` name one file, however spelt (`a.wav`, `./a.wav`, a symbolic or hard link).
|
|
/// When either exists, only two regular files can be one, compared by identity (device and inode);
|
|
/// a FIFO or device is never opened, so the check can't block. When neither exists yet, they are
|
|
/// one if they would be created at the same place.
|
|
fn same_file(a: &Path, b: &Path) -> bool {
|
|
if a.exists() || b.exists() {
|
|
return a.is_file() && b.is_file() && same_file::is_same_file(a, b).unwrap_or(false);
|
|
}
|
|
matches!((destination(a), destination(b)), (Some(a), Some(b)) if a == b)
|
|
}
|
|
|
|
/// With `in_place`, an `output` that is a symbolic link to `input` resolved to the file it names.
|
|
/// A save writes a new file and renames it over its path, which would replace the link and leave
|
|
/// the input as it was: through the resolved path, `convert` and `--save` both replace the input.
|
|
fn in_place_target<'a>(in_place: bool, input: Option<&Path>, output: &'a Path) -> Cow<'a, Path> {
|
|
let link = output.symlink_metadata().is_ok_and(|m| m.file_type().is_symlink());
|
|
if in_place
|
|
&& link
|
|
&& input.is_some_and(|input| same_file(input, output))
|
|
&& let Ok(target) = output.canonicalize()
|
|
{
|
|
return Cow::Owned(target);
|
|
}
|
|
Cow::Borrowed(output)
|
|
}
|
|
|
|
/// Where a file that doesn't exist yet would be created: its folder resolved, links followed.
|
|
fn destination(path: &Path) -> Option<PathBuf> {
|
|
let folder = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
|
Some(folder.canonicalize().ok()?.join(path.file_name()?))
|
|
}
|
|
|
|
/// An output [`Command::check_outputs`] refuses: `parse` prints it as a usage error (exit 2).
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub(crate) enum OutputError {
|
|
#[error("{output} {} is the {input}, which it would replace: choose another file, or pass --in-place", .path.display())]
|
|
ReplacesInput { output: &'static str, input: &'static str, path: PathBuf },
|
|
#[error("--bounce {} is the --in session, which it would replace with audio: choose another file", .0.display())]
|
|
BounceReplacesInput(PathBuf),
|
|
#[error("{output} {} is the script file, which it would replace: choose another file", .path.display())]
|
|
ReplacesScript { output: &'static str, path: PathBuf },
|
|
#[error("--bounce and --save both name {}, so the save would replace the bounce: choose another file for one of them", .0.display())]
|
|
SameOutputs(PathBuf),
|
|
}
|
|
|
|
impl OutputError {
|
|
/// Two arguments that may not name one file: clap's conflict kind.
|
|
fn into_clap(self, cmd: &mut clap::Command) -> clap::Error {
|
|
cmd.error(ErrorKind::ArgumentConflict, self)
|
|
}
|
|
}
|
|
|
|
/// The bit depths `convert` writes. `32` is the same as `32f`.
|
|
#[derive(Clone, Copy, ValueEnum)]
|
|
pub(crate) enum BitDepthArg {
|
|
#[value(name = "16")]
|
|
Int16,
|
|
#[value(name = "24")]
|
|
Int24,
|
|
#[value(name = "32f", alias = "32")]
|
|
Float32,
|
|
}
|
|
|
|
impl From<BitDepthArg> for soundcraft_audio_io::BitDepth {
|
|
fn from(depth: BitDepthArg) -> Self {
|
|
match depth {
|
|
BitDepthArg::Int16 => Self::Int16,
|
|
BitDepthArg::Int24 => Self::Int24,
|
|
BitDepthArg::Float32 => Self::Float32,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A bounce position: seconds, or a time string the engine reads.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) enum TimeArg {
|
|
Seconds(f64),
|
|
Text(String),
|
|
}
|
|
|
|
impl TimeArg {
|
|
/// The `start` / `end` parameter of `file.bounce_mix`.
|
|
pub(crate) fn to_json(&self) -> Value {
|
|
match self {
|
|
Self::Seconds(s) => json!({"seconds": s}),
|
|
Self::Text(t) => json!(t),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One `--cmd ID[=JSON]`: the command id and its parameters (`{}` when none are given).
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) struct CmdSpec {
|
|
pub(crate) id: String,
|
|
pub(crate) params: Value,
|
|
}
|
|
|
|
/// Why a value was refused: clap prints it after the flag and its value (exit 2).
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub(crate) enum ValueError {
|
|
#[error("expected seconds (12.5) or a time string, not an empty value")]
|
|
EmptyTime,
|
|
#[error("expected a finite number of seconds")]
|
|
NotFinite,
|
|
#[error("expected a command id, optionally followed by `=` and JSON parameters")]
|
|
EmptyCommandId,
|
|
#[error("bad JSON: {0}")]
|
|
Json(#[from] serde_json::Error),
|
|
}
|
|
|
|
fn time_arg(s: &str) -> Result<TimeArg, ValueError> {
|
|
if s.is_empty() {
|
|
return Err(ValueError::EmptyTime);
|
|
}
|
|
match s.parse::<f64>() {
|
|
// JSON has no NaN or infinity: serde_json would send the engine `null`.
|
|
Ok(secs) if !secs.is_finite() => Err(ValueError::NotFinite),
|
|
Ok(secs) => Ok(TimeArg::Seconds(secs)),
|
|
Err(_) => Ok(TimeArg::Text(s.to_owned())),
|
|
}
|
|
}
|
|
|
|
fn json_value(s: &str) -> Result<Value, ValueError> {
|
|
Ok(serde_json::from_str(s)?)
|
|
}
|
|
|
|
/// `ID` or `ID=JSON`; malformed JSON is an error, not `{}`.
|
|
fn cmd_spec(s: &str) -> Result<CmdSpec, ValueError> {
|
|
let (id, params) = match s.split_once('=') {
|
|
Some((id, p)) => (id, json_value(p)?),
|
|
None => (s, json!({})),
|
|
};
|
|
if id.is_empty() {
|
|
return Err(ValueError::EmptyCommandId);
|
|
}
|
|
Ok(CmdSpec { id: id.to_owned(), params })
|
|
}
|
|
|
|
/// Parse the arguments (without the binary name). clap reads them as `OsString`s: a folder need
|
|
/// not be valid Unicode, text must. A flag wins over its environment variable, unless that is
|
|
/// empty, and a bad value from one is a usage error naming the variable. A bare `--help` or `-h`
|
|
/// is help even after a mistake on the same line (`run --bounce --help`): clap reports the mistake
|
|
/// first. An output that would replace an input is refused here too
|
|
/// ([`Command::check_outputs`]), so nothing has run when it is reported.
|
|
pub(crate) fn parse(args: &[OsString]) -> Result<Cli, clap::Error> {
|
|
let mut cmd = Cli::command().mut_subcommands(|sub| sub.mut_args(blank_env_is_unset));
|
|
let argv = std::iter::once(OsString::from("soundcraft-cli")).chain(args.iter().cloned());
|
|
let matches = match cmd.try_get_matches_from_mut(argv) {
|
|
Err(e) if e.kind() != ErrorKind::DisplayHelp => {
|
|
let Some(flag) = args.iter().take_while(|a| *a != "--").find(|a| *a == "--help" || *a == "-h") else {
|
|
return Err(name_env_vars(e, &cmd, args));
|
|
};
|
|
let help = [OsString::from("soundcraft-cli"), args.first().cloned().unwrap_or_default(), flag.clone()];
|
|
return Err(cmd.try_get_matches_from_mut(help).err().unwrap_or(e));
|
|
}
|
|
matches => matches?,
|
|
};
|
|
let cli = Cli::from_arg_matches(&matches)?;
|
|
if let Err(e) = cli.command.check_outputs() {
|
|
return Err(match matches.subcommand_name().and_then(|name| cmd.find_subcommand_mut(name)) {
|
|
Some(sub) => e.into_clap(sub),
|
|
None => e.into_clap(&mut cmd),
|
|
});
|
|
}
|
|
Ok(cli)
|
|
}
|