#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LoopKind {
#[default]
None,
Forward,
PingPong,
}
pub trait SampleSource {
fn len(&self) -> usize;
fn loop_start(&self) -> usize;
fn loop_end(&self) -> usize;
fn loop_kind(&self) -> LoopKind;
fn at(&self, idx: usize) -> f32;
fn is_empty(&self) -> bool {
self.len() == 0
}
}
pub trait PitchModel {
type Note: Copy;
fn note_to_freq(&self, note: Self::Note) -> f32;
}
#[derive(Clone, Debug, Default)]
pub struct MixerVoice {
pub pos: f32,
pub direction: i8,
pub freq: f32,
pub volume: f32,
pub active: bool,
}
impl MixerVoice {
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;
}
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();
let pos = self.pos;
if pos < 0.0 {
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;
let step = self.freq / out_rate;
let signed_step = step * self.direction as f32;
self.pos += signed_step;
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
}
}
#[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
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct StmC3Pitch {
pub c3_hz: f32,
}
impl PitchModel for StmC3Pitch {
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;
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)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum XmPitchTable {
Amiga,
Linear,
}
#[derive(Clone, Copy, Debug)]
pub struct XmPitch {
pub table: XmPitchTable,
}
impl Default for XmPitch {
fn default() -> Self {
XmPitch {
table: XmPitchTable::Amiga,
}
}
}
impl XmPitch {
#[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,
];
pub const PERIOD_TAB_PUB: [u16; 96] = Self::PERIOD_TAB;
fn amiga_period(real_note: i32, finetune: i32) -> f32 {
let n_mod = real_note.rem_euclid(12) as usize;
let n_div = real_note.div_euclid(12);
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 {
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 {
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);
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::*;
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,
};
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() {
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() {
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); 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);
}
}