1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
21pub enum Backend {
22 Plugin,
24 Fake,
26}
27
28#[derive(Clone, Debug)]
30pub struct RenderRequest<'a> {
31 pub preset: &'a Path,
33 pub midi: &'a Path,
35 pub bpm: Bpm,
37 pub out: &'a Path,
40 pub synth: Synth,
42 pub plugin: Option<&'a Path>,
44 pub force: bool,
46 pub backend: Backend,
48}
49
50#[derive(Clone, Debug)]
52pub struct RenderReport {
53 pub plugin: Option<PathBuf>,
55 pub name: Option<String>,
57 pub notes: usize,
59 pub check: LoadCheck,
61 pub record: RenderRecord,
63 pub wav: PathBuf,
65 pub record_path: PathBuf,
67 pub tail_cap: Seconds,
69}
70
71impl RenderReport {
72 #[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 #[must_use]
133 pub fn warnings(&self) -> Vec<String> {
134 self.check.warning().into_iter().collect()
135 }
136}
137
138#[derive(Debug)]
140pub enum RenderFileError {
141 Read {
143 path: PathBuf,
145 cause: String,
147 },
148 Output {
150 path: PathBuf,
152 cause: String,
154 fix: String,
156 },
157 NoPlugin(Synth),
159 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
205fn 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
231type WriteOutput<'a> = &'a dyn Fn(&mut dyn Write) -> io::Result<()>;
233
234fn 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 #[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 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
302fn 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
312pub 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}