use crate::header::{ModHeader, PATTERN_ROWS};
use crate::samples::SampleBody;
pub const PAULA_CLOCK: f32 = 7_093_789.2 / 2.0;
pub const DEFAULT_SPEED: u8 = 6;
pub const DEFAULT_BPM: u8 = 125;
pub const CHANNELS_PER_MOD: usize = 4;
pub const PERIOD_MIN: u16 = 113;
pub const PERIOD_MAX: u16 = 856;
pub const PERIOD_MIN_EXT: u16 = 108;
pub const PERIOD_MAX_EXT: u16 = 907;
#[rustfmt::skip]
pub const PROTRACKER_SINE_TABLE: [u8; 32] = [
0, 24, 49, 74, 97, 120, 141, 161,
180, 197, 212, 224, 235, 244, 250, 253,
255, 253, 250, 244, 235, 224, 212, 197,
180, 161, 141, 120, 97, 74, 49, 24,
];
#[rustfmt::skip]
pub const PERIOD_TABLE: [[u16; 36]; 16] = [
[856,808,762,720,678,640,604,570,538,508,480,453,
428,404,381,360,339,320,302,285,269,254,240,226,
214,202,190,180,170,160,151,143,135,127,120,113],
[850,802,757,715,674,637,601,567,535,505,477,450,
425,401,379,357,337,318,300,284,268,253,239,225,
213,201,189,179,169,159,150,142,134,126,119,113],
[844,796,752,709,670,632,597,563,532,502,474,447,
422,398,376,355,335,316,298,282,266,251,237,224,
211,199,188,177,167,158,149,141,133,125,118,112],
[838,791,746,704,665,628,592,559,528,498,470,444,
419,395,373,352,332,314,296,280,264,249,235,222,
209,198,187,176,166,157,148,140,132,125,118,111],
[832,785,741,699,660,623,588,555,524,495,467,441,
416,392,370,350,330,312,294,278,262,247,233,220,
208,196,185,175,165,156,147,139,131,124,117,110],
[826,779,736,694,655,619,584,551,520,491,463,437,
413,390,368,347,328,309,292,276,260,245,232,219,
206,195,184,174,164,155,146,138,130,123,116,109],
[820,774,730,689,651,614,580,547,516,487,460,434,
410,387,365,345,325,307,290,274,258,244,230,217,
205,193,183,172,163,154,145,137,129,122,115,109],
[814,768,725,684,646,610,575,543,513,484,457,431,
407,384,363,342,323,305,288,272,256,242,228,216,
204,192,181,171,161,152,144,136,128,121,114,108],
[907,856,808,762,720,678,640,604,570,538,508,480,
453,428,404,381,360,339,320,302,285,269,254,240,
226,214,202,190,180,170,160,151,143,135,127,120],
[900,850,802,757,715,675,636,601,567,535,505,477,
450,425,401,379,357,337,318,300,284,268,253,238,
225,212,200,189,179,169,159,150,142,134,126,119],
[894,844,796,752,709,670,632,597,563,532,502,474,
447,422,398,376,355,335,316,298,282,266,251,237,
223,211,199,188,177,167,158,149,141,133,125,118],
[887,838,791,746,704,665,628,592,559,528,498,470,
444,419,395,373,352,332,314,296,280,264,249,235,
222,209,198,187,176,166,157,148,140,132,125,118],
[881,832,785,741,699,660,623,588,555,524,494,467,
441,416,392,370,350,330,312,294,278,262,247,233,
220,208,196,185,175,165,156,147,139,131,123,117],
[875,826,779,736,694,655,619,584,551,520,491,463,
437,413,390,368,347,328,309,292,276,260,245,232,
219,206,195,184,174,164,155,146,138,130,123,116],
[868,820,774,730,689,651,614,580,547,516,487,460,
434,410,387,365,345,325,307,290,274,258,244,230,
217,205,193,183,172,163,154,145,137,129,122,115],
[862,814,768,725,684,646,610,575,543,513,484,457,
431,407,384,363,342,323,305,288,272,256,242,228,
216,203,192,181,171,161,152,144,136,128,121,114],
];
#[inline]
pub fn finetune_row(finetune: i8) -> usize {
(finetune as u8 & 0x0F) as usize
}
pub fn note_index_for_period(period: u16) -> Option<usize> {
for row in PERIOD_TABLE.iter() {
for (note_idx, &p) in row.iter().enumerate() {
if p == period {
return Some(note_idx);
}
}
}
None
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Note {
pub period: u16,
pub sample: u8,
pub effect: u8,
pub effect_param: u8,
}
impl Note {
fn decode(raw: [u8; 4]) -> Self {
let period = (((raw[0] & 0x0F) as u16) << 8) | raw[1] as u16;
let sample = (raw[0] & 0xF0) | (raw[2] >> 4);
let effect = raw[2] & 0x0F;
let effect_param = raw[3];
Note {
period,
sample,
effect,
effect_param,
}
}
}
#[derive(Clone, Debug)]
pub struct Pattern {
pub rows: Vec<Vec<Note>>, }
pub fn parse_patterns(header: &ModHeader, bytes: &[u8]) -> Vec<Pattern> {
let channels = header.channels as usize;
let mut patterns = Vec::with_capacity(header.n_patterns as usize);
let base = header.pattern_data_offset();
for p in 0..header.n_patterns as usize {
let mut rows = Vec::with_capacity(PATTERN_ROWS);
for r in 0..PATTERN_ROWS {
let mut row = Vec::with_capacity(channels);
for c in 0..channels {
let off = base + (p * PATTERN_ROWS + r) * channels * 4 + c * 4;
let raw = if off + 4 <= bytes.len() {
[bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]]
} else {
[0; 4]
};
row.push(Note::decode(raw));
}
rows.push(row);
}
patterns.push(Pattern { rows });
}
patterns
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Waveform {
pub shape: u8,
pub no_retrigger: bool,
}
impl Waveform {
fn set(&mut self, nibble: u8) {
self.shape = nibble & 0x3;
self.no_retrigger = nibble & 0x4 != 0;
}
}
#[derive(Clone, Debug, Default)]
pub struct Channel {
pub sample_index: u8,
pub sample_pos: f32,
pub period: u16,
pub volume: u8,
pub active: bool,
pub finetune: i8,
pub effect: u8,
pub effect_param: u8,
pub arp_base_period: u16,
pub mem_porta_up: u8,
pub mem_porta_down: u8,
pub tone_porta_target: u16,
pub tone_porta_speed: u8,
pub mem_vibrato: u8,
pub vib_pos: i8,
pub vib_wave: Waveform,
pub mem_tremolo: u8,
pub trem_pos: i8,
pub trem_wave: Waveform,
pub mem_sample_offset: u8,
pub mem_volslide: u8,
pub retrig_ticks: u8,
pub cut_tick: u8,
pub delay: Option<DelayedTrigger>,
pub glissando: bool,
pub pending_sample: u8,
pub pending_led: Option<bool>,
pub last_mixed_sample: f32,
pub ramp_prev_sample: f32,
pub ramp_remaining_frames: u32,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct DelayedTrigger {
pub tick: u8,
pub period: u16,
pub sample: u8,
}
impl Channel {
fn effective_period(&self, vib_offset: i16) -> u16 {
let p = self.period as i32 + vib_offset as i32;
p.clamp(PERIOD_MIN_EXT as i32, PERIOD_MAX_EXT as i32) as u16
}
fn mix_one(
&mut self,
samples: &[SampleBody],
out_rate: f32,
vib_offset: i16,
trem_offset: i16,
) -> f32 {
if !self.active || self.period == 0 {
return 0.0;
}
let idx = self.sample_index as usize;
if idx == 0 || idx > samples.len() {
return 0.0;
}
let body = &samples[idx - 1];
if body.pcm.is_empty() {
return 0.0;
}
let pcm_len = body.pcm.len();
let looped = body.is_looped();
let effective_end_f = if looped {
((body.loop_start as usize + body.loop_length as usize).min(pcm_len)) as f32
} else {
pcm_len as f32
};
let pos = self.sample_pos;
if pos >= effective_end_f {
if looped {
let loop_start = body.loop_start as f32;
let span = effective_end_f - loop_start;
if span > 0.0 {
let over = pos - loop_start;
self.sample_pos = loop_start + over.rem_euclid(span);
} else {
self.active = false;
return 0.0;
}
} else {
self.active = false;
return 0.0;
}
}
let effective_end_idx = effective_end_f as usize;
let i = self.sample_pos as usize;
let frac = self.sample_pos - i as f32;
let s0_idx = i.min(pcm_len.saturating_sub(1));
let s0 = body.pcm[s0_idx] as f32 / 128.0;
let s1_idx = if i + 1 < effective_end_idx {
i + 1
} else if looped {
body.loop_start as usize
} else {
s0_idx
};
let s1 = body.pcm[s1_idx.min(pcm_len.saturating_sub(1))] as f32 / 128.0;
let interp = s0 + (s1 - s0) * frac;
let eff_vol = (self.volume as i16 + trem_offset).clamp(0, 64);
let out_raw = interp * (eff_vol as f32 / 64.0);
let out = if self.ramp_remaining_frames > 0 {
let total = PlayerState::RAMP_FRAMES as f32;
let consumed = total - self.ramp_remaining_frames as f32;
let t = (consumed / total).clamp(0.0, 1.0);
let mixed = self.ramp_prev_sample * (1.0 - t) + out_raw * t;
self.ramp_remaining_frames -= 1;
if self.ramp_remaining_frames == 0 {
self.ramp_prev_sample = 0.0;
}
mixed
} else {
out_raw
};
self.last_mixed_sample = out;
let eff_period = self.effective_period(vib_offset) as f32;
let chan_rate = PAULA_CLOCK / eff_period;
let step = chan_rate / out_rate;
self.sample_pos += step;
out
}
}
#[derive(Clone, Copy, Debug)]
struct Jump {
order: Option<u8>,
row: u8,
}
pub struct PlayerState {
pub samples: Vec<SampleBody>,
pub patterns: Vec<Pattern>,
pub order: Vec<u8>,
pub song_length: u8,
pub channels: Vec<Channel>,
pub speed: u8,
pub bpm: u8,
pub order_index: u8,
pub row: u8,
pub tick: u8,
pub tick_sample_cursor: u32,
pub sample_rate: u32,
pub ended: bool,
pending_jump: Option<Jump>,
loop_rows: Vec<u8>,
loop_counts: Vec<u8>,
pattern_delay: u8,
in_pattern_delay_repeat: bool,
led_filter: bool,
led_filter_state: Vec<f32>,
led_filter_state2: Vec<f32>,
led_filter_alpha: f32,
led_filter_alpha2: f32,
pan_separation: f32,
}
impl PlayerState {
pub fn new(
header: &ModHeader,
samples: Vec<SampleBody>,
patterns: Vec<Pattern>,
sample_rate: u32,
) -> Self {
let channels = (0..header.channels)
.map(|_| Channel::default())
.collect::<Vec<_>>();
let n_ch = channels.len();
PlayerState {
samples,
patterns,
order: header.order.clone(),
song_length: header.song_length,
channels,
speed: DEFAULT_SPEED,
bpm: DEFAULT_BPM,
order_index: 0,
row: 0,
tick: 0,
tick_sample_cursor: 0,
sample_rate,
ended: false,
pending_jump: None,
loop_rows: vec![0; n_ch],
loop_counts: vec![0; n_ch],
pattern_delay: 0,
in_pattern_delay_repeat: false,
led_filter: true,
led_filter_state: Vec::new(),
led_filter_state2: Vec::new(),
led_filter_alpha: f32::NAN,
led_filter_alpha2: f32::NAN,
pan_separation: Self::DEFAULT_PAN_SEPARATION,
}
}
pub const DEFAULT_PAN_SEPARATION: f32 = 0.5;
pub fn set_pan_separation(&mut self, sep: f32) {
self.pan_separation = sep.clamp(0.0, 1.0);
}
pub fn pan_separation(&self) -> f32 {
self.pan_separation
}
pub const RAMP_FRAMES: u32 = 44;
pub const FIXED_RC_CUTOFF_HZ: f32 = 16_000.0;
pub const LED_FILTER_CUTOFF_HZ: f32 = 11_500.0;
fn compute_alpha(sample_rate: u32, fc: f32) -> f32 {
let fs = sample_rate as f32;
let two_pi = 2.0 * std::f32::consts::PI;
1.0 - (-two_pi * fc / fs).exp()
}
fn ensure_led_filter(&mut self, n_outputs: usize) {
if self.led_filter_state.len() < n_outputs {
self.led_filter_state.resize(n_outputs, 0.0);
}
if self.led_filter_state2.len() < n_outputs {
self.led_filter_state2.resize(n_outputs, 0.0);
}
if self.led_filter_alpha.is_nan() {
self.led_filter_alpha = Self::compute_alpha(self.sample_rate, Self::FIXED_RC_CUTOFF_HZ);
}
if self.led_filter_alpha2.is_nan() {
self.led_filter_alpha2 =
Self::compute_alpha(self.sample_rate, Self::LED_FILTER_CUTOFF_HZ);
}
}
#[inline]
fn led_filter_step(&mut self, idx: usize, x: f32) -> f32 {
let a1 = self.led_filter_alpha;
let prev1 = self.led_filter_state[idx];
let y1 = a1 * x + (1.0 - a1) * prev1;
self.led_filter_state[idx] = y1;
if !self.led_filter {
return y1;
}
let a2 = self.led_filter_alpha2;
let prev2 = self.led_filter_state2[idx];
let y2 = a2 * y1 + (1.0 - a2) * prev2;
self.led_filter_state2[idx] = y2;
y2
}
pub fn samples_per_tick(&self) -> u32 {
((self.sample_rate as f32) * 2.5 / self.bpm as f32) as u32
}
fn enter_row(&mut self) {
let pattern_idx = self
.order
.get(self.order_index as usize)
.copied()
.unwrap_or(0) as usize;
if pattern_idx >= self.patterns.len() {
self.ended = true;
return;
}
let row_notes: Vec<Note> = self.patterns[pattern_idx].rows[self.row as usize].clone();
for (ch_idx, note) in row_notes.iter().enumerate() {
if ch_idx >= self.channels.len() {
break;
}
let effect = note.effect;
let param = note.effect_param;
let x = param >> 4;
let y = param & 0x0F;
let ch = &mut self.channels[ch_idx];
if ch.effect == 0x0 && ch.effect_param != 0 && ch.arp_base_period != 0 {
ch.period = ch.arp_base_period;
}
ch.effect = effect;
ch.effect_param = param;
ch.cut_tick = 0;
ch.delay = None;
ch.retrig_ticks = 0;
let has_note = note.period != 0;
let is_note_delay_pre = note.effect == 0xE && (note.effect_param >> 4) == 0xD;
if note.sample != 0 {
let idx = note.sample as usize;
if idx >= 1 && idx <= self.samples.len() {
let body = &self.samples[idx - 1];
ch.volume = body.volume;
ch.finetune = body.finetune;
}
if has_note || is_note_delay_pre {
ch.sample_index = note.sample;
} else {
ch.pending_sample = note.sample;
}
}
let is_tone_porta = matches!(effect, 0x3 | 0x5);
let is_note_delay = effect == 0xE && x == 0xD;
if note.period != 0 && is_tone_porta {
ch.tone_porta_target = note.period;
if effect == 0x3 && param != 0 {
ch.tone_porta_speed = param;
}
ch.arp_base_period = ch.period;
} else if note.period != 0 && is_note_delay {
ch.delay = Some(DelayedTrigger {
tick: y,
period: note.period,
sample: note.sample,
});
ch.arp_base_period = ch.period;
} else if note.period != 0 {
let mut note_period = note.period;
if effect == 0xE && x == 0x5 {
let new_ft = y as i8;
let signed_ft = if new_ft & 0x8 != 0 {
new_ft - 16
} else {
new_ft
};
ch.finetune = signed_ft;
if let Some(note_idx) = note_index_for_period(note.period) {
note_period = PERIOD_TABLE[finetune_row(signed_ft)][note_idx];
}
}
ch.period = note_period;
if note.sample == 0 && ch.pending_sample != 0 {
ch.sample_index = ch.pending_sample;
}
ch.pending_sample = 0;
let mut offset_frames: u32 = 0;
if effect == 0x9 {
let used = if param == 0 {
ch.mem_sample_offset
} else {
param
};
ch.mem_sample_offset = used;
offset_frames = (used as u32) * 0x100;
}
ch.sample_pos = offset_frames as f32;
ch.active = true;
ch.arp_base_period = note_period;
ch.ramp_prev_sample = ch.last_mixed_sample;
ch.ramp_remaining_frames = PlayerState::RAMP_FRAMES;
if !ch.vib_wave.no_retrigger {
ch.vib_pos = 0;
}
if !ch.trem_wave.no_retrigger {
ch.trem_pos = 0;
}
} else {
ch.arp_base_period = ch.period;
}
apply_tick0_effect(
ch_idx,
effect,
param,
&mut self.channels,
&mut self.pending_jump,
&mut self.loop_rows,
&mut self.loop_counts,
&mut self.pattern_delay,
self.order_index,
self.row,
);
}
for ch in &self.channels {
if ch.effect == 0xF {
let p = ch.effect_param;
if p == 0 {
} else if p < 0x20 {
self.speed = p;
} else {
self.bpm = p;
}
}
}
let mut new_led: Option<bool> = None;
for ch in &mut self.channels {
if let Some(new_state) = ch.pending_led.take() {
new_led = Some(new_state);
}
}
if let Some(s) = new_led {
self.led_filter = s;
}
}
fn advance_tick(&mut self) {
if self.tick == 0 {
if !self.in_pattern_delay_repeat {
self.enter_row();
}
} else {
for ch_idx in 0..self.channels.len() {
apply_tickn_effect(ch_idx, self.tick, &mut self.channels, &self.samples);
}
}
}
fn next_row(&mut self) {
if self.pattern_delay > 0 {
self.pattern_delay -= 1;
self.in_pattern_delay_repeat = true;
return;
}
self.in_pattern_delay_repeat = false;
if let Some(jump) = self.pending_jump.take() {
if let Some(order) = jump.order {
self.order_index = order;
} else {
self.order_index = self.order_index.saturating_add(1);
}
self.row = jump.row;
} else {
self.row += 1;
if self.row as usize >= PATTERN_ROWS {
self.row = 0;
self.order_index = self.order_index.saturating_add(1);
}
}
if self.order_index >= self.song_length {
self.ended = true;
}
}
pub fn channel_is_left(i: usize) -> bool {
matches!(i % 4, 0 | 3)
}
fn vibrato_offset(ch: &Channel) -> i16 {
let rate = ch.mem_vibrato >> 4;
let depth = ch.mem_vibrato & 0x0F;
if depth == 0 || ch.effect != 0x4 && ch.effect != 0x6 {
let _ = rate; return 0;
}
let idx = (ch.vib_pos.unsigned_abs() & 31) as usize;
let base = match ch.vib_wave.shape {
0 | 3 => PROTRACKER_SINE_TABLE[idx] as i32,
1 => {
let raw = (idx << 3) as i32;
if ch.vib_pos < 0 {
255 - raw
} else {
raw
}
}
_ => 255, };
let delta = (base * depth as i32) >> 7;
if ch.vib_pos < 0 {
-(delta as i16)
} else {
delta as i16
}
}
fn tremolo_offset(ch: &Channel) -> i16 {
let depth = ch.mem_tremolo & 0x0F;
if depth == 0 || ch.effect != 0x7 {
return 0;
}
let idx = (ch.trem_pos.unsigned_abs() & 31) as usize;
let base = match ch.trem_wave.shape {
0 | 3 => PROTRACKER_SINE_TABLE[idx] as i32,
1 => {
let raw = (idx << 3) as i32;
if ch.trem_pos < 0 {
255 - raw
} else {
raw
}
}
_ => 255,
};
let delta = (base * depth as i32) >> 6;
if ch.trem_pos < 0 {
-(delta as i16)
} else {
delta as i16
}
}
fn sample_all_channels(&mut self, per_channel: &mut [f32]) -> (f32, f32) {
let out_rate = self.sample_rate as f32;
let mut l = 0.0f32;
let mut r = 0.0f32;
let n_ch = self.channels.len();
let s = self.pan_separation;
let near = (1.0 + s) * 0.5;
let far = (1.0 - s) * 0.5;
for (i, ch) in self.channels.iter_mut().enumerate() {
let vib = Self::vibrato_offset(ch);
let trem = Self::tremolo_offset(ch);
let smp = ch.mix_one(&self.samples, out_rate, vib, trem);
per_channel[i] = smp;
if Self::channel_is_left(i) {
l += smp * near;
r += smp * far;
} else {
l += smp * far;
r += smp * near;
}
}
let norm = ((n_ch as f32 / 2.0) + 1.0).max(1.0);
(l / norm, r / norm)
}
fn render_one(&mut self, out: &mut [i16]) {
let mut per_channel = vec![0.0f32; self.channels.len()];
let (l, r) = self.sample_all_channels(&mut per_channel);
self.ensure_led_filter(2);
let l = self.led_filter_step(0, l);
let r = self.led_filter_step(1, r);
let l = l.clamp(-1.0, 1.0);
let r = r.clamp(-1.0, 1.0);
out[0] = (l * 32767.0) as i16;
out[1] = (r * 32767.0) as i16;
}
pub fn render(&mut self, dst: &mut [i16]) -> usize {
assert!(dst.len() % 2 == 0);
let mut produced = 0usize;
let total_frames = dst.len() / 2;
while produced < total_frames {
if self.ended {
break;
}
if self.tick_sample_cursor == 0 {
self.advance_tick();
}
let spt = self.samples_per_tick().max(1);
let remaining_in_tick = spt.saturating_sub(self.tick_sample_cursor);
let want = (total_frames - produced).min(remaining_in_tick as usize);
for _ in 0..want {
let off = produced * 2;
self.render_one(&mut dst[off..off + 2]);
produced += 1;
}
self.tick_sample_cursor += want as u32;
if self.tick_sample_cursor >= spt {
self.tick_sample_cursor = 0;
self.tick += 1;
if self.tick >= self.speed {
self.tick = 0;
self.next_row();
}
}
}
produced
}
pub fn render_per_channel(&mut self, planes: &mut [&mut [i16]], n_frames: usize) -> usize {
assert_eq!(
planes.len(),
self.channels.len(),
"render_per_channel: plane count must equal MOD channel count"
);
for p in planes.iter() {
assert!(
p.len() >= n_frames,
"render_per_channel: every plane must hold at least n_frames samples"
);
}
let mut produced = 0usize;
let mut scratch = vec![0.0f32; self.channels.len()];
while produced < n_frames {
if self.ended {
break;
}
if self.tick_sample_cursor == 0 {
self.advance_tick();
}
let spt = self.samples_per_tick().max(1);
let remaining_in_tick = spt.saturating_sub(self.tick_sample_cursor);
let want = (n_frames - produced).min(remaining_in_tick as usize);
let n_planes = planes.len();
for _ in 0..want {
let _ = self.sample_all_channels(&mut scratch);
self.ensure_led_filter(n_planes);
for (ch_idx, plane) in planes.iter_mut().enumerate() {
let raw = scratch[ch_idx];
let filtered = self.led_filter_step(ch_idx, raw);
let v = filtered.clamp(-1.0, 1.0);
plane[produced] = (v * 32767.0) as i16;
}
produced += 1;
}
self.tick_sample_cursor += want as u32;
if self.tick_sample_cursor >= spt {
self.tick_sample_cursor = 0;
self.tick += 1;
if self.tick >= self.speed {
self.tick = 0;
self.next_row();
}
}
}
produced
}
}
#[allow(clippy::too_many_arguments)]
fn apply_tick0_effect(
ch_idx: usize,
effect: u8,
param: u8,
channels: &mut [Channel],
pending_jump: &mut Option<Jump>,
loop_rows: &mut [u8],
loop_counts: &mut [u8],
pattern_delay: &mut u8,
order_index: u8,
row: u8,
) {
let x = param >> 4;
let y = param & 0x0F;
let ch = &mut channels[ch_idx];
match effect {
0x0 => {
}
0x1
if param != 0 => {
ch.mem_porta_up = param;
}
0x2
if param != 0 => {
ch.mem_porta_down = param;
}
0x3
if param != 0 => {
ch.tone_porta_speed = param;
}
0x4 => {
let mut rate = x;
let mut depth = y;
if rate == 0 {
rate = ch.mem_vibrato >> 4;
}
if depth == 0 {
depth = ch.mem_vibrato & 0x0F;
}
ch.mem_vibrato = (rate << 4) | depth;
}
0x5
if param != 0 => {
ch.mem_volslide = param;
}
0x6
if param != 0 => {
ch.mem_volslide = param;
}
0x7 => {
let mut rate = x;
let mut depth = y;
if rate == 0 {
rate = ch.mem_tremolo >> 4;
}
if depth == 0 {
depth = ch.mem_tremolo & 0x0F;
}
ch.mem_tremolo = (rate << 4) | depth;
}
0x9 => {
}
0xA
if param != 0 => {
ch.mem_volslide = param;
}
0xB => {
*pending_jump = Some(Jump {
order: Some(param),
row: 0,
});
}
0xC => {
ch.volume = param.min(64);
}
0xD => {
let next_row = (x * 10 + y).min(63);
*pending_jump = Some(Jump {
order: None,
row: next_row,
});
}
0xE => apply_extended_tick0(
ch_idx,
x,
y,
channels,
pending_jump,
loop_rows,
loop_counts,
pattern_delay,
order_index,
row,
),
0xF => {
}
_ => {}
}
}
#[allow(clippy::too_many_arguments)]
fn apply_extended_tick0(
ch_idx: usize,
x: u8,
y: u8,
channels: &mut [Channel],
pending_jump: &mut Option<Jump>,
loop_rows: &mut [u8],
loop_counts: &mut [u8],
pattern_delay: &mut u8,
order_index: u8,
row: u8,
) {
let ch = &mut channels[ch_idx];
match x {
0x0 => {
ch.pending_led = Some(y == 0);
}
0x1 => {
ch.period = ch.period.saturating_sub(y as u16).max(PERIOD_MIN);
}
0x2 => {
ch.period = (ch.period + y as u16).min(PERIOD_MAX);
}
0x3 => {
ch.glissando = y != 0;
}
0x4 => {
ch.vib_wave.set(y);
}
0x5 => {
let raw = y;
let signed = if raw & 0x8 != 0 {
(raw as i8) - 16
} else {
raw as i8
};
ch.finetune = signed;
}
0x6 => {
if y == 0 {
loop_rows[ch_idx] = row;
} else if loop_counts[ch_idx] == 0 {
loop_counts[ch_idx] = y;
*pending_jump = Some(Jump {
order: Some(order_index),
row: loop_rows[ch_idx],
});
} else {
loop_counts[ch_idx] -= 1;
if loop_counts[ch_idx] > 0 {
*pending_jump = Some(Jump {
order: Some(order_index),
row: loop_rows[ch_idx],
});
}
}
}
0x7 => {
ch.trem_wave.set(y);
}
0x8 => { }
0x9 => {
ch.retrig_ticks = y;
}
0xA => {
ch.volume = (ch.volume as u16 + y as u16).min(64) as u8;
}
0xB => {
ch.volume = ch.volume.saturating_sub(y);
}
0xC => {
ch.cut_tick = y;
}
0xD => { }
0xE => {
*pattern_delay = y;
}
0xF => {
}
_ => {}
}
}
fn apply_tickn_effect(ch_idx: usize, tick: u8, channels: &mut [Channel], samples: &[SampleBody]) {
let ch = &mut channels[ch_idx];
let effect = ch.effect;
let param = ch.effect_param;
let x = param >> 4;
let y = param & 0x0F;
if let Some(delayed) = ch.delay {
if tick == delayed.tick {
if delayed.sample != 0 {
let idx = delayed.sample as usize;
ch.sample_index = delayed.sample;
if idx >= 1 && idx <= samples.len() {
let body = &samples[idx - 1];
ch.volume = body.volume;
ch.finetune = body.finetune;
}
} else if ch.pending_sample != 0 {
ch.sample_index = ch.pending_sample;
}
ch.pending_sample = 0;
ch.period = delayed.period;
ch.sample_pos = 0.0;
ch.active = true;
ch.arp_base_period = delayed.period;
ch.ramp_prev_sample = ch.last_mixed_sample;
ch.ramp_remaining_frames = PlayerState::RAMP_FRAMES;
if !ch.vib_wave.no_retrigger {
ch.vib_pos = 0;
}
if !ch.trem_wave.no_retrigger {
ch.trem_pos = 0;
}
ch.delay = None;
}
}
if effect == 0xE && x == 0xC && tick == y && y != 0 {
ch.volume = 0;
}
if effect == 0xE && x == 0x9 && ch.retrig_ticks != 0 && tick % ch.retrig_ticks == 0 {
ch.sample_pos = 0.0;
ch.active = true;
ch.ramp_prev_sample = ch.last_mixed_sample;
ch.ramp_remaining_frames = PlayerState::RAMP_FRAMES;
}
match effect {
0x0
if param != 0 => {
let semis = match tick % 3 {
0 => 0,
1 => x as i32,
2 => y as i32,
_ => 0,
};
if semis == 0 {
ch.period = ch.arp_base_period;
} else {
let ft_row = finetune_row(ch.finetune);
let mut matched = None;
for (i, &p) in PERIOD_TABLE[ft_row].iter().enumerate() {
if p == ch.arp_base_period {
matched = Some(i);
break;
}
}
if let Some(base_idx) = matched {
let target = (base_idx as i32 + semis).clamp(0, 35) as usize;
ch.period = PERIOD_TABLE[ft_row][target];
} else {
let factor = 2.0f32.powf(semis as f32 / 12.0);
let p = (ch.arp_base_period as f32 / factor) as u16;
ch.period = p.max(PERIOD_MIN);
}
}
}
0x1 => {
let used = if param == 0 { ch.mem_porta_up } else { param };
ch.period = ch.period.saturating_sub(used as u16).max(PERIOD_MIN);
}
0x2 => {
let used = if param == 0 { ch.mem_porta_down } else { param };
ch.period = (ch.period + used as u16).min(PERIOD_MAX);
}
0x3 => {
tone_porta_step(ch);
}
0x4 => {
let rate = ch.mem_vibrato >> 4;
advance_lfo(&mut ch.vib_pos, rate);
}
0x5 => {
tone_porta_step(ch);
volume_slide_step(ch, ch.mem_volslide);
}
0x6 => {
let rate = ch.mem_vibrato >> 4;
advance_lfo(&mut ch.vib_pos, rate);
volume_slide_step(ch, ch.mem_volslide);
}
0x7 => {
let rate = ch.mem_tremolo >> 4;
advance_lfo(&mut ch.trem_pos, rate);
}
0xA => {
let slide = if param == 0 { ch.mem_volslide } else { param };
volume_slide_step(ch, slide);
}
_ => {}
}
}
fn tone_porta_step(ch: &mut Channel) {
if ch.tone_porta_target == 0 || ch.tone_porta_speed == 0 {
return;
}
let target = ch.tone_porta_target;
let step = ch.tone_porta_speed as i32;
let cur = ch.period as i32;
let new = if cur < target as i32 {
(cur + step).min(target as i32)
} else if cur > target as i32 {
(cur - step).max(target as i32)
} else {
cur
};
ch.period = new.clamp(PERIOD_MIN_EXT as i32, PERIOD_MAX_EXT as i32) as u16;
if ch.glissando {
let ft_row = finetune_row(ch.finetune);
let row = &PERIOD_TABLE[ft_row];
let mut best = ch.period;
let mut best_diff = i32::MAX;
for &p in row.iter() {
let d = (p as i32 - ch.period as i32).abs();
if d < best_diff {
best_diff = d;
best = p;
}
}
ch.period = best;
}
}
fn volume_slide_step(ch: &mut Channel, slide: u8) {
let x = slide >> 4;
let y = slide & 0x0F;
if x != 0 {
ch.volume = (ch.volume as u16 + x as u16).min(64) as u8;
} else if y != 0 {
ch.volume = ch.volume.saturating_sub(y);
}
}
fn advance_lfo(pos: &mut i8, rate: u8) {
let next = *pos as i32 + rate as i32;
if next > 31 {
*pos = (next - 64) as i8;
} else {
*pos = next as i8;
}
}
#[cfg(test)]
pub mod tests {
use super::*;
use crate::header::parse_header;
use crate::samples::extract_samples;
pub fn synth_square_mod() -> Vec<u8> {
let mut out = vec![0u8; crate::header::HEADER_FIXED_SIZE];
out[0..4].copy_from_slice(b"test");
out[20 + 22..20 + 24].copy_from_slice(&16u16.to_be_bytes());
out[20 + 24] = 0;
out[20 + 25] = 64;
out[20 + 26..20 + 28].copy_from_slice(&0u16.to_be_bytes());
out[20 + 28..20 + 30].copy_from_slice(&16u16.to_be_bytes());
out[950] = 1;
out[951] = 0x7F;
out[952] = 0;
out[1080..1084].copy_from_slice(b"M.K.");
let mut pat = vec![0u8; 64 * 4 * 4];
let rows_and_periods = [(0, 428u16), (16, 381), (32, 339), (48, 320)];
for &(row, period) in &rows_and_periods {
let off = row * 4 * 4;
let p_hi = ((period >> 8) & 0x0F) as u8;
let p_lo = (period & 0xFF) as u8;
let sample_hi = 0u8; let sample_lo = 1u8;
pat[off] = (sample_hi << 4) | p_hi;
pat[off + 1] = p_lo;
pat[off + 2] = sample_lo << 4; pat[off + 3] = 0; }
out.extend(pat);
for i in 0..32 {
let v: i8 = if i < 16 { 100 } else { -100 };
out.push(v as u8);
}
out
}
pub fn synth_mod_with_pattern(rows: &[(usize, usize, Note)]) -> Vec<u8> {
let mut out = vec![0u8; crate::header::HEADER_FIXED_SIZE];
out[0..4].copy_from_slice(b"test");
out[20 + 22..20 + 24].copy_from_slice(&16u16.to_be_bytes());
out[20 + 24] = 0;
out[20 + 25] = 64;
out[20 + 26..20 + 28].copy_from_slice(&0u16.to_be_bytes());
out[20 + 28..20 + 30].copy_from_slice(&16u16.to_be_bytes());
out[950] = 1;
out[951] = 0x7F;
out[952] = 0;
out[1080..1084].copy_from_slice(b"M.K.");
let mut pat = vec![0u8; 64 * 4 * 4];
for &(row, channel, ref note) in rows {
let off = row * 4 * 4 + channel * 4;
let p_hi = ((note.period >> 8) & 0x0F) as u8;
let p_lo = (note.period & 0xFF) as u8;
let sample_hi = (note.sample & 0xF0) >> 4;
let sample_lo = note.sample & 0x0F;
pat[off] = (sample_hi << 4) | p_hi;
pat[off + 1] = p_lo;
pat[off + 2] = (sample_lo << 4) | note.effect;
pat[off + 3] = note.effect_param;
}
out.extend(pat);
for i in 0..32 {
let v: i8 = if i < 16 { 100 } else { -100 };
out.push(v as u8);
}
out
}
fn make_player(bytes: &[u8]) -> PlayerState {
let header = parse_header(bytes).unwrap();
let samples = extract_samples(&header, bytes);
let patterns = parse_patterns(&header, bytes);
PlayerState::new(&header, samples, patterns, 44_100)
}
#[test]
fn decodes_patterns() {
let bytes = synth_square_mod();
let header = parse_header(&bytes).unwrap();
let patterns = parse_patterns(&header, &bytes);
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0].rows.len(), 64);
assert_eq!(patterns[0].rows[0].len(), 4);
let n = patterns[0].rows[0][0];
assert_eq!(n.period, 428);
assert_eq!(n.sample, 1);
}
#[test]
fn player_renders_nonzero_audio() {
let bytes = synth_square_mod();
let mut player = make_player(&bytes);
let mut buf = vec![0i16; 4410 * 2];
let produced = player.render(&mut buf);
assert_eq!(produced, 4410);
let nonzero = buf.iter().filter(|&&x| x != 0).count();
assert!(
nonzero > 100,
"expected non-silent PCM, got {nonzero} non-zero samples"
);
}
#[test]
fn samples_per_tick_default() {
let bytes = synth_square_mod();
let player = make_player(&bytes);
assert_eq!(player.samples_per_tick(), 882);
}
#[test]
fn render_per_channel_isolates_channels() {
let bytes = synth_square_mod();
let mut player = make_player(&bytes);
let n_frames = 4410;
let mut planes: Vec<Vec<i16>> = (0..player.channels.len())
.map(|_| vec![0i16; n_frames])
.collect();
let produced = {
let mut views: Vec<&mut [i16]> = planes.iter_mut().map(|v| v.as_mut_slice()).collect();
player.render_per_channel(&mut views, n_frames)
};
assert_eq!(produced, n_frames);
let ch0_nonzero = planes[0].iter().filter(|&&s| s != 0).count();
assert!(
ch0_nonzero > 100,
"channel 0 should carry audible signal, got {ch0_nonzero} non-zero samples"
);
for (i, plane) in planes.iter().enumerate().skip(1) {
let nonzero = plane.iter().filter(|&&s| s != 0).count();
assert_eq!(
nonzero, 0,
"channel {i} should be silent in synth_square_mod, got {nonzero} non-zero samples"
);
}
}
#[test]
fn render_per_channel_matches_mixed_song_length() {
let bytes = synth_square_mod();
let mut player_mixed = make_player(&bytes);
let mut player_planar = make_player(&bytes);
let n_frames = 2205;
let mut mixed = vec![0i16; n_frames * 2];
let produced_mixed = player_mixed.render(&mut mixed);
let mut planes: Vec<Vec<i16>> = (0..player_planar.channels.len())
.map(|_| vec![0i16; n_frames])
.collect();
let produced_planar = {
let mut views: Vec<&mut [i16]> = planes.iter_mut().map(|v| v.as_mut_slice()).collect();
player_planar.render_per_channel(&mut views, n_frames)
};
assert_eq!(produced_mixed, n_frames);
assert_eq!(produced_planar, n_frames);
}
#[test]
fn period_table_cross_check_against_spec() {
let ft0 = &PERIOD_TABLE[0];
assert_eq!(ft0[0], 856, "C-1 @ ft 0");
assert_eq!(ft0[11], 453, "B-1 @ ft 0");
assert_eq!(ft0[12], 428, "C-2 @ ft 0");
assert_eq!(ft0[33], 127, "A-3 @ ft 0");
assert_eq!(ft0[35], 113, "B-3 @ ft 0");
assert_eq!(PERIOD_TABLE[1][12], 425, "C-2 @ ft +1");
assert_eq!(PERIOD_TABLE[15][12], 431, "C-2 @ ft -1");
}
#[test]
fn sine_table_matches_protracker_half_wave() {
assert_eq!(PROTRACKER_SINE_TABLE[0], 0);
assert_eq!(PROTRACKER_SINE_TABLE[8], 180);
assert_eq!(PROTRACKER_SINE_TABLE[16], 255);
assert_eq!(PROTRACKER_SINE_TABLE[24], 180);
assert_eq!(PROTRACKER_SINE_TABLE[31], 24);
}
fn step_one_tick(player: &mut PlayerState) {
let spt = player.samples_per_tick() as usize;
let mut buf = vec![0i16; spt * 2];
player.render(&mut buf);
}
#[test]
fn tone_porta_reaches_target_period_exactly() {
let bytes = synth_mod_with_pattern(&[
(
0,
0,
Note {
period: 428,
sample: 1,
effect: 0,
effect_param: 0,
},
),
(
1,
0,
Note {
period: 254,
sample: 0,
effect: 0x3,
effect_param: 0x10,
},
),
(
2,
0,
Note {
period: 0,
sample: 0,
effect: 0x3,
effect_param: 0x00,
},
),
(
3,
0,
Note {
period: 0,
sample: 0,
effect: 0x3,
effect_param: 0x00,
},
),
]);
let mut player = make_player(&bytes);
for _ in 0..24 {
step_one_tick(&mut player);
}
assert_eq!(
player.channels[0].period, 254,
"tone porta must clamp at target"
);
}
#[test]
fn vibrato_modulates_period_symmetrically() {
let bytes = synth_mod_with_pattern(&[
(
0,
0,
Note {
period: 428,
sample: 1,
effect: 0x4,
effect_param: 0x84,
},
),
(
1,
0,
Note {
period: 0,
sample: 0,
effect: 0x4,
effect_param: 0x00,
},
),
]);
let mut player = make_player(&bytes);
let mut max_delta = 0i32;
let mut min_delta = 0i32;
for _ in 0..12 {
step_one_tick(&mut player);
let off = PlayerState::vibrato_offset(&player.channels[0]) as i32;
max_delta = max_delta.max(off);
min_delta = min_delta.min(off);
}
assert!(
max_delta >= 4,
"expected positive vibrato swing, got {max_delta}"
);
assert!(
min_delta <= -4,
"expected negative vibrato swing, got {min_delta}"
);
}
#[test]
fn tremolo_modulates_volume_symmetrically() {
let bytes = synth_mod_with_pattern(&[
(
0,
0,
Note {
period: 428,
sample: 1,
effect: 0xC,
effect_param: 0x20,
},
),
(
1,
0,
Note {
period: 0,
sample: 0,
effect: 0x7,
effect_param: 0x84,
},
),
(
2,
0,
Note {
period: 0,
sample: 0,
effect: 0x7,
effect_param: 0x00,
},
),
]);
let mut player = make_player(&bytes);
let mut max_delta = 0i32;
let mut min_delta = 0i32;
for _ in 0..18 {
step_one_tick(&mut player);
let off = PlayerState::tremolo_offset(&player.channels[0]) as i32;
max_delta = max_delta.max(off);
min_delta = min_delta.min(off);
}
assert!(
max_delta >= 8,
"expected positive tremolo swing, got {max_delta}"
);
assert!(
min_delta <= -8,
"expected negative tremolo swing, got {min_delta}"
);
}
#[test]
fn sample_offset_advances_into_sample() {
let mut bytes = synth_mod_with_pattern(&[(
0,
0,
Note {
period: 428,
sample: 1,
effect: 0x9,
effect_param: 0x01,
},
)]);
bytes[20 + 22..20 + 24].copy_from_slice(&256u16.to_be_bytes());
bytes[20 + 28..20 + 30].copy_from_slice(&0u16.to_be_bytes());
bytes.extend(std::iter::repeat_n(0u8, 480));
let mut player = make_player(&bytes);
step_one_tick(&mut player);
assert_eq!(
player.channels[0].mem_sample_offset, 0x01,
"9xx memory not latched"
);
assert!(
player.channels[0].sample_pos >= 256.0,
"expected sample_pos >= 256, got {}",
player.channels[0].sample_pos
);
}
#[test]
fn fine_porta_up_and_down_shift_period_once() {
let bytes = synth_mod_with_pattern(&[
(
0,
0,
Note {
period: 428,
sample: 1,
effect: 0,
effect_param: 0,
},
),
(
1,
0,
Note {
period: 0,
sample: 0,
effect: 0xE,
effect_param: 0x12,
},
),
(
2,
0,
Note {
period: 0,
sample: 0,
effect: 0xE,
effect_param: 0x23,
},
),
]);
let mut player = make_player(&bytes);
step_one_tick(&mut player); assert_eq!(player.channels[0].period, 428);
for _ in 0..5 {
step_one_tick(&mut player);
}
step_one_tick(&mut player); assert_eq!(player.channels[0].period, 426, "E12 must slide up by 2");
for _ in 0..5 {
step_one_tick(&mut player);
}
step_one_tick(&mut player); assert_eq!(
player.channels[0].period, 429,
"E23 must slide down by 3 from 426"
);
}
#[test]
fn pattern_loop_e6_loops_then_advances() {
let bytes = synth_mod_with_pattern(&[
(
0,
0,
Note {
period: 428,
sample: 1,
effect: 0,
effect_param: 0,
},
),
(
1,
0,
Note {
period: 0,
sample: 0,
effect: 0xE,
effect_param: 0x60,
},
),
(
2,
0,
Note {
period: 0,
sample: 0,
effect: 0xE,
effect_param: 0x62,
},
),
(
3,
0,
Note {
period: 339,
sample: 1,
effect: 0,
effect_param: 0,
},
),
]);
let mut player = make_player(&bytes);
let mut visited: Vec<u8> = Vec::new();
for _ in 0..60 {
step_one_tick(&mut player);
if let Some(&last) = visited.last() {
if last != player.row {
visited.push(player.row);
}
} else {
visited.push(player.row);
}
if player.row > 3 || player.ended {
break;
}
}
let prefix = &visited[..visited.len().min(8)];
assert_eq!(
prefix,
&[0, 1, 2, 1, 2, 1, 2, 3][..prefix.len()],
"E62 should loop rows 1..=2 twice before advancing; got {visited:?}"
);
}
#[test]
fn retrig_e9_restarts_sample_cursor() {
let bytes = synth_mod_with_pattern(&[(
0,
0,
Note {
period: 428,
sample: 1,
effect: 0xE,
effect_param: 0x91,
},
)]);
let mut player = make_player(&bytes);
step_one_tick(&mut player);
let pos_after_t0 = player.channels[0].sample_pos;
step_one_tick(&mut player);
let pos_after_t1 = player.channels[0].sample_pos;
step_one_tick(&mut player);
let pos_after_t2 = player.channels[0].sample_pos;
assert!(
(pos_after_t1 - pos_after_t0).abs() < 1.0,
"E91 should retrigger; pos_after_t0={pos_after_t0}, pos_after_t1={pos_after_t1}"
);
assert!(
(pos_after_t2 - pos_after_t1).abs() < 1.0,
"E91 should retrigger again; pos_after_t1={pos_after_t1}, pos_after_t2={pos_after_t2}"
);
}
#[test]
fn note_cut_ec_zeros_volume_at_tick() {
let bytes = synth_mod_with_pattern(&[(
0,
0,
Note {
period: 428,
sample: 1,
effect: 0xE,
effect_param: 0xC3,
},
)]);
let mut player = make_player(&bytes);
step_one_tick(&mut player);
assert_eq!(player.channels[0].volume, 64);
step_one_tick(&mut player); step_one_tick(&mut player); step_one_tick(&mut player); assert_eq!(player.channels[0].volume, 0, "EC3 must cut volume at t=3");
}
#[test]
fn note_delay_ed_postpones_trigger() {
let bytes = synth_mod_with_pattern(&[(
0,
0,
Note {
period: 428,
sample: 1,
effect: 0xE,
effect_param: 0xD3,
},
)]);
let mut player = make_player(&bytes);
step_one_tick(&mut player);
assert!(!player.channels[0].active, "ED3 must not trigger on tick 0");
step_one_tick(&mut player);
step_one_tick(&mut player);
step_one_tick(&mut player);
assert!(
player.channels[0].active,
"ED3 must trigger at tick 3; state={:?}",
player.channels[0]
);
assert_eq!(player.channels[0].period, 428);
}
#[test]
fn fine_volume_slide_ea_eb_shifts_volume_once() {
let bytes = synth_mod_with_pattern(&[
(
0,
0,
Note {
period: 428,
sample: 1,
effect: 0xC,
effect_param: 0x20,
},
),
(
1,
0,
Note {
period: 0,
sample: 0,
effect: 0xE,
effect_param: 0xA3,
},
),
(
2,
0,
Note {
period: 0,
sample: 0,
effect: 0xE,
effect_param: 0xB5,
},
),
]);
let mut player = make_player(&bytes);
for _ in 0..6 {
step_one_tick(&mut player);
}
step_one_tick(&mut player);
assert_eq!(player.channels[0].volume, 0x23);
for _ in 0..5 {
step_one_tick(&mut player);
}
step_one_tick(&mut player);
assert_eq!(player.channels[0].volume, 0x1E);
}
#[test]
fn e5_finetune_applies_on_note_row() {
let bytes = synth_mod_with_pattern(&[
(
0,
0,
Note {
period: 428,
sample: 1,
effect: 0xE,
effect_param: 0x50,
},
),
(
1,
0,
Note {
period: 428,
sample: 1,
effect: 0xE,
effect_param: 0x51,
},
),
]);
let mut player = make_player(&bytes);
step_one_tick(&mut player);
assert_eq!(player.channels[0].period, 428);
assert_eq!(player.channels[0].finetune, 0);
for _ in 0..5 {
step_one_tick(&mut player);
}
step_one_tick(&mut player);
assert_eq!(player.channels[0].finetune, 1);
assert_eq!(
player.channels[0].period, 425,
"finetune +1 should retune C-2 to 425"
);
}
#[test]
fn pattern_delay_ee_repeats_row_without_retriggering_effects() {
let bytes = synth_mod_with_pattern(&[
(
0,
0,
Note {
period: 428,
sample: 1,
effect: 0xC,
effect_param: 0x00,
},
),
(
1,
0,
Note {
period: 0,
sample: 0,
effect: 0xE,
effect_param: 0xA2,
},
),
(
1,
1,
Note {
period: 0,
sample: 0,
effect: 0xE,
effect_param: 0xE1,
},
),
]);
let mut player = make_player(&bytes);
for _ in 0..6 {
step_one_tick(&mut player);
}
step_one_tick(&mut player);
assert_eq!(player.channels[0].volume, 2);
for _ in 0..5 {
step_one_tick(&mut player);
}
step_one_tick(&mut player);
assert_eq!(
player.channels[0].volume, 2,
"EEx pattern-delay repeat must not re-fire EA2 (volume must stay 2)"
);
}
#[test]
fn pattern_delay_ee_does_not_retrigger_held_note() {
let bytes = synth_mod_with_pattern(&[
(
0,
0,
Note {
period: 428,
sample: 1,
effect: 0,
effect_param: 0,
},
),
(
1,
0,
Note {
period: 339,
sample: 1,
effect: 0xE,
effect_param: 0xE3,
},
),
]);
let mut player = make_player(&bytes);
for _ in 0..6 {
step_one_tick(&mut player);
}
step_one_tick(&mut player);
assert_eq!(player.channels[0].period, 339, "row 1 note must trigger");
let pos_after_first_trigger = player.channels[0].sample_pos;
let mut prev_pos = pos_after_first_trigger;
for tick_idx in 0..23 {
step_one_tick(&mut player);
let cur_pos = player.channels[0].sample_pos;
assert!(
cur_pos != 0.0 || prev_pos == 0.0,
"tick {tick_idx}: sample_pos jumped back to 0 \
(prev={prev_pos}, cur={cur_pos}) — the EE pattern-delay \
repeat must NOT re-trigger a note that was already \
played on the first pass through the row"
);
prev_pos = cur_pos;
}
}
fn synth_mod_with_loop_sample(
pcm: &[i8],
loop_start_words: u16,
loop_length_words: u16,
) -> Vec<u8> {
let mut out = vec![0u8; crate::header::HEADER_FIXED_SIZE];
out[0..4].copy_from_slice(b"loop");
let length_words = (pcm.len() / 2) as u16;
out[20 + 22..20 + 24].copy_from_slice(&length_words.to_be_bytes());
out[20 + 24] = 0;
out[20 + 25] = 64;
out[20 + 26..20 + 28].copy_from_slice(&loop_start_words.to_be_bytes());
out[20 + 28..20 + 30].copy_from_slice(&loop_length_words.to_be_bytes());
out[950] = 1;
out[951] = 0x7F;
out[952] = 0;
out[1080..1084].copy_from_slice(b"M.K.");
let mut pat = vec![0u8; 64 * 4 * 4];
let p_hi = ((428u16 >> 8) & 0x0F) as u8;
let p_lo = (428u16 & 0xFF) as u8;
pat[0] = p_hi;
pat[1] = p_lo;
pat[2] = 1u8 << 4;
pat[3] = 0;
out.extend(pat);
out.extend(pcm.iter().map(|&s| s as u8));
out
}
#[test]
fn loop_wrap_stays_inside_loop_region() {
let mut pcm: Vec<i8> = vec![50; 64];
pcm.extend(std::iter::repeat_n(-100i8, 136));
assert!(pcm.len().is_multiple_of(2));
let bytes = synth_mod_with_loop_sample(&pcm, 0, 32);
let mut player = make_player(&bytes);
let n_frames = 2205;
let mut planes: Vec<Vec<i16>> = (0..player.channels.len())
.map(|_| vec![0i16; n_frames])
.collect();
let _ = {
let mut views: Vec<&mut [i16]> = planes.iter_mut().map(|v| v.as_mut_slice()).collect();
player.render_per_channel(&mut views, n_frames)
};
for (i, &v) in planes[0].iter().enumerate().skip(64) {
assert!(
v >= 0,
"frame {i}: expected positive +50/128 sample, got {v} \
— mixer leaked past loop boundary into sentinel tail \
(negative value implies the -100 sentinel was read)"
);
}
}
#[test]
fn loop_wrap_handles_loop_end_at_pcm_end() {
let mut pcm: Vec<i8> = (0..16).map(|_| 100).collect();
pcm.extend(std::iter::repeat_n(-100i8, 16));
let bytes = synth_mod_with_loop_sample(&pcm, 0, 16);
let mut player = make_player(&bytes);
let mut buf = vec![0i16; 4410 * 2];
let produced = player.render(&mut buf);
assert_eq!(produced, 4410);
let nonzero = buf.iter().filter(|&&x| x != 0).count();
assert!(nonzero > 4000, "expected loud square wave output");
}
#[test]
fn sample_swap_without_note_is_deferred() {
let mut bytes = vec![0u8; crate::header::HEADER_FIXED_SIZE];
bytes[0..4].copy_from_slice(b"swap");
bytes[20 + 22..20 + 24].copy_from_slice(&16u16.to_be_bytes());
bytes[20 + 24] = 0;
bytes[20 + 25] = 64;
bytes[20 + 26..20 + 28].copy_from_slice(&0u16.to_be_bytes());
bytes[20 + 28..20 + 30].copy_from_slice(&0u16.to_be_bytes());
bytes[50 + 22..50 + 24].copy_from_slice(&16u16.to_be_bytes());
bytes[50 + 24] = 3;
bytes[50 + 25] = 32;
bytes[50 + 26..50 + 28].copy_from_slice(&0u16.to_be_bytes());
bytes[50 + 28..50 + 30].copy_from_slice(&0u16.to_be_bytes());
bytes[950] = 1;
bytes[951] = 0x7F;
bytes[952] = 0;
bytes[1080..1084].copy_from_slice(b"M.K.");
let mut pat = vec![0u8; 64 * 4 * 4];
let off = 0;
pat[off] = ((428u16 >> 8) & 0x0F) as u8;
pat[off + 1] = (428u16 & 0xFF) as u8;
pat[off + 2] = 1u8 << 4;
let off = 4 * 4;
pat[off] = 0; pat[off + 1] = 0;
pat[off + 2] = 2u8 << 4; pat[off + 3] = 0;
let off = 2 * 4 * 4;
pat[off] = ((381u16 >> 8) & 0x0F) as u8;
pat[off + 1] = (381u16 & 0xFF) as u8;
pat[off + 2] = 0; pat[off + 3] = 0;
bytes.extend(pat);
bytes.extend(std::iter::repeat_n(50i8 as u8, 32));
bytes.extend(std::iter::repeat_n((-50i8) as u8, 32));
let mut player = make_player(&bytes);
for _ in 0..6 {
step_one_tick(&mut player);
}
assert_eq!(player.channels[0].sample_index, 1, "row 0: sample 1 active");
assert_eq!(player.channels[0].volume, 64, "row 0: vol from sample 1");
for _ in 0..6 {
step_one_tick(&mut player);
}
assert_eq!(
player.channels[0].sample_index, 1,
"row 1: sample 2 written without note — active sample MUST still be 1"
);
assert_eq!(
player.channels[0].volume, 32,
"row 1: sample 2's default volume must apply immediately"
);
assert_eq!(
player.channels[0].finetune, 3,
"row 1: sample 2's finetune must apply immediately"
);
assert_eq!(
player.channels[0].pending_sample, 2,
"row 1: pending sample swap should be queued"
);
for _ in 0..6 {
step_one_tick(&mut player);
}
assert_eq!(
player.channels[0].sample_index, 2,
"row 2: pending sample 2 swap must be consumed by note trigger"
);
assert_eq!(
player.channels[0].pending_sample, 0,
"row 2: pending_sample must clear on consumption"
);
}
#[test]
fn period_clamp_constants_match_pt_spec() {
assert_eq!(PERIOD_MIN, 113);
assert_eq!(PERIOD_MAX, 856);
assert_eq!(PERIOD_MIN_EXT, 108);
assert_eq!(PERIOD_MAX_EXT, 907);
assert_eq!(PERIOD_TABLE[7][35], 108, "FT +7 B-3 must equal 108");
assert_eq!(PERIOD_TABLE[8][0], 907, "FT -8 C-1 must equal 907");
}
#[test]
fn porta_up_clamps_at_period_113() {
let bytes = synth_mod_with_pattern(&[
(
0,
0,
Note {
period: 120,
sample: 1,
effect: 0,
effect_param: 0,
},
),
(
1,
0,
Note {
period: 0,
sample: 0,
effect: 0x1,
effect_param: 0xFF,
},
),
]);
let mut player = make_player(&bytes);
for _ in 0..12 {
step_one_tick(&mut player);
}
assert_eq!(
player.channels[0].period, 113,
"1xx must clamp at period 113 (B-3)"
);
}
#[test]
fn porta_down_clamps_at_period_856() {
let bytes = synth_mod_with_pattern(&[
(
0,
0,
Note {
period: 800,
sample: 1,
effect: 0,
effect_param: 0,
},
),
(
1,
0,
Note {
period: 0,
sample: 0,
effect: 0x2,
effect_param: 0xFF,
},
),
]);
let mut player = make_player(&bytes);
for _ in 0..12 {
step_one_tick(&mut player);
}
assert_eq!(
player.channels[0].period, 856,
"2xx must clamp at period 856 (C-1)"
);
}
#[test]
fn effective_period_accepts_finetune_extreme_below_113() {
let mut ch = Channel {
period: 108,
..Channel::default()
};
ch.effect = 0;
ch.mem_vibrato = 0;
let eff = ch.effective_period(0);
assert_eq!(eff, 108, "FT +7 B-3 (period 108) must not be clamped");
}
#[test]
fn led_filter_alpha_matches_one_pole_lowpass_at_cutoff() {
let a = PlayerState::compute_alpha(44_100, PlayerState::LED_FILTER_CUTOFF_HZ);
let two_pi = 2.0 * std::f32::consts::PI;
let expected = 1.0 - (-two_pi * PlayerState::LED_FILTER_CUTOFF_HZ / 44_100.0).exp();
let diff = (a - expected).abs();
assert!(
diff < 1e-6,
"LED alpha mismatch: got {a}, expected {expected}"
);
assert!(a > 0.0 && a <= 1.0, "alpha {a} outside (0, 1]");
}
#[test]
fn led_filter_attenuates_nyquist_input() {
let make_player_with_led = |led: bool| -> PlayerState {
let bytes = synth_square_mod();
let mut player = make_player(&bytes);
player.led_filter = led;
player.led_filter_state.clear();
player.led_filter_state2.clear();
player.led_filter_alpha = f32::NAN;
player.led_filter_alpha2 = f32::NAN;
player.ensure_led_filter(2);
player
};
let drive = |player: &mut PlayerState, n: usize| -> Vec<f32> {
let mut out = Vec::with_capacity(n);
for i in 0..n {
let x = if i % 2 == 0 { 1.0 } else { -1.0 };
out.push(player.led_filter_step(0, x));
}
out
};
let mut p_on = make_player_with_led(true);
let mut p_off = make_player_with_led(false);
let on = drive(&mut p_on, 256);
let off = drive(&mut p_off, 256);
let on_pp: f32 = on[128..].iter().map(|x| x.abs()).fold(0.0f32, f32::max);
let off_pp: f32 = off[128..].iter().map(|x| x.abs()).fold(0.0f32, f32::max);
assert!(
off_pp < 1.0,
"filter-off magnitude {off_pp} should attenuate via the \
always-on RC pole even when LED is off"
);
assert!(
on_pp < off_pp,
"filter-on magnitude {on_pp} should be strictly < \
filter-off magnitude {off_pp} (LED adds a second pole)"
);
}
#[test]
fn led_filter_default_is_on_at_song_start() {
let bytes = synth_square_mod();
let player = make_player(&bytes);
assert!(
player.led_filter,
"LED filter must default to ON (Amiga power-on state)"
);
}
#[test]
fn e0x_toggles_led_filter() {
let bytes = synth_mod_with_pattern(&[
(
0,
0,
Note {
period: 428,
sample: 1,
effect: 0,
effect_param: 0,
},
),
(
1,
0,
Note {
period: 0,
sample: 0,
effect: 0xE,
effect_param: 0x01,
},
),
(
2,
0,
Note {
period: 0,
sample: 0,
effect: 0xE,
effect_param: 0x00,
},
),
]);
let mut player = make_player(&bytes);
for _ in 0..6 {
step_one_tick(&mut player);
}
assert!(player.led_filter, "row 0: LED still ON");
step_one_tick(&mut player);
assert!(!player.led_filter, "row 1: E01 must clear LED");
for _ in 0..5 {
step_one_tick(&mut player);
}
step_one_tick(&mut player);
assert!(player.led_filter, "row 2: E00 must restore LED");
}
#[test]
fn fxx_speed_bpm_split_at_0x20() {
let bytes_speed = synth_mod_with_pattern(&[(
0,
0,
Note {
period: 0,
sample: 0,
effect: 0xF,
effect_param: 0x1F,
},
)]);
let mut player = make_player(&bytes_speed);
step_one_tick(&mut player);
assert_eq!(player.speed, 0x1F, "F1F must set speed to 31");
assert_eq!(player.bpm, DEFAULT_BPM, "F1F must NOT touch BPM");
let bytes_bpm = synth_mod_with_pattern(&[(
0,
0,
Note {
period: 0,
sample: 0,
effect: 0xF,
effect_param: 0x20,
},
)]);
let mut player = make_player(&bytes_bpm);
step_one_tick(&mut player);
assert_eq!(player.bpm, 0x20, "F20 must set BPM to 32");
assert_eq!(player.speed, DEFAULT_SPEED, "F20 must NOT touch speed");
}
#[test]
fn e6_dxy_same_row_last_channel_wins() {
let bytes = synth_mod_with_pattern(&[
(
0,
0,
Note {
period: 428,
sample: 1,
effect: 0,
effect_param: 0,
},
),
(
1,
0,
Note {
period: 0,
sample: 0,
effect: 0xE,
effect_param: 0x60,
},
),
(
2,
0,
Note {
period: 0,
sample: 0,
effect: 0xE,
effect_param: 0x61,
},
),
(
2,
1,
Note {
period: 0,
sample: 0,
effect: 0xD,
effect_param: 0x05,
},
),
]);
let mut player = make_player(&bytes);
for _ in 0..18 {
step_one_tick(&mut player);
}
step_one_tick(&mut player);
assert!(
player.ended || player.row == 5,
"Dxy must override E6x when on a higher-numbered channel; \
got row={}, order={}, ended={}",
player.row,
player.order_index,
player.ended,
);
}
#[test]
fn vibrato_first_half_lowers_pitch_per_firelight_pseudocode() {
let mut ch = Channel {
period: 428,
sample_index: 1,
volume: 64,
active: true,
effect: 0x4,
mem_vibrato: 0x84, vib_pos: 8, ..Channel::default()
};
ch.vib_wave.shape = 0; let off = PlayerState::vibrato_offset(&ch);
assert!(
off > 0,
"Per FireLight §5.5: positive vib_pos must ADD to period \
(lowering pitch). Got offset {off}."
);
let eff = ch.effective_period(off);
assert!(
eff > ch.period,
"effective_period must rise on positive vib_pos"
);
ch.vib_pos = -8;
let off = PlayerState::vibrato_offset(&ch);
assert!(
off < 0,
"Per FireLight §5.5: negative vib_pos must SUBTRACT from period \
(raising pitch). Got offset {off}."
);
}
#[test]
fn loop_metadata_clamped_when_out_of_range() {
let mut bytes = vec![0u8; crate::header::HEADER_FIXED_SIZE];
bytes[0..4].copy_from_slice(b"clmp");
bytes[20 + 22..20 + 24].copy_from_slice(&16u16.to_be_bytes());
bytes[20 + 25] = 64;
bytes[20 + 26..20 + 28].copy_from_slice(&100u16.to_be_bytes());
bytes[20 + 28..20 + 30].copy_from_slice(&200u16.to_be_bytes());
bytes[950] = 1;
bytes[951] = 0x7F;
bytes[952] = 0;
bytes[1080..1084].copy_from_slice(b"M.K.");
bytes.extend(std::iter::repeat_n(0u8, 64 * 4 * 4));
bytes.extend(std::iter::repeat_n(0u8, 32));
let header = crate::header::parse_header(&bytes).unwrap();
let samples = crate::samples::extract_samples(&header, &bytes);
let s = &samples[0];
let end = s.loop_start + s.loop_length;
assert!(
end as usize <= s.pcm.len(),
"loop_end ({end}) must be clamped to pcm.len() ({})",
s.pcm.len()
);
}
#[test]
fn arpeggio_base_persists_across_rows_without_new_note() {
let bytes = synth_mod_with_pattern(&[
(
0,
0,
Note {
period: 428,
sample: 1,
effect: 0,
effect_param: 0x34,
},
),
(
1,
0,
Note {
period: 0,
sample: 0,
effect: 0,
effect_param: 0x34,
},
),
]);
let mut player = make_player(&bytes);
for _ in 0..8 {
step_one_tick(&mut player);
}
assert_eq!(
player.channels[0].arp_base_period, 428,
"arpeggio base must persist across rows that have no new \
note — got {}, expected 428 (the original note period). \
Pre-fix this would equal the previous row's last-tick \
modulated period.",
player.channels[0].arp_base_period
);
assert_eq!(
player.channels[0].period,
PERIOD_TABLE[0][12 + 3],
"row 1 tick 1 of a continuation arpeggio must land on \
base + x semitones (PERIOD_TABLE[0][15] = {}), not on \
a doubly-shifted value",
PERIOD_TABLE[0][12 + 3]
);
}
}