Skip to main content

sound_to_patch/render/
file.rs

1//! The `render` command: a preset and a MIDI file to a WAV file and a
2//! render record, through the load check.
3
4use std::error::Error;
5use std::fmt;
6use std::fs;
7use std::io::{self, BufWriter, Write};
8use std::path::{Path, PathBuf};
9
10use super::check::{LoadCheck, LoadFault, render_checked};
11use super::fake::{FakePatch, FakeRenderer};
12use super::plugin::{HOST_ENV, PluginRenderer, find_plugin, host_binary};
13use super::state::{preset_state, saved_preset};
14use super::{HostIdentity, RenderError, RenderJob, RenderRecord, Renderer};
15use crate::audio::write_wav_to;
16use crate::synth::Synth;
17use crate::units::{Bpm, Seconds};
18
19/// What renders a job.
20#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
21pub enum Backend {
22    /// The synth's plug-in in the host process.
23    Plugin,
24    /// The fake renderer, for tests of the command.
25    Fake,
26}
27
28/// A render of a preset file.
29#[derive(Clone, Debug)]
30pub struct RenderRequest<'a> {
31    /// The preset file.
32    pub preset: &'a Path,
33    /// The MIDI file with the notes.
34    pub midi: &'a Path,
35    /// The tempo of the MIDI file.
36    pub bpm: Bpm,
37    /// The WAV file to write. The record goes next to it, with `.json` in
38    /// place of `.wav`.
39    pub out: &'a Path,
40    /// The synth.
41    pub synth: Synth,
42    /// The plug-in bundle; `None` searches the standard VST3 folders.
43    pub plugin: Option<&'a Path>,
44    /// Whether an existing output may be replaced.
45    pub force: bool,
46    /// What renders.
47    pub backend: Backend,
48}
49
50/// What a render of a preset file did, step by step.
51#[derive(Clone, Debug)]
52pub struct RenderReport {
53    /// The plug-in bundle, for the plug-in backend.
54    pub plugin: Option<PathBuf>,
55    /// The preset's name, when it has one.
56    pub name: Option<String>,
57    /// How many notes played.
58    pub notes: usize,
59    /// The load check.
60    pub check: LoadCheck,
61    /// The record written next to the audio.
62    pub record: RenderRecord,
63    /// The WAV file.
64    pub wav: PathBuf,
65    /// The record file.
66    pub record_path: PathBuf,
67    /// The tail cap of the job's recipe.
68    pub tail_cap: Seconds,
69}
70
71impl RenderReport {
72    /// The lines that describe each step.
73    #[must_use]
74    pub fn summary(&self) -> Vec<String> {
75        let HostIdentity {
76            synth,
77            build,
78            host,
79            arch,
80        } = &self.record.identity;
81        let from = self
82            .plugin
83            .as_ref()
84            .map(|path| format!(" from {}", path.display()))
85            .unwrap_or_default();
86        let record = &self.record;
87        let tail = if record.tail_capped {
88            format!(
89                "the tail was still sounding at the cap, {} after the last note-off",
90                self.tail_cap
91            )
92        } else {
93            "the tail went quiet".to_owned()
94        };
95        vec![
96            format!(
97                "plug-in: {} {build}{from}, in {host} as {arch}",
98                synth.name()
99            ),
100            format!(
101                "preset: {}, {} oversampling",
102                self.name.as_deref().unwrap_or("(no name)"),
103                record.oversampling
104            ),
105            format!(
106                "notes: {}, the last note-off at {:.3} s, {} BPM",
107                self.notes,
108                record.last_note_off.get(),
109                record.bpm.get()
110            ),
111            format!(
112                "init patch: 2 renders, {:.2} dB apart",
113                self.check.init_spread_db
114            ),
115            if self.check.init_like {
116                "load check: passed with a warning; the saved state matches".to_owned()
117            } else {
118                format!(
119                    "load check: passed; the saved state matches, and the render lies {:.2} dB from the init patch, beyond the margin of {:.2} dB",
120                    self.check.init_distance_db, self.check.margin_db
121                )
122            },
123            format!("clip: {:.3} s, {tail}", record.length.get()),
124            format!("wrote {}", self.wav.display()),
125            format!("wrote {}", self.record_path.display()),
126        ]
127    }
128}
129
130impl RenderReport {
131    /// The warnings of the render: an init-like clip.
132    #[must_use]
133    pub fn warnings(&self) -> Vec<String> {
134        self.check.warning().into_iter().collect()
135    }
136}
137
138/// A render of a preset file that failed.
139#[derive(Debug)]
140pub enum RenderFileError {
141    /// An input cannot be read.
142    Read {
143        /// The file.
144        path: PathBuf,
145        /// Why.
146        cause: String,
147    },
148    /// An output is refused or cannot be written.
149    Output {
150        /// The file.
151        path: PathBuf,
152        /// Why.
153        cause: String,
154        /// How to fix it.
155        fix: String,
156    },
157    /// No plug-in bundle was found.
158    NoPlugin(Synth),
159    /// The render failed or does not count.
160    Load(LoadFault),
161}
162
163impl fmt::Display for RenderFileError {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        match self {
166            Self::Read { path, cause } => write!(
167                f,
168                "cannot read {}: {cause}. Check the path and the permissions",
169                path.display()
170            ),
171            Self::Output { path, cause, fix } => {
172                write!(f, "cannot write {}: {cause}. {fix}", path.display())
173            }
174            Self::NoPlugin(synth) => write!(
175                f,
176                "cannot find the {synth} plug-in: no bundle in /Library/Audio/Plug-Ins/VST3 or the user's VST3 folder. Install {synth}, or pass --plugin with the path of its .vst3 bundle"
177            ),
178            Self::Load(fault) => write!(f, "{fault}"),
179        }
180    }
181}
182
183impl Error for RenderFileError {}
184
185impl From<LoadFault> for RenderFileError {
186    fn from(fault: LoadFault) -> Self {
187        Self::Load(fault)
188    }
189}
190
191impl From<RenderError> for RenderFileError {
192    fn from(error: RenderError) -> Self {
193        Self::Load(LoadFault::Render(error))
194    }
195}
196
197fn exists(path: &Path) -> RenderFileError {
198    RenderFileError::Output {
199        path: path.to_owned(),
200        cause: "the file exists".to_owned(),
201        fix: "Pass --force to replace it, or write to a new file".to_owned(),
202    }
203}
204
205/// The error for a forced write whose rename failed after an earlier output
206/// was already placed: the folder now holds a new file beside an old one.
207fn mixed(path: &Path, placed: &[&Path], cause: &io::Error) -> RenderFileError {
208    let new = placed
209        .iter()
210        .map(|placed| placed.display().to_string())
211        .collect::<Vec<_>>()
212        .join(" and ");
213    let old = if path.exists() {
214        "still holds the old one"
215    } else {
216        "was not written"
217    };
218    RenderFileError::Output {
219        path: path.to_owned(),
220        cause: format!(
221            "{cause}. {new} now holds the new render, but {} {old}, so the files no longer match",
222            path.display()
223        ),
224        fix: format!(
225            "Make {} replaceable and render again with --force",
226            path.display()
227        ),
228    }
229}
230
231/// Writes one output file.
232type WriteOutput<'a> = &'a dyn Fn(&mut dyn Write) -> io::Result<()>;
233
234/// Writes each output to a new temporary file in `folder`, and renames the
235/// temporary files over the outputs only once every write succeeded. A
236/// failed write removes the temporary files and leaves the outputs as they
237/// were. A failed rename leaves them as they were without `force`; with
238/// `force` it can leave a new output beside an old one, and the error says
239/// so.
240fn write_outputs(
241    folder: &Path,
242    outputs: &[(&Path, WriteOutput<'_>)],
243    force: bool,
244) -> Result<(), RenderFileError> {
245    let failed = |path: &Path, cause: io::Error| RenderFileError::Output {
246        path: path.to_owned(),
247        cause: cause.to_string(),
248        fix: "Check that the folder is writable and the disk has room".to_owned(),
249    };
250    let mut builder = tempfile::Builder::new();
251    builder.prefix(".sound-to-patch-").suffix(".partial");
252    // The umask applies, as it does to a file that File::create makes.
253    #[cfg(unix)]
254    builder.permissions(std::os::unix::fs::PermissionsExt::from_mode(0o666));
255    let mut written = Vec::with_capacity(outputs.len());
256    for &(path, write) in outputs {
257        let temporary = builder
258            .tempfile_in(folder)
259            .and_then(|mut temporary| {
260                let mut file = BufWriter::new(&mut temporary);
261                write(&mut file)?;
262                file.flush()?;
263                drop(file);
264                temporary.as_file().sync_all()?;
265                Ok(temporary)
266            })
267            .map_err(|cause| failed(path, cause))?;
268        written.push((path, temporary));
269    }
270    let mut placed = Vec::with_capacity(written.len());
271    for (path, temporary) in written {
272        let persisted = if force {
273            temporary.persist(path)
274        } else {
275            temporary.persist_noclobber(path)
276        };
277        let Err(error) = persisted else {
278            placed.push(path);
279            continue;
280        };
281        // Without force every placed output is new, so removing it restores
282        // the folder. With force an output already renamed stays replaced.
283        // The rename of a later output fails when the file cannot be
284        // replaced, for example under an immutable flag or an ACL that denies
285        // delete, or when another program changes the folder. The error
286        // then names the mismatch.
287        if !force {
288            for path in placed {
289                let _ = fs::remove_file(path);
290            }
291            if error.error.kind() == io::ErrorKind::AlreadyExists {
292                return Err(exists(path));
293            }
294        } else if !placed.is_empty() {
295            return Err(mixed(path, &placed, &error.error));
296        }
297        return Err(failed(path, error.error));
298    }
299    Ok(())
300}
301
302/// The patch the fake backend plays: a dark tone, unlike the fake's init
303/// patch.
304fn fake_patch() -> FakePatch {
305    FakePatch {
306        harmonics: vec![1.0, 0.25],
307        attack: Seconds::from_const(0.01),
308        release: Seconds::from_const(0.3),
309    }
310}
311
312/// Renders a preset file and its MIDI file, checks the load, and writes the
313/// WAV file and the record.
314///
315/// # Errors
316///
317/// Returns a [`RenderFileError`] for an input that cannot be read, an
318/// output that exists without `force`, a missing plug-in, and a render
319/// that fails or does not pass the load check.
320pub fn render_file(request: &RenderRequest<'_>) -> Result<RenderReport, RenderFileError> {
321    let out = request.out;
322    let is_wav = out
323        .extension()
324        .and_then(|ext| ext.to_str())
325        .is_some_and(|ext| ext.eq_ignore_ascii_case("wav"));
326    if !is_wav {
327        return Err(RenderFileError::Output {
328            path: out.to_owned(),
329            cause: "the name does not end in .wav".to_owned(),
330            fix: "Name the output file with .wav; the record goes next to it with .json".to_owned(),
331        });
332    }
333    let record_path = out.with_extension("json");
334    let folder = out
335        .parent()
336        .filter(|folder| !folder.as_os_str().is_empty())
337        .unwrap_or(Path::new("."));
338    if !folder.is_dir() {
339        return Err(RenderFileError::Output {
340            path: out.to_owned(),
341            cause: format!("the folder {} does not exist", folder.display()),
342            fix: "Create the folder, or write to one that exists".to_owned(),
343        });
344    }
345    for path in [out, record_path.as_path()] {
346        if path.is_dir() {
347            return Err(RenderFileError::Output {
348                path: path.to_owned(),
349                cause: "it is a folder".to_owned(),
350                fix: "Write to a file name that no folder has".to_owned(),
351            });
352        }
353        if !request.force && path.exists() {
354            return Err(exists(path));
355        }
356    }
357    let preset = std::fs::read(request.preset).map_err(|source| RenderFileError::Read {
358        path: request.preset.to_owned(),
359        cause: source.to_string(),
360    })?;
361    let state = preset_state(request.synth, &preset, &super::Recipe::current())?;
362    let name = saved_preset(state.bytes())
363        .and_then(|root| {
364            root.get("preset_name")
365                .and_then(|v| v.as_str())
366                .map(str::to_owned)
367        })
368        .filter(|name| !name.is_empty());
369    let job = RenderJob::from_midi(state, request.midi, request.bpm)?;
370    let (mut renderer, plugin): (Box<dyn Renderer>, Option<PathBuf>) = match request.backend {
371        Backend::Fake => (
372            Box::new(FakeRenderer::new(request.synth, 0).with_patch(fake_patch())),
373            None,
374        ),
375        Backend::Plugin => {
376            let plugin = match request.plugin {
377                Some(path) => path.to_owned(),
378                None => {
379                    find_plugin(request.synth).ok_or(RenderFileError::NoPlugin(request.synth))?
380                }
381            };
382            let host = host_binary().ok_or_else(|| RenderError::Host {
383                cause: "the program cannot find its own folder".to_owned(),
384                fix: format!("Set {HOST_ENV} to the path of sound-to-patch-host"),
385            })?;
386            let renderer = PluginRenderer::start(request.synth, &plugin, &host)?;
387            (Box::new(renderer), Some(plugin))
388        }
389    };
390    let (mut output, check) = render_checked(renderer.as_mut(), &job)?;
391    output.record.load_check = Some(check);
392    let json =
393        serde_json::to_string_pretty(&output.record).map_err(|source| RenderFileError::Output {
394            path: record_path.clone(),
395            cause: source.to_string(),
396            fix: "Report it; a record always encodes".to_owned(),
397        })?;
398    let json = json + "\n";
399    write_outputs(
400        folder,
401        &[
402            (out, &|file| write_wav_to(file, &output.audio)),
403            (&record_path, &|file| file.write_all(json.as_bytes())),
404        ],
405        request.force,
406    )?;
407    Ok(RenderReport {
408        plugin,
409        name,
410        notes: job.notes().len(),
411        check,
412        record: output.record,
413        wav: out.to_owned(),
414        record_path,
415        tail_cap: job.recipe().tail_cap(),
416    })
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    fn write_both(folder: &Path, force: bool) -> Result<(), RenderFileError> {
424        write_outputs(
425            folder,
426            &[
427                (&folder.join("clip.wav"), &|file| {
428                    file.write_all(b"new clip")
429                }),
430                (&folder.join("clip.json"), &|_| {
431                    Err(io::Error::other("the disk is full"))
432                }),
433            ],
434            force,
435        )
436    }
437
438    #[test]
439    fn a_failed_record_write_leaves_no_new_output() {
440        let dir = tempfile::tempdir().unwrap();
441        let error = write_both(dir.path(), false).unwrap_err().to_string();
442        assert!(error.contains("clip.json: the disk is full"), "{error}");
443        assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0);
444    }
445
446    #[test]
447    fn a_failed_record_write_keeps_the_old_outputs() {
448        let dir = tempfile::tempdir().unwrap();
449        let out = dir.path().join("clip.wav");
450        let record = dir.path().join("clip.json");
451        fs::write(&out, "old clip").unwrap();
452        fs::write(&record, "old record").unwrap();
453        write_both(dir.path(), true).unwrap_err();
454        assert_eq!(fs::read(&out).unwrap(), b"old clip");
455        assert_eq!(fs::read(&record).unwrap(), b"old record");
456        assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 2);
457    }
458}