kord 0.8.1

A tool to easily explore music theory principles.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
//! Helpers for transforming full-song datasets into sanitized training samples.

use std::{
    collections::HashMap,
    fs,
    io::ErrorKind,
    path::{Path, PathBuf},
    time::Instant,
};

use hound::{SampleFormat, WavReader};
use midly::{MetaMessage, MidiMessage, Smf, Timing, TrackEventKind};
use symphonia::{
    core::{audio::SampleBuffer, codecs::DecoderOptions, errors::Error as SymphoniaError, formats::FormatOptions, io::MediaSourceStream, meta::MetadataOptions, probe::Hint},
    default::{get_codecs, get_probe},
};
use tracing::{debug, info, info_span, instrument, warn};

use crate::analyze::base::{get_frequency_space, get_smoothed_frequency_space};
use crate::core::{
    base::{Err, Res},
    note::{HasNoteId, Note},
};

use super::{helpers::save_kord_item, KordItem, FREQUENCY_SPACE_SIZE};

const DEFAULT_MIN_NOTE_FRACTION: f64 = 0.2;
const DEFAULT_MIN_NOTES: usize = 1;
const DEFAULT_MAX_NOTES: usize = 20;
const DEFAULT_MIN_DURATION_SECONDS: f64 = 0.0;

/// Options that control how song processing sanitizes measures into training samples.
///
/// These knobs influence how measures are translated into labels and audio buffers without
/// suppressing any measures. They can be used to smooth away extremely transient notes or to
/// cap how many notes participate in the final chord mask, but every measure is always emitted.
#[derive(Clone, Debug)]
pub struct SongProcessingOptions {
    /// The minimum fraction of a measure that a note must sound to be considered part of the chord.
    pub min_note_fraction: f64,
    /// The minimum number of distinct notes required for a measure to be considered chordal.
    pub min_notes: usize,
    /// The maximum number of notes to include from a measure (after sorting by prominence).
    pub max_notes: usize,
    /// Minimum audio duration required (in seconds) for a measure.
    pub min_duration_seconds: f64,
    /// Maximum number of samples to emit. When `None`, all qualifying measures are emitted.
    pub max_samples: Option<usize>,
}

impl Default for SongProcessingOptions {
    fn default() -> Self {
        Self {
            min_note_fraction: DEFAULT_MIN_NOTE_FRACTION,
            min_notes: DEFAULT_MIN_NOTES,
            max_notes: DEFAULT_MAX_NOTES,
            min_duration_seconds: DEFAULT_MIN_DURATION_SECONDS,
            max_samples: None,
        }
    }
}

/// Tracking container that carries aggregated information about a single measure while the
/// MIDI file is parsed. Every measure knows the ticks spanned, the note durations observed,
/// and its relative index in the song.
#[derive(Clone, Debug)]
struct MeasureInfo {
    /// The zero-based measure number within the song.
    index: u64,
    /// The absolute MIDI tick where this measure begins.
    start_tick: u64,
    /// The absolute MIDI tick where this measure ends (may extend beyond the
    /// measure boundary if notes are still sounding).
    end_tick: u64,
    /// Map from MIDI note number (0-127) to the total number of ticks that
    /// note sounded within this measure. Notes spanning multiple measures contribute
    /// proportionally to each measure they occupy.
    note_ticks: HashMap<u8, u64>,
}

/// Result returned from parsing a MIDI file.
///
/// Abstracts the complex tuple type used throughout this module so callers don't need to
/// reason about the concrete tuple shape.
#[derive(Clone, Debug)]
struct ParseMidiResult {
    /// Sorted list of tempo changes as (tick, microseconds_per_quarter_note)
    /// pairs. Used to convert MIDI ticks to wall-clock seconds. Defaults to 500,000 µs/qn
    /// (120 BPM) if empty.
    tempo_events: Vec<(u64, u32)>,
    /// Map from measure index to aggregated note information. Each measure tracks
    /// which notes sounded and for how many ticks, allowing proper chord labeling even when
    /// notes span measure boundaries.
    measures: HashMap<u64, MeasureInfo>,
}

/// Process a paired MIDI + WAV file into sanitized training samples saved on disk.
///
/// Every measure in the MIDI file yields a sample. The measure's aligned audio segment is
/// zero-padded to the next whole second, and that duration is encoded into the emitted file
/// name (`*_{seconds}s_*`) alongside the originating measure index and chord tones so that
/// downstream tooling can reason about sample length without reopening the binary payload.
#[instrument(skip(destination, midi_path, audio_path, options))]
pub fn process_song_samples(destination: impl AsRef<Path>, midi_path: impl AsRef<Path>, audio_path: impl AsRef<Path>, options: SongProcessingOptions) -> Res<Vec<PathBuf>> {
    let destination = destination.as_ref();
    fs::create_dir_all(destination)?;

    info!(
        destination = %destination.display(),
        midi = %midi_path.as_ref().display(),
        audio = %audio_path.as_ref().display(),
        "Starting sample processing"
    );

    let midi_parse_start = Instant::now();
    let midi_bytes = fs::read(&midi_path)?;
    let smf = Smf::parse(&midi_bytes)?;
    let ppq = match smf.header.timing {
        Timing::Metrical(t) => t.as_int(),
        _ => {
            return Err(anyhow::Error::msg("Only metrical MIDI files are supported."));
        }
    };

    let ParseMidiResult { tempo_events, measures } = parse_midi(&smf, ppq)?;
    debug!(
        elapsed_ms = midi_parse_start.elapsed().as_millis(),
        measure_count = measures.len(),
        tempo_event_count = tempo_events.len(),
        "MIDI parsed"
    );

    let audio_load_start = Instant::now();
    let (audio_data, sample_rate) = load_audio_mono(&audio_path)?;
    let total_audio_seconds = audio_data.len() as f64 / sample_rate as f64;
    debug!(elapsed_ms = audio_load_start.elapsed().as_millis(), sample_rate, samples = audio_data.len(), "Audio buffered");

    let mut saved_paths = Vec::new();
    let mut sorted_measures: Vec<_> = measures.into_values().collect();
    sorted_measures.sort_by_key(|m| m.index);

    let midi_stem = midi_path.as_ref().file_stem().and_then(|s| s.to_str()).unwrap_or("song");

    for measure in sorted_measures {
        if let Some(limit) = options.max_samples {
            if saved_paths.len() >= limit {
                break;
            }
        }

        let measure_span = info_span!("measure", index = measure.index);
        let _enter = measure_span.enter();
        let measure_timer = Instant::now();

        let total_ticks = (measure.end_tick - measure.start_tick).max(1) as f64;
        let mut note_fractions = measure.note_ticks.iter().map(|(note, ticks)| (*note, *ticks as f64 / total_ticks)).collect::<Vec<_>>();
        note_fractions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));

        let mut selected_notes: Vec<u8> = note_fractions.iter().filter(|(_, fraction)| *fraction >= options.min_note_fraction).map(|(note, _)| *note).collect();

        if selected_notes.len() < options.min_notes {
            for (note, _) in note_fractions.iter() {
                if selected_notes.contains(note) {
                    continue;
                }

                selected_notes.push(*note);

                if selected_notes.len() >= options.min_notes {
                    break;
                }
            }
        }

        if selected_notes.is_empty() && !note_fractions.is_empty() {
            selected_notes = note_fractions.iter().map(|(note, _)| *note).collect();
        }

        if selected_notes.len() > options.max_notes {
            selected_notes.truncate(options.max_notes);
        }

        let mut chord_notes: Vec<Note> = selected_notes.iter().map(|note| Note::try_from_midi(*note)).collect::<Res<_>>()?;

        chord_notes.sort_by_key(|n| n.id_index());

        let start_seconds = ticks_to_seconds(measure.start_tick, &tempo_events, ppq)?;
        let end_seconds = ticks_to_seconds(measure.end_tick, &tempo_events, ppq)?;

        if end_seconds <= start_seconds {
            continue;
        }

        if end_seconds > total_audio_seconds {
            warn!(end_seconds, total_audio_seconds, "Skipping measure that exceeds available audio duration");
            continue;
        }

        let duration_seconds = end_seconds - start_seconds;
        if duration_seconds < options.min_duration_seconds - (f32::EPSILON as f64) {
            debug!(duration_seconds, min_duration = options.min_duration_seconds, "Skipping measure shorter than minimum duration");
            continue;
        }

        let length_in_seconds = duration_seconds.ceil().clamp(1.0, u8::MAX as f64) as u8;
        let target_samples = length_in_seconds as usize * sample_rate as usize;

        let start_sample = (start_seconds * sample_rate as f64).floor() as usize;
        let end_sample = (end_seconds * sample_rate as f64).ceil() as usize;
        if start_sample >= audio_data.len() {
            warn!(start_sample, audio_len = audio_data.len(), "Stopping because measure start exceeds audio length");
            break;
        }

        let mut buffer = vec![0.0f32; target_samples];
        let available_end = end_sample.min(audio_data.len());
        let available_samples = available_end.saturating_sub(start_sample);
        buffer[..available_samples].copy_from_slice(&audio_data[start_sample..available_end]);

        if available_samples == 0 {
            debug!("Skipping measure with zero available samples");
            continue;
        }

        let frequency_space = get_frequency_space(&buffer, length_in_seconds);
        let smoothed = get_smoothed_frequency_space(&frequency_space, length_in_seconds);

        let mut spectrum = [0f32; FREQUENCY_SPACE_SIZE];
        for (index, (_, magnitude)) in smoothed.into_iter().enumerate().take(FREQUENCY_SPACE_SIZE) {
            spectrum[index] = magnitude;
        }

        let label = if chord_notes.is_empty() { 0 } else { Note::id_mask(&chord_notes) };
        let note_names = if chord_notes.is_empty() {
            "rest".to_string()
        } else {
            chord_notes.iter().map(ToString::to_string).collect::<Vec<_>>().join("_")
        };

        let item = KordItem {
            path: destination.to_path_buf(),
            frequency_space: spectrum,
            label,
        };

        let prefix = format!("{}_measure_{:04}_{}s_", midi_stem, measure.index, length_in_seconds);
        let path = save_kord_item(destination, &prefix, &note_names, &item)?;
        debug!(
            processing_duration_ms = measure_timer.elapsed().as_millis(),
            length_in_seconds,
            note_count = chord_notes.len(),
            path = %path.display(),
            "Saved measure"
        );
        saved_paths.push(path);
    }

    if saved_paths.is_empty() {
        warn!("No qualifying measures produced any samples");
        return Err(anyhow::Error::msg(format!(
            "No qualifying measures were found when processing {} and {}.",
            midi_path.as_ref().display(),
            audio_path.as_ref().display()
        )));
    }

    Ok(saved_paths)
}

/// Parse the MIDI file into tempo events and per-measure note statistics.
///
/// The parser keeps track of tempo and time-signature meta events so that later conversions
/// to wall-clock time can account for rubato sections or meter changes.
fn parse_midi(smf: &Smf<'_>, ppq: u16) -> Res<ParseMidiResult> {
    let mut tempo_events = vec![(0u64, 500_000u32)];
    let mut time_signature_numerator = 4u8;
    let mut time_signature_denominator = 4u32;
    let mut measures: HashMap<u64, MeasureInfo> = HashMap::new();
    let mut note_starts: HashMap<u8, Vec<u64>> = HashMap::with_capacity(88);
    let mut max_tick = 0u64;

    for track in &smf.tracks {
        let mut tick_accumulator = 0u64;
        for event in track {
            tick_accumulator += event.delta.as_int() as u64;
            max_tick = max_tick.max(tick_accumulator);

            match event.kind {
                TrackEventKind::Meta(MetaMessage::Tempo(value)) => {
                    tempo_events.push((tick_accumulator, value.as_int()));
                }
                TrackEventKind::Meta(MetaMessage::TimeSignature(numerator, denominator, ..)) => {
                    let computed_denominator = 1u32 << (denominator as u32);
                    if time_signature_numerator != numerator || time_signature_denominator != computed_denominator {
                        time_signature_numerator = numerator;
                        time_signature_denominator = computed_denominator;
                    }
                }
                TrackEventKind::Midi { channel, message } => {
                    if channel.as_int() == 9 {
                        // General MIDI reserves channel 10 (index 9) for percussion, which should not
                        // contribute to harmonic labeling.
                        continue;
                    }

                    match message {
                        MidiMessage::NoteOn { key, vel } => {
                            if vel.as_int() > 0 {
                                note_starts.entry(key.as_int()).or_default().push(tick_accumulator);
                            } else {
                                register_note_off(
                                    key.as_int(),
                                    tick_accumulator,
                                    &mut note_starts,
                                    &mut measures,
                                    ppq,
                                    time_signature_numerator,
                                    time_signature_denominator,
                                );
                            }
                        }
                        MidiMessage::NoteOff { key, .. } => {
                            register_note_off(
                                key.as_int(),
                                tick_accumulator,
                                &mut note_starts,
                                &mut measures,
                                ppq,
                                time_signature_numerator,
                                time_signature_denominator,
                            );
                        }
                        _ => {}
                    }
                }
                _ => {}
            }
        }
    }

    for (note, starts) in note_starts.iter_mut() {
        for start in starts.drain(..) {
            insert_note_segment(*note, start, max_tick, &mut measures, ppq, time_signature_numerator, time_signature_denominator);
        }
    }

    tempo_events.sort_by_key(|(tick, _)| *tick);
    tempo_events.dedup_by(|a, b| {
        if a.0 == b.0 {
            b.1 = a.1;
            true
        } else {
            false
        }
    });

    Ok(ParseMidiResult { tempo_events, measures })
}

/// Handle a MIDI note-off (or zero-velocity note-on) by recording the note duration inside
/// the appropriate measure bucket.
fn register_note_off(note: u8, tick: u64, note_starts: &mut HashMap<u8, Vec<u64>>, measures: &mut HashMap<u64, MeasureInfo>, ppq: u16, numerator: u8, denominator: u32) {
    if let Some(starts) = note_starts.get_mut(&note) {
        if let Some(start_tick) = starts.pop() {
            insert_note_segment(note, start_tick, tick, measures, ppq, numerator, denominator);
        }
    }
}

/// Partition a note segment across measure boundaries and accumulate the tick counts for
/// each slice. This ensures notes that span multiple bars contribute proportionally to every
/// measure they occupy.
///
/// # Algorithm
///
/// 1. Calculate the measure length in ticks based on time signature
/// 2. Walk forward from the note's start tick to its end tick
/// 3. For each measure the note intersects:
///    - Calculate which portion of the note falls within that measure
///    - Add that duration to the measure's note_ticks counter
/// 4. Stop when the entire note duration has been accounted for
///
/// # Example
///
/// A whole note starting at tick 1800 in 4/4 time (measure_ticks=1920):
/// - Measure 0 (ticks 0-1920): contributes 120 ticks
/// - Measure 1 (ticks 1920-3840): contributes 1920 ticks (full measure)
/// - Total: 2040 ticks across two measures
fn insert_note_segment(note: u8, start_tick: u64, end_tick: u64, measures: &mut HashMap<u64, MeasureInfo>, ppq: u16, numerator: u8, denominator: u32) {
    if end_tick <= start_tick {
        return;
    }

    let measure_ticks = compute_measure_ticks(ppq, numerator, denominator);

    let mut current_start = start_tick;
    while current_start < end_tick {
        let measure_index = current_start / measure_ticks;
        let measure_start_tick = measure_index * measure_ticks;
        let measure_end_tick = (measure_index + 1) * measure_ticks;
        let segment_end = end_tick.min(measure_end_tick);
        let duration = segment_end.saturating_sub(current_start);

        let entry = measures.entry(measure_index).or_insert_with(|| MeasureInfo {
            index: measure_index,
            start_tick: measure_start_tick,
            end_tick: measure_end_tick,
            note_ticks: HashMap::new(),
        });

        *entry.note_ticks.entry(note).or_insert(0) += duration;
        entry.end_tick = entry.end_tick.max(segment_end);

        current_start = segment_end;
    }
}

/// Compute the number of ticks contained in a single measure for the current meter.
///
/// # Formula
///
/// `ticks_per_measure = (ppq × numerator × 4) / denominator`
///
/// Where:
/// - `ppq` (pulses per quarter note) is the MIDI file's time resolution
/// - `numerator` is the top number of the time signature (beats per measure)
/// - `denominator` is the bottom number (note value that gets one beat)
/// - The factor of 4 normalizes denominator to quarter notes
///
/// # Examples
///
/// - 4/4 time, ppq=480: `(480 × 4 × 4) / 4 = 1920` ticks
/// - 3/4 time, ppq=480: `(480 × 3 × 4) / 4 = 1440` ticks
/// - 6/8 time, ppq=480: `(480 × 6 × 4) / 8 = 1440` ticks
fn compute_measure_ticks(ppq: u16, numerator: u8, denominator: u32) -> u64 {
    let ppq = ppq as u64;
    let numerator = numerator as u64;
    let denominator = denominator as u64;
    // Each measure contains `numerator` beats, where each beat represents a `denominator` note.
    (ppq * numerator * 4) / denominator.max(1)
}

/// Load an audio file into a mono floating-point buffer, normalizing integer sample formats to
/// `[-1.0, 1.0]` in the process. Supports WAV and FLAC sources.
fn load_audio_mono(path: impl AsRef<Path>) -> Res<(Vec<f32>, u32)> {
    let path = path.as_ref();
    let extension = path.extension().and_then(|ext| ext.to_str()).map(|ext| ext.to_ascii_lowercase());

    match extension.as_deref() {
        Some("flac") => load_flac_mono(path),
        _ => load_wav_mono(path),
    }
}

/// Load a WAV file into a mono floating-point buffer, normalizing integer sample formats to
/// `[-1.0, 1.0]` in the process.
fn load_wav_mono(path: impl AsRef<Path>) -> Res<(Vec<f32>, u32)> {
    let mut reader = WavReader::open(path)?;
    let spec = reader.spec();
    let sample_rate = spec.sample_rate;
    let channels = spec.channels as usize;

    let raw_samples = match spec.sample_format {
        SampleFormat::Float => reader.samples::<f32>().map(|s| s.map_err(anyhow::Error::from)).collect::<Res<Vec<_>>>()?,
        SampleFormat::Int => {
            if spec.bits_per_sample <= 16 {
                reader
                    .samples::<i16>()
                    .map(|s| s.map(|v| v as f32 / i16::MAX as f32).map_err(anyhow::Error::from))
                    .collect::<Res<Vec<_>>>()?
            } else {
                reader
                    .samples::<i32>()
                    .map(|s| s.map(|v| v as f32 / i32::MAX as f32).map_err(anyhow::Error::from))
                    .collect::<Res<Vec<_>>>()?
            }
        }
    };

    if channels == 0 {
        return Err(anyhow::Error::msg("Audio file has zero channels."));
    }

    let mut mono = Vec::with_capacity(raw_samples.len() / channels);
    for chunk in raw_samples.chunks(channels) {
        if chunk.is_empty() {
            continue;
        }
        let sum: f32 = chunk.iter().sum();
        mono.push(sum / channels as f32);
    }

    Ok((mono, sample_rate))
}

/// Load a FLAC file into a mono floating-point buffer using Symphonia.
fn load_flac_mono(path: impl AsRef<Path>) -> Res<(Vec<f32>, u32)> {
    let path = path.as_ref();
    let file = std::fs::File::open(path)?;
    let mss = MediaSourceStream::new(Box::new(file), Default::default());

    let format_opts = FormatOptions::default();
    let metadata_opts = MetadataOptions::default();

    let mut hint = Hint::new();
    if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) {
        hint.with_extension(ext);
    }

    let probed = get_probe().format(&hint, mss, &format_opts, &metadata_opts)?;
    let mut format = probed.format;

    let track = format
        .default_track()
        .ok_or_else(|| anyhow::Error::msg(format!("No playable audio track found in {}", path.display())))?;

    let codec_params = &track.codec_params;
    let sample_rate = codec_params.sample_rate.ok_or_else(|| anyhow::Error::msg(format!("Missing sample rate in {}", path.display())))?;

    let mut decoder = get_codecs().make(codec_params, &DecoderOptions::default())?;

    let mut mono = Vec::new();

    loop {
        match format.next_packet() {
            Ok(packet) => match decoder.decode(&packet) {
                Ok(decoded) => {
                    let spec = *decoded.spec();
                    let channels = spec.channels.count();

                    if channels == 0 {
                        return Err(anyhow::Error::msg("Audio file has zero channels."));
                    }

                    let frames = decoded.frames();
                    let mut buffer = SampleBuffer::<f32>::new(frames as u64, spec);
                    buffer.copy_interleaved_ref(decoded);

                    for frame in buffer.samples().chunks(channels) {
                        if frame.is_empty() {
                            continue;
                        }

                        let sum: f32 = frame.iter().copied().sum();
                        mono.push(sum / channels as f32);
                    }
                }
                Err(SymphoniaError::DecodeError(_)) => {
                    continue;
                }
                Err(SymphoniaError::ResetRequired) => {
                    decoder.reset();
                }
                Err(err) => return Err(err.into()),
            },
            Err(SymphoniaError::IoError(err)) => {
                if err.kind() == ErrorKind::UnexpectedEof {
                    break;
                }

                return Err(err.into());
            }
            Err(SymphoniaError::DecodeError(_)) => {
                continue;
            }
            Err(SymphoniaError::ResetRequired) => {
                decoder.reset();
            }
            Err(err) => return Err(err.into()),
        }
    }

    if mono.is_empty() {
        return Err(anyhow::Error::msg(format!("Decoded FLAC file {} produced no audio samples.", path.display())));
    }

    Ok((mono, sample_rate))
}

/// Convert an absolute tick offset into seconds using the precomputed tempo changes.
fn ticks_to_seconds(ticks: u64, tempo_events: &[(u64, u32)], ppq: u16) -> Result<f64, Err> {
    if tempo_events.is_empty() {
        return Err(anyhow::Error::msg("No tempo events available to convert ticks to seconds."));
    }

    let mut elapsed = 0f64;
    let mut last_tick = 0u64;
    let mut current_tempo = tempo_events.first().map(|(_, tempo)| *tempo).unwrap_or(500_000);

    for &(event_tick, tempo) in tempo_events.iter().skip(1) {
        if event_tick >= ticks {
            break;
        }

        elapsed += (event_tick - last_tick) as f64 * seconds_per_tick(ppq, current_tempo);
        last_tick = event_tick;
        current_tempo = tempo;
    }

    Ok(elapsed + (ticks - last_tick) as f64 * seconds_per_tick(ppq, current_tempo))
}

/// Compute the duration of a single tick given the current tempo (microseconds per quarter
/// note) and pulses-per-quarter resolution.
fn seconds_per_tick(ppq: u16, tempo: u32) -> f64 {
    (tempo as f64 / 1_000_000.0) / ppq as f64
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::pitch::HasFrequency;
    use crate::ml::base::helpers::load_kord_item;
    use std::f32::consts::PI;
    use std::fs;
    use std::path::Path;
    use tempfile::tempdir;

    /// Integration test that exercises real project fixtures end-to-end, ensuring the
    /// processor can read, sanitize, and persist multiple samples from realistic material.
    #[test]
    fn test_process_song_samples_with_repo_fixtures() {
        let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
        let midi_path = manifest_dir.join("tests/test.mid");
        let audio_path = manifest_dir.join("tests/test.wav");

        assert!(midi_path.exists(), "Fixture MIDI file missing at {:?}", midi_path);
        assert!(audio_path.exists(), "Fixture audio file missing at {:?}", audio_path);

        let destination = manifest_dir.join(".hidden/test_data/process_song");
        if destination.exists() {
            fs::remove_dir_all(&destination).unwrap();
        }
        fs::create_dir_all(&destination).unwrap();

        let options = SongProcessingOptions {
            max_samples: Some(64),
            ..Default::default()
        };

        let outputs = process_song_samples(&destination, &midi_path, &audio_path, options).expect("processing fixtures");
        assert!(!outputs.is_empty(), "Expected at least one processed sample");

        for output in outputs {
            assert!(output.starts_with(&destination), "Output {output:?} not in destination {destination:?}");
            assert!(output.exists(), "Missing output file {output:?}");

            let filename = output.file_name().and_then(|name| name.to_str()).expect("Output filename should be valid UTF-8");
            let _seconds_segment = filename
                .split('_')
                .find(|segment| segment.ends_with('s') && segment.trim_end_matches('s').chars().all(|c| c.is_ascii_digit()) && !segment.trim_end_matches('s').is_empty())
                .expect("Processed filename should include a seconds segment");

            let item = load_kord_item(&output).unwrap();
            assert_ne!(item.label, 0, "Processed sample should have a non-zero chord label");
            assert!(item.frequency_space.iter().any(|&v| v != 0.0), "Frequency space should contain data");
        }
    }

    #[test]
    fn test_process_song_samples_with_flac_fixture() {
        let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
        let midi_path = manifest_dir.join("tests/test.mid");
        let audio_path = manifest_dir.join("tests/test.flac");

        assert!(midi_path.exists(), "Fixture MIDI file missing at {:?}", midi_path);
        assert!(audio_path.exists(), "Fixture FLAC file missing at {:?}", audio_path);

        let destination = manifest_dir.join(".hidden/test_data/process_song_flac");
        if destination.exists() {
            fs::remove_dir_all(&destination).unwrap();
        }
        fs::create_dir_all(&destination).unwrap();

        let options = SongProcessingOptions {
            max_samples: Some(64),
            ..Default::default()
        };

        let outputs = process_song_samples(&destination, &midi_path, &audio_path, options).expect("processing flac fixtures");
        assert!(!outputs.is_empty(), "Expected at least one processed sample");
    }

    /// Synthetic test that constructs a single-measure MIDI/WAV pair and verifies the
    /// resulting filename and chord label are computed deterministically.
    #[test]
    fn test_process_song_samples_creates_expected_output() {
        let dir = tempdir().unwrap();
        let midi_path = dir.path().join("test.mid");
        let audio_path = dir.path().join("test.wav");
        let destination = dir.path().join("samples");

        write_test_midi(&midi_path).unwrap();
        write_test_wav(&audio_path, 2.0).unwrap();

        let options = SongProcessingOptions {
            max_samples: Some(4),
            ..Default::default()
        };

        let outputs = process_song_samples(&destination, &midi_path, &audio_path, options).unwrap();
        assert_eq!(outputs.len(), 1);

        let filename = outputs[0].file_name().and_then(|name| name.to_str()).expect("Output filename should be valid UTF-8");
        assert!(filename.contains("_2s_"), "Expected filename to include approximate duration (\"_2s_\"), found {filename:?}");

        let item = load_kord_item(&outputs[0]).unwrap();
        let expected_notes = vec![Note::try_from_midi(60).unwrap(), Note::try_from_midi(64).unwrap(), Note::try_from_midi(67).unwrap()];

        assert_eq!(item.label, Note::id_mask(&expected_notes));
    }

    /// Even when the caller specifies thresholds that cannot be satisfied by the underlying
    /// material, every measure should still be emitted with the best available note set.
    #[test]
    fn test_process_song_samples_emits_measure_with_strict_thresholds() {
        let dir = tempdir().unwrap();
        let midi_path = dir.path().join("strict.mid");
        let audio_path = dir.path().join("strict.wav");
        let destination = dir.path().join("samples");

        write_test_midi(&midi_path).unwrap();
        write_test_wav(&audio_path, 2.0).unwrap();

        let options = SongProcessingOptions {
            max_samples: Some(4),
            min_note_fraction: 1.1,
            min_notes: 5,
            ..Default::default()
        };

        let outputs = process_song_samples(&destination, &midi_path, &audio_path, options).unwrap();
        assert_eq!(outputs.len(), 1);

        let item = load_kord_item(&outputs[0]).unwrap();
        let expected_notes = vec![Note::try_from_midi(60).unwrap(), Note::try_from_midi(64).unwrap(), Note::try_from_midi(67).unwrap()];

        assert_eq!(item.label, Note::id_mask(&expected_notes));
    }

    /// Ensure percussion-channel notes do not influence chord labeling when processing samples.
    #[test]
    fn test_process_song_samples_ignores_percussion_notes() {
        let dir = tempdir().unwrap();
        let midi_path = dir.path().join("test_percussion.mid");
        let audio_path = dir.path().join("test.wav");
        let destination = dir.path().join("samples");

        write_test_midi_with_percussion(&midi_path).unwrap();
        write_test_wav(&audio_path, 2.0).unwrap();

        let options = SongProcessingOptions {
            max_samples: Some(4),
            ..Default::default()
        };

        let outputs = process_song_samples(&destination, &midi_path, &audio_path, options).unwrap();
        assert_eq!(outputs.len(), 1);

        let item = load_kord_item(&outputs[0]).unwrap();
        let expected_notes = vec![Note::try_from_midi(60).unwrap(), Note::try_from_midi(64).unwrap(), Note::try_from_midi(67).unwrap()];
        assert_eq!(item.label, Note::id_mask(&expected_notes));

        let percussion_mask = Note::id_mask(&[Note::try_from_midi(36).unwrap(), Note::try_from_midi(38).unwrap()]);
        assert_eq!(item.label & percussion_mask, 0, "Percussion notes should be ignored when labeling samples");
    }

    /// Emit a minimal 4/4 MIDI file that plays a single C major triad for one measure.
    fn write_test_midi(path: &Path) -> Res<()> {
        write_test_midi_internal(path, false)
    }

    /// Emit the same MIDI file as [`write_test_midi`], but with an additional percussion track on channel 10.
    fn write_test_midi_with_percussion(path: &Path) -> Res<()> {
        write_test_midi_internal(path, true)
    }

    fn write_test_midi_internal(path: &Path, include_percussion: bool) -> Res<()> {
        let mut data = Vec::new();
        data.extend_from_slice(b"MThd");
        data.extend_from_slice(&6u32.to_be_bytes());
        data.extend_from_slice(&1u16.to_be_bytes());
        let track_count = if include_percussion { 3u16 } else { 2u16 };
        data.extend_from_slice(&track_count.to_be_bytes());
        data.extend_from_slice(&480u16.to_be_bytes());

        let mut track0 = Vec::new();
        push_vlq(&mut track0, 0);
        track0.extend_from_slice(&[0xFF, 0x58, 0x04, 0x04, 0x02, 0x18, 0x08]);
        push_vlq(&mut track0, 0);
        track0.extend_from_slice(&[0xFF, 0x51, 0x03, 0x07, 0xA1, 0x20]);
        push_vlq(&mut track0, 0);
        track0.extend_from_slice(&[0xFF, 0x2F, 0x00]);

        data.extend_from_slice(b"MTrk");
        data.extend_from_slice(&(track0.len() as u32).to_be_bytes());
        data.extend_from_slice(&track0);

        let mut track1 = Vec::new();
        push_vlq(&mut track1, 0);
        track1.extend_from_slice(&[0x90, 0x3C, 0x64]);
        push_vlq(&mut track1, 0);
        track1.extend_from_slice(&[0x90, 0x40, 0x64]);
        push_vlq(&mut track1, 0);
        track1.extend_from_slice(&[0x90, 0x43, 0x64]);
        push_vlq(&mut track1, 1920);
        track1.extend_from_slice(&[0x80, 0x3C, 0x40]);
        push_vlq(&mut track1, 0);
        track1.extend_from_slice(&[0x80, 0x40, 0x40]);
        push_vlq(&mut track1, 0);
        track1.extend_from_slice(&[0x80, 0x43, 0x40]);
        push_vlq(&mut track1, 0);
        track1.extend_from_slice(&[0xFF, 0x2F, 0x00]);

        data.extend_from_slice(b"MTrk");
        data.extend_from_slice(&(track1.len() as u32).to_be_bytes());
        data.extend_from_slice(&track1);

        if include_percussion {
            let mut percussion_track = Vec::new();
            push_vlq(&mut percussion_track, 0);
            percussion_track.extend_from_slice(&[0x99, 0x24, 0x64]); // Kick drum on channel 10.
            push_vlq(&mut percussion_track, 240);
            percussion_track.extend_from_slice(&[0x89, 0x24, 0x40]);
            push_vlq(&mut percussion_track, 0);
            percussion_track.extend_from_slice(&[0x99, 0x26, 0x64]); // Snare drum.
            push_vlq(&mut percussion_track, 240);
            percussion_track.extend_from_slice(&[0x89, 0x26, 0x40]);
            push_vlq(&mut percussion_track, 0);
            percussion_track.extend_from_slice(&[0xFF, 0x2F, 0x00]);

            data.extend_from_slice(b"MTrk");
            data.extend_from_slice(&(percussion_track.len() as u32).to_be_bytes());
            data.extend_from_slice(&percussion_track);
        }

        std::fs::write(path, data)?;
        Ok(())
    }

    /// Render a synthetic WAV file containing the same pitches as the MIDI fixture for a
    /// specified number of seconds.
    fn write_test_wav(path: &Path, seconds: f32) -> Res<()> {
        let sample_rate = 44_100u32;
        let total_samples = (sample_rate as f32 * seconds) as usize;
        let spec = hound::WavSpec {
            channels: 1,
            sample_rate,
            bits_per_sample: 16,
            sample_format: hound::SampleFormat::Int,
        };

        let mut writer = hound::WavWriter::create(path, spec)?;
        let frequencies = vec![
            Note::try_from_midi(60).unwrap().frequency(),
            Note::try_from_midi(64).unwrap().frequency(),
            Note::try_from_midi(67).unwrap().frequency(),
        ];

        for sample_index in 0..total_samples {
            let t = sample_index as f32 / sample_rate as f32;
            let mut value = 0.0f32;
            for freq in &frequencies {
                value += (2.0 * PI * freq * t).sin();
            }
            value /= frequencies.len() as f32;
            let scaled = (value * 0.4 * i16::MAX as f32) as i16;
            writer.write_sample(scaled)?;
        }

        writer.finalize()?;
        Ok(())
    }

    /// Verifies that tempo changes are correctly applied when converting ticks to seconds.
    #[test]
    fn test_tempo_changes() {
        let dir = tempdir().unwrap();
        let midi_path = dir.path().join("tempo_change.mid");
        let audio_path = dir.path().join("test.wav");
        let destination = dir.path().join("samples");

        write_test_midi_with_tempo_change(&midi_path).unwrap();
        write_test_wav(&audio_path, 4.0).unwrap();

        let options = SongProcessingOptions {
            max_samples: Some(4),
            ..Default::default()
        };

        let outputs = process_song_samples(&destination, &midi_path, &audio_path, options);
        assert!(outputs.is_ok(), "Should handle tempo changes correctly");
        assert!(!outputs.unwrap().is_empty(), "Should produce samples with tempo changes");
    }

    /// Verifies proper handling of complex time signatures like 7/8 and 5/4.
    #[test]
    fn test_complex_time_signature() {
        let dir = tempdir().unwrap();
        let midi_path = dir.path().join("complex_meter.mid");
        let audio_path = dir.path().join("test.wav");
        let destination = dir.path().join("samples");

        write_test_midi_with_complex_time_sig(&midi_path).unwrap();
        write_test_wav(&audio_path, 3.0).unwrap();

        let options = SongProcessingOptions::default();

        let outputs = process_song_samples(&destination, &midi_path, &audio_path, options);
        assert!(outputs.is_ok(), "Should handle 7/8 time signature");
    }

    /// Verifies that notes spanning multiple measures are properly attributed to each measure.
    #[test]
    fn test_notes_spanning_measures() {
        let dir = tempdir().unwrap();
        let midi_path = dir.path().join("spanning.mid");
        let audio_path = dir.path().join("test.wav");
        let destination = dir.path().join("samples");

        write_test_midi_with_long_notes(&midi_path).unwrap();
        write_test_wav(&audio_path, 6.0).unwrap();

        let options = SongProcessingOptions {
            max_samples: Some(10),
            ..Default::default()
        };

        let outputs = process_song_samples(&destination, &midi_path, &audio_path, options).unwrap();
        assert!(outputs.len() >= 2, "Long notes should create multiple measure samples");
    }

    /// Helper that creates a MIDI file with a tempo change mid-song.
    fn write_test_midi_with_tempo_change(path: &Path) -> Res<()> {
        let mut data = Vec::new();
        data.extend_from_slice(b"MThd");
        data.extend_from_slice(&6u32.to_be_bytes());
        data.extend_from_slice(&1u16.to_be_bytes());
        data.extend_from_slice(&2u16.to_be_bytes());
        data.extend_from_slice(&480u16.to_be_bytes());

        let mut track0 = Vec::new();
        push_vlq(&mut track0, 0);
        track0.extend_from_slice(&[0xFF, 0x58, 0x04, 0x04, 0x02, 0x18, 0x08]);
        push_vlq(&mut track0, 0);
        track0.extend_from_slice(&[0xFF, 0x51, 0x03, 0x07, 0xA1, 0x20]); // Initial tempo: 120 BPM
        push_vlq(&mut track0, 1920); // After one measure
        track0.extend_from_slice(&[0xFF, 0x51, 0x03, 0x05, 0xB8, 0xD8]); // Faster tempo: 160 BPM
        push_vlq(&mut track0, 0);
        track0.extend_from_slice(&[0xFF, 0x2F, 0x00]);

        data.extend_from_slice(b"MTrk");
        data.extend_from_slice(&(track0.len() as u32).to_be_bytes());
        data.extend_from_slice(&track0);

        let mut track1 = Vec::new();
        push_vlq(&mut track1, 0);
        track1.extend_from_slice(&[0x90, 0x3C, 0x64]);
        push_vlq(&mut track1, 1920);
        track1.extend_from_slice(&[0x80, 0x3C, 0x40]);
        push_vlq(&mut track1, 0);
        track1.extend_from_slice(&[0x90, 0x40, 0x64]);
        push_vlq(&mut track1, 1920);
        track1.extend_from_slice(&[0x80, 0x40, 0x40]);
        push_vlq(&mut track1, 0);
        track1.extend_from_slice(&[0xFF, 0x2F, 0x00]);

        data.extend_from_slice(b"MTrk");
        data.extend_from_slice(&(track1.len() as u32).to_be_bytes());
        data.extend_from_slice(&track1);

        std::fs::write(path, data)?;
        Ok(())
    }

    /// Helper that creates a MIDI file with 7/8 time signature.
    fn write_test_midi_with_complex_time_sig(path: &Path) -> Res<()> {
        let mut data = Vec::new();
        data.extend_from_slice(b"MThd");
        data.extend_from_slice(&6u32.to_be_bytes());
        data.extend_from_slice(&1u16.to_be_bytes());
        data.extend_from_slice(&2u16.to_be_bytes());
        data.extend_from_slice(&480u16.to_be_bytes());

        let mut track0 = Vec::new();
        push_vlq(&mut track0, 0);
        track0.extend_from_slice(&[0xFF, 0x58, 0x04, 0x07, 0x03, 0x18, 0x08]); // 7/8 time
        push_vlq(&mut track0, 0);
        track0.extend_from_slice(&[0xFF, 0x51, 0x03, 0x07, 0xA1, 0x20]);
        push_vlq(&mut track0, 0);
        track0.extend_from_slice(&[0xFF, 0x2F, 0x00]);

        data.extend_from_slice(b"MTrk");
        data.extend_from_slice(&(track0.len() as u32).to_be_bytes());
        data.extend_from_slice(&track0);

        let mut track1 = Vec::new();
        push_vlq(&mut track1, 0);
        track1.extend_from_slice(&[0x90, 0x3C, 0x64]);
        push_vlq(&mut track1, 1680); // 7/8 measure in ticks
        track1.extend_from_slice(&[0x80, 0x3C, 0x40]);
        push_vlq(&mut track1, 0);
        track1.extend_from_slice(&[0xFF, 0x2F, 0x00]);

        data.extend_from_slice(b"MTrk");
        data.extend_from_slice(&(track1.len() as u32).to_be_bytes());
        data.extend_from_slice(&track1);

        std::fs::write(path, data)?;
        Ok(())
    }

    /// Helper that creates a MIDI file with notes spanning multiple measures.
    fn write_test_midi_with_long_notes(path: &Path) -> Res<()> {
        let mut data = Vec::new();
        data.extend_from_slice(b"MThd");
        data.extend_from_slice(&6u32.to_be_bytes());
        data.extend_from_slice(&1u16.to_be_bytes());
        data.extend_from_slice(&2u16.to_be_bytes());
        data.extend_from_slice(&480u16.to_be_bytes());

        let mut track0 = Vec::new();
        push_vlq(&mut track0, 0);
        track0.extend_from_slice(&[0xFF, 0x58, 0x04, 0x04, 0x02, 0x18, 0x08]);
        push_vlq(&mut track0, 0);
        track0.extend_from_slice(&[0xFF, 0x51, 0x03, 0x07, 0xA1, 0x20]);
        push_vlq(&mut track0, 0);
        track0.extend_from_slice(&[0xFF, 0x2F, 0x00]);

        data.extend_from_slice(b"MTrk");
        data.extend_from_slice(&(track0.len() as u32).to_be_bytes());
        data.extend_from_slice(&track0);

        let mut track1 = Vec::new();
        push_vlq(&mut track1, 0);
        track1.extend_from_slice(&[0x90, 0x3C, 0x64]); // Note on
        push_vlq(&mut track1, 5760); // Lasts 3 measures (3 * 1920 ticks)
        track1.extend_from_slice(&[0x80, 0x3C, 0x40]); // Note off
        push_vlq(&mut track1, 0);
        track1.extend_from_slice(&[0xFF, 0x2F, 0x00]);

        data.extend_from_slice(b"MTrk");
        data.extend_from_slice(&(track1.len() as u32).to_be_bytes());
        data.extend_from_slice(&track1);

        std::fs::write(path, data)?;
        Ok(())
    }

    /// Helper that encodes an integer using the MIDI variable-length quantity format.
    fn push_vlq(buffer: &mut Vec<u8>, mut value: u32) {
        let mut bytes = [0u8; 4];
        let mut count = 0;
        loop {
            bytes[count] = (value & 0x7F) as u8;
            count += 1;
            value >>= 7;
            if value == 0 {
                break;
            }
        }

        for index in (0..count).rev() {
            let mut byte = bytes[index];
            if index != 0 {
                byte |= 0x80;
            }
            buffer.push(byte);
        }
    }
}