oxideav-midi 0.0.4

Pure-Rust MIDI — Standard MIDI File (SMF) parser + transport metadata + soft-synth scaffold (SoundFont 2 / SFZ / DLS / pure-tone fallback). External instruments are loaded from disk; nothing is bundled in the binary.
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
//! MIDI — Standard MIDI File (SMF) parser + transport metadata + soft-synth.
//!
//! * **[`smf`]** — pure-Rust parser for the Standard MIDI File format
//!   (Type 0 / 1 / 2). Header (`MThd`) + tracks (`MTrk`) + every common
//!   channel-voice message, sysex (`F0` / `F7`), and meta event
//!   (tempo, time signature, key signature, text, marker, end-of-track,
//!   SMPTE offset, sequencer-specific). Running status is honoured;
//!   VLQs are bounded to 4 bytes per spec; chunk lengths are validated
//!   against remaining bytes; total events per file are capped at
//!   [`smf::MAX_EVENTS_PER_FILE`].
//! * **[`paths`]** — per-OS SoundFont/SFZ/DLS search paths plus the
//!   `OXIDEAV_SOUNDFONT_PATH` environment override. `find_soundfonts`
//!   walks them and returns every instrument-bank file present.
//! * **[`instruments`]** — [`instruments::Instrument`] trait. Three
//!   adapters:
//!     * **[`instruments::sf2`]** — full SoundFont 2 RIFF reader +
//!       voice generator. Walks the `sfbk` form, cross-resolves the
//!       preset → instrument → zone → sample chain, and renders
//!       sm24-aware 24-bit PCM at the requested pitch via linear
//!       interpolation. Honours the volume + modulation DAHDSR
//!       envelopes, the initial low-pass biquad filter, mod-env →
//!       pitch / filter routing, exclusive-class drum cuts, and
//!       native stereo zones.
//!     * **[`instruments::sfz`]** — text patch reader **plus voice
//!       generator**. Strips comments, walks `<control>` /
//!       `<global>` / `<master>` / `<group>` / `<region>` sections,
//!       flattens inheritance into one fully-resolved opcode map per
//!       region, and (via
//!       [`SfzInstrument::open`](instruments::sfz::SfzInstrument::open))
//!       reads every referenced sample off disk. Voice generation
//!       decodes the WAV sample bytes, picks the matching region by
//!       (key, velocity), shifts pitch off `pitch_keycenter` + `tune` +
//!       `transpose`, and runs a DAHDSR amplitude envelope
//!       (`ampeg_*`) + vibrato LFO (`lfo01_*`).
//!     * **[`instruments::dls`]** — DLS (Downloadable Sounds)
//!       Level 1 + Level 2 RIFF reader **plus voice generator**.
//!       Walks the `DLS ` form, parses the `colh` / `vers` / `ptbl`
//!       pool table / `lins-list` instrument table / `wvpl-list`
//!       wave pool, and surfaces a fully-cross-resolved
//!       [`DlsBank`](instruments::dls::DlsBank) of instruments →
//!       regions → wave-pool samples with their `wsmp` loops,
//!       `wlnk` cue references, and `art1` / `art2` articulation
//!       connection blocks. `make_voice` resolves the wlnk → ptbl →
//!       wave-pool entry, decodes the PCM, and plays the sample
//!       through [`SamplePlayer`](instruments::sample_voice::SamplePlayer).
//!       `art1`/`art2` connection-block evaluation is round 2.
//!     * **[`instruments::sample_voice`]** — shared sample-playback
//!       voice (mono in, mono out) used by both SFZ and DLS. Covers
//!       DAHDSR amplitude envelope, four loop modes, pitch bend,
//!       and a vibrato LFO.
//!     * **[`instruments::wav_pcm`]** — minimal RIFF/WAVE PCM
//!       decoder used by the SFZ and DLS sample loaders.
//!     * **[`instruments::tone`]** — sine/triangle/saw/square
//!       fallback so the synth produces *something* even when no
//!       on-disk bank is present.
//! * **[`mixer`]** — polyphonic voice pool (32 voices) with stereo
//!   mixdown, per-channel volume / pan / sustain pedal handling, and
//!   oldest-voice preemption when the pool is full. Round 75 adds the
//!   full RPN 1 / RPN 2 / RPN 5 control surface (channel fine + coarse
//!   tune + modulation-depth range), CC 1 (mod wheel) → per-voice
//!   depth, CC 74 (MPE "third dimension") → per-voice timbre, the
//!   `MpeZone` / `MpeRole` topology built from MCM messages, and
//!   universal-SysEx-driven master volume / master fine / master
//!   coarse tuning that sum with per-channel tuning into the
//!   effective pitch each voice receives.
//! * **[`scheduler`]** — SMF event scheduler. Merges every track into a
//!   single time-ordered stream, converts ticks → samples against the
//!   current tempo + division, and dispatches each event into the
//!   mixer at the right audio sample. Round 75 wires the Universal
//!   Real-Time / Non-Real-Time SysEx routing: GM 1 / GM 2 / GM Off
//!   reset, CA-25 Master Fine / Master Coarse Tuning, Master Volume,
//!   plus the CC 1 / CC 74 / MPE-MCM channel-CC paths.
//! * **[`downloader`]** — stub that names a planned default bank
//!   (TimGM6mb) but currently returns [`Error::Unsupported`].
//!
//! The decoder factory ([`make_decoder`]) is registered under codec id
//! [`CODEC_ID_STR`] = `"midi"`. Round-3 wires SMF events end-to-end:
//! `send_packet` parses the SMF and primes the scheduler; `receive_frame`
//! pulls one chunk of stereo PCM ([`FRAME_SAMPLES`] samples per channel
//! at [`OUTPUT_SAMPLE_RATE`]) until both the event stream and the voice
//! pool have run dry, then returns [`Error::Eof`].
//!
//! Without an instrument bank the decoder uses
//! [`instruments::tone::ToneInstrument`] — the pure-tone fallback —
//! so a `.mid` file plays back as audible-but-not-musical sine /
//! triangle / square waves. To use a real bank, build the decoder by
//! hand and pass an [`Sf2Instrument`](instruments::sf2::Sf2Instrument)
//! to [`MidiDecoder::with_instrument`]; the decoder factory wired into
//! the registry today does not yet plumb a bank-discovery hook.

pub mod downloader;
pub mod instruments;
pub mod mixer;
pub mod paths;
pub mod scheduler;
pub mod smf;
pub mod tuning;

use std::path::{Path, PathBuf};
use std::sync::Arc;

use oxideav_core::{
    AudioFrame, CodecCapabilities, CodecId, CodecInfo, CodecParameters, CodecRegistry, Decoder,
    Error, Frame, Packet, Result,
};

use crate::instruments::dls::DlsInstrument;
use crate::instruments::sf2::Sf2Instrument;
use crate::instruments::sfz::SfzInstrument;
use crate::instruments::tone::ToneInstrument;
use crate::instruments::Instrument;
use crate::mixer::Mixer;
use crate::scheduler::Scheduler;

/// Public codec id string. Matches the aggregator feature name `midi`.
pub const CODEC_ID_STR: &str = "midi";

/// Round-3 audio output sample rate. Hard-coded to 44 100 Hz so the
/// decoder doesn't need a parameter from the caller (the SMF container
/// itself doesn't carry one). Round-4 may wire this through
/// `CodecParameters::sample_rate`.
pub const OUTPUT_SAMPLE_RATE: u32 = 44_100;

/// Number of *per-channel* samples emitted per
/// [`Decoder::receive_frame`] call. ~23 ms at 44.1 kHz — small enough
/// for low playback latency, big enough that the per-call overhead is
/// dwarfed by the inner mix loop.
pub const FRAME_SAMPLES: usize = 1024;

/// Channel count of the PCM output bus. Stereo. Same fixed assumption
/// as [`OUTPUT_SAMPLE_RATE`].
pub const OUTPUT_CHANNELS: u16 = 2;

/// Register the MIDI codec. Round-3 produces interleaved S16 stereo
/// PCM at [`OUTPUT_SAMPLE_RATE`] — the registry-built decoder uses the
/// pure-tone fallback because we don't yet have a bank-discovery hook
/// in the factory signature. Callers who want SoundFont 2 playback
/// should build the decoder by hand via [`MidiDecoder::with_instrument`].
pub fn register_codecs(reg: &mut CodecRegistry) {
    let caps = CodecCapabilities::audio("midi_synth")
        .with_lossy(false)
        .with_lossless(true)
        .with_intra_only(false)
        .with_max_channels(OUTPUT_CHANNELS);
    reg.register(
        CodecInfo::new(CodecId::new(CODEC_ID_STR))
            .capabilities(caps)
            .decoder(make_decoder),
    );
}

fn make_decoder(_params: &CodecParameters) -> Result<Box<dyn Decoder>> {
    Ok(Box::new(MidiDecoder::new(
        Arc::new(ToneInstrument::new()),
        OUTPUT_SAMPLE_RATE,
    )))
}

/// Soft-synth decoder: SMF in, interleaved S16 stereo PCM out.
///
/// Stateful — accepts exactly one SMF blob via [`send_packet`] and then
/// streams audio frames out of [`receive_frame`] until both the event
/// scheduler and the voice pool have run dry, at which point
/// [`Error::Eof`] is returned. Calling `send_packet` again replaces the
/// scheduler with a fresh one (re-priming for a new file).
///
/// State that survives across `receive_frame` calls:
///   * the merged event list + cursor + sample clock (in [`Scheduler`])
///   * the voice pool + per-channel CC state (in [`Mixer`])
///   * a small carry-over flag that lets the decoder render a few
///     extra trailing chunks after the last event so release tails
///     don't get cut off mid-envelope.
///
/// [`send_packet`]: Decoder::send_packet
/// [`receive_frame`]: Decoder::receive_frame
pub struct MidiDecoder {
    codec_id: CodecId,
    instrument: Arc<dyn Instrument>,
    sample_rate: u32,
    /// `None` until the first `send_packet` arrives.
    scheduler: Option<Scheduler>,
    mixer: Mixer,
    /// Scratch stereo planes — reused across `receive_frame` calls so
    /// we don't reallocate on every chunk.
    left: Vec<f32>,
    right: Vec<f32>,
    /// Sample PTS of the next emitted frame (in `1/sample_rate` units).
    next_pts: i64,
    /// Set once the scheduler has run dry; we keep emitting frames
    /// until the voice pool falls silent too.
    drained: bool,
    /// Set once we've returned `Error::Eof` once — subsequent calls
    /// keep returning `Eof`.
    finished: bool,
    /// Bound on extra "tail" chunks emitted after the scheduler is done
    /// but voices may still be releasing. Worst-case the longest
    /// release in [`Sf2Voice`](instruments::sf2::Sf2Voice) is 50 ms = 3
    /// chunks at 1024 samples / 44.1 kHz; tone voices are 100 ms = 5
    /// chunks. Bound generously at 32 to also cover a long looping
    /// sample whose release window is unusually long.
    tail_chunks_remaining: usize,
}

impl MidiDecoder {
    /// Hard cap on how many extra audio chunks we'll emit after the
    /// last SMF event has fired. Voice release tails (50–100 ms with
    /// the round-2/3 envelopes) live inside this budget; without it,
    /// a malformed or never-releasing voice could keep the decoder
    /// emitting forever.
    pub const TAIL_CHUNK_CAP: usize = 32;

    /// Build a decoder bound to a specific instrument and sample rate.
    /// Use this directly when you have a SoundFont 2 bank loaded and
    /// want to drive the synth with it; the [`make_decoder`] factory
    /// (called by the codec registry) builds one with the pure-tone
    /// fallback because there's no instrument-discovery plumbing in
    /// the factory signature yet.
    pub fn new(instrument: Arc<dyn Instrument>, sample_rate: u32) -> Self {
        Self {
            codec_id: CodecId::new(CODEC_ID_STR),
            instrument,
            sample_rate,
            scheduler: None,
            mixer: Mixer::new(),
            left: vec![0.0; FRAME_SAMPLES],
            right: vec![0.0; FRAME_SAMPLES],
            next_pts: 0,
            drained: false,
            finished: false,
            tail_chunks_remaining: Self::TAIL_CHUNK_CAP,
        }
    }

    /// Convenience constructor: same as [`new`](Self::new) but takes a
    /// concrete [`Instrument`] by value and wraps it in an `Arc`.
    pub fn with_instrument(instrument: Arc<dyn Instrument>) -> Self {
        Self::new(instrument, OUTPUT_SAMPLE_RATE)
    }

    /// Build a decoder bound to an instrument loaded from a path on
    /// disk. The format is dispatched by [`InstrumentSource`] so the
    /// caller picks SFZ / SF2 / DLS explicitly (file extensions are
    /// not always reliable indicators).
    pub fn with_instrument_source(source: InstrumentSource) -> Result<Self> {
        let inst = source.load()?;
        Ok(Self::new(inst, OUTPUT_SAMPLE_RATE))
    }
}

/// Source descriptor for an external instrument bank. Paired with
/// [`MidiDecoder::with_instrument_source`] so a caller that only knows
/// "I have an SF2 file at this path" doesn't have to type the full
/// `Arc::new(Sf2Instrument::open(...))` chain.
#[derive(Clone, Debug)]
pub enum InstrumentSource {
    /// SoundFont 2 bank — loaded via [`Sf2Instrument::open`].
    Sf2(PathBuf),
    /// SFZ text patch — loaded via [`SfzInstrument::open`] (samples
    /// are read off disk relative to the patch's directory).
    Sfz(PathBuf),
    /// DLS Level 1 / 2 bank — loaded via [`DlsInstrument::open`].
    Dls(PathBuf),
    /// Pure-tone fallback (no on-disk file).
    Tone,
}

impl InstrumentSource {
    /// Load the bank at the named path and wrap it in an `Arc<dyn
    /// Instrument>`. Returns the underlying error (file-not-found,
    /// invalid magic, malformed RIFF chunk, etc.) on failure.
    pub fn load(self) -> Result<Arc<dyn Instrument>> {
        match self {
            InstrumentSource::Sf2(p) => Ok(Arc::new(Sf2Instrument::open(&p)?)),
            InstrumentSource::Sfz(p) => Ok(Arc::new(SfzInstrument::open(&p)?)),
            InstrumentSource::Dls(p) => Ok(Arc::new(DlsInstrument::open(&p)?)),
            InstrumentSource::Tone => Ok(Arc::new(ToneInstrument::new())),
        }
    }

    /// Convenience constructors mirroring the enum variants. Each
    /// takes anything `Path`-convertible.
    pub fn sf2(path: impl AsRef<Path>) -> Self {
        Self::Sf2(path.as_ref().to_path_buf())
    }
    pub fn sfz(path: impl AsRef<Path>) -> Self {
        Self::Sfz(path.as_ref().to_path_buf())
    }
    pub fn dls(path: impl AsRef<Path>) -> Self {
        Self::Dls(path.as_ref().to_path_buf())
    }
}

impl MidiDecoder {
    /// Sample rate the decoder is rendering at. Equal to whatever was
    /// passed to [`new`](Self::new) (default [`OUTPUT_SAMPLE_RATE`] when
    /// constructed via the registry).
    pub fn sample_rate(&self) -> u32 {
        self.sample_rate
    }

    /// Borrow the active scheduler — `None` until the first
    /// `send_packet`. Useful for diagnostics + tests.
    pub fn scheduler(&self) -> Option<&Scheduler> {
        self.scheduler.as_ref()
    }

    /// Convert the planar stereo `(left, right)` buffers into one
    /// interleaved S16 [`AudioFrame`].
    fn build_audio_frame(&mut self) -> Frame {
        let n = self.left.len();
        let mut bytes = Vec::with_capacity(n * 2 * 2); // 2 bytes/sample × 2 channels
        for i in 0..n {
            let l = (self.left[i].clamp(-1.0, 1.0) * 32_767.0) as i16;
            let r = (self.right[i].clamp(-1.0, 1.0) * 32_767.0) as i16;
            bytes.extend_from_slice(&l.to_le_bytes());
            bytes.extend_from_slice(&r.to_le_bytes());
        }
        let pts = Some(self.next_pts);
        self.next_pts = self.next_pts.saturating_add(n as i64);
        Frame::Audio(AudioFrame {
            samples: n as u32,
            pts,
            data: vec![bytes],
        })
    }
}

impl Decoder for MidiDecoder {
    fn codec_id(&self) -> &CodecId {
        &self.codec_id
    }

    fn send_packet(&mut self, packet: &Packet) -> Result<()> {
        // Confirm the packet at least *looks* like an SMF — saves the
        // user from a "synthesis pending" misdiagnosis when the real
        // issue is a mis-routed packet.
        if packet.data.len() < 4 || &packet.data[0..4] != b"MThd" {
            return Err(Error::invalid(
                "MIDI: packet does not start with the 'MThd' header chunk",
            ));
        }
        let smf = crate::smf::parse(&packet.data)?;
        // Prime the scheduler. Dropping the previous one (if any)
        // discards any partially-played file — callers should call
        // `flush` first if that matters.
        self.scheduler = Some(Scheduler::new(&smf, self.sample_rate));
        self.mixer.all_notes_off();
        self.next_pts = 0;
        self.drained = false;
        self.finished = false;
        self.tail_chunks_remaining = Self::TAIL_CHUNK_CAP;
        Ok(())
    }

    fn receive_frame(&mut self) -> Result<Frame> {
        if self.finished {
            return Err(Error::Eof);
        }
        let scheduler = self.scheduler.as_mut().ok_or(Error::NeedMore)?;

        // Step the scheduler over one chunk-worth of samples; this may
        // dispatch any number of events into the mixer. The scheduler
        // is `drained` either when it just transitioned to done, or
        // when it was already done coming into this call (we keep
        // running the mixer-only tail in that case).
        let was_done = scheduler.is_done();
        let now_done = scheduler.step(FRAME_SAMPLES, &mut self.mixer, self.instrument.as_ref());
        if was_done || now_done {
            self.drained = true;
        }

        // Mix down whatever the pool currently holds.
        let active = self.mixer.mix_stereo(&mut self.left, &mut self.right);

        // Termination: scheduler done AND no live voices AND we've
        // already burned at least one tail chunk. The tail-chunk cap
        // keeps a never-finishing voice (looping sample with no
        // release fired) from holding the decoder open forever.
        if self.drained {
            if active == 0 || self.tail_chunks_remaining == 0 {
                self.finished = true;
                // Still hand back this final chunk (silent or near-silent)
                // — the caller can decide to discard it. Returning Eof
                // here would lose any release-tail samples.
                return Ok(self.build_audio_frame());
            }
            self.tail_chunks_remaining = self.tail_chunks_remaining.saturating_sub(1);
        }

        Ok(self.build_audio_frame())
    }

    fn flush(&mut self) -> Result<()> {
        // Mark the scheduler done so subsequent receive_frame calls
        // run only the release tail.
        if let Some(s) = self.scheduler.as_mut() {
            // Drain by stepping a huge amount of samples — every event
            // will fire, and the cursor will advance to the end. This
            // is cheaper than re-engineering the scheduler API around
            // an explicit "skip to end" entry point.
            s.step(u32::MAX as usize, &mut self.mixer, self.instrument.as_ref());
        }
        Ok(())
    }

    fn reset(&mut self) -> Result<()> {
        self.scheduler = None;
        self.mixer.all_notes_off();
        self.next_pts = 0;
        self.drained = false;
        self.finished = false;
        self.tail_chunks_remaining = Self::TAIL_CHUNK_CAP;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use oxideav_core::TimeBase;

    fn minimal_smf() -> Vec<u8> {
        // MThd format-0, ntrks=1, division=96; one MTrk with EOT.
        let mut b = vec![];
        b.extend_from_slice(b"MThd");
        b.extend_from_slice(&6u32.to_be_bytes());
        b.extend_from_slice(&0u16.to_be_bytes());
        b.extend_from_slice(&1u16.to_be_bytes());
        b.extend_from_slice(&96u16.to_be_bytes());
        b.extend_from_slice(b"MTrk");
        b.extend_from_slice(&4u32.to_be_bytes());
        b.extend_from_slice(&[0x00, 0xFF, 0x2F, 0x00]);
        b
    }

    /// Build a 5-second SMF: tempo, two notes on channel 1, one note on
    /// channel 10 (drums), a tempo change, and an EOT five seconds in.
    fn five_second_smf() -> Vec<u8> {
        // 480 ticks / qn at 120 BPM = 240 ticks / sec. Five seconds =
        // 1200 ticks. Halfway tempo change (tick 600) to 250 000 us/qn
        // (240 BPM) ⇒ second half is 240 ticks per second × 2 = 480
        // ticks/sec — but we wrote 1200 ticks of "music" assuming the
        // initial tempo so the wall-clock length will be ≈ 3.75 s, not
        // a pure 5 s. That's fine: the test only asserts "non-silent
        // PCM with a sensible duration", not exact timing.
        let mut blob = Vec::new();
        blob.extend_from_slice(b"MThd");
        blob.extend_from_slice(&6u32.to_be_bytes());
        blob.extend_from_slice(&1u16.to_be_bytes()); // format 1
        blob.extend_from_slice(&3u16.to_be_bytes()); // 3 tracks
        blob.extend_from_slice(&480u16.to_be_bytes()); // 480 tpqn

        // Track 1: tempo + tempo change + EOT.
        let mut t1: Vec<u8> = Vec::new();
        // tick 0 set tempo 500_000 us/qn (= 120 BPM)
        t1.extend_from_slice(&[0x00, 0xFF, 0x51, 0x03, 0x07, 0xA1, 0x20]);
        // tick 600 set tempo 250_000 us/qn (= 240 BPM): VLQ(600) = [0x84, 0x58]
        t1.extend_from_slice(&[0x84, 0x58, 0xFF, 0x51, 0x03, 0x03, 0xD0, 0x90]);
        // tick 1200 EOT: VLQ(600) again
        t1.extend_from_slice(&[0x84, 0x58, 0xFF, 0x2F, 0x00]);
        push_track(&mut blob, &t1);

        // Track 2: two notes on channel 1, played sequentially.
        let mut t2: Vec<u8> = Vec::new();
        // tick 0 note on chan 1 key 60 vel 100
        t2.extend_from_slice(&[0x00, 0x91, 0x3C, 0x64]);
        // tick 240 note off chan 1 key 60 vel 0; VLQ(240) = [0x81, 0x70]
        t2.extend_from_slice(&[0x81, 0x70, 0x81, 0x3C, 0x40]);
        // tick 240 + 0 note on chan 1 key 64 vel 100
        t2.extend_from_slice(&[0x00, 0x91, 0x40, 0x64]);
        // tick + 240 note off
        t2.extend_from_slice(&[0x81, 0x70, 0x81, 0x40, 0x40]);
        // tick + 720 EOT (so EOT at tick 1200): VLQ(720) = [0x85, 0x50]
        t2.extend_from_slice(&[0x85, 0x50, 0xFF, 0x2F, 0x00]);
        push_track(&mut blob, &t2);

        // Track 3: one drum hit on channel 10 (index 9) — note 36 (kick).
        let mut t3: Vec<u8> = Vec::new();
        // tick 0 note on chan 9 key 36 vel 100
        t3.extend_from_slice(&[0x00, 0x99, 0x24, 0x64]);
        // tick 480 note off (VLQ 480 = [0x83, 0x60])
        t3.extend_from_slice(&[0x83, 0x60, 0x89, 0x24, 0x40]);
        // tick + 720 EOT
        t3.extend_from_slice(&[0x85, 0x50, 0xFF, 0x2F, 0x00]);
        push_track(&mut blob, &t3);

        blob
    }

    fn push_track(blob: &mut Vec<u8>, events: &[u8]) {
        blob.extend_from_slice(b"MTrk");
        blob.extend_from_slice(&(events.len() as u32).to_be_bytes());
        blob.extend_from_slice(events);
    }

    #[test]
    fn registers_codec_under_midi_id() {
        let mut reg = CodecRegistry::new();
        register_codecs(&mut reg);
        assert!(reg.has_decoder(&CodecId::new(CODEC_ID_STR)));
    }

    #[test]
    fn decoder_rejects_non_smf_packets() {
        let mut reg = CodecRegistry::new();
        register_codecs(&mut reg);
        let params = CodecParameters::audio(CodecId::new(CODEC_ID_STR));
        let mut dec = reg.first_decoder(&params).unwrap();
        let pkt = Packet::new(0, TimeBase::new(1, 44_100), b"not midi".to_vec());
        let err = dec.send_packet(&pkt).unwrap_err();
        assert!(matches!(err, Error::InvalidData(_)));
    }

    #[test]
    fn empty_smf_produces_eof_after_initial_chunks() {
        let mut reg = CodecRegistry::new();
        register_codecs(&mut reg);
        let params = CodecParameters::audio(CodecId::new(CODEC_ID_STR));
        let mut dec = reg.first_decoder(&params).unwrap();
        let pkt = Packet::new(0, TimeBase::new(1, 44_100), minimal_smf());
        dec.send_packet(&pkt).unwrap();
        // The empty-file SMF (one EOT, nothing else) drains immediately.
        // We should get one final near-silent chunk and then Eof.
        let _ = dec.receive_frame().expect("initial chunk");
        // Subsequent calls return Eof.
        let mut got_eof = false;
        for _ in 0..4 {
            match dec.receive_frame() {
                Err(Error::Eof) => {
                    got_eof = true;
                    break;
                }
                Ok(_) => continue,
                Err(other) => panic!("unexpected error {other:?}"),
            }
        }
        assert!(got_eof, "decoder should drain to Eof on an empty SMF");
    }

    /// End-to-end: 5-second SMF with notes on channels 1 and 10 + a
    /// tempo change → drives audio out via the tone fallback. Asserts
    /// frame layout, non-silence, and a sensible peak amplitude.
    #[test]
    fn end_to_end_five_second_smf_produces_pcm() {
        let mut dec = MidiDecoder::new(Arc::new(ToneInstrument::new()), OUTPUT_SAMPLE_RATE);
        let blob = five_second_smf();
        let pkt = Packet::new(0, TimeBase::new(1, 44_100), blob);
        dec.send_packet(&pkt).unwrap();

        let mut all_samples: Vec<i16> = Vec::new();
        let mut frame_count = 0;
        // Bounded loop — 44_100 * 6 / 1024 ≈ 258 chunks for 6 seconds
        // of audio. Cap at 1024 so a misbehaving decoder can't hang.
        for _ in 0..1024 {
            match dec.receive_frame() {
                Ok(Frame::Audio(af)) => {
                    assert_eq!(af.samples, FRAME_SAMPLES as u32);
                    assert_eq!(af.data.len(), 1, "interleaved S16 = single plane");
                    let bytes = &af.data[0];
                    assert_eq!(bytes.len(), FRAME_SAMPLES * 4, "stereo S16 = 4 bytes/frame");
                    for chunk in bytes.chunks_exact(2) {
                        all_samples.push(i16::from_le_bytes([chunk[0], chunk[1]]));
                    }
                    frame_count += 1;
                }
                Ok(_) => panic!("expected Audio frame"),
                Err(Error::Eof) => break,
                Err(other) => panic!("unexpected error: {other:?}"),
            }
        }

        // We rendered both channels interleaved — divide by 2 to get
        // per-channel sample count.
        let per_channel = all_samples.len() / 2;
        // The fixture runs through ~1200 ticks at a per-tick rate that
        // halves halfway through (120 → 240 BPM tempo change).
        //
        //   first half : 600 ticks * 45.9375 samples/tick =  27 562 samples
        //   second half: 600 ticks * 22.96875 samples/tick = 13 781 samples
        //                                                    ─────────────
        //   total music: ~41 344 samples (= ~0.94 s wall-clock)
        //
        // The release tails on the (already-done) tone voices are
        // contained in this window. Lower bound: ≥ 30 000 samples
        // (~680 ms) so a regression that emits a single chunk and
        // quits is caught.
        assert!(
            per_channel >= 30_000,
            "expected ≥ 30 k samples (~0.7 s) of audio, got {} samples / channel ({} frames)",
            per_channel,
            frame_count,
        );

        // Non-silence check: at least 5 % of samples must be non-zero.
        let nonzero = all_samples.iter().filter(|s| s.abs() > 16).count();
        let nonzero_ratio = nonzero as f64 / all_samples.len() as f64;
        assert!(
            nonzero_ratio > 0.05,
            "audio is mostly silent: {:.2}% non-zero",
            nonzero_ratio * 100.0,
        );

        // Peak amplitude check: must be audible (>= 1 % of i16 range)
        // but must not have clipped (the fallback's headroom keeps it
        // well under 0 dBFS).
        let peak = all_samples
            .iter()
            .map(|s| s.unsigned_abs())
            .max()
            .unwrap_or(0);
        assert!(
            peak > 327,
            "peak {} too quiet — synth is producing near-silent output",
            peak,
        );
        assert!(
            peak < 32_767,
            "peak {} indicates clipping — mix bus should have headroom",
            peak,
        );
    }

    /// End-to-end with the round-2 SF2 fixture (a 20-frame sample-rate
    /// 22 050 Hz looping ramp at root key 60). Exercises the full path
    /// SMF → scheduler → SF2 voice generator → mixer → PCM.
    #[test]
    fn end_to_end_with_sf2_fixture() {
        use crate::instruments::sf2::Sf2Instrument;
        let blob = build_looping_sf2_fixture();
        let inst = Sf2Instrument::from_bytes("fixture", &blob).expect("parse fixture");
        let mut dec = MidiDecoder::new(Arc::new(inst), OUTPUT_SAMPLE_RATE);
        let smf = five_second_smf();
        let pkt = Packet::new(0, TimeBase::new(1, 44_100), smf);
        dec.send_packet(&pkt).unwrap();

        let mut all_samples: Vec<i16> = Vec::new();
        for _ in 0..1024 {
            match dec.receive_frame() {
                Ok(Frame::Audio(af)) => {
                    for chunk in af.data[0].chunks_exact(2) {
                        all_samples.push(i16::from_le_bytes([chunk[0], chunk[1]]));
                    }
                }
                Err(Error::Eof) => break,
                Ok(_) => panic!("expected Audio frame"),
                Err(other) => panic!("error: {other:?}"),
            }
        }
        // Same per-channel lower bound as the tone-fallback test —
        // music is ~0.94 s wall-clock.
        assert!(
            all_samples.len() / 2 >= 30_000,
            "expected ≥ 30 k samples / channel, got {}",
            all_samples.len() / 2,
        );
        let nonzero = all_samples.iter().filter(|s| s.abs() > 16).count();
        assert!(
            nonzero > all_samples.len() / 20,
            "expected ≥ 5 % non-silent samples, got {} / {}",
            nonzero,
            all_samples.len(),
        );
        let peak = all_samples
            .iter()
            .map(|s| s.unsigned_abs())
            .max()
            .unwrap_or(0);
        assert!(peak > 327, "SF2 fixture rendered too quiet (peak {peak})");
    }

    /// Build the same minimal looping SF2 the round-2 voice tests use:
    /// one preset (program 0, bank 0), one instrument, one mono sample
    /// — a 20-frame ramp at 22 050 Hz with `sampleModes=1` so the
    /// voice keeps producing audio for the whole MIDI note duration.
    /// Inlined here (rather than re-exported from `instruments::sf2`)
    /// so the lib-level test stays self-contained.
    fn build_looping_sf2_fixture() -> Vec<u8> {
        // 20-frame ramp climbing from -8000 to +8000 in i16.
        let mut smpl_bytes = Vec::with_capacity(40);
        for i in 0i32..20 {
            let v = (i * 800 - 8000) as i16;
            smpl_bytes.extend_from_slice(&v.to_le_bytes());
        }

        // INFO list.
        let mut info = Vec::new();
        push_riff(&mut info, b"ifil", &[0x02, 0x00, 0x04, 0x00]); // 2.4
        push_riff(&mut info, b"INAM", b"MidiTestBank\0");
        let mut info_list = Vec::from(b"INFO" as &[u8]);
        info_list.extend_from_slice(&info);

        // sdta list.
        let mut sdta = Vec::new();
        push_riff(&mut sdta, b"smpl", &smpl_bytes);
        let mut sdta_list = Vec::from(b"sdta" as &[u8]);
        sdta_list.extend_from_slice(&sdta);

        // pdta list. Generators: sampleModes=54, sampleID=53, instrument=41.
        const GEN_SAMPLE_MODES: u16 = 54;
        const GEN_SAMPLE_ID: u16 = 53;
        const GEN_INSTRUMENT: u16 = 41;
        let phdr = concat_records(&[
            phdr_record("Test Preset", 0, 0, 0),
            phdr_record("EOP", 0, 0, 1),
        ]);
        let pbag = concat_records(&[bag_record(0, 0), bag_record(1, 0)]);
        let pmod = vec![0u8; 10];
        let pgen = concat_records(&[gen_record(GEN_INSTRUMENT, 0), gen_record(0, 0)]);
        let inst = concat_records(&[inst_record("Test Inst", 0), inst_record("EOI", 2)]);
        let ibag = concat_records(&[bag_record(0, 0), bag_record(2, 0)]);
        let imod = vec![0u8; 10];
        let igen = concat_records(&[
            gen_record(GEN_SAMPLE_MODES, 1),
            gen_record(GEN_SAMPLE_ID, 0),
            gen_record(0, 0),
        ]);
        let shdr = concat_records(&[
            shdr_record("RampLoop", 0, 20, 5, 15, 22_050, 60, 0, 0, 1),
            shdr_record("EOS", 0, 0, 0, 0, 0, 0, 0, 0, 0),
        ]);

        let mut pdta = Vec::new();
        push_riff(&mut pdta, b"phdr", &phdr);
        push_riff(&mut pdta, b"pbag", &pbag);
        push_riff(&mut pdta, b"pmod", &pmod);
        push_riff(&mut pdta, b"pgen", &pgen);
        push_riff(&mut pdta, b"inst", &inst);
        push_riff(&mut pdta, b"ibag", &ibag);
        push_riff(&mut pdta, b"imod", &imod);
        push_riff(&mut pdta, b"igen", &igen);
        push_riff(&mut pdta, b"shdr", &shdr);
        let mut pdta_list = Vec::from(b"pdta" as &[u8]);
        pdta_list.extend_from_slice(&pdta);

        // Outer RIFF/sfbk wrapper.
        let mut body = Vec::from(b"sfbk" as &[u8]);
        push_riff(&mut body, b"LIST", &info_list);
        push_riff(&mut body, b"LIST", &sdta_list);
        push_riff(&mut body, b"LIST", &pdta_list);
        let mut out = Vec::from(b"RIFF" as &[u8]);
        out.extend_from_slice(&(body.len() as u32).to_le_bytes());
        out.extend_from_slice(&body);
        out
    }

    fn push_riff(out: &mut Vec<u8>, tag: &[u8; 4], payload: &[u8]) {
        out.extend_from_slice(tag);
        out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
        out.extend_from_slice(payload);
        if payload.len() % 2 == 1 {
            out.push(0);
        }
    }

    fn concat_records(rs: &[Vec<u8>]) -> Vec<u8> {
        let mut out = Vec::new();
        for r in rs {
            out.extend_from_slice(r);
        }
        out
    }

    fn name20(s: &str) -> [u8; 20] {
        let mut buf = [0u8; 20];
        let bytes = s.as_bytes();
        let n = bytes.len().min(19);
        buf[..n].copy_from_slice(&bytes[..n]);
        buf
    }

    fn phdr_record(name: &str, program: u16, bank: u16, pbag_start: u16) -> Vec<u8> {
        let mut r = vec![0u8; 38];
        r[0..20].copy_from_slice(&name20(name));
        r[20..22].copy_from_slice(&program.to_le_bytes());
        r[22..24].copy_from_slice(&bank.to_le_bytes());
        r[24..26].copy_from_slice(&pbag_start.to_le_bytes());
        r
    }

    fn inst_record(name: &str, ibag_start: u16) -> Vec<u8> {
        let mut r = vec![0u8; 22];
        r[0..20].copy_from_slice(&name20(name));
        r[20..22].copy_from_slice(&ibag_start.to_le_bytes());
        r
    }

    fn bag_record(gen_start: u16, mod_start: u16) -> Vec<u8> {
        let mut r = vec![0u8; 4];
        r[0..2].copy_from_slice(&gen_start.to_le_bytes());
        r[2..4].copy_from_slice(&mod_start.to_le_bytes());
        r
    }

    fn gen_record(oper: u16, amount: u16) -> Vec<u8> {
        let mut r = vec![0u8; 4];
        r[0..2].copy_from_slice(&oper.to_le_bytes());
        r[2..4].copy_from_slice(&amount.to_le_bytes());
        r
    }

    #[allow(clippy::too_many_arguments)]
    fn shdr_record(
        name: &str,
        start: u32,
        end: u32,
        start_loop: u32,
        end_loop: u32,
        sample_rate: u32,
        original_key: u8,
        pitch_correction: i8,
        sample_link: u16,
        sample_type: u16,
    ) -> Vec<u8> {
        let mut r = vec![0u8; 46];
        r[0..20].copy_from_slice(&name20(name));
        r[20..24].copy_from_slice(&start.to_le_bytes());
        r[24..28].copy_from_slice(&end.to_le_bytes());
        r[28..32].copy_from_slice(&start_loop.to_le_bytes());
        r[32..36].copy_from_slice(&end_loop.to_le_bytes());
        r[36..40].copy_from_slice(&sample_rate.to_le_bytes());
        r[40] = original_key;
        r[41] = pitch_correction as u8;
        r[42..44].copy_from_slice(&sample_link.to_le_bytes());
        r[44..46].copy_from_slice(&sample_type.to_le_bytes());
        r
    }

    /// End-to-end SMF with a pitch-bend event mid-note: feed the
    /// decoder, check that the channel state's pitch bend changed by
    /// the time the bend tick has fired.
    #[test]
    fn end_to_end_pitch_bend_event() {
        let mut dec = MidiDecoder::new(Arc::new(ToneInstrument::new()), OUTPUT_SAMPLE_RATE);
        let blob = pitch_bend_smf();
        let pkt = Packet::new(0, TimeBase::new(1, 44_100), blob);
        dec.send_packet(&pkt).unwrap();
        // Pull frames until the scheduler has dispatched everything,
        // including the pitch bend (located at tick 480, ≈ 23 k samples
        // = ~22 chunks of 1024).
        for _ in 0..64 {
            match dec.receive_frame() {
                Ok(_) => {}
                Err(Error::Eof) => break,
                Err(e) => panic!("unexpected: {e:?}"),
            }
        }
        // Inspect the scheduler — the bend should have been applied.
        // We can't poke the mixer directly through the decoder API; the
        // test relies on the scheduler having walked past the event.
        let s = dec.scheduler().unwrap();
        assert!(s.is_done(), "scheduler should have drained the bend");
    }

    /// SMF with: tempo, note-on at tick 0, pitch-bend max-up at tick
    /// 480, note-off at tick 960, EOT at tick 1200.
    fn pitch_bend_smf() -> Vec<u8> {
        let mut blob = Vec::new();
        blob.extend_from_slice(b"MThd");
        blob.extend_from_slice(&6u32.to_be_bytes());
        blob.extend_from_slice(&0u16.to_be_bytes());
        blob.extend_from_slice(&1u16.to_be_bytes());
        blob.extend_from_slice(&480u16.to_be_bytes());

        let mut t: Vec<u8> = Vec::new();
        // tick 0 set tempo 500_000 us/qn (= 120 BPM).
        t.extend_from_slice(&[0x00, 0xFF, 0x51, 0x03, 0x07, 0xA1, 0x20]);
        // tick 0 note on chan 0 key 60 vel 100.
        t.extend_from_slice(&[0x00, 0x90, 0x3C, 0x64]);
        // tick 480 pitch bend max-up. VLQ(480) = 83 60.
        t.extend_from_slice(&[0x83, 0x60, 0xE0, 0x7F, 0x7F]);
        // tick 480 → tick 960: note-off. VLQ(480) = 83 60.
        t.extend_from_slice(&[0x83, 0x60, 0x80, 0x3C, 0x40]);
        // tick + 240 EOT. VLQ(240) = 81 70.
        t.extend_from_slice(&[0x81, 0x70, 0xFF, 0x2F, 0x00]);
        push_track(&mut blob, &t);
        blob
    }

    /// End-to-end SMF with a channel-aftertouch event mid-note: assert
    /// the decoder doesn't crash and audio still gets produced.
    #[test]
    fn end_to_end_channel_aftertouch_event() {
        let mut dec = MidiDecoder::new(Arc::new(ToneInstrument::new()), OUTPUT_SAMPLE_RATE);
        let blob = aftertouch_smf();
        let pkt = Packet::new(0, TimeBase::new(1, 44_100), blob);
        dec.send_packet(&pkt).unwrap();
        let mut samples: Vec<i16> = Vec::new();
        for _ in 0..64 {
            match dec.receive_frame() {
                Ok(Frame::Audio(af)) => {
                    for chunk in af.data[0].chunks_exact(2) {
                        samples.push(i16::from_le_bytes([chunk[0], chunk[1]]));
                    }
                }
                Err(Error::Eof) => break,
                Ok(_) => panic!("expected audio"),
                Err(e) => panic!("unexpected: {e:?}"),
            }
        }
        // We rendered audio.
        assert!(!samples.is_empty(), "no audio rendered");
        let nonzero = samples.iter().filter(|s| s.abs() > 16).count();
        assert!(
            nonzero > samples.len() / 20,
            "expected ≥ 5 % non-silent: {} / {}",
            nonzero,
            samples.len(),
        );
    }

    /// SMF with: tempo, note-on at tick 0, channel pressure at tick 240,
    /// note-off at tick 480, EOT at tick 720.
    fn aftertouch_smf() -> Vec<u8> {
        let mut blob = Vec::new();
        blob.extend_from_slice(b"MThd");
        blob.extend_from_slice(&6u32.to_be_bytes());
        blob.extend_from_slice(&0u16.to_be_bytes());
        blob.extend_from_slice(&1u16.to_be_bytes());
        blob.extend_from_slice(&480u16.to_be_bytes());

        let mut t: Vec<u8> = Vec::new();
        t.extend_from_slice(&[0x00, 0xFF, 0x51, 0x03, 0x07, 0xA1, 0x20]);
        t.extend_from_slice(&[0x00, 0x90, 0x3C, 0x64]);
        // VLQ(240) = 81 70. Channel pressure D0 with value 0x60.
        t.extend_from_slice(&[0x81, 0x70, 0xD0, 0x60]);
        // VLQ(240): note off.
        t.extend_from_slice(&[0x81, 0x70, 0x80, 0x3C, 0x40]);
        // VLQ(240): EOT.
        t.extend_from_slice(&[0x81, 0x70, 0xFF, 0x2F, 0x00]);
        push_track(&mut blob, &t);
        blob
    }

    #[test]
    fn reset_clears_scheduler_and_voices() {
        let mut dec = MidiDecoder::new(Arc::new(ToneInstrument::new()), OUTPUT_SAMPLE_RATE);
        let pkt = Packet::new(0, TimeBase::new(1, 44_100), five_second_smf());
        dec.send_packet(&pkt).unwrap();
        let _ = dec.receive_frame().unwrap();
        dec.reset().unwrap();
        // After reset, receive_frame returns NeedMore (no scheduler).
        match dec.receive_frame() {
            Err(Error::NeedMore) => {}
            other => panic!("expected NeedMore after reset, got {other:?}"),
        }
    }
}