Skip to main content

sound_to_patch/
preset.rs

1//! Preset files of the target synths, behind one adapter per synth.
2//!
3//! - [`format`](mod@format): the [`PresetFormat`] and
4//!   [`PresetModel`](format::PresetModel) traits that read, edit and write
5//!   a preset, and the tree that `preset inspect` prints.
6//! - [`registry`]: the [`ParamRegistry`](registry::ParamRegistry) of each
7//!   synth, with the law between stored and physical values and its
8//!   provenance.
9//! - [`template`]: where the template of a new preset comes from.
10//! - [`vital`]: the Vital adapter.
11//! - [`edits`]: the edit list an agent writes, and its check against the
12//!   registry.
13//! - [`apply`]: applies checked edits to a model.
14//! - [`mapper`]: the [`SynthMapper`](mapper::SynthMapper) trait that turns
15//!   layer values into edits, and the synth-neutral plan of the pitch and
16//!   envelope groups.
17//! - [`catalogue`]: the layer and pattern of each control in the agent
18//!   reference.
19//!
20//! [`inspect`] runs `preset inspect` and [`apply_file`] runs `preset apply`.
21
22use std::error::Error;
23use std::fmt;
24use std::fs;
25use std::io;
26use std::io::{Read, Write as _};
27use std::path::{Path, PathBuf};
28use std::sync::atomic::{AtomicUsize, Ordering};
29
30use crate::preset::edits::{EditError, EditErrors};
31use crate::preset::format::{DescribeOptions, FormatError, PresetFormat, format_for};
32use crate::preset::registry::registry_for;
33use crate::preset::template::{Severity, TemplateError, locate_template};
34use crate::synth::{SynthArg, SynthError};
35
36pub mod apply;
37pub mod catalogue;
38pub mod edits;
39pub mod format;
40pub mod mapper;
41pub mod registry;
42pub mod template;
43pub mod vital;
44
45/// A preset command that failed.
46#[derive(Debug)]
47pub enum PresetError {
48    /// No usable target synth.
49    Synth(SynthError),
50    /// A file cannot be read.
51    Read {
52        /// The file.
53        path: PathBuf,
54        /// Why.
55        cause: io::Error,
56    },
57    /// A file is not a valid preset.
58    Format {
59        /// The file.
60        path: PathBuf,
61        /// Why.
62        error: FormatError,
63    },
64    /// The edit list is not valid or does not apply.
65    Edits {
66        /// The edit list file.
67        path: PathBuf,
68        /// Every problem.
69        errors: EditErrors,
70    },
71    /// No usable template.
72    Template(TemplateError),
73    /// The output path is refused.
74    Output {
75        /// The path.
76        path: PathBuf,
77        /// Why.
78        cause: String,
79        /// How to fix it.
80        fix: String,
81    },
82}
83
84impl fmt::Display for PresetError {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        match self {
87            Self::Synth(error) => error.fmt(f),
88            Self::Read { path, cause } => write!(
89                f,
90                "cannot read {}: {cause}. Check the path and the permissions",
91                path.display()
92            ),
93            Self::Format { path, error } => write!(f, "{}: {error}", path.display()),
94            Self::Edits { path, errors } => write!(f, "{}: {errors}", path.display()),
95            Self::Template(error) => error.fmt(f),
96            Self::Output { path, cause, fix } => {
97                write!(f, "cannot write {}: {cause}. {fix}", path.display())
98            }
99        }
100    }
101}
102
103impl Error for PresetError {
104    fn source(&self) -> Option<&(dyn Error + 'static)> {
105        match self {
106            Self::Synth(error) => Some(error),
107            Self::Read { cause, .. } => Some(cause),
108            Self::Format { error, .. } => Some(error),
109            Self::Edits { errors, .. } => Some(errors),
110            Self::Template(error) => Some(error),
111            Self::Output { .. } => None,
112        }
113    }
114}
115
116impl From<SynthError> for PresetError {
117    fn from(error: SynthError) -> Self {
118        Self::Synth(error)
119    }
120}
121
122/// The text `preset inspect` prints: the header fields of the adapter, a
123/// blank line, then the decoded tree.
124///
125/// # Errors
126///
127/// A [`PresetError`] when the synth cannot be resolved, the file cannot be
128/// read or it is not a valid preset.
129pub fn inspect(
130    path: &Path,
131    synth: &SynthArg,
132    opts: &DescribeOptions,
133) -> Result<String, PresetError> {
134    let format = format_for(synth.resolve(Some(path))?)?;
135    let bytes = read_capped(path, format.max_bytes())?;
136    let model = format.read(&bytes).map_err(|error| PresetError::Format {
137        path: path.to_owned(),
138        error,
139    })?;
140    Ok(format!("{}\n{}", model.header(), model.describe(opts)))
141}
142
143/// Reads at most `max + 1` bytes of a file, so a caller can refuse an
144/// oversized file without loading it.
145pub(crate) fn read_prefix(path: &Path, max: usize) -> io::Result<Vec<u8>> {
146    let limit = u64::try_from(max).map_or(u64::MAX, |max| max.saturating_add(1));
147    let mut bytes = Vec::new();
148    fs::File::open(path).and_then(|file| file.take(limit).read_to_end(&mut bytes))?;
149    Ok(bytes)
150}
151
152/// The largest edit list `preset apply` reads. A list that sets every
153/// control of a synth takes about 100 kB.
154const EDIT_LIST_MAX_BYTES: usize = 1 << 20;
155
156/// Reads the edit list as text. A file larger than
157/// [`EDIT_LIST_MAX_BYTES`] is refused without loading it, and a file that
158/// is not UTF-8 is refused with the offset of the first bad byte.
159fn read_edit_list(path: &Path) -> Result<String, PresetError> {
160    let refuse = |cause: String, fix: &str| PresetError::Edits {
161        path: path.to_owned(),
162        errors: EditErrors(vec![EditError {
163            place: "the edit list".to_owned(),
164            cause,
165            fix: fix.to_owned(),
166        }]),
167    };
168    let bytes = read_prefix(path, EDIT_LIST_MAX_BYTES).map_err(|cause| PresetError::Read {
169        path: path.to_owned(),
170        cause,
171    })?;
172    if bytes.len() > EDIT_LIST_MAX_BYTES {
173        return Err(refuse(
174            format!("the file is larger than the limit of {EDIT_LIST_MAX_BYTES} bytes"),
175            "Pass the edit list itself; a list holds one short record per edit",
176        ));
177    }
178    String::from_utf8(bytes).map_err(|error| {
179        refuse(
180            format!(
181                "byte {} is not UTF-8 text",
182                error.utf8_error().valid_up_to()
183            ),
184            "Save the file as UTF-8",
185        )
186    })
187}
188
189/// Reads at most `max` bytes of a file, so an oversized file is refused
190/// without loading it.
191fn read_capped(path: &Path, max: usize) -> Result<Vec<u8>, PresetError> {
192    let bytes = read_prefix(path, max).map_err(|cause| PresetError::Read {
193        path: path.to_owned(),
194        cause,
195    })?;
196    if bytes.len() > max {
197        return Err(PresetError::Format {
198            path: path.to_owned(),
199            error: FormatError::new(
200                "cannot read the preset",
201                format!("the file is larger than the limit of {max} bytes"),
202                "Check that the file is a preset of the target synth",
203            ),
204        });
205    }
206    Ok(bytes)
207}
208
209/// The inputs of `preset apply`.
210#[derive(Clone, Debug)]
211pub struct ApplyRequest<'a> {
212    /// The edit list file.
213    pub edits: &'a Path,
214    /// The output preset file.
215    pub out: &'a Path,
216    /// A template path, or `None` for the synth's own lookup.
217    pub template: Option<&'a Path>,
218    /// The target synth.
219    pub synth: &'a SynthArg,
220    /// The preset name, or `None` for the output file stem.
221    pub name: Option<&'a str>,
222    /// Whether an existing output file may be replaced.
223    pub force: bool,
224}
225
226/// Whether two paths name the same file. Paths that do not exist yet are
227/// compared as written.
228fn same_file(a: &Path, b: &Path) -> bool {
229    match (fs::canonicalize(a), fs::canonicalize(b)) {
230        (Ok(a), Ok(b)) => a == b,
231        _ => a == b,
232    }
233}
234
235/// Refuses an output path with the wrong extension, one that is an input,
236/// an existing file without `force`, or one inside the factory folder of
237/// the synth.
238fn check_output(request: &ApplyRequest<'_>, format: &dyn PresetFormat) -> Result<(), PresetError> {
239    let out = request.out;
240    let synth = format.synth();
241    let refuse = |cause: String, fix: String| PresetError::Output {
242        path: out.to_owned(),
243        cause,
244        fix,
245    };
246    let extension = out.extension().and_then(|ext| ext.to_str()).unwrap_or("");
247    if !extension.eq_ignore_ascii_case(format.extension()) {
248        return Err(refuse(
249            format!(
250                "a {} preset needs the .{} extension",
251                synth.name(),
252                format.extension()
253            ),
254            format!("Name the output like bass.{}", format.extension()),
255        ));
256    }
257    if same_file(out, request.edits)
258        || request
259            .template
260            .is_some_and(|template| same_file(out, template))
261    {
262        return Err(refuse(
263            "it is the edit list or the template".to_owned(),
264            "Write to a new file; the inputs stay as they are".to_owned(),
265        ));
266    }
267    if !request.force && fs::symlink_metadata(out).is_ok() {
268        return Err(exists(out));
269    }
270    // The folder as the system resolves it: `-o x.vital` inside a factory
271    // folder, a link into one and `Factory/../x.vital` all count by where
272    // the file lands. A folder that does not resolve is taken as written.
273    let parent = out
274        .parent()
275        .filter(|dir| !dir.as_os_str().is_empty())
276        .unwrap_or_else(|| Path::new("."));
277    let parent = fs::canonicalize(parent).unwrap_or_else(|_| parent.to_owned());
278    let factory = format.factory_folder();
279    let inside = !factory.is_empty()
280        && parent.ancestors().any(|dir| {
281            let tail: Vec<_> = dir.iter().rev().take(factory.len()).collect();
282            tail.len() == factory.len()
283                && tail.iter().zip(factory.iter().rev()).all(|(have, want)| {
284                    have.to_str()
285                        .is_some_and(|have| have.eq_ignore_ascii_case(want))
286                })
287        });
288    if inside {
289        return Err(refuse(
290            format!(
291                "it is inside {}, the factory folder of {}",
292                factory.join("/"),
293                synth.name()
294            ),
295            "Write into your own preset folder; factory presets stay untouched".to_owned(),
296        ));
297    }
298    Ok(())
299}
300
301/// Runs `preset apply`: reads the edit list, takes the template, applies
302/// the edits and writes the preset. Returns the report to print.
303///
304/// The output must carry the extension of the synth, and must not be the
305/// edit list, the template or a file in the factory folder of the synth,
306/// and must not exist unless the request sets `force`. The file is
307/// written to a temporary file next to it and then published: renamed
308/// with `force`, hard-linked without it, so a file that appears meanwhile
309/// is never replaced. A run that returns an error leaves no file, and a
310/// run killed during the write leaves at most the temporary `.partial`
311/// file next to the output. A folder without hard links, such as one on
312/// exFAT, gets the file through an exclusive create and a write in place
313/// instead: the file is visible while it is written, an error removes it,
314/// and a run killed during the write leaves a partial output file.
315///
316/// # Errors
317///
318/// A [`PresetError`] for any refused input, edit or path.
319pub fn apply_file(request: &ApplyRequest<'_>) -> Result<String, PresetError> {
320    let out = request.out;
321    let synth = request.synth.resolve(request.template.or(Some(out)))?;
322    let format = format_for(synth)?;
323    let registry = registry_for(synth)?;
324    check_output(request, format)?;
325    let edit_errors = |errors: EditErrors| PresetError::Edits {
326        path: request.edits.to_owned(),
327        errors,
328    };
329    let text = read_edit_list(request.edits)?;
330    let list = edits::parse(&text, edits::Path::Agent).map_err(edit_errors)?;
331    let template = locate_template(format.template(), request.template, format.max_bytes())
332        .map_err(PresetError::Template)?;
333    let template_path = match &template.origin {
334        template::Origin::CommandLine(path) | template::Origin::Candidate(path) => path.clone(),
335        template::Origin::Builtin => PathBuf::from("built-in template"),
336    };
337    let mut model = format
338        .read(&template.bytes)
339        .map_err(|error| PresetError::Format {
340            path: template_path.clone(),
341            error,
342        })?;
343    let checks = format.template().check(model.as_ref());
344    if let Some(error) = checks
345        .iter()
346        .find(|check| check.severity == Severity::Error)
347    {
348        return Err(PresetError::Format {
349            path: template_path,
350            error: FormatError::new(
351                "cannot use the template",
352                error.text.clone(),
353                error.fix.clone(),
354            ),
355        });
356    }
357    let resolved = edits::resolve(&list, registry).map_err(edit_errors)?;
358    let report = apply::apply(format, registry, model.as_mut(), &resolved).map_err(edit_errors)?;
359    let name = request
360        .name
361        .map(str::to_owned)
362        .or_else(|| {
363            out.file_stem()
364                .and_then(|stem| stem.to_str())
365                .map(str::to_owned)
366        })
367        .unwrap_or_default();
368    let write_error = |error: FormatError| PresetError::Format {
369        path: out.to_owned(),
370        error,
371    };
372    model.set_name(&name).map_err(write_error)?;
373    let bytes = format.write(model.as_ref()).map_err(write_error)?;
374    format.read(&bytes).map_err(write_error)?;
375    write_atomically(out, format.extension(), &bytes, request.force)?;
376    let stamp = model
377        .header()
378        .fields
379        .into_iter()
380        .find(|field| field.label == "build")
381        .map(|field| field.value);
382    Ok(apply_summary(
383        out,
384        &template.origin,
385        &name,
386        stamp.as_deref(),
387        &report,
388        &checks,
389    ))
390}
391
392/// The error for an output file that exists without `--force`.
393fn exists(out: &Path) -> PresetError {
394    PresetError::Output {
395        path: out.to_owned(),
396        cause: "the file exists".to_owned(),
397        fix: "Pass --force to replace it, or write to a new file".to_owned(),
398    }
399}
400
401/// Writes a file through a new temporary sibling, so a failed write leaves
402/// no partial file. With `force` a rename replaces `out`. Without it a
403/// hard link publishes the file and fails if `out` exists, so a file that
404/// appears after the check is never replaced.
405fn write_atomically(
406    out: &Path,
407    extension: &str,
408    bytes: &[u8],
409    force: bool,
410) -> Result<(), PresetError> {
411    publish(out, extension, bytes, force, |from, to| {
412        fs::hard_link(from, to)
413    })
414}
415
416/// [`write_atomically`] with the hard link as a parameter, so a test can
417/// stand in for a file system without links.
418fn publish(
419    out: &Path,
420    extension: &str,
421    bytes: &[u8],
422    force: bool,
423    link: impl Fn(&Path, &Path) -> io::Result<()>,
424) -> Result<(), PresetError> {
425    static RUN: AtomicUsize = AtomicUsize::new(0);
426    let temporary = out.with_extension(format!(
427        "{extension}.{}-{}.partial",
428        std::process::id(),
429        RUN.fetch_add(1, Ordering::Relaxed)
430    ));
431    let output_error = |cause: io::Error| PresetError::Output {
432        path: out.to_owned(),
433        cause: cause.to_string(),
434        fix: "Check that the folder exists, is writable and has free space".to_owned(),
435    };
436    // A failed create_new made no file, and the path may be another's.
437    let mut file = fs::OpenOptions::new()
438        .write(true)
439        .create_new(true)
440        .open(&temporary)
441        .map_err(output_error)?;
442    let failed = |cause: io::Error| {
443        let _ = fs::remove_file(&temporary);
444        output_error(cause)
445    };
446    file.write_all(bytes)
447        .and_then(|()| file.sync_all())
448        .map_err(failed)?;
449    drop(file);
450    if force {
451        return fs::rename(&temporary, out).map_err(failed);
452    }
453    let linked = link(&temporary, out);
454    let _ = fs::remove_file(&temporary);
455    match linked {
456        Ok(()) => Ok(()),
457        Err(cause) if cause.kind() == io::ErrorKind::AlreadyExists => Err(exists(out)),
458        // FAT, exFAT and some network folders have no hard links. An
459        // exclusive create still never replaces a file, but the file is
460        // visible while it is written. An error removes it; a kill during
461        // the write leaves it partial.
462        Err(_) => {
463            let mut file = fs::OpenOptions::new()
464                .write(true)
465                .create_new(true)
466                .open(out)
467                .map_err(|cause| match cause.kind() {
468                    io::ErrorKind::AlreadyExists => exists(out),
469                    _ => output_error(cause),
470                })?;
471            file.write_all(bytes)
472                .and_then(|()| file.sync_all())
473                .map_err(|cause| {
474                    let _ = fs::remove_file(out);
475                    output_error(cause)
476                })
477        }
478    }
479}
480
481fn apply_summary(
482    out: &Path,
483    origin: &template::Origin,
484    name: &str,
485    stamp: Option<&str>,
486    report: &apply::ApplyReport,
487    checks: &[template::TemplateCheck],
488) -> String {
489    use std::fmt::Write as _;
490    let mut text = format!("wrote {} ({name:?}) from {origin}\n", out.display());
491    if let Some(stamp) = stamp {
492        let _ = writeln!(text, "  build {stamp}, kept from the template");
493    }
494    for change in &report.changes {
495        let mut notes = Vec::new();
496        if change.uncalibrated {
497            notes.push("uncalibrated");
498        }
499        if change.raw {
500            notes.push("raw");
501        }
502        let notes = if notes.is_empty() {
503            String::new()
504        } else {
505            format!(" [{}]", notes.join(", "))
506        };
507        let _ = writeln!(text, "  set {} = {}{notes}", change.target, change.value);
508    }
509    for skipped in &report.skipped {
510        let _ = writeln!(text, "  skipped {}: {}", skipped.target, skipped.why);
511    }
512    for check in checks {
513        let _ = writeln!(text, "  {check}");
514    }
515    for warning in &report.warnings {
516        let _ = writeln!(text, "  warning: {warning}");
517    }
518    text
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn a_file_that_appears_after_the_check_is_not_replaced() {
527        let dir = tempfile::tempdir().unwrap();
528        let out = dir.path().join("Out.vital");
529        fs::write(&out, "theirs").unwrap();
530        let error = write_atomically(&out, "vital", b"ours", false)
531            .unwrap_err()
532            .to_string();
533        assert!(error.contains("the file exists. Pass --force"), "{error}");
534        assert_eq!(fs::read_to_string(&out).unwrap(), "theirs");
535        assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
536        write_atomically(&out, "vital", b"ours", true).unwrap();
537        assert_eq!(fs::read_to_string(&out).unwrap(), "ours");
538        let new = dir.path().join("New.vital");
539        write_atomically(&new, "vital", b"new", false).unwrap();
540        assert_eq!(fs::read_to_string(&new).unwrap(), "new");
541        assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 2);
542    }
543
544    #[test]
545    fn a_folder_without_hard_links_still_gets_a_new_file() {
546        let dir = tempfile::tempdir().unwrap();
547        let no_links = |_: &Path, _: &Path| Err(io::Error::from(io::ErrorKind::Unsupported));
548        let new = dir.path().join("New.vital");
549        publish(&new, "vital", b"new", false, no_links).unwrap();
550        assert_eq!(fs::read_to_string(&new).unwrap(), "new");
551        let error = publish(&new, "vital", b"again", false, no_links)
552            .unwrap_err()
553            .to_string();
554        assert!(error.contains("the file exists. Pass --force"), "{error}");
555        assert_eq!(fs::read_to_string(&new).unwrap(), "new");
556        assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
557    }
558
559    #[test]
560    fn an_edit_list_is_refused_as_an_edit_list() {
561        let dir = tempfile::tempdir().unwrap();
562        let big = dir.path().join("big.json");
563        fs::write(&big, vec![b' '; EDIT_LIST_MAX_BYTES + 1]).unwrap();
564        let error = read_edit_list(&big).unwrap_err().to_string();
565        assert_eq!(
566            error,
567            format!(
568                "{}: the edit list has 1 problem(s):\n  the edit list: the file is larger than the limit of 1048576 bytes. Pass the edit list itself; a list holds one short record per edit",
569                big.display()
570            )
571        );
572        fs::write(&big, vec![b' '; EDIT_LIST_MAX_BYTES]).unwrap();
573        assert_eq!(read_edit_list(&big).unwrap().len(), EDIT_LIST_MAX_BYTES);
574        let latin = dir.path().join("latin.json");
575        fs::write(&latin, b"{\"x\": \"caf\xe9\"}").unwrap();
576        let error = read_edit_list(&latin).unwrap_err().to_string();
577        assert!(
578            error.ends_with("the edit list: byte 10 is not UTF-8 text. Save the file as UTF-8"),
579            "{error}"
580        );
581    }
582
583    #[test]
584    fn an_oversized_file_is_refused_unread() {
585        let dir = tempfile::tempdir().unwrap();
586        let path = dir.path().join("big.vital");
587        fs::write(&path, vec![b' '; 11]).unwrap();
588        let error = read_capped(&path, 10).unwrap_err().to_string();
589        assert!(
590            error.contains("larger than the limit of 10 bytes"),
591            "{error}"
592        );
593        assert_eq!(read_capped(&path, 11).unwrap().len(), 11);
594    }
595}