1use 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
47pub const LAYERS_FILE: &str = "layers.json";
49pub const SPECTROGRAM_FILE: &str = "spectrogram.png";
51pub const NOTES_FILE: &str = "notes.mid";
53
54fn 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
73fn 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#[derive(Clone, Copy, Debug)]
83pub struct AnalyzeRequest<'a> {
84 pub stem: &'a Path,
86 pub bpm: Bpm,
88 pub bpm_exact: bool,
90 pub synth: Synth,
92 pub notes: Option<&'a Path>,
94 pub profile: Option<Profile>,
98 pub out: Option<&'a Path>,
100 pub force: bool,
105}
106
107impl AnalyzeRequest<'_> {
108 pub fn out_dir(&self) -> Result<PathBuf, AnalyzeError> {
115 out_dir(self.stem, self.out)
116 }
117
118 #[must_use]
120 pub fn source(&self) -> String {
121 source_of(self.stem)
122 }
123}
124
125#[derive(Clone, Debug)]
127pub struct Analysis {
128 pub layers: PathBuf,
130 pub spectrogram: PathBuf,
132 pub notes: Option<PathBuf>,
134 pub final_notes: Option<FinalNotes>,
136 pub tempo: Tempo,
138 pub transcription: Option<Transcription>,
140 pub pitch: PitchLayer,
142 pub voices: VoicesLayer,
144 pub envelope: EnvelopeLayer,
146 pub skipped: Vec<SkippedPacket>,
148 pub warnings: Vec<String>,
150}
151
152impl Analysis {
153 #[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
167fn 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
183pub fn analyze(
210 request: &AnalyzeRequest<'_>,
211 transcriber: &Transcriber,
212) -> Result<Analysis, AnalyzeError> {
213 analyze_with(request, transcriber, mapper_for(request.synth).ok())
214}
215
216pub 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(), ¬es)?;
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 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 (
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 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
335fn 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 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
368fn 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
386fn remove_created(created: &[PathBuf]) {
389 for folder in created {
390 let _ = std::fs::remove_dir(folder);
391 }
392}
393
394type Change<'a> = (&'static str, Option<&'a [u8]>);
397
398enum Undo {
400 MovedAside {
403 backup: tempfile::TempPath,
405 to: PathBuf,
407 },
408 Placed(PathBuf),
410}
411
412impl Undo {
413 fn revert(self) {
414 match self {
415 Self::MovedAside { backup, to } => {
416 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
429fn 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
449fn 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 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
486fn 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
512fn 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
531fn 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
598fn 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
630fn 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
671fn 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
705fn two_channels(channels: &[Vec<f64>]) -> Option<(&[f64], &[f64])> {
707 match channels {
708 [left, right, ..] => Some((left, right)),
709 _ => None,
710 }
711}
712
713fn 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 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
761fn 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#[derive(Clone, Copy, Debug)]
773pub struct NotesRequest<'a> {
774 pub stem: &'a Path,
776 pub bpm: Bpm,
778 pub bpm_exact: bool,
780 pub profile: Option<Profile>,
782 pub out: Option<&'a Path>,
784 pub force: bool,
788}
789
790#[derive(Clone, Debug)]
792pub struct NotesReport {
793 pub path: PathBuf,
795 pub tempo: Tempo,
797 pub transcription: Transcription,
800 pub skipped: Vec<SkippedPacket>,
802 pub warnings: Vec<String>,
804}
805
806impl NotesReport {
807 #[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
817fn 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
872pub 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 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 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
962fn 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
971fn 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
1004fn check_backups(dir: &Path, force: bool) -> Result<Vec<PathBuf>, AnalyzeError> {
1011 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 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
1043fn remove_backups(backups: &[PathBuf]) {
1046 for backup in backups {
1047 let _ = std::fs::remove_file(backup);
1048 }
1049}
1050
1051fn 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
1065fn 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
1101fn 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
1112fn 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1140pub enum Guarded {
1141 Report,
1143 ReportBeside,
1146 Notes,
1148}
1149
1150impl Guarded {
1151 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
1164fn 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 #[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#[derive(Debug)]
1189pub enum AnalyzeError {
1190 Notes(MidiError),
1192 Decode(DecodeError),
1194 OutIsFile {
1196 path: PathBuf,
1198 },
1199 NoFolderName {
1202 source: String,
1204 stem: Option<String>,
1206 },
1207 OtherSource {
1209 path: PathBuf,
1211 recorded: String,
1213 source: String,
1215 guarded: Guarded,
1217 },
1218 OtherAudio {
1222 path: PathBuf,
1224 source: String,
1226 guarded: Guarded,
1228 },
1229 NotOurs {
1232 path: PathBuf,
1234 guarded: Guarded,
1236 },
1237 Backup {
1240 backups: Vec<(PathBuf, &'static str)>,
1243 },
1244 Unowned {
1246 path: PathBuf,
1248 },
1249 NotesDescribed {
1252 path: PathBuf,
1254 },
1255 Transcription(NotesError),
1257 Pitch(PitchError),
1259 Envelope(EnvelopeError),
1261 Voices(VoicesError),
1263 NoNotes {
1265 source: String,
1267 },
1268 Midi(MidiWriteError),
1270 Write {
1272 path: PathBuf,
1274 source: io::Error,
1276 },
1277 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 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 #[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 #[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 #[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 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 #[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, ¬e, std::slice::from_ref(¬e), &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, ¬e, std::slice::from_ref(¬e), &held).unwrap();
1564 assert_eq!(unread, None);
1565 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}