Skip to main content

sound_to_patch/
report.rs

1//! The outputs of a stem: `layers.json`, `spectrogram.png` and the final
2//! `notes.mid` from `analyze`, and the first `notes.mid` from `notes`.
3//!
4//! [`analyze`] decodes the stem, refines its tempo, reads the note list if
5//! one is given or else runs the notes step, reads the pitch layer, runs
6//! the stem estimators and the descriptors, maps the pitch, voices and
7//! envelope groups to the synth's suggested edits, rewrites the note list
8//! with the layers into the final notes, and writes the three files into
9//! the output folder.
10//! [`notes`] refines the tempo, transcribes the stem and writes its notes
11//! into the same folder. The schema of `layers.json` is committed as
12//! `schemas/layers.schema.json`.
13
14use std::error::Error;
15use std::fmt;
16use std::io::{self, Write as _};
17use std::path::{Path, PathBuf};
18
19use crate::audio::{self, Audio, BeatGrid, DecodeError, SampleRate, SkippedPacket, Stem, Tempo};
20use crate::confidence::ConfidenceError;
21use crate::confidence::Level;
22use crate::dsp::{StftParams, Summary, describe};
23use crate::estimate::envelope::{
24    EnvelopeError, EnvelopeInput, EnvelopeLayer, KNEE_END_GAP, NoteEnvelope, SUSTAIN_SHARE,
25    amp_proxy, full_band_level, full_band_track,
26};
27use crate::estimate::pitch::{PitchError, PitchInput, PitchLayer};
28use crate::estimate::voices::{VoicesError, VoicesInput, VoicesLayer};
29use crate::notes::{
30    DEFAULT_VELOCITY, DropSpan, FinalNotes, FinishInput, GlideSpan, MidiError, MidiWriteError,
31    Note, NoteFacts, NoteList, NotesError, PAD_FALL, PitchTrack, Profile, QUIET_GATE, Transcriber,
32    Transcription, Velocities, VibratoSpan, Voicing, finish, midi_track_name, read_midi_near,
33    transcribe_stem, voicing_of_notes, write_named_midi,
34};
35use crate::preset::mapper::pitch_envelope::velocity_reaches_level;
36use crate::preset::mapper::{MapContext, Mapped, PitchEnvelopeGroups, SynthMapper, mapper_for};
37use crate::preset::registry::registry_for;
38use crate::synth::{Synth, capabilities};
39use crate::units::{Bpm, Hz, Seconds};
40
41mod layers;
42pub mod spectrogram;
43
44pub use layers::{SCHEMA, layers_json};
45pub use spectrogram::Spectrogram;
46
47/// The report file name.
48pub const LAYERS_FILE: &str = "layers.json";
49/// The spectrogram file name.
50pub const SPECTROGRAM_FILE: &str = "spectrogram.png";
51/// The notes file name.
52pub const NOTES_FILE: &str = "notes.mid";
53
54/// The output folder of a stem: `out`, or `<stem folder>/<stem file stem>/`.
55/// A file stem of `.` or `..` would name the stem's folder or its parent,
56/// and a name without an extension, such as `.wav` or `bass`, would name
57/// the stem file itself, so such a stem needs `out`.
58fn out_dir(stem: &Path, out: Option<&Path>) -> Result<PathBuf, AnalyzeError> {
59    if let Some(out) = out {
60        return Ok(out.to_path_buf());
61    }
62    match (stem.file_stem(), stem.extension()) {
63        (Some(name), Some(_)) if name != "." && name != ".." => Ok(stem.with_file_name(name)),
64        (name, extension) => Err(AnalyzeError::NoFolderName {
65            source: source_of(stem),
66            stem: extension
67                .and(name)
68                .map(|name| name.to_string_lossy().into_owned()),
69        }),
70    }
71}
72
73/// The file name of a stem, as a report records it.
74fn source_of(stem: &Path) -> String {
75    stem.file_name()
76        .unwrap_or(stem.as_os_str())
77        .to_string_lossy()
78        .into_owned()
79}
80
81/// What [`analyze`] reads and where it writes.
82#[derive(Clone, Copy, Debug)]
83pub struct AnalyzeRequest<'a> {
84    /// The audio stem.
85    pub stem: &'a Path,
86    /// The tempo of the stem, refined from the stem unless `bpm_exact`.
87    pub bpm: Bpm,
88    /// Use `bpm` as it is.
89    pub bpm_exact: bool,
90    /// The target synth, written into the report.
91    pub synth: Synth,
92    /// A MIDI file with the stem's notes. Without it the notes step runs.
93    pub notes: Option<&'a Path>,
94    /// The profile that overrides detection in the notes step, if any. The
95    /// notes step does not run when `notes` gives the notes, so the profile
96    /// then has no effect; the command line rejects the two together.
97    pub profile: Option<Profile>,
98    /// The output folder; by default `<stem folder>/<stem file stem>/`.
99    pub out: Option<&'a Path>,
100    /// Replace files in the output folder that no report of this stem
101    /// owns: a report or notes of another stem, a report of the same name
102    /// with other content, a file that is not this tool's, or a
103    /// `spectrogram.png` without a report.
104    pub force: bool,
105}
106
107impl AnalyzeRequest<'_> {
108    /// Returns the output folder.
109    ///
110    /// # Errors
111    ///
112    /// [`AnalyzeError::NoFolderName`] without `out` for a stem whose name
113    /// has no extension or is `.` or `..` without it.
114    pub fn out_dir(&self) -> Result<PathBuf, AnalyzeError> {
115        out_dir(self.stem, self.out)
116    }
117
118    /// Returns the file name of the stem, as the report records it.
119    #[must_use]
120    pub fn source(&self) -> String {
121        source_of(self.stem)
122    }
123}
124
125/// What [`analyze`] found and wrote.
126#[derive(Clone, Debug)]
127pub struct Analysis {
128    /// The path of `layers.json`.
129    pub layers: PathBuf,
130    /// The path of `spectrogram.png`.
131    pub spectrogram: PathBuf,
132    /// The path of the final `notes.mid`; `None` without notes.
133    pub notes: Option<PathBuf>,
134    /// The final note list that `notes.mid` holds; `None` without notes.
135    pub final_notes: Option<FinalNotes>,
136    /// The given, refined and used tempo.
137    pub tempo: Tempo,
138    /// The notes step, `None` when `--notes` gave the notes.
139    pub transcription: Option<Transcription>,
140    /// The pitch layer: glides, pitch drops and vibrato.
141    pub pitch: PitchLayer,
142    /// The voices layer: the count of voices and the joins of a mono line.
143    pub voices: VoicesLayer,
144    /// The envelope layer: the amplitude envelope and the pump.
145    pub envelope: EnvelopeLayer,
146    /// The packets that did not decode and became silence.
147    pub skipped: Vec<SkippedPacket>,
148    /// What the analysis leaves out of the file; see [`audio::Decoded::warnings`].
149    pub warnings: Vec<String>,
150}
151
152impl Analysis {
153    /// Returns the lines that tell what each step found, in order: the
154    /// tempo, the notes step when it ran, the pitch layer, the
155    /// envelope layer, then the voices layer.
156    #[must_use]
157    pub fn summary(&self) -> Vec<String> {
158        std::iter::once(self.tempo.summary())
159            .chain(self.transcription.iter().flat_map(transcription_summary))
160            .chain(self.pitch.summary())
161            .chain(self.envelope.summary())
162            .chain(self.voices.summary())
163            .collect()
164    }
165}
166
167/// The f0 track for given notes: the pitch layer reads it, and skips a
168/// list that is empty or plays chords without it.
169fn given_track(
170    transcriber: &Transcriber,
171    mono: &[f64],
172    rate: audio::SampleRate,
173    notes: &NoteList,
174) -> Result<PitchTrack, AnalyzeError> {
175    if notes.is_empty() || voicing_of_notes(notes) == Voicing::Polyphonic {
176        return Ok(PitchTrack::default());
177    }
178    transcriber
179        .track(mono, rate)
180        .map_err(|error| AnalyzeError::Transcription(NotesError::Track(error)))
181}
182
183/// Analyses a stem and writes `layers.json`, `spectrogram.png` and, when
184/// the stem has notes, the final `notes.mid` into the output folder,
185/// creating it.
186///
187/// The tempo is refined from the stem unless the request says `bpm_exact`.
188/// Without a note list the stem runs through [`transcribe_stem`] once,
189/// with `transcriber`; with one, the model never runs, but the f0 track
190/// of `transcriber` still reads the stem for the pitch layer.
191///
192/// The files are replaced; nothing else in the folder changes. The stem
193/// is read once, for its checksum and for the decoder. Each file goes to
194/// a hidden temporary sibling and is renamed into place, `notes.mid`
195/// first and `layers.json` last. The old report moves aside before any
196/// file changes, so a `layers.json` that exists always belongs to the
197/// files next to it, and a failure puts the old files back. The final
198/// `notes.mid` replaces the first one of `notes`.
199///
200/// # Errors
201///
202/// Returns an [`AnalyzeError`] when the stem gives no folder name and no
203/// `out` is given, when the output folder is a file, or, without `force`,
204/// when it holds a report or notes of another stem, a report of the same
205/// name with other content, a `layers.json` or `notes.mid` that is not
206/// this tool's, a `spectrogram.png` without a report, or the backup that a
207/// killed run left; when the note list or the stem cannot be read, when the
208/// notes step fails, or when an output file cannot be written.
209pub fn analyze(
210    request: &AnalyzeRequest<'_>,
211    transcriber: &Transcriber,
212) -> Result<Analysis, AnalyzeError> {
213    analyze_with(request, transcriber, mapper_for(request.synth).ok())
214}
215
216/// [`analyze`] with a given mapper for the suggested edits, such as a test
217/// mapper. Without one, or for a synth without a registry, every
218/// `suggested_edits` and `unrepresentable` list is empty.
219///
220/// # Errors
221///
222/// As [`analyze`].
223pub fn analyze_with(
224    request: &AnalyzeRequest<'_>,
225    transcriber: &Transcriber,
226    mapper: Option<&dyn SynthMapper>,
227) -> Result<Analysis, AnalyzeError> {
228    let dir = request.out_dir()?;
229    let source = request.source();
230    let (bytes, checksum, backups) =
231        guard(&dir, request.stem, &source, request.force, Guarded::Report)?;
232    let decoded = audio::decode_bytes(request.stem, bytes).map_err(AnalyzeError::Decode)?;
233    let skipped = decoded.skipped().to_vec();
234    let warnings = decoded.warnings();
235    let stem_audio = decoded.into_audio();
236    let mono = stem_audio.mono();
237    let tempo = tempo_of(
238        &mono,
239        stem_audio.sample_rate(),
240        request.bpm,
241        request.bpm_exact,
242    )?;
243    let (notes, transcription, track) = if let Some(path) = request.notes {
244        let notes =
245            read_midi_near(path, tempo.given(), tempo.trusted()).map_err(AnalyzeError::Notes)?;
246        let track = given_track(transcriber, &mono, stem_audio.sample_rate(), &notes)?;
247        (Some(notes), None, Some(track))
248    } else {
249        let transcription = transcribe_stem(
250            transcriber,
251            &mono,
252            stem_audio.sample_rate(),
253            tempo.used(),
254            request.profile,
255        )
256        .map_err(AnalyzeError::Transcription)?;
257        // A stem without notes keeps the frame rule of a stem without a list.
258        let notes = (!transcription.notes.is_empty()).then(|| transcription.notes.clone());
259        (notes, Some(transcription), None)
260    };
261    let empty = NoteList::default();
262    let note_list = notes.as_ref().unwrap_or(&empty);
263    let grid = BeatGrid::new(tempo.used(), Seconds::clamped(0.0));
264    let voicing = transcription
265        .as_ref()
266        .map_or_else(|| voicing_of_notes(note_list), |t| t.voicing);
267    let (pitch, envelope, voices, mono_line) = read_layers(
268        &mono,
269        &stem_audio,
270        note_list,
271        track
272            .as_ref()
273            .or(transcription.as_ref().map(|t| &t.track))
274            .unwrap_or(&PitchTrack::default()),
275        // The profile counts only where the notes step ran: given notes
276        // take no profile.
277        (
278            voicing,
279            transcription.as_ref().and_then(|t| t.profile) == Some(Profile::Pad),
280        ),
281        grid,
282    )?;
283    let final_notes = final_notes(
284        &mono,
285        stem_audio.sample_rate(),
286        note_list,
287        (&pitch, &envelope),
288        mono_line,
289        tempo.used(),
290    )?;
291    // The stem and descriptor steps read the channels, not the mono mix.
292    drop(mono);
293    let stem = Stem::new(stem_audio, grid, notes.as_ref())?;
294    let params = StftParams::fixed(stem.frame_len());
295    let descriptors = describe(
296        stem.mid(),
297        stem.sample_rate(),
298        params,
299        stem.median_f0(),
300        stem.hard_limited(),
301    )?;
302    let read = (&pitch, &envelope, &voices);
303    let json = layers_json(
304        &stem,
305        &descriptors,
306        request.synth,
307        (&source, &checksum),
308        &tempo,
309        (
310            transcription.as_ref().map(|t| &t.record),
311            final_notes.as_ref(),
312        ),
313        (read, &map_groups(mapper, request.synth, read)),
314    )?;
315    let png = Spectrogram::of(stem.mid(), stem.sample_rate()).to_png()?;
316
317    let midi = final_notes.as_ref().map(|list| (list, source.as_str()));
318    let (layers, spectrogram, notes_file) = write_outputs(&dir, &json, &png, midi)?;
319    remove_backups(&backups);
320    Ok(Analysis {
321        layers,
322        spectrogram,
323        notes: notes_file,
324        final_notes,
325        tempo,
326        transcription,
327        pitch,
328        voices,
329        envelope,
330        skipped,
331        warnings,
332    })
333}
334
335/// Writes the files of `analyze` into `dir`, creating it, through
336/// [`replace_files`]: `notes.mid`, or its removal when there are no notes,
337/// then `spectrogram.png`, then `layers.json`. Returns the paths of
338/// `layers.json`, `spectrogram.png` and `notes.mid` when written. The
339/// notes, when given, are encoded with the stem's name as the track name.
340fn write_outputs(
341    dir: &Path,
342    json: &str,
343    png: &[u8],
344    notes: Option<(&FinalNotes, &str)>,
345) -> Result<(PathBuf, PathBuf, Option<PathBuf>), AnalyzeError> {
346    let midi = notes
347        .map(|(list, name)| list.to_midi(Some(name)))
348        .transpose()
349        .map_err(AnalyzeError::Midi)?;
350    // The guard has passed, so a `notes.mid` here is this stem's or one that
351    // `--force` replaces. A run without notes leaves none that the report
352    // does not describe.
353    replace_files(
354        dir,
355        &[
356            (NOTES_FILE, midi.as_deref()),
357            (SPECTROGRAM_FILE, Some(png)),
358            (LAYERS_FILE, Some(json.as_bytes())),
359        ],
360    )?;
361    Ok((
362        dir.join(LAYERS_FILE),
363        dir.join(SPECTROGRAM_FILE),
364        midi.map(|_| dir.join(NOTES_FILE)),
365    ))
366}
367
368/// Creates the output folder and its parents, and returns the folders it
369/// created, the deepest first.
370fn create_dir(dir: &Path) -> Result<Vec<PathBuf>, AnalyzeError> {
371    let created: Vec<PathBuf> = dir
372        .ancestors()
373        .take_while(|folder| !folder.as_os_str().is_empty() && !folder.exists())
374        .map(Path::to_path_buf)
375        .collect();
376    if let Err(source) = std::fs::create_dir_all(dir) {
377        remove_created(&created);
378        return Err(AnalyzeError::Write {
379            path: dir.to_path_buf(),
380            source,
381        });
382    }
383    Ok(created)
384}
385
386/// Removes the folders that [`create_dir`] created, the deepest first.
387/// Only an empty folder goes: `remove_dir` keeps one with files.
388fn remove_created(created: &[PathBuf]) {
389    for folder in created {
390        let _ = std::fs::remove_dir(folder);
391    }
392}
393
394/// A file of a write: its name in the folder, and its new bytes or `None`
395/// to remove it.
396type Change<'a> = (&'static str, Option<&'a [u8]>);
397
398/// A step of [`replace_files`] that a failure undoes.
399enum Undo {
400    /// An old file moved to a hidden backup; undo renames it back, and
401    /// success drops the backup, which removes it.
402    MovedAside {
403        /// The backup.
404        backup: tempfile::TempPath,
405        /// Where the file was.
406        to: PathBuf,
407    },
408    /// A new file landed here; undo removes it.
409    Placed(PathBuf),
410}
411
412impl Undo {
413    fn revert(self) {
414        match self {
415            Self::MovedAside { backup, to } => {
416                // A backup that cannot go back stays on disk, so the old
417                // file is hidden but not lost.
418                if let Err(error) = backup.persist(&to) {
419                    let _ = error.path.keep();
420                }
421            }
422            Self::Placed(path) => {
423                let _ = std::fs::remove_file(path);
424            }
425        }
426    }
427}
428
429/// Applies `changes` to `dir`, creating it, such that a failure leaves the
430/// folder as it was and success leaves exactly the new files. Each new file
431/// is first written to a hidden temporary sibling with a name of its own,
432/// so two runs never share one. Then each old file moves aside to a hidden
433/// backup and the new one is renamed into place, in order, except that an
434/// old `layers.json` moves aside before any other file changes and the new
435/// one lands last: a `layers.json` that exists always belongs to the files
436/// next to it, and none is ever cut short. A failure renames every backup
437/// back, removes every placed file and every folder this call created; the
438/// temporary files remove themselves. A process killed during the renames
439/// can leave a backup, `.<name>.<random>.old`, that holds the old file.
440fn replace_files(dir: &Path, changes: &[Change<'_>]) -> Result<(), AnalyzeError> {
441    let created = create_dir(dir)?;
442    let result = swap_files(dir, changes);
443    if result.is_err() {
444        remove_created(&created);
445    }
446    result
447}
448
449/// [`replace_files`] in a folder that exists.
450fn swap_files(dir: &Path, changes: &[Change<'_>]) -> Result<(), AnalyzeError> {
451    let mut staged = changes
452        .iter()
453        .map(|&(name, bytes)| Ok((name, bytes.map(|b| stage(dir, name, b)).transpose()?)))
454        .collect::<Result<Vec<_>, AnalyzeError>>()?;
455    // The report goes last, after every file it describes.
456    staged.sort_by_key(|&(name, _)| name == LAYERS_FILE);
457    let mut undo = Vec::new();
458    let report = staged.iter().any(|&(name, _)| name == LAYERS_FILE);
459    let result = (|| {
460        if report {
461            move_aside(dir, LAYERS_FILE, &mut undo)?;
462        }
463        for (name, new) in staged {
464            if name != LAYERS_FILE {
465                move_aside(dir, name, &mut undo)?;
466            }
467            if let Some(new) = new {
468                let to = dir.join(name);
469                new.persist(&to).map_err(|error| AnalyzeError::Write {
470                    path: to.clone(),
471                    source: error.error,
472                })?;
473                undo.push(Undo::Placed(to));
474            }
475        }
476        Ok(())
477    })();
478    if result.is_err() {
479        for step in undo.into_iter().rev() {
480            step.revert();
481        }
482    }
483    result
484}
485
486/// Moves the file `name` in `dir` to a hidden backup, if it exists. A
487/// folder in its place stays, so the rename of the new file onto it fails.
488fn move_aside(dir: &Path, name: &str, undo: &mut Vec<Undo>) -> Result<(), AnalyzeError> {
489    let path = dir.join(name);
490    let fail = |source| AnalyzeError::Write {
491        path: path.clone(),
492        source,
493    };
494    match std::fs::symlink_metadata(&path) {
495        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
496        Err(error) => return Err(fail(error)),
497        Ok(meta) if meta.is_dir() => return Ok(()),
498        Ok(_) => {}
499    }
500    let backup = tempfile::Builder::new()
501        .prefix(&format!(".{name}."))
502        .suffix(".old")
503        .make_in(dir, |backup| std::fs::rename(&path, backup))
504        .map_err(fail)?;
505    undo.push(Undo::MovedAside {
506        backup: backup.into_temp_path(),
507        to: path,
508    });
509    Ok(())
510}
511
512/// The mapper's edits for the pitch, voices and envelope groups.
513fn map_groups(
514    mapper: Option<&dyn SynthMapper>,
515    synth: Synth,
516    (pitch, envelope, voices): (&PitchLayer, &EnvelopeLayer, &VoicesLayer),
517) -> Mapped {
518    let (Some(mapper), Ok(registry), Ok(capabilities)) =
519        (mapper, registry_for(synth), capabilities(synth))
520    else {
521        return Mapped::default();
522    };
523    let ctx = MapContext {
524        registry,
525        capabilities,
526    };
527    let groups = PitchEnvelopeGroups::from_layers(pitch, voices, envelope);
528    mapper.pitch_envelope(&groups, &ctx)
529}
530
531/// The final note list: the list rewritten with the layers; `None` for an
532/// empty list. Only the records the mapper writes into the preset, those
533/// above low confidence in a group above low confidence, leave the notes
534/// as routes.
535fn final_notes(
536    mono: &[f64],
537    rate: SampleRate,
538    notes: &NoteList,
539    (pitch, envelope): (&PitchLayer, &EnvelopeLayer),
540    mono_line: bool,
541    bpm: Bpm,
542) -> Result<Option<FinalNotes>, AnalyzeError> {
543    if notes.is_empty() {
544        return Ok(None);
545    }
546    let facts = note_facts(mono, rate, notes, envelope, !mono_line)?;
547    let group = pitch.confidence.level();
548    let confident = |confidence: &crate::confidence::Confidence<_>| {
549        group
550            .min(confidence.level())
551            .is_some_and(|level| level > Level::Low)
552    };
553    let glides: Vec<GlideSpan> = pitch
554        .glides
555        .iter()
556        .filter(|g| confident(&g.confidence))
557        .map(|g| GlideSpan {
558            start: g.value.start,
559            interval: g.value.interval,
560            time: g.value.time,
561        })
562        .collect();
563    let drops: Vec<DropSpan> = pitch
564        .drops
565        .iter()
566        .filter(|d| confident(&d.confidence))
567        .map(|d| DropSpan {
568            onset: d.value.onset,
569            f_end: d.value.f_end,
570            df_hz: d.value.df_hz,
571            settled: d.value.settled(),
572        })
573        .collect();
574    let vibrato: Vec<VibratoSpan> = pitch
575        .vibrato
576        .iter()
577        .filter(|v| confident(&v.confidence))
578        .map(|v| VibratoSpan {
579            start: v.value.start,
580            end: v.value.end,
581            pitch: v.value.pitch,
582        })
583        .collect();
584    let level = |from, to, f0| full_band_level(mono, rate, (from, to), f0);
585    Ok(Some(finish(&FinishInput {
586        notes,
587        facts: &facts,
588        bpm,
589        mono: mono_line,
590        level_velocity: velocity_reaches_level(envelope.level_spread.value().copied()),
591        glides: &glides,
592        drops: &drops,
593        vibrato: &vibrato,
594        level: &level,
595    })))
596}
597
598/// The knee, the pad end and the full-band level of each note of the
599/// list. The envelope layer owns the full-band level.
600fn note_facts(
601    mono: &[f64],
602    rate: SampleRate,
603    notes: &NoteList,
604    envelope: &EnvelopeLayer,
605    pad: bool,
606) -> Result<Vec<NoteFacts>, AnalyzeError> {
607    let list = notes.notes();
608    list.iter()
609        .enumerate()
610        .map(|(i, note)| {
611            let read = envelope
612                .notes
613                .iter()
614                .find(|n| n.onset == note.onset() && n.pitch == note.pitch());
615            let pad_end = match read {
616                Some(read) if pad => pad_end(mono, rate, note, list, read)?,
617                None if pad => full_band_pad_end(mono, rate, note),
618                _ => None,
619            };
620            Ok(NoteFacts {
621                knee: read.and_then(|n| n.knee),
622                pad_end,
623                level: envelope.levels.get(i).copied().flatten(),
624                read: read.is_some(),
625            })
626        })
627        .collect()
628}
629
630/// Where a pad note's amp proxy falls [`PAD_FALL`] below its plateau, the
631/// peak plus the sustain level, first after its knee or else its peak;
632/// `None` without a sustain, a proxy or such a fall.
633fn pad_end(
634    mono: &[f64],
635    rate: SampleRate,
636    note: &Note,
637    list: &[Note],
638    read: &NoteEnvelope,
639) -> Result<Option<Seconds>, AnalyzeError> {
640    let Some(sustain) = read.sustain else {
641        return Ok(None);
642    };
643    let others: Vec<&Note> = list
644        .iter()
645        .filter(|o| *o != note && o.onset() < note.offset() && note.onset() < o.offset())
646        .collect();
647    let Some(proxy) = amp_proxy(mono, rate, note, &others)
648        .map_err(|error| AnalyzeError::Envelope(error.into()))?
649    else {
650        return Ok(None);
651    };
652    let db = proxy.db();
653    let Some((peak_at, peak)) = db
654        .iter()
655        .copied()
656        .enumerate()
657        .reduce(|a, b| if b.1 > a.1 { b } else { a })
658    else {
659        return Ok(None);
660    };
661    let floor = peak + sustain.get() - PAD_FALL.get();
662    let from = read.knee.map_or(peak_at, |knee| proxy.index(knee.get()));
663    Ok(db
664        .iter()
665        .enumerate()
666        .skip(from)
667        .find(|&(_, &level)| level <= floor)
668        .map(|(i, _)| Seconds::clamped(proxy.time(i))))
669}
670
671/// Where the full-band level of a pad note that the envelope layer could
672/// not read falls [`PAD_FALL`] below its plateau, first after its peak.
673/// The plateau is the median of the last [`SUSTAIN_SHARE`] of the held
674/// part, from the first frame within 1 dB of the peak to
675/// [`KNEE_END_GAP`] before the note's end, as the envelope layer reads a
676/// sustain; `None` without a held part or such a fall.
677fn full_band_pad_end(mono: &[f64], rate: SampleRate, note: &Note) -> Option<Seconds> {
678    let track = full_band_track(
679        mono,
680        rate,
681        (note.onset(), note.offset()),
682        Hz::from(note.pitch()),
683    );
684    let db = track.db();
685    let (peak_at, peak) = db
686        .iter()
687        .copied()
688        .enumerate()
689        .reduce(|a, b| if b.1 > a.1 { b } else { a })?;
690    let held_from = db.iter().position(|&level| level >= peak - 1.0)?;
691    let held_to = track.index(note.offset().get() - KNEE_END_GAP.get());
692    let held = db.get(held_from..held_to).filter(|part| !part.is_empty())?;
693    let window = crate::cast::floor_to_usize(SUSTAIN_SHARE * crate::cast::usize_to_f64(held.len()))
694        .unwrap_or(0)
695        .clamp(1, held.len());
696    let plateau = Summary::of(&held[held.len() - window..])?.median();
697    let floor = plateau - PAD_FALL.get();
698    db.iter()
699        .enumerate()
700        .skip(peak_at)
701        .find(|&(_, &level)| level <= floor)
702        .map(|(i, _)| Seconds::clamped(track.time(i)))
703}
704
705/// The first two channels of a stem that has them.
706fn two_channels(channels: &[Vec<f64>]) -> Option<(&[f64], &[f64])> {
707    match channels {
708        [left, right, ..] => Some((left, right)),
709        _ => None,
710    }
711}
712
713/// Reads the pitch, envelope and voices layers of the mono mix, and the
714/// one voicing of the final notes: a mono line holds at most one voice at
715/// a time by the voices layer, or the layer counts none, and `--profile
716/// pad` makes any line polyphonic when the notes step ran. The envelope's full-band levels read that voicing, so
717/// they wait for the voices layer. `voicing` is the notes step's, which
718/// the pitch layer reads.
719fn read_layers(
720    mono: &[f64],
721    audio: &Audio,
722    notes: &NoteList,
723    track: &PitchTrack,
724    (voicing, pad_profile): (Voicing, bool),
725    grid: BeatGrid,
726) -> Result<(PitchLayer, EnvelopeLayer, VoicesLayer, bool), AnalyzeError> {
727    let rate = audio.sample_rate();
728    let pitch = PitchLayer::of(&PitchInput {
729        mono,
730        rate,
731        track,
732        notes,
733        voicing,
734    })
735    .map_err(AnalyzeError::Pitch)?;
736    let input = EnvelopeInput {
737        mono,
738        rate,
739        notes,
740        grid,
741        stereo: two_channels(audio.channels()),
742    };
743    let mut envelope = EnvelopeLayer::of(&input).map_err(AnalyzeError::Envelope)?;
744    let voices = VoicesLayer::of(&VoicesInput {
745        mono,
746        rate,
747        notes,
748        envelope: &envelope,
749    })
750    .map_err(AnalyzeError::Voices)?;
751    // A layer that counted no voices leaves the line mono: the mono rules
752    // only join and cut notes, while the pad rule reads what the count
753    // could not.
754    let mono_line = (voices.mono || !voices.confidence.is_estimate()) && !pad_profile;
755    envelope
756        .read_levels(&input, mono_line)
757        .map_err(AnalyzeError::Envelope)?;
758    Ok((pitch, envelope, voices, mono_line))
759}
760
761/// The tempo of a stem: `bpm` as given when `exact`, else refined from
762/// the mono mix.
763fn tempo_of(mono: &[f64], rate: SampleRate, bpm: Bpm, exact: bool) -> Result<Tempo, AnalyzeError> {
764    Ok(if exact {
765        Tempo::exact(bpm)?
766    } else {
767        Tempo::refine(mono, rate, bpm)?
768    })
769}
770
771/// What [`notes`] reads and where it writes.
772#[derive(Clone, Copy, Debug)]
773pub struct NotesRequest<'a> {
774    /// The audio stem.
775    pub stem: &'a Path,
776    /// The tempo of the stem, refined from the stem unless `bpm_exact`.
777    pub bpm: Bpm,
778    /// Use `bpm` as it is.
779    pub bpm_exact: bool,
780    /// The profile that overrides detection, if any.
781    pub profile: Option<Profile>,
782    /// The output folder; by default `<stem folder>/<stem file stem>/`.
783    pub out: Option<&'a Path>,
784    /// Write next to a report that is not this stem's, replace a
785    /// `notes.mid` of another stem or with no track name, or replace the
786    /// final notes and remove the report that describes them.
787    pub force: bool,
788}
789
790/// What [`notes`] found and wrote.
791#[derive(Clone, Debug)]
792pub struct NotesReport {
793    /// The path of `notes.mid`.
794    pub path: PathBuf,
795    /// The given, refined and used tempo.
796    pub tempo: Tempo,
797    /// The notes step: the model run, the track, the settings and the
798    /// counts of each cleanup step.
799    pub transcription: Transcription,
800    /// The packets that did not decode and became silence.
801    pub skipped: Vec<SkippedPacket>,
802    /// What the analysis leaves out of the file; see [`audio::Decoded::warnings`].
803    pub warnings: Vec<String>,
804}
805
806impl NotesReport {
807    /// Returns the lines that tell what each step found and kept, in
808    /// order: the tempo, then the notes step.
809    #[must_use]
810    pub fn summary(&self) -> Vec<String> {
811        std::iter::once(self.tempo.summary())
812            .chain(transcription_summary(&self.transcription))
813            .collect()
814    }
815}
816
817/// The lines of the notes step: what detection found, the settings and the
818/// count after each cleanup step.
819fn transcription_summary(t: &Transcription) -> Vec<String> {
820    let detection = &t.detection;
821    let range = |range: crate::notes::PitchRange| {
822        format!("MIDI {} to {}", range.lowest().get(), range.highest().get())
823    };
824    let settings = &t.settings;
825    let steps = &t.steps;
826    vec![
827        format!(
828            "detected range: {}, from the {}",
829            range(detection.settings.range),
830            detection.source.name()
831        ),
832        format!(
833            "detected voicing: {}, chord share {:.2}",
834            detection.voicing.name(),
835            detection.chord_share
836        ),
837        format!("register window: {} dB", detection.window.get()),
838        format!(
839            "cleanup voicing: {}, {}",
840            t.voicing.name(),
841            t.profile.map_or_else(
842                || "as detected".to_owned(),
843                |p| format!("from --profile {p}")
844            )
845        ),
846        format!(
847            "settings: {}, {}, minimum note {} frames ({:.1} ms), melodia {}",
848            t.profile
849                .map_or_else(|| "detected".to_owned(), |p| format!("profile {p}")),
850            range(settings.range),
851            settings.min_note_frames,
852            settings.note_params().minimum_note_length_ms,
853            if settings.melodia { "on" } else { "off" }
854        ),
855        format!("decoded: {} notes", steps.decoded),
856        format!(
857            "octave check: {} notes, {} moved down an octave, {} flagged, {} unchecked",
858            steps.decoded, steps.octaves.moved, steps.octaves.flagged, steps.octaves.unchecked
859        ),
860        format!("ghost merge: {} notes", steps.merged),
861        format!("attack split: {} notes", steps.split),
862        format!("split join: {} notes", steps.joined),
863        format!(
864            "quiet-note guard: {} notes, {} dropped below {} dB",
865            steps.kept,
866            steps.quiet,
867            QUIET_GATE.get()
868        ),
869    ]
870}
871
872/// Transcribes a stem and writes `notes.mid` into the output folder,
873/// creating it.
874///
875/// The tempo is refined from the stem unless the request says
876/// `bpm_exact`. The stem runs through [`transcribe_stem`] at that tempo
877/// with the detected settings, or those of the profile, and the file
878/// carries that tempo. The file is written to a hidden temporary sibling
879/// and renamed into place, so a failure leaves the old file. Nothing else
880/// in the folder changes, except that `force` removes a `layers.json`
881/// that describes the final notes once the new file is in place, so no
882/// report describes a file that is gone.
883///
884/// # Errors
885///
886/// Returns an [`AnalyzeError`] when the stem gives no folder name and no
887/// `out` is given, when the output folder is a file, or, without `force`,
888/// when it holds a `layers.json` that describes the final notes, a report
889/// or notes of another stem, a report of the same name with other content,
890/// a `layers.json` or `notes.mid` that is not this tool's, or the backup
891/// that a killed run left; when the stem cannot be decoded or transcribed,
892/// when it gives no note, or when the file cannot be written.
893pub fn notes(
894    request: &NotesRequest<'_>,
895    transcriber: &Transcriber,
896) -> Result<NotesReport, AnalyzeError> {
897    let dir = out_dir(request.stem, request.out)?;
898    let source = source_of(request.stem);
899    // A report that describes the final notes would describe a file this
900    // command replaces, so it stops the run, whatever its stem; `--force`
901    // removes it with the old file.
902    let report = dir.join(LAYERS_FILE);
903    let describes = describes_notes(&report);
904    if describes && !request.force {
905        return Err(AnalyzeError::NotesDescribed { path: report });
906    }
907    let (stem_bytes, _, backups) = guard(
908        &dir,
909        request.stem,
910        &source,
911        request.force,
912        Guarded::ReportBeside,
913    )?;
914    let decoded = audio::decode_bytes(request.stem, stem_bytes).map_err(AnalyzeError::Decode)?;
915    let skipped = decoded.skipped().to_vec();
916    let warnings = decoded.warnings();
917    let stem_audio = decoded.into_audio();
918    let mono = stem_audio.mono();
919    let tempo = tempo_of(
920        &mono,
921        stem_audio.sample_rate(),
922        request.bpm,
923        request.bpm_exact,
924    )?;
925    let transcription = transcribe_stem(
926        transcriber,
927        &mono,
928        stem_audio.sample_rate(),
929        tempo.used(),
930        request.profile,
931    )
932    .map_err(AnalyzeError::Transcription)?;
933    if transcription.notes.is_empty() {
934        return Err(AnalyzeError::NoNotes { source });
935    }
936    let bytes = write_named_midi(
937        &transcription.notes,
938        tempo.used(),
939        Velocities::Constant(DEFAULT_VELOCITY),
940        &source,
941    )
942    .map_err(AnalyzeError::Midi)?;
943    // The report that describes the final notes goes with them: removed
944    // only once the new file lands, and kept when the write fails.
945    let mut changes: Vec<Change<'_>> = Vec::with_capacity(2);
946    if describes {
947        changes.push((LAYERS_FILE, None));
948    }
949    changes.push((NOTES_FILE, Some(&bytes)));
950    replace_files(&dir, &changes)?;
951    remove_backups(&backups);
952    let path = dir.join(NOTES_FILE);
953    Ok(NotesReport {
954        path,
955        tempo,
956        transcription,
957        skipped,
958        warnings,
959    })
960}
961
962/// Whether a `layers.json` describes the final notes, the `notes.mid` that
963/// `analyze` writes next to it.
964fn describes_notes(report: &Path) -> bool {
965    std::fs::read_to_string(report)
966        .ok()
967        .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
968        .is_some_and(|report| report.get("notes").is_some_and(|notes| !notes.is_null()))
969}
970
971/// Runs the guards of the output folder for the stem file `stem` named
972/// `source` and returns the bytes of the file, their SHA-256 and the
973/// backups that a forced run removes once its files are in place. The
974/// guards that read only names run before the stem is read, so a refusal
975/// answers first. The stem is read once, so the decoder gets the bytes the
976/// checksum read, as a pipe allows. `guarded` is [`Guarded::Report`] for
977/// `analyze`, which also guards `spectrogram.png`, and
978/// [`Guarded::ReportBeside`] for `notes`.
979fn guard(
980    dir: &Path,
981    stem: &Path,
982    source: &str,
983    force: bool,
984    guarded: Guarded,
985) -> Result<(Vec<u8>, String, Vec<PathBuf>), AnalyzeError> {
986    let recorded = check_out_dir(dir, source, force, guarded)?;
987    let backups = check_backups(dir, force)?;
988    if guarded == Guarded::Report {
989        check_spectrogram(dir, force)?;
990    }
991    check_notes_file(dir, source, force)?;
992    let bytes = audio::read_stem(stem).map_err(AnalyzeError::Decode)?;
993    let checksum = crate::render::sha256_hex(&bytes);
994    if recorded.is_some_and(|recorded| recorded != checksum) {
995        return Err(AnalyzeError::OtherAudio {
996            path: dir.join(LAYERS_FILE),
997            source: source.to_owned(),
998            guarded,
999        });
1000    }
1001    Ok((bytes, checksum, backups))
1002}
1003
1004/// The backups of report files in the folder, `.<file>.<random>.old`,
1005/// with the file each backs up. A run that was killed during its renames
1006/// left them. With `force` off, fails with every backup when one of them
1007/// stands for a file that the folder lacks, and otherwise returns none, so
1008/// a plain run removes no backup. With `force` on, returns every backup,
1009/// so the forced run removes them once its new files are in place.
1010fn check_backups(dir: &Path, force: bool) -> Result<Vec<PathBuf>, AnalyzeError> {
1011    // A folder that does not exist yet holds no backup.
1012    let Ok(entries) = std::fs::read_dir(dir) else {
1013        return Ok(Vec::new());
1014    };
1015    let mut names: Vec<String> = entries
1016        .flatten()
1017        .map(|entry| entry.file_name().to_string_lossy().into_owned())
1018        .collect();
1019    names.sort();
1020    let backups: Vec<(PathBuf, &'static str)> = [LAYERS_FILE, NOTES_FILE, SPECTROGRAM_FILE]
1021        .into_iter()
1022        .flat_map(|file| {
1023            let prefix = format!(".{file}.");
1024            names
1025                .iter()
1026                .filter(move |name| {
1027                    // The suffix is the one `move_aside` writes, so case matters.
1028                    name.starts_with(&prefix)
1029                        && Path::new(name).extension().is_some_and(|ext| ext == "old")
1030                })
1031                .map(move |name| (dir.join(name), file))
1032        })
1033        .collect();
1034    if force {
1035        return Ok(backups.into_iter().map(|(path, _)| path).collect());
1036    }
1037    if backups.iter().any(|(_, file)| !dir.join(file).exists()) {
1038        return Err(AnalyzeError::Backup { backups });
1039    }
1040    Ok(Vec::new())
1041}
1042
1043/// Removes the backups that a forced run replaced, after its new files
1044/// are in place. A backup that cannot go stays, as before the run.
1045fn remove_backups(backups: &[PathBuf]) {
1046    for backup in backups {
1047        let _ = std::fs::remove_file(backup);
1048    }
1049}
1050
1051/// The `source` and `source_sha256` of a `layers.json`; `None` when it
1052/// cannot be read, is not JSON or has no string `source`. A report from
1053/// before the checksum has no `source_sha256`.
1054fn recorded_stem(report: &Path) -> Option<(String, Option<String>)> {
1055    let report: serde_json::Value =
1056        serde_json::from_str(&std::fs::read_to_string(report).ok()?).ok()?;
1057    let name = report.get("source")?.as_str()?.to_owned();
1058    let sha256 = report
1059        .get("source_sha256")
1060        .and_then(serde_json::Value::as_str)
1061        .map(str::to_owned);
1062    Some((name, sha256))
1063}
1064
1065/// Fails when the output folder is a file, or, with `force` off, holds a
1066/// `layers.json` that is not a report or whose source is not `source`.
1067/// Returns the checksum that a report of `source` records, which
1068/// [`guard`] compares once it has read the stem; a report from before the
1069/// checksum has none and is matched by the name alone. `guarded` says what
1070/// `--force` does to that report.
1071fn check_out_dir(
1072    dir: &Path,
1073    source: &str,
1074    force: bool,
1075    guarded: Guarded,
1076) -> Result<Option<String>, AnalyzeError> {
1077    if dir.exists() && !dir.is_dir() {
1078        return Err(AnalyzeError::OutIsFile {
1079            path: dir.to_path_buf(),
1080        });
1081    }
1082    let existing = dir.join(LAYERS_FILE);
1083    if force || !existing.exists() {
1084        return Ok(None);
1085    }
1086    match recorded_stem(&existing) {
1087        None => Err(AnalyzeError::NotOurs {
1088            path: existing,
1089            guarded,
1090        }),
1091        Some((name, _)) if name != source => Err(AnalyzeError::OtherSource {
1092            path: existing,
1093            recorded: name,
1094            source: source.to_owned(),
1095            guarded,
1096        }),
1097        Some((_, sha256)) => Ok(sha256),
1098    }
1099}
1100
1101/// Fails when the output folder holds a `spectrogram.png` but no
1102/// `layers.json` and `force` is off: no report owns that file, so it is
1103/// the user's. [`check_out_dir`] has passed a `layers.json` that exists.
1104fn check_spectrogram(dir: &Path, force: bool) -> Result<(), AnalyzeError> {
1105    let existing = dir.join(SPECTROGRAM_FILE);
1106    if force || dir.join(LAYERS_FILE).exists() || !existing.exists() {
1107        return Ok(());
1108    }
1109    Err(AnalyzeError::Unowned { path: existing })
1110}
1111
1112/// Fails when the output folder holds a `notes.mid` whose track name is
1113/// not `source` and `force` is off.
1114fn check_notes_file(dir: &Path, source: &str, force: bool) -> Result<(), AnalyzeError> {
1115    let existing = dir.join(NOTES_FILE);
1116    if force || !existing.exists() {
1117        return Ok(());
1118    }
1119    match std::fs::read(&existing)
1120        .ok()
1121        .and_then(|bytes| midi_track_name(&bytes))
1122    {
1123        Some(recorded) if recorded == source => Ok(()),
1124        Some(recorded) => Err(AnalyzeError::OtherSource {
1125            path: existing,
1126            recorded,
1127            source: source.to_owned(),
1128            guarded: Guarded::Notes,
1129        }),
1130        None => Err(AnalyzeError::NotOurs {
1131            path: existing,
1132            guarded: Guarded::Notes,
1133        }),
1134    }
1135}
1136
1137/// The file of another stem that stopped a run, and what `--force` does
1138/// to it.
1139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1140pub enum Guarded {
1141    /// A `layers.json` that `analyze --force` replaces.
1142    Report,
1143    /// A `layers.json` that `notes --force` leaves, writing `notes.mid`
1144    /// next to it.
1145    ReportBeside,
1146    /// A `notes.mid` that `notes --force` replaces.
1147    Notes,
1148}
1149
1150impl Guarded {
1151    /// The fix that a refusal names.
1152    fn fix(self) -> &'static str {
1153        match self {
1154            Self::Report | Self::Notes => {
1155                "Pass --out <dir> for this stem, or --force to replace it"
1156            }
1157            Self::ReportBeside => {
1158                "Pass --out <dir> for this stem, or --force to write notes.mid next to it"
1159            }
1160        }
1161    }
1162}
1163
1164/// Writes `bytes` to a new hidden temporary sibling of `name` in `dir` and
1165/// flushes it to disk. The file removes itself when dropped, so a failed
1166/// write leaves none.
1167fn stage(dir: &Path, name: &str, bytes: &[u8]) -> Result<tempfile::NamedTempFile, AnalyzeError> {
1168    let prefix = format!(".{name}.");
1169    let mut builder = tempfile::Builder::new();
1170    builder.prefix(&prefix).suffix(".tmp");
1171    // The umask applies, as it does to a file that File::create makes.
1172    #[cfg(unix)]
1173    builder.permissions(std::os::unix::fs::PermissionsExt::from_mode(0o666));
1174    builder
1175        .tempfile_in(dir)
1176        .and_then(|mut file| {
1177            file.write_all(bytes)?;
1178            file.as_file().sync_all()?;
1179            Ok(file)
1180        })
1181        .map_err(|source| AnalyzeError::Write {
1182            path: dir.join(name),
1183            source,
1184        })
1185}
1186
1187/// An analysis that failed.
1188#[derive(Debug)]
1189pub enum AnalyzeError {
1190    /// The note list cannot be read.
1191    Notes(MidiError),
1192    /// The stem cannot be decoded.
1193    Decode(DecodeError),
1194    /// The output folder is a file.
1195    OutIsFile {
1196        /// The path of the folder.
1197        path: PathBuf,
1198    },
1199    /// The default output folder of a stem has no name of its own: the
1200    /// stem's name has no extension, or is `.` or `..` without it.
1201    NoFolderName {
1202        /// The file name of the stem.
1203        source: String,
1204        /// Its name without the extension; `None` without an extension.
1205        stem: Option<String>,
1206    },
1207    /// The output folder holds the report or the notes of another stem.
1208    OtherSource {
1209        /// The existing `layers.json` or `notes.mid`.
1210        path: PathBuf,
1211        /// The stem it names.
1212        recorded: String,
1213        /// The stem of this run.
1214        source: String,
1215        /// What the file is, and what `--force` does to it.
1216        guarded: Guarded,
1217    },
1218    /// The output folder holds a report of the stem's name with other
1219    /// content: the recorded checksum differs, so it is another stem of
1220    /// that name or this stem changed since that run.
1221    OtherAudio {
1222        /// The existing `layers.json`.
1223        path: PathBuf,
1224        /// The file name of the stem.
1225        source: String,
1226        /// What `--force` does to the report.
1227        guarded: Guarded,
1228    },
1229    /// The output folder holds a `layers.json` or a `notes.mid` that this
1230    /// tool did not write: it cannot be read, or names no stem.
1231    NotOurs {
1232        /// The existing file.
1233        path: PathBuf,
1234        /// What the file would be, and what `--force` does to it.
1235        guarded: Guarded,
1236    },
1237    /// The output folder holds the backup of a report file, but not the
1238    /// file: a run was killed during its renames.
1239    Backup {
1240        /// Every backup in the folder, `.<file>.<random>.old`, with the
1241        /// file it backs up.
1242        backups: Vec<(PathBuf, &'static str)>,
1243    },
1244    /// The output folder holds a `spectrogram.png` that no report owns.
1245    Unowned {
1246        /// The existing file.
1247        path: PathBuf,
1248    },
1249    /// The output folder holds a `layers.json` that describes the final
1250    /// notes, which `notes` would replace with the first list.
1251    NotesDescribed {
1252        /// The existing `layers.json`.
1253        path: PathBuf,
1254    },
1255    /// The model run, a decode or the f0 track failed.
1256    Transcription(NotesError),
1257    /// The pitch layer failed.
1258    Pitch(PitchError),
1259    /// The envelope layer failed.
1260    Envelope(EnvelopeError),
1261    /// The voices layer failed.
1262    Voices(VoicesError),
1263    /// The stem gave no note.
1264    NoNotes {
1265        /// The file name of the stem.
1266        source: String,
1267    },
1268    /// The notes cannot be stored as MIDI.
1269    Midi(MidiWriteError),
1270    /// An output file or folder cannot be written.
1271    Write {
1272        /// The path that failed.
1273        path: PathBuf,
1274        /// The cause.
1275        source: io::Error,
1276    },
1277    /// An internal step failed: a confidence that could not be built, or a
1278    /// JSON or PNG encoder error.
1279    Internal(String),
1280}
1281
1282impl fmt::Display for AnalyzeError {
1283    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1284        match self {
1285            Self::Notes(error) => write!(f, "{error}"),
1286            Self::Decode(error) => write!(f, "{error}"),
1287            Self::OutIsFile { path } => write!(
1288                f,
1289                "the output folder {} is a file. Pass --out <dir>",
1290                path.display()
1291            ),
1292            Self::NoFolderName { source, stem } => match stem {
1293                Some(stem) => write!(
1294                    f,
1295                    "`{source}` gives no folder name: without its extension it is `{stem}`. Pass --out <dir>"
1296                ),
1297                None => write!(
1298                    f,
1299                    "`{source}` gives no folder name: it has no extension to strip, so the folder would be the stem file itself. Pass --out <dir>"
1300                ),
1301            },
1302            Self::OtherSource {
1303                path,
1304                recorded,
1305                source,
1306                guarded,
1307            } => {
1308                let what = match guarded {
1309                    Guarded::Report | Guarded::ReportBeside => "is the report",
1310                    Guarded::Notes => "holds the notes",
1311                };
1312                write!(
1313                    f,
1314                    "{} {what} of `{recorded}`, not of `{source}`. {}",
1315                    path.display(),
1316                    guarded.fix()
1317                )
1318            }
1319            Self::OtherAudio {
1320                path,
1321                source,
1322                guarded,
1323            } => write!(
1324                f,
1325                "{} is the report of `{source}` with other content: another stem of that name, or this stem changed since that run. {}",
1326                path.display(),
1327                guarded.fix()
1328            ),
1329            Self::NotOurs { path, guarded } => {
1330                let what = match guarded {
1331                    Guarded::Report | Guarded::ReportBeside => {
1332                        "is not a report of this tool: it cannot be read, is not JSON or names no `source`"
1333                    }
1334                    Guarded::Notes => {
1335                        "holds no notes of this tool: it cannot be read as MIDI or has no track name"
1336                    }
1337                };
1338                write!(f, "{} exists but {what}. {}", path.display(), guarded.fix())
1339            }
1340            Self::Backup { backups } => {
1341                let list = backups
1342                    .iter()
1343                    .map(|(path, file)| format!("{} of {file}", path.display()))
1344                    .collect::<Vec<_>>()
1345                    .join(", ");
1346                write!(
1347                    f,
1348                    "the folder holds backups that a run left when it stopped before it ended: {list}. Rename each backup to its file name to restore the earlier files, or pass --force to write new files and remove the backups"
1349                )
1350            }
1351            Self::Unowned { path } => write!(
1352                f,
1353                "{} exists, and no report in the folder owns it. {}",
1354                path.display(),
1355                Guarded::Report.fix()
1356            ),
1357            Self::NotesDescribed { path } => write!(
1358                f,
1359                "{} describes the final notes.mid that `analyze` wrote, and `notes` would replace that file with the first note list. Pass --out <dir> for the first list, or --force to replace notes.mid and remove the report",
1360                path.display()
1361            ),
1362            Self::Transcription(error) => write!(f, "{error}"),
1363            Self::Pitch(error) => write!(f, "{error}"),
1364            Self::Envelope(error) => write!(f, "{error}"),
1365            Self::Voices(error) => write!(f, "{error}"),
1366            Self::NoNotes { source } => write!(
1367                f,
1368                "`{source}` gave no note, so nothing was written: it is silent, or no note sounds within {} dB of its loudest passage. Check the stem, or pass --profile bass|lead|pad",
1369                QUIET_GATE.get()
1370            ),
1371            Self::Midi(error) => write!(f, "{error}"),
1372            Self::Write { path, source } => write!(
1373                f,
1374                "cannot write {}: {source}. Check the folder and its permissions, or pass another --out",
1375                path.display()
1376            ),
1377            Self::Internal(message) => write!(
1378                f,
1379                "the analysis failed internally ({message}). Report this as a bug"
1380            ),
1381        }
1382    }
1383}
1384
1385impl Error for AnalyzeError {
1386    fn source(&self) -> Option<&(dyn Error + 'static)> {
1387        match self {
1388            Self::Notes(error) => Some(error),
1389            Self::Decode(error) => Some(error),
1390            Self::Transcription(error) => Some(error),
1391            Self::Pitch(error) => Some(error),
1392            Self::Envelope(error) => Some(error),
1393            Self::Voices(error) => Some(error),
1394            Self::Midi(error) => Some(error),
1395            Self::Write { source, .. } => Some(source),
1396            Self::OutIsFile { .. }
1397            | Self::NoFolderName { .. }
1398            | Self::OtherSource { .. }
1399            | Self::OtherAudio { .. }
1400            | Self::NotOurs { .. }
1401            | Self::Backup { .. }
1402            | Self::Unowned { .. }
1403            | Self::NotesDescribed { .. }
1404            | Self::NoNotes { .. }
1405            | Self::Internal(_) => None,
1406        }
1407    }
1408}
1409
1410impl From<ConfidenceError> for AnalyzeError {
1411    fn from(error: ConfidenceError) -> Self {
1412        Self::Internal(error.to_string())
1413    }
1414}
1415
1416impl From<serde_json::Error> for AnalyzeError {
1417    fn from(error: serde_json::Error) -> Self {
1418        Self::Internal(error.to_string())
1419    }
1420}
1421
1422impl From<png::EncodingError> for AnalyzeError {
1423    fn from(error: png::EncodingError) -> Self {
1424        Self::Internal(error.to_string())
1425    }
1426}
1427
1428#[cfg(test)]
1429mod tests {
1430    use super::*;
1431    use crate::units::{Decibels, MidiPitch};
1432
1433    /// The names in a folder, sorted, hidden ones included.
1434    fn entries(dir: &Path) -> Vec<String> {
1435        let mut names: Vec<String> = std::fs::read_dir(dir)
1436            .unwrap()
1437            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
1438            .collect();
1439        names.sort();
1440        names
1441    }
1442
1443    /// A name with a missing folder in it cannot be staged: here the second
1444    /// stage fails after the first file is staged. The staged file goes,
1445    /// and the old files stay.
1446    #[test]
1447    fn a_failed_stage_leaves_the_old_files_and_no_temporary_file() {
1448        let dir = tempfile::tempdir().unwrap();
1449        std::fs::write(dir.path().join(NOTES_FILE), b"old notes").unwrap();
1450        std::fs::write(dir.path().join(LAYERS_FILE), b"old report").unwrap();
1451        let error = replace_files(
1452            dir.path(),
1453            &[
1454                (NOTES_FILE, Some(b"new notes".as_slice())),
1455                ("missing/spectrogram.png", Some(b"new png".as_slice())),
1456                (LAYERS_FILE, Some(b"new report".as_slice())),
1457            ],
1458        )
1459        .unwrap_err();
1460        assert!(matches!(error, AnalyzeError::Write { .. }), "{error}");
1461        assert_eq!(entries(dir.path()), [LAYERS_FILE, NOTES_FILE]);
1462        assert_eq!(
1463            std::fs::read(dir.path().join(NOTES_FILE)).unwrap(),
1464            b"old notes"
1465        );
1466        assert_eq!(
1467            std::fs::read(dir.path().join(LAYERS_FILE)).unwrap(),
1468            b"old report"
1469        );
1470    }
1471
1472    /// A path through a file cannot be read, so its move aside fails after
1473    /// the old report moved aside and the new notes landed. Every step
1474    /// before it is undone.
1475    #[test]
1476    fn a_failed_move_aside_restores_every_earlier_step() {
1477        let dir = tempfile::tempdir().unwrap();
1478        std::fs::write(dir.path().join(NOTES_FILE), b"old notes").unwrap();
1479        std::fs::write(dir.path().join(LAYERS_FILE), b"old report").unwrap();
1480        std::fs::write(dir.path().join("plain"), b"a file").unwrap();
1481        let error = replace_files(
1482            dir.path(),
1483            &[
1484                (NOTES_FILE, Some(b"new notes".as_slice())),
1485                ("plain/spectrogram.png", None),
1486                (LAYERS_FILE, Some(b"new report".as_slice())),
1487            ],
1488        )
1489        .unwrap_err();
1490        assert!(matches!(error, AnalyzeError::Write { .. }), "{error}");
1491        assert_eq!(entries(dir.path()), [LAYERS_FILE, NOTES_FILE, "plain"]);
1492        assert_eq!(
1493            std::fs::read(dir.path().join(NOTES_FILE)).unwrap(),
1494            b"old notes"
1495        );
1496        assert_eq!(
1497            std::fs::read(dir.path().join(LAYERS_FILE)).unwrap(),
1498            b"old report"
1499        );
1500    }
1501
1502    /// A failed write into a folder the run created removes that folder and
1503    /// the parents it created, and keeps the parent that existed.
1504    #[test]
1505    fn a_failed_write_removes_the_folders_it_created() {
1506        let dir = tempfile::tempdir().unwrap();
1507        let out = dir.path().join("new").join("out");
1508        replace_files(&out, &[("missing/notes.mid", Some(b"notes".as_slice()))]).unwrap_err();
1509        assert_eq!(entries(dir.path()), Vec::<String>::new());
1510        // A folder that existed stays, empty or not.
1511        std::fs::create_dir(dir.path().join("kept")).unwrap();
1512        replace_files(
1513            &dir.path().join("kept"),
1514            &[("missing/notes.mid", Some(b"notes".as_slice()))],
1515        )
1516        .unwrap_err();
1517        assert_eq!(entries(dir.path()), ["kept"]);
1518    }
1519
1520    /// A 220 Hz tone held at full level to 0.5 s that then falls 60 dB/s
1521    /// reaches 20 dB under its plateau at 0.833 s, within its list span.
1522    #[test]
1523    fn a_pad_note_ends_where_its_level_falls_20_db_under_the_plateau() {
1524        let rate = SampleRate::new(22_050).unwrap();
1525        let fs = 22_050.0;
1526        let mono: Vec<f64> = (0..33_075)
1527            .map(|n| {
1528                let t = f64::from(n) / fs;
1529                let gain = if t < 0.5 {
1530                    1.0
1531                } else {
1532                    10f64.powf(-3.0 * (t - 0.5))
1533                };
1534                0.5 * gain * (std::f64::consts::TAU * 220.0 * t).sin()
1535            })
1536            .collect();
1537        let pitch = MidiPitch::new(57.0).unwrap();
1538        let note = Note::new(Seconds::clamped(0.0), Seconds::clamped(1.5), pitch).unwrap();
1539        let read = NoteEnvelope {
1540            onset: note.onset(),
1541            end: note.offset(),
1542            pitch,
1543            peak_db: Decibels::clamped(0.0),
1544            attack: None,
1545            attack_upper_bound: false,
1546            hold: Seconds::clamped(0.5),
1547            decay: None,
1548            sustain: Some(Decibels::clamped(0.0)),
1549            knee: Some(Seconds::clamped(0.5)),
1550            ripple: None,
1551            residual: None,
1552            overlap: false,
1553            tail: None,
1554        };
1555        let end = pad_end(&mono, rate, &note, std::slice::from_ref(&note), &read)
1556            .unwrap()
1557            .unwrap();
1558        assert!((end.get() - (0.5 + 20.0 / 60.0)).abs() < 0.01, "{end}");
1559        let held = NoteEnvelope {
1560            sustain: None,
1561            ..read
1562        };
1563        let unread = pad_end(&mono, rate, &note, std::slice::from_ref(&note), &held).unwrap();
1564        assert_eq!(unread, None);
1565        // Listed only to 0.6 s, the tone falls 20 dB after its list end, so
1566        // it has no pad end: the search stops there, and the release belongs
1567        // to the preset's amp envelope.
1568        let short = Note::new(note.onset(), Seconds::clamped(0.6), pitch).unwrap();
1569        let read = NoteEnvelope {
1570            end: short.offset(),
1571            ..read
1572        };
1573        let end = pad_end(&mono, rate, &short, std::slice::from_ref(&short), &read).unwrap();
1574        assert_eq!(end, None);
1575    }
1576}