sound_to_patch/cli.rs
1//! The command-line definition.
2//!
3//! The binary parses it, and `cargo xtask` renders the man page and the shell
4//! completions from the same tree, so the three never disagree. Each command
5//! keeps its arguments in a module of its own.
6
7use std::error::Error;
8
9use clap::{Parser, Subcommand};
10
11pub mod analyze;
12pub mod notes;
13pub mod preset;
14pub mod render;
15
16/// Arguments of the `sound-to-patch` command.
17///
18/// # Examples
19///
20/// ```
21/// use clap::Parser;
22/// use clap::error::ErrorKind;
23/// use sound_to_patch::cli::Cli;
24///
25/// let err = Cli::try_parse_from(["sound-to-patch", "--version"]).unwrap_err();
26/// assert_eq!(err.kind(), ErrorKind::DisplayVersion);
27/// ```
28#[derive(Debug, Parser)]
29#[command(
30 name = "sound-to-patch",
31 version,
32 about,
33 long_about = None,
34 arg_required_else_help = true
35)]
36pub struct Cli {
37 /// The command to run.
38 #[command(subcommand)]
39 pub command: Command,
40}
41
42/// The commands, one variant per line.
43#[derive(Debug, Subcommand)]
44pub enum Command {
45 /// Analyse a stem and write layers.json, spectrogram.png and notes.mid.
46 ///
47 /// The tempo is refined from the stem near --bpm, and the stem is
48 /// transcribed unless --notes gives its notes. Each step prints what it
49 /// found.
50 ///
51 /// Example: sound-to-patch analyze bass.wav --bpm 140
52 Analyze(analyze::AnalyzeArgs),
53 /// Transcribe a stem and write notes.mid.
54 ///
55 /// The pitch range, the voicing and the minimum note length come from
56 /// the stem unless --profile sets them. Each step prints what it found.
57 ///
58 /// Example: sound-to-patch notes bass.wav --bpm 140
59 Notes(notes::NotesArgs),
60 /// Read, inspect and write synth presets.
61 Preset(preset::PresetArgs),
62 /// Render a preset and a MIDI file to a WAV file through the synth.
63 ///
64 /// The synth's plug-in loads headless in the plug-in host process. A
65 /// render counts only after the load check: the clip sounds, and the
66 /// state the plug-in saves back holds the preset. A clip whose octave
67 /// bands match the plug-in's init patch gives a warning. Each step
68 /// prints what it found.
69 ///
70 /// Example: sound-to-patch render bass.vital --midi notes.mid --bpm 140 -o bass.wav
71 Render(render::RenderArgs),
72}
73
74/// The text a command prints: the result to standard output, warnings to
75/// standard error.
76#[derive(Clone, Debug, Default, PartialEq, Eq)]
77pub struct Output {
78 /// The result.
79 pub stdout: String,
80 /// Warnings, one per line.
81 pub stderr: String,
82}
83
84impl From<String> for Output {
85 fn from(stdout: String) -> Self {
86 Self {
87 stdout,
88 stderr: String::new(),
89 }
90 }
91}
92
93impl Command {
94 /// Runs the command and returns the text to print.
95 ///
96 /// # Errors
97 ///
98 /// The error of the command; its message names what failed, the cause
99 /// and the fix.
100 pub fn run(&self) -> Result<Output, Box<dyn Error + Send + Sync>> {
101 match self {
102 Self::Analyze(args) => {
103 let analysis = crate::report::analyze(
104 &crate::report::AnalyzeRequest {
105 stem: &args.stem,
106 bpm: args.bpm,
107 bpm_exact: args.bpm_exact,
108 synth: args.synth()?,
109 notes: args.notes.as_deref(),
110 profile: args.profile,
111 out: args.out.as_deref(),
112 force: args.force,
113 },
114 &crate::notes::Transcriber::new()?,
115 )?;
116 let line = |text: String| text + "\n";
117 Ok(Output {
118 stdout: analysis
119 .summary()
120 .into_iter()
121 .chain(
122 [&analysis.layers, &analysis.spectrogram]
123 .into_iter()
124 .chain(&analysis.notes)
125 .map(|path| format!("wrote {}", path.display())),
126 )
127 .map(line)
128 .collect(),
129 stderr: skipped_warnings(&analysis.skipped)
130 + &warnings(analysis.warnings.clone()),
131 })
132 }
133 Self::Notes(args) => {
134 let report = crate::report::notes(
135 &crate::report::NotesRequest {
136 stem: &args.stem,
137 bpm: args.bpm,
138 bpm_exact: args.bpm_exact,
139 profile: args.profile,
140 out: args.out.as_deref(),
141 force: args.force,
142 },
143 &crate::notes::Transcriber::new()?,
144 )?;
145 let line = |text: String| text + "\n";
146 Ok(Output {
147 stdout: report
148 .summary()
149 .into_iter()
150 .chain([format!("wrote {}", report.path.display())])
151 .map(line)
152 .collect(),
153 stderr: skipped_warnings(&report.skipped) + &warnings(report.warnings.clone()),
154 })
155 }
156 Self::Render(args) => render(args),
157 Self::Preset(args) => match &args.command {
158 preset::PresetCommand::Inspect(inspect) => Ok(crate::preset::inspect(
159 &inspect.file,
160 &inspect.synth,
161 &inspect.describe_options(),
162 )?
163 .into()),
164 preset::PresetCommand::Apply(apply) => {
165 Ok(crate::preset::apply_file(&crate::preset::ApplyRequest {
166 edits: &apply.edits,
167 out: &apply.out,
168 template: apply.template.as_deref(),
169 synth: &apply.synth,
170 name: apply.name.as_deref(),
171 force: apply.force,
172 })?
173 .into())
174 }
175 },
176 }
177 }
178}
179
180fn render(args: &render::RenderArgs) -> Result<Output, Box<dyn Error + Send + Sync>> {
181 let report = crate::render::file::render_file(&crate::render::file::RenderRequest {
182 preset: &args.preset,
183 midi: &args.midi,
184 bpm: args.bpm,
185 out: &args.out,
186 synth: args.synth.resolve(Some(&args.preset))?,
187 plugin: args.plugin.as_deref(),
188 force: args.force,
189 backend: args.backend,
190 })?;
191 Ok(Output {
192 stdout: report
193 .summary()
194 .into_iter()
195 .map(|line| line + "\n")
196 .collect(),
197 stderr: warnings(report.warnings()),
198 })
199}
200
201/// Renders each line as a `warning:` line for standard error.
202fn warnings(lines: impl IntoIterator<Item = String>) -> String {
203 lines
204 .into_iter()
205 .map(|line| ["warning: ", &line, "\n"].concat())
206 .collect()
207}
208
209/// The warnings for packets that did not decode and became silence.
210fn skipped_warnings(skipped: &[crate::audio::SkippedPacket]) -> String {
211 warnings(
212 skipped
213 .iter()
214 .map(|packet| format!("{packet} did not decode and became silence")),
215 )
216}
217
218#[cfg(test)]
219mod tests {
220 use super::warnings;
221
222 #[test]
223 fn each_line_becomes_a_warning_line() {
224 assert_eq!(
225 warnings(["a".to_owned(), "b".to_owned()]),
226 "warning: a\nwarning: b\n"
227 );
228 }
229
230 #[test]
231 fn no_lines_give_no_text() {
232 assert_eq!(warnings([]), "");
233 }
234}