oxideav-mod 0.0.5

Amiga ProTracker / SoundTracker module (MOD) codec for oxideav
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
//! Shared tracker mixer core.
//!
//! The different tracker formats that this crate handles (MOD, STM, XM)
//! diverge in how they pitch notes and how they store PCM, but the
//! per-voice mixer loop is always the same: read a sample from a source,
//! multiply by a volume scalar, advance the read position by
//! `source_rate / output_rate`, and handle end-of-sample / loop.
//!
//! This module factors that common loop out so MOD's 8-bit Paula samples,
//! STM's 8-bit signed samples, and XM's 8 or 16-bit delta-decoded samples
//! can all feed the same `MixerVoice`.
//!
//! Three abstractions live here:
//!
//! - [`SampleSource`] — read-only view into a decoded PCM body plus loop
//!   metadata. Implementations in this crate exist for MOD
//!   ([`crate::samples::SampleBody`]), STM ([`crate::stm::StmSampleBody`])
//!   and XM ([`crate::xm::XmSampleHeader`]).
//! - [`PitchModel`] — converts a format-specific "note" (Amiga period,
//!   STM C3-relative octave/semitone, or XM note+finetune under one of
//!   the two XM frequency tables) into an output frequency in Hz. The
//!   mixer core consumes only the Hz value.
//! - [`MixerVoice`] — the actual generic voice. Owns a cursor into an
//!   arbitrary `SampleSource`, a current frequency (set by the player
//!   from a [`PitchModel`] result), and a linear-volume scalar 0..=1.
//!   Emits one `f32` sample per call. Format-agnostic.

/// Loop behaviour for a sample body. Tracker formats share the same three
/// modes — no loop / forward / ping-pong — so we encode them here rather
/// than repeat the enum per format.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LoopKind {
    /// Play once, stop when past end.
    #[default]
    None,
    /// On reaching loop_end, jump back to loop_start.
    Forward,
    /// On reaching loop_end, reverse direction. On reaching loop_start
    /// while reversed, resume forward.
    PingPong,
}

/// Read-only view into a tracker sample body.
///
/// Implementations must return samples in the range `-1.0..=1.0`. The
/// caller (the [`MixerVoice`]) manages the fractional read position, so
/// `at` takes an integer sample index.
pub trait SampleSource {
    /// Total number of PCM frames.
    fn len(&self) -> usize;

    /// Loop start index (frames).
    fn loop_start(&self) -> usize;

    /// Loop end index (frames), exclusive.
    fn loop_end(&self) -> usize;

    /// Loop mode.
    fn loop_kind(&self) -> LoopKind;

    /// Sample at integer index, normalised to `-1.0..=1.0`. Callers are
    /// responsible for ensuring `idx < len()`; implementations may return
    /// 0.0 for out-of-range indices defensively.
    fn at(&self, idx: usize) -> f32;

    /// True if this sample has no PCM data.
    fn is_empty(&self) -> bool {
        self.len() == 0
    }
}

/// Abstraction over the pitch math for a given tracker format.
///
/// Each format carries a format-specific "note token" (Amiga period for
/// MOD, C3-relative semitone position for STM, or XM's note + finetune
/// pair), from which the output frequency is derived. This trait exposes
/// only the final frequency in Hz — the mixer core never sees periods.
pub trait PitchModel {
    /// The player's note token. Keep it `Copy` so it can sit in a voice
    /// cheaply.
    type Note: Copy;

    /// Convert a note token to an output frequency in Hz. Implementations
    /// must return a positive value; 0 or negative means "silent".
    fn note_to_freq(&self, note: Self::Note) -> f32;
}

/// Generic, format-agnostic mixer voice. The caller assigns a frequency
/// (from a [`PitchModel`]) and a linear volume; the voice steps through
/// its `SampleSource` at `freq / sample_rate` and emits one float per
/// call. Ping-pong, forward-loop and one-shot modes are all handled.
///
/// `Voice` does not own the sample source — `render_one` takes the source
/// by reference. That lets the caller store sources somewhere else (a
/// slab on `PlayerState`, typically) and address them by index while the
/// voice only tracks the *current* index.
#[derive(Clone, Debug, Default)]
pub struct MixerVoice {
    /// Fractional sample cursor into the source.
    pub pos: f32,
    /// Current playback direction (+1 forward, -1 reversed for ping-pong).
    pub direction: i8,
    /// Output frequency in Hz (updated by the player per row / per tick).
    pub freq: f32,
    /// Linear volume, 0..=1.
    pub volume: f32,
    /// True while the voice is emitting sound. Cleared when a one-shot
    /// sample reaches its end.
    pub active: bool,
}

impl MixerVoice {
    /// Trigger a fresh note. Resets the cursor to 0 and sets the
    /// frequency + volume. Direction is forward.
    pub fn trigger(&mut self, freq: f32, volume: f32) {
        self.pos = 0.0;
        self.direction = 1;
        self.freq = freq;
        self.volume = volume;
        self.active = true;
    }

    /// Mix one sample from `source` at the given output sample rate.
    /// Returns the post-volume float in `-1.0..=1.0`.
    pub fn render_one<S: SampleSource + ?Sized>(&mut self, source: &S, out_rate: f32) -> f32 {
        if !self.active || source.is_empty() || self.freq <= 0.0 || out_rate <= 0.0 {
            return 0.0;
        }

        let len = source.len();
        let loop_start = source.loop_start().min(len.saturating_sub(1));
        let loop_end = source.loop_end().min(len);
        let kind = source.loop_kind();

        // Resolve position into a valid integer index. For ping-pong we
        // may already have flipped direction last step; keep the pos
        // inside [loop_start, loop_end) while looping, or stop on end.
        let pos = self.pos;
        if pos < 0.0 {
            // Ping-pong may dip below loop_start briefly — bounce.
            if matches!(kind, LoopKind::PingPong) {
                let over = -pos;
                self.pos = loop_start as f32 + over;
                self.direction = 1;
            } else {
                self.active = false;
                return 0.0;
            }
        }

        if self.pos >= len as f32 {
            match kind {
                LoopKind::Forward if loop_end > loop_start => {
                    let span = (loop_end - loop_start) as f32;
                    let over = self.pos - loop_start as f32;
                    self.pos = loop_start as f32 + over.rem_euclid(span);
                }
                LoopKind::PingPong if loop_end > loop_start => {
                    let over = self.pos - (loop_end as f32 - 1.0);
                    self.pos = (loop_end as f32 - 1.0 - over).max(loop_start as f32);
                    self.direction = -1;
                }
                _ => {
                    self.active = false;
                    return 0.0;
                }
            }
        }

        let i = (self.pos as usize).min(len - 1);
        let frac = self.pos - (i as f32);
        let s0 = source.at(i);
        let s1_idx = if i + 1 < len {
            i + 1
        } else if !matches!(kind, LoopKind::None) && loop_end > loop_start {
            loop_start
        } else {
            i
        };
        let s1 = source.at(s1_idx);
        let interp = s0 + (s1 - s0) * frac;

        // Advance. Step is signed for ping-pong.
        let step = self.freq / out_rate;
        let signed_step = step * self.direction as f32;
        self.pos += signed_step;

        // Ping-pong end-of-loop bounce (forward → reverse).
        if matches!(kind, LoopKind::PingPong) {
            if self.direction == 1 && self.pos >= loop_end as f32 && loop_end > loop_start {
                let over = self.pos - (loop_end as f32 - 1.0);
                self.pos = (loop_end as f32 - 1.0 - over).max(loop_start as f32);
                self.direction = -1;
            } else if self.direction == -1 && self.pos < loop_start as f32 {
                let over = loop_start as f32 - self.pos;
                self.pos = loop_start as f32 + over;
                self.direction = 1;
            }
        }

        interp * self.volume
    }
}

// ---------------- Pitch models ----------------

/// MOD / ProTracker pitch model: Amiga Paula period → frequency.
///
/// Output rate = `paula_clock / period`. The PAL constant is
/// `7_093_789.2 / 2 ≈ 3_546_894.6 Hz`.
#[derive(Clone, Copy, Debug)]
pub struct AmigaPeriodPitch {
    pub paula_clock: f32,
}

impl Default for AmigaPeriodPitch {
    fn default() -> Self {
        AmigaPeriodPitch {
            paula_clock: crate::player::PAULA_CLOCK,
        }
    }
}

impl PitchModel for AmigaPeriodPitch {
    type Note = u16;

    fn note_to_freq(&self, note: Self::Note) -> f32 {
        if note == 0 {
            0.0
        } else {
            self.paula_clock / note as f32
        }
    }
}

/// STM pitch model: C3-relative `(octave, semitone)` + sample-specific C3
/// frequency. STM stores the note byte as `octave<<4 | semitone`, with
/// C-3 at octave=3, semitone=0 being the sample's declared C3 frequency.
///
/// Mid-2020s trackers typically use C-5 as the reference note, but the
/// STM v1 spec explicitly ties the "C3 frequency" instrument field to the
/// value sounded at octave 3 / semitone 0, so that's what we implement
/// here. If a given STM file disagrees, the audible pitch will be octave-
/// shifted but the *relative* pitch between notes remains correct.
///
/// Note value layout: `note = (octave << 4) | semitone`, `semitone in
/// 0..=11`. Freq = c3_hz * 2^((octave-3) + semitone/12).
#[derive(Clone, Copy, Debug, Default)]
pub struct StmC3Pitch {
    pub c3_hz: f32,
}

impl PitchModel for StmC3Pitch {
    /// `(octave, semitone)`; octave is 0..=7, semitone is 0..=11.
    type Note = (u8, u8);

    fn note_to_freq(&self, note: Self::Note) -> f32 {
        if self.c3_hz <= 0.0 {
            return 0.0;
        }
        let (octave, semitone) = note;
        // Semitone distance from C-3, in 1/12-octave steps.
        let semis_from_c3 = (octave as f32 - 3.0) * 12.0 + semitone as f32;
        self.c3_hz * 2.0f32.powf(semis_from_c3 / 12.0)
    }
}

/// XM frequency-table selection. Chosen per-file (`XmHeader.flags`
/// bit 0). The enum is independent of the header type so the mixer can
/// carry it without pulling in the whole parser struct.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum XmPitchTable {
    Amiga,
    Linear,
}

/// XM pitch model: note + finetune + relative-note, under one of two
/// frequency tables (Amiga or Linear).
///
/// XM note numbering: `1..=96` = C-0..B-7 (note `1` is C-0, so
/// `real_note = pattern_note - 1 + relative_note`, with `real_note = 48`
/// corresponding to C-4, which is the centre of the XM keyboard).
///
/// Formulas from `docs/audio/trackers/xm/FastTracker-2-v2.04-xm.txt`:
/// - Linear: `Period = 10*12*16*4 - Note*16*4 - FineTune/2`,
///   `Freq   = 8363 * 2 ^ ((6*12*16*4 - Period) / (12*16*4))`.
/// - Amiga: interpolate a 96-entry `PeriodTab` via
///   `note % 12 * 8 + finetune/16`, then `Freq = 8363 * 1712 / Period`.
///
/// `Note` here is a pair `(real_note, finetune)` where `real_note` is
/// `0..=118` (0 = C-0, 48 = C-4). The tracker-format code is responsible
/// for applying `relative_note` before handing to `note_to_freq`.
#[derive(Clone, Copy, Debug)]
pub struct XmPitch {
    pub table: XmPitchTable,
}

impl Default for XmPitch {
    fn default() -> Self {
        XmPitch {
            table: XmPitchTable::Amiga,
        }
    }
}

impl XmPitch {
    /// XM Amiga-table period lookup. 96-entry table indexed by
    /// `(note % 12) * 8 + finetune/16`, with linear interpolation on the
    /// fractional part of `finetune/16`.
    #[rustfmt::skip]
    const PERIOD_TAB: [u16; 96] = [
        907,900,894,887,881,875,868,862,856,850,844,838,832,826,820,814,
        808,802,796,791,785,779,774,768,762,757,752,746,741,736,730,725,
        720,715,709,704,699,694,689,684,678,675,670,665,660,655,651,646,
        640,636,632,628,623,619,614,610,604,601,597,592,588,584,580,575,
        570,567,563,559,555,551,547,543,538,535,532,528,524,520,516,513,
        508,505,502,498,494,491,487,484,480,477,474,470,467,463,460,457,
    ];

    /// Public re-export of the period table for use by the XM player's
    /// own period-based pitch math (vibrato / tone-porta in Amiga mode).
    pub const PERIOD_TAB_PUB: [u16; 96] = Self::PERIOD_TAB;

    fn amiga_period(real_note: i32, finetune: i32) -> f32 {
        // finetune/16 can be negative; wrap index accordingly.
        let n_mod = real_note.rem_euclid(12) as usize;
        let n_div = real_note.div_euclid(12);
        // finetune / 16 with floor semantics, then interpolate fractional.
        let ft = finetune as f32 / 16.0;
        let ft_floor = ft.floor();
        let frac = ft - ft_floor;
        let base_idx = (n_mod as isize * 8 + ft_floor as isize).clamp(0, 95) as usize;
        let next_idx = (base_idx + 1).min(95);
        let p0 = Self::PERIOD_TAB[base_idx] as f32;
        let p1 = Self::PERIOD_TAB[next_idx] as f32;
        let p = p0 * (1.0 - frac) + p1 * frac;
        let octave_div = 2.0f32.powi(n_div);
        (p * 16.0) / octave_div
    }

    fn linear_period(real_note: i32, finetune: i32) -> f32 {
        // Period = 10*12*16*4 - Note*16*4 - FineTune/2;
        let p =
            10.0 * 12.0 * 16.0 * 4.0 - (real_note as f32) * 16.0 * 4.0 - (finetune as f32) / 2.0;
        p.max(1.0)
    }
}

impl PitchModel for XmPitch {
    /// `(real_note, finetune)` where `real_note` is already adjusted by
    /// `relative_note` and sits in `0..=118`, `finetune` is `-128..=127`.
    type Note = (i32, i32);

    fn note_to_freq(&self, note: Self::Note) -> f32 {
        let (real_note, finetune) = note;
        match self.table {
            XmPitchTable::Amiga => {
                let p = Self::amiga_period(real_note, finetune);
                if p <= 0.0 {
                    0.0
                } else {
                    8363.0 * 1712.0 / p
                }
            }
            XmPitchTable::Linear => {
                let p = Self::linear_period(real_note, finetune);
                // Freq = 8363*2^((6*12*16*4 - Period) / (12*16*4))
                8363.0 * 2.0f32.powf((6.0 * 12.0 * 16.0 * 4.0 - p) / (12.0 * 16.0 * 4.0))
            }
        }
    }
}

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

    /// Trivial in-memory sample source for unit tests.
    struct TestSource {
        pcm: Vec<f32>,
        loop_start: usize,
        loop_end: usize,
        kind: LoopKind,
    }

    impl SampleSource for TestSource {
        fn len(&self) -> usize {
            self.pcm.len()
        }
        fn loop_start(&self) -> usize {
            self.loop_start
        }
        fn loop_end(&self) -> usize {
            self.loop_end
        }
        fn loop_kind(&self) -> LoopKind {
            self.kind
        }
        fn at(&self, idx: usize) -> f32 {
            self.pcm.get(idx).copied().unwrap_or(0.0)
        }
    }

    #[test]
    fn amiga_period_pitch_matches_formula() {
        let p = AmigaPeriodPitch {
            paula_clock: 3_546_894.6,
        };
        // Period 428 = classic C-2; expected rate ~8287.14
        let f = p.note_to_freq(428);
        assert!((f - 8287.14).abs() < 0.5, "got {f}");
    }

    #[test]
    fn amiga_period_pitch_zero_means_silent() {
        let p = AmigaPeriodPitch {
            paula_clock: 3_546_894.6,
        };
        assert_eq!(p.note_to_freq(0), 0.0);
    }

    #[test]
    fn stm_c3_pitch_doubles_per_octave() {
        let p = StmC3Pitch { c3_hz: 8363.0 };
        let c3 = p.note_to_freq((3, 0));
        let c4 = p.note_to_freq((4, 0));
        assert!((c3 - 8363.0).abs() < 0.5, "c3 = {c3}");
        assert!((c4 - 16726.0).abs() < 1.0, "c4 = {c4}");
    }

    #[test]
    fn stm_c3_pitch_semitone_is_twelfth_root_of_two() {
        let p = StmC3Pitch { c3_hz: 440.0 };
        let f0 = p.note_to_freq((3, 0));
        let f1 = p.note_to_freq((3, 1));
        let ratio = f1 / f0;
        assert!((ratio - 1.059463).abs() < 0.001);
    }

    #[test]
    fn xm_linear_pitch_c4_is_8363_hz() {
        // For XM, real_note 48 = C-4 corresponds to 8363 Hz under the
        // Linear table at finetune 0 (this is the XM convention:
        // `RelativeTone = 0` maps C-4 → sample's native 8363 Hz).
        let p = XmPitch {
            table: XmPitchTable::Linear,
        };
        let f = p.note_to_freq((48, 0));
        assert!((f - 8363.0).abs() < 1.0, "got {f}");
    }

    #[test]
    fn xm_amiga_pitch_doubles_per_octave() {
        // The XM Amiga-table formula in the v2.04 spec does not put the
        // sample's native 8363 Hz at an integer note (the reference rate
        // lands between C-3 and C-4, at the non-integer N ≈ 36.9). We
        // therefore don't pin an absolute reference frequency — we just
        // check the invariant that still matters: one XM octave really is
        // a 2× ratio in the output frequency.
        let p = XmPitch {
            table: XmPitchTable::Amiga,
        };
        let c4 = p.note_to_freq((48, 0));
        let c5 = p.note_to_freq((60, 0));
        assert!(c4 > 0.0);
        assert!((c5 / c4 - 2.0).abs() < 1e-3, "ratio {}", c5 / c4);
    }

    #[test]
    fn xm_linear_pitch_one_octave_doubles() {
        let p = XmPitch {
            table: XmPitchTable::Linear,
        };
        let c4 = p.note_to_freq((48, 0));
        let c5 = p.note_to_freq((60, 0));
        assert!((c5 / c4 - 2.0).abs() < 1e-3);
    }

    #[test]
    fn voice_on_one_shot_goes_silent_at_end() {
        let src = TestSource {
            pcm: vec![0.5; 4],
            loop_start: 0,
            loop_end: 4,
            kind: LoopKind::None,
        };
        let mut v = MixerVoice::default();
        v.trigger(44100.0, 1.0); // one sample-unit per render
                                 // Render past the end.
        for _ in 0..10 {
            v.render_one(&src, 44100.0);
        }
        assert!(!v.active, "voice should deactivate past end");
    }

    #[test]
    fn voice_forward_loop_wraps() {
        let src = TestSource {
            pcm: vec![0.25, 0.5, 0.75, 1.0],
            loop_start: 0,
            loop_end: 4,
            kind: LoopKind::Forward,
        };
        let mut v = MixerVoice::default();
        v.trigger(44100.0, 1.0);
        for _ in 0..100 {
            let s = v.render_one(&src, 44100.0);
            assert!(s.abs() <= 1.0);
        }
        assert!(v.active, "looped voice must stay active");
    }

    #[test]
    fn voice_with_zero_freq_is_silent() {
        let src = TestSource {
            pcm: vec![1.0; 8],
            loop_start: 0,
            loop_end: 8,
            kind: LoopKind::None,
        };
        let mut v = MixerVoice::default();
        v.trigger(0.0, 1.0);
        assert_eq!(v.render_one(&src, 44100.0), 0.0);
    }
}