Skip to main content

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}