Skip to main content

oxideav_mod/
mixer.rs

1//! Shared tracker mixer core.
2//!
3//! The different tracker formats that this crate handles (MOD, STM, XM)
4//! diverge in how they pitch notes and how they store PCM, but the
5//! per-voice mixer loop is always the same: read a sample from a source,
6//! multiply by a volume scalar, advance the read position by
7//! `source_rate / output_rate`, and handle end-of-sample / loop.
8//!
9//! This module factors that common loop out so MOD's 8-bit Paula samples,
10//! STM's 8-bit signed samples, and XM's 8 or 16-bit delta-decoded samples
11//! can all feed the same `MixerVoice`.
12//!
13//! Three abstractions live here:
14//!
15//! - [`SampleSource`] — read-only view into a decoded PCM body plus loop
16//!   metadata. Implementations in this crate exist for MOD
17//!   ([`crate::samples::SampleBody`]), STM ([`crate::stm::StmSampleBody`])
18//!   and XM ([`crate::xm::XmSampleHeader`]).
19//! - [`PitchModel`] — converts a format-specific "note" (Amiga period,
20//!   STM C3-relative octave/semitone, or XM note+finetune under one of
21//!   the two XM frequency tables) into an output frequency in Hz. The
22//!   mixer core consumes only the Hz value.
23//! - [`MixerVoice`] — the actual generic voice. Owns a cursor into an
24//!   arbitrary `SampleSource`, a current frequency (set by the player
25//!   from a [`PitchModel`] result), and a linear-volume scalar 0..=1.
26//!   Emits one `f32` sample per call. Format-agnostic.
27
28/// Loop behaviour for a sample body. Tracker formats share the same three
29/// modes — no loop / forward / ping-pong — so we encode them here rather
30/// than repeat the enum per format.
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
32pub enum LoopKind {
33    /// Play once, stop when past end.
34    #[default]
35    None,
36    /// On reaching loop_end, jump back to loop_start.
37    Forward,
38    /// On reaching loop_end, reverse direction. On reaching loop_start
39    /// while reversed, resume forward.
40    PingPong,
41}
42
43/// Read-only view into a tracker sample body.
44///
45/// Implementations must return samples in the range `-1.0..=1.0`. The
46/// caller (the [`MixerVoice`]) manages the fractional read position, so
47/// `at` takes an integer sample index.
48pub trait SampleSource {
49    /// Total number of PCM frames.
50    fn len(&self) -> usize;
51
52    /// Loop start index (frames).
53    fn loop_start(&self) -> usize;
54
55    /// Loop end index (frames), exclusive.
56    fn loop_end(&self) -> usize;
57
58    /// Loop mode.
59    fn loop_kind(&self) -> LoopKind;
60
61    /// Sample at integer index, normalised to `-1.0..=1.0`. Callers are
62    /// responsible for ensuring `idx < len()`; implementations may return
63    /// 0.0 for out-of-range indices defensively.
64    fn at(&self, idx: usize) -> f32;
65
66    /// True if this sample has no PCM data.
67    fn is_empty(&self) -> bool {
68        self.len() == 0
69    }
70}
71
72/// Abstraction over the pitch math for a given tracker format.
73///
74/// Each format carries a format-specific "note token" (Amiga period for
75/// MOD, C3-relative semitone position for STM, or XM's note + finetune
76/// pair), from which the output frequency is derived. This trait exposes
77/// only the final frequency in Hz — the mixer core never sees periods.
78pub trait PitchModel {
79    /// The player's note token. Keep it `Copy` so it can sit in a voice
80    /// cheaply.
81    type Note: Copy;
82
83    /// Convert a note token to an output frequency in Hz. Implementations
84    /// must return a positive value; 0 or negative means "silent".
85    fn note_to_freq(&self, note: Self::Note) -> f32;
86}
87
88/// Generic, format-agnostic mixer voice. The caller assigns a frequency
89/// (from a [`PitchModel`]) and a linear volume; the voice steps through
90/// its `SampleSource` at `freq / sample_rate` and emits one float per
91/// call. Ping-pong, forward-loop and one-shot modes are all handled.
92///
93/// `Voice` does not own the sample source — `render_one` takes the source
94/// by reference. That lets the caller store sources somewhere else (a
95/// slab on `PlayerState`, typically) and address them by index while the
96/// voice only tracks the *current* index.
97#[derive(Clone, Debug, Default)]
98pub struct MixerVoice {
99    /// Fractional sample cursor into the source.
100    pub pos: f32,
101    /// Current playback direction (+1 forward, -1 reversed for ping-pong).
102    pub direction: i8,
103    /// Output frequency in Hz (updated by the player per row / per tick).
104    pub freq: f32,
105    /// Linear volume, 0..=1.
106    pub volume: f32,
107    /// True while the voice is emitting sound. Cleared when a one-shot
108    /// sample reaches its end.
109    pub active: bool,
110}
111
112impl MixerVoice {
113    /// Trigger a fresh note. Resets the cursor to 0 and sets the
114    /// frequency + volume. Direction is forward.
115    pub fn trigger(&mut self, freq: f32, volume: f32) {
116        self.pos = 0.0;
117        self.direction = 1;
118        self.freq = freq;
119        self.volume = volume;
120        self.active = true;
121    }
122
123    /// Mix one sample from `source` at the given output sample rate.
124    /// Returns the post-volume float in `-1.0..=1.0`.
125    pub fn render_one<S: SampleSource + ?Sized>(&mut self, source: &S, out_rate: f32) -> f32 {
126        if !self.active || source.is_empty() || self.freq <= 0.0 || out_rate <= 0.0 {
127            return 0.0;
128        }
129
130        let len = source.len();
131        let loop_start = source.loop_start().min(len.saturating_sub(1));
132        let loop_end = source.loop_end().min(len);
133        let kind = source.loop_kind();
134
135        // Resolve position into a valid integer index. For ping-pong we
136        // may already have flipped direction last step; keep the pos
137        // inside [loop_start, loop_end) while looping, or stop on end.
138        let pos = self.pos;
139        if pos < 0.0 {
140            // Ping-pong may dip below loop_start briefly — bounce.
141            if matches!(kind, LoopKind::PingPong) {
142                let over = -pos;
143                self.pos = loop_start as f32 + over;
144                self.direction = 1;
145            } else {
146                self.active = false;
147                return 0.0;
148            }
149        }
150
151        if self.pos >= len as f32 {
152            match kind {
153                LoopKind::Forward if loop_end > loop_start => {
154                    let span = (loop_end - loop_start) as f32;
155                    let over = self.pos - loop_start as f32;
156                    self.pos = loop_start as f32 + over.rem_euclid(span);
157                }
158                LoopKind::PingPong if loop_end > loop_start => {
159                    let over = self.pos - (loop_end as f32 - 1.0);
160                    self.pos = (loop_end as f32 - 1.0 - over).max(loop_start as f32);
161                    self.direction = -1;
162                }
163                _ => {
164                    self.active = false;
165                    return 0.0;
166                }
167            }
168        }
169
170        let i = (self.pos as usize).min(len - 1);
171        let frac = self.pos - (i as f32);
172        let s0 = source.at(i);
173        let s1_idx = if i + 1 < len {
174            i + 1
175        } else if !matches!(kind, LoopKind::None) && loop_end > loop_start {
176            loop_start
177        } else {
178            i
179        };
180        let s1 = source.at(s1_idx);
181        let interp = s0 + (s1 - s0) * frac;
182
183        // Advance. Step is signed for ping-pong.
184        let step = self.freq / out_rate;
185        let signed_step = step * self.direction as f32;
186        self.pos += signed_step;
187
188        // Ping-pong end-of-loop bounce (forward → reverse).
189        if matches!(kind, LoopKind::PingPong) {
190            if self.direction == 1 && self.pos >= loop_end as f32 && loop_end > loop_start {
191                let over = self.pos - (loop_end as f32 - 1.0);
192                self.pos = (loop_end as f32 - 1.0 - over).max(loop_start as f32);
193                self.direction = -1;
194            } else if self.direction == -1 && self.pos < loop_start as f32 {
195                let over = loop_start as f32 - self.pos;
196                self.pos = loop_start as f32 + over;
197                self.direction = 1;
198            }
199        }
200
201        interp * self.volume
202    }
203}
204
205// ---------------- Pitch models ----------------
206
207/// MOD / ProTracker pitch model: Amiga Paula period → frequency.
208///
209/// Output rate = `paula_clock / period`. The PAL constant is
210/// `7_093_789.2 / 2 ≈ 3_546_894.6 Hz`.
211#[derive(Clone, Copy, Debug)]
212pub struct AmigaPeriodPitch {
213    pub paula_clock: f32,
214}
215
216impl Default for AmigaPeriodPitch {
217    fn default() -> Self {
218        AmigaPeriodPitch {
219            paula_clock: crate::player::PAULA_CLOCK,
220        }
221    }
222}
223
224impl PitchModel for AmigaPeriodPitch {
225    type Note = u16;
226
227    fn note_to_freq(&self, note: Self::Note) -> f32 {
228        if note == 0 {
229            0.0
230        } else {
231            self.paula_clock / note as f32
232        }
233    }
234}
235
236/// STM pitch model: C3-relative `(octave, semitone)` + sample-specific C3
237/// frequency. STM stores the note byte as `octave<<4 | semitone`, with
238/// C-3 at octave=3, semitone=0 being the sample's declared C3 frequency.
239///
240/// Mid-2020s trackers typically use C-5 as the reference note, but the
241/// STM v1 spec explicitly ties the "C3 frequency" instrument field to the
242/// value sounded at octave 3 / semitone 0, so that's what we implement
243/// here. If a given STM file disagrees, the audible pitch will be octave-
244/// shifted but the *relative* pitch between notes remains correct.
245///
246/// Note value layout: `note = (octave << 4) | semitone`, `semitone in
247/// 0..=11`. Freq = c3_hz * 2^((octave-3) + semitone/12).
248#[derive(Clone, Copy, Debug, Default)]
249pub struct StmC3Pitch {
250    pub c3_hz: f32,
251}
252
253impl PitchModel for StmC3Pitch {
254    /// `(octave, semitone)`; octave is 0..=7, semitone is 0..=11.
255    type Note = (u8, u8);
256
257    fn note_to_freq(&self, note: Self::Note) -> f32 {
258        if self.c3_hz <= 0.0 {
259            return 0.0;
260        }
261        let (octave, semitone) = note;
262        // Semitone distance from C-3, in 1/12-octave steps.
263        let semis_from_c3 = (octave as f32 - 3.0) * 12.0 + semitone as f32;
264        self.c3_hz * 2.0f32.powf(semis_from_c3 / 12.0)
265    }
266}
267
268/// XM frequency-table selection. Chosen per-file (`XmHeader.flags`
269/// bit 0). The enum is independent of the header type so the mixer can
270/// carry it without pulling in the whole parser struct.
271#[derive(Clone, Copy, Debug, PartialEq, Eq)]
272pub enum XmPitchTable {
273    Amiga,
274    Linear,
275}
276
277/// XM pitch model: note + finetune + relative-note, under one of two
278/// frequency tables (Amiga or Linear).
279///
280/// XM note numbering: `1..=96` = C-0..B-7 (note `1` is C-0, so
281/// `real_note = pattern_note - 1 + relative_note`, with `real_note = 48`
282/// corresponding to C-4, which is the centre of the XM keyboard).
283///
284/// Formulas from `docs/audio/trackers/xm/FastTracker-2-v2.04-xm.txt`:
285/// - Linear: `Period = 10*12*16*4 - Note*16*4 - FineTune/2`,
286///   `Freq   = 8363 * 2 ^ ((6*12*16*4 - Period) / (12*16*4))`.
287/// - Amiga: interpolate a 96-entry `PeriodTab` via
288///   `note % 12 * 8 + finetune/16`, then `Freq = 8363 * 1712 / Period`.
289///
290/// `Note` here is a pair `(real_note, finetune)` where `real_note` is
291/// `0..=118` (0 = C-0, 48 = C-4). The tracker-format code is responsible
292/// for applying `relative_note` before handing to `note_to_freq`.
293#[derive(Clone, Copy, Debug)]
294pub struct XmPitch {
295    pub table: XmPitchTable,
296}
297
298impl Default for XmPitch {
299    fn default() -> Self {
300        XmPitch {
301            table: XmPitchTable::Amiga,
302        }
303    }
304}
305
306impl XmPitch {
307    /// XM Amiga-table period lookup. 96-entry table indexed by
308    /// `(note % 12) * 8 + finetune/16`, with linear interpolation on the
309    /// fractional part of `finetune/16`.
310    #[rustfmt::skip]
311    const PERIOD_TAB: [u16; 96] = [
312        907,900,894,887,881,875,868,862,856,850,844,838,832,826,820,814,
313        808,802,796,791,785,779,774,768,762,757,752,746,741,736,730,725,
314        720,715,709,704,699,694,689,684,678,675,670,665,660,655,651,646,
315        640,636,632,628,623,619,614,610,604,601,597,592,588,584,580,575,
316        570,567,563,559,555,551,547,543,538,535,532,528,524,520,516,513,
317        508,505,502,498,494,491,487,484,480,477,474,470,467,463,460,457,
318    ];
319
320    /// Public re-export of the period table for use by the XM player's
321    /// own period-based pitch math (vibrato / tone-porta in Amiga mode).
322    pub const PERIOD_TAB_PUB: [u16; 96] = Self::PERIOD_TAB;
323
324    fn amiga_period(real_note: i32, finetune: i32) -> f32 {
325        // finetune/16 can be negative; wrap index accordingly.
326        let n_mod = real_note.rem_euclid(12) as usize;
327        let n_div = real_note.div_euclid(12);
328        // finetune / 16 with floor semantics, then interpolate fractional.
329        let ft = finetune as f32 / 16.0;
330        let ft_floor = ft.floor();
331        let frac = ft - ft_floor;
332        let base_idx = (n_mod as isize * 8 + ft_floor as isize).clamp(0, 95) as usize;
333        let next_idx = (base_idx + 1).min(95);
334        let p0 = Self::PERIOD_TAB[base_idx] as f32;
335        let p1 = Self::PERIOD_TAB[next_idx] as f32;
336        let p = p0 * (1.0 - frac) + p1 * frac;
337        let octave_div = 2.0f32.powi(n_div);
338        (p * 16.0) / octave_div
339    }
340
341    fn linear_period(real_note: i32, finetune: i32) -> f32 {
342        // Period = 10*12*16*4 - Note*16*4 - FineTune/2;
343        let p =
344            10.0 * 12.0 * 16.0 * 4.0 - (real_note as f32) * 16.0 * 4.0 - (finetune as f32) / 2.0;
345        p.max(1.0)
346    }
347}
348
349impl PitchModel for XmPitch {
350    /// `(real_note, finetune)` where `real_note` is already adjusted by
351    /// `relative_note` and sits in `0..=118`, `finetune` is `-128..=127`.
352    type Note = (i32, i32);
353
354    fn note_to_freq(&self, note: Self::Note) -> f32 {
355        let (real_note, finetune) = note;
356        match self.table {
357            XmPitchTable::Amiga => {
358                let p = Self::amiga_period(real_note, finetune);
359                if p <= 0.0 {
360                    0.0
361                } else {
362                    8363.0 * 1712.0 / p
363                }
364            }
365            XmPitchTable::Linear => {
366                let p = Self::linear_period(real_note, finetune);
367                // Freq = 8363*2^((6*12*16*4 - Period) / (12*16*4))
368                8363.0 * 2.0f32.powf((6.0 * 12.0 * 16.0 * 4.0 - p) / (12.0 * 16.0 * 4.0))
369            }
370        }
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    /// Trivial in-memory sample source for unit tests.
379    struct TestSource {
380        pcm: Vec<f32>,
381        loop_start: usize,
382        loop_end: usize,
383        kind: LoopKind,
384    }
385
386    impl SampleSource for TestSource {
387        fn len(&self) -> usize {
388            self.pcm.len()
389        }
390        fn loop_start(&self) -> usize {
391            self.loop_start
392        }
393        fn loop_end(&self) -> usize {
394            self.loop_end
395        }
396        fn loop_kind(&self) -> LoopKind {
397            self.kind
398        }
399        fn at(&self, idx: usize) -> f32 {
400            self.pcm.get(idx).copied().unwrap_or(0.0)
401        }
402    }
403
404    #[test]
405    fn amiga_period_pitch_matches_formula() {
406        let p = AmigaPeriodPitch {
407            paula_clock: 3_546_894.6,
408        };
409        // Period 428 = classic C-2; expected rate ~8287.14
410        let f = p.note_to_freq(428);
411        assert!((f - 8287.14).abs() < 0.5, "got {f}");
412    }
413
414    #[test]
415    fn amiga_period_pitch_zero_means_silent() {
416        let p = AmigaPeriodPitch {
417            paula_clock: 3_546_894.6,
418        };
419        assert_eq!(p.note_to_freq(0), 0.0);
420    }
421
422    #[test]
423    fn stm_c3_pitch_doubles_per_octave() {
424        let p = StmC3Pitch { c3_hz: 8363.0 };
425        let c3 = p.note_to_freq((3, 0));
426        let c4 = p.note_to_freq((4, 0));
427        assert!((c3 - 8363.0).abs() < 0.5, "c3 = {c3}");
428        assert!((c4 - 16726.0).abs() < 1.0, "c4 = {c4}");
429    }
430
431    #[test]
432    fn stm_c3_pitch_semitone_is_twelfth_root_of_two() {
433        let p = StmC3Pitch { c3_hz: 440.0 };
434        let f0 = p.note_to_freq((3, 0));
435        let f1 = p.note_to_freq((3, 1));
436        let ratio = f1 / f0;
437        assert!((ratio - 1.059463).abs() < 0.001);
438    }
439
440    #[test]
441    fn xm_linear_pitch_c4_is_8363_hz() {
442        // For XM, real_note 48 = C-4 corresponds to 8363 Hz under the
443        // Linear table at finetune 0 (this is the XM convention:
444        // `RelativeTone = 0` maps C-4 → sample's native 8363 Hz).
445        let p = XmPitch {
446            table: XmPitchTable::Linear,
447        };
448        let f = p.note_to_freq((48, 0));
449        assert!((f - 8363.0).abs() < 1.0, "got {f}");
450    }
451
452    #[test]
453    fn xm_amiga_pitch_doubles_per_octave() {
454        // The XM Amiga-table formula in the v2.04 spec does not put the
455        // sample's native 8363 Hz at an integer note (the reference rate
456        // lands between C-3 and C-4, at the non-integer N ≈ 36.9). We
457        // therefore don't pin an absolute reference frequency — we just
458        // check the invariant that still matters: one XM octave really is
459        // a 2× ratio in the output frequency.
460        let p = XmPitch {
461            table: XmPitchTable::Amiga,
462        };
463        let c4 = p.note_to_freq((48, 0));
464        let c5 = p.note_to_freq((60, 0));
465        assert!(c4 > 0.0);
466        assert!((c5 / c4 - 2.0).abs() < 1e-3, "ratio {}", c5 / c4);
467    }
468
469    #[test]
470    fn xm_linear_pitch_one_octave_doubles() {
471        let p = XmPitch {
472            table: XmPitchTable::Linear,
473        };
474        let c4 = p.note_to_freq((48, 0));
475        let c5 = p.note_to_freq((60, 0));
476        assert!((c5 / c4 - 2.0).abs() < 1e-3);
477    }
478
479    #[test]
480    fn voice_on_one_shot_goes_silent_at_end() {
481        let src = TestSource {
482            pcm: vec![0.5; 4],
483            loop_start: 0,
484            loop_end: 4,
485            kind: LoopKind::None,
486        };
487        let mut v = MixerVoice::default();
488        v.trigger(44100.0, 1.0); // one sample-unit per render
489                                 // Render past the end.
490        for _ in 0..10 {
491            v.render_one(&src, 44100.0);
492        }
493        assert!(!v.active, "voice should deactivate past end");
494    }
495
496    #[test]
497    fn voice_forward_loop_wraps() {
498        let src = TestSource {
499            pcm: vec![0.25, 0.5, 0.75, 1.0],
500            loop_start: 0,
501            loop_end: 4,
502            kind: LoopKind::Forward,
503        };
504        let mut v = MixerVoice::default();
505        v.trigger(44100.0, 1.0);
506        for _ in 0..100 {
507            let s = v.render_one(&src, 44100.0);
508            assert!(s.abs() <= 1.0);
509        }
510        assert!(v.active, "looped voice must stay active");
511    }
512
513    #[test]
514    fn voice_with_zero_freq_is_silent() {
515        let src = TestSource {
516            pcm: vec![1.0; 8],
517            loop_start: 0,
518            loop_end: 8,
519            kind: LoopKind::None,
520        };
521        let mut v = MixerVoice::default();
522        v.trigger(0.0, 1.0);
523        assert_eq!(v.render_one(&src, 44100.0), 0.0);
524    }
525}