use crate::mixer::{MixerVoice, PitchModel, StmC3Pitch};
use crate::stm::{
StmCell, StmHeader, StmNoteKind, StmPattern, StmSampleBody, PATTERN_ROWS, STM_CHANNELS,
};
pub const DEFAULT_SPEED_TICKS: u8 = 6;
#[rustfmt::skip]
const SINE_TABLE: [i8; 64] = [
0, 12, 25, 37, 49, 60, 71, 81,
90, 98, 106, 112, 117, 122, 125, 126,
127, 126, 125, 122, 117, 112, 106, 98,
90, 81, 71, 60, 49, 37, 25, 12,
0, -12, -25, -37, -49, -60, -71, -81,
-90, -98,-106,-112,-117,-122,-125,-126,
-127,-126,-125,-122,-117,-112,-106, -98,
-90, -81, -71, -60, -49, -37, -25, -12,
];
const SEMITONE_UNITS: f32 = 16.0;
fn note_to_semis(octave: u8, semitone: u8) -> f32 {
(octave as f32) * 12.0 + (semitone as f32)
}
fn semis_to_freq(c3_hz: f32, semis_from_c0: f32) -> f32 {
if c3_hz <= 0.0 {
return 0.0;
}
c3_hz * 2.0f32.powf((semis_from_c0 - 3.0 * 12.0) / 12.0)
}
#[derive(Clone, Debug, Default)]
pub struct StmChannel {
pub instrument: u8,
pub note: (u8, u8),
pub volume: u8,
pub voice: MixerVoice,
pub effect: u8,
pub effect_param: u8,
pub cur_semis: f32,
pub porta_target_semis: f32,
pub porta_speed: u8,
pub porta_updown_mem: u8,
pub vib_pos: u8,
pub vib_speed: u8,
pub vib_depth: u8,
pub vol_slide_mem: u8,
pub trem_pos: u8,
pub trem_speed: u8,
pub trem_depth: u8,
pub note_cut_tick: u8,
pub note_delay_tick: u8,
pub pending_note: (u8, u8),
pub pending_instrument: u8,
pub pending_volume: u8,
pub has_pending_delay: bool,
}
pub struct StmPlayerState {
pub samples: Vec<StmSampleBody>,
pub patterns: Vec<StmPattern>,
pub order: Vec<u8>,
pub n_patterns: u8,
pub channels: [StmChannel; STM_CHANNELS],
pub speed: u8,
pub tempo: u8,
pub sample_rate: u32,
pub order_index: usize,
pub row: u8,
pub tick: u8,
pub tick_sample_cursor: u32,
pub ended: bool,
pub global_volume: u8,
pub pending_order_jump: Option<u8>,
pub pending_break_row: Option<u8>,
}
impl StmPlayerState {
pub fn new(
header: &StmHeader,
samples: Vec<StmSampleBody>,
patterns: Vec<StmPattern>,
sample_rate: u32,
) -> Self {
let order: Vec<u8> = header
.order
.iter()
.copied()
.take_while(|&b| b != 255)
.collect();
StmPlayerState {
samples,
patterns,
order,
n_patterns: header.n_patterns,
channels: Default::default(),
speed: DEFAULT_SPEED_TICKS,
tempo: header.tempo.max(1),
sample_rate,
order_index: 0,
row: 0,
tick: 0,
tick_sample_cursor: 0,
ended: false,
global_volume: header.global_volume.max(1),
pending_order_jump: None,
pending_break_row: None,
}
}
pub fn samples_per_tick(&self) -> u32 {
let bpm_equiv = ((self.tempo as u32) * 125 / 0x60).max(30);
((self.sample_rate as f32) * 2.5 / bpm_equiv as f32).max(1.0) as u32
}
fn cell_at(&self, row: u8, ch: usize) -> Option<StmCell> {
let pat_idx = *self.order.get(self.order_index)? as usize;
let pattern = self.patterns.get(pat_idx)?;
pattern.rows.get(row as usize)?.get(ch).copied()
}
fn enter_row(&mut self) {
for ch_idx in 0..STM_CHANNELS {
let Some(cell) = self.cell_at(self.row, ch_idx) else {
continue;
};
let ch = &mut self.channels[ch_idx];
ch.effect = cell.command;
ch.effect_param = cell.command_param;
ch.note_cut_tick = 0;
ch.note_delay_tick = 0;
ch.has_pending_delay = false;
let is_tone_porta_cell = cell.command == 0x3 || cell.command == 0x5;
if cell.instrument != 0 {
ch.instrument = cell.instrument;
if let Some(body) = self.samples.get(cell.instrument as usize - 1) {
ch.volume = body.volume.min(64);
}
}
if cell.volume > 0 && cell.volume <= 64 {
ch.volume = cell.volume;
}
let ep = ch.effect_param;
match ch.effect {
0x1 | 0x2 if ep != 0 => {
ch.porta_updown_mem = ep;
}
0x3 if ep != 0 => {
ch.porta_speed = ep;
}
0x4 => {
let vx = ep >> 4;
let vy = ep & 0x0F;
if vx != 0 {
ch.vib_speed = vx;
}
if vy != 0 {
ch.vib_depth = vy;
}
}
0x7 => {
let tx = ep >> 4;
let ty = ep & 0x0F;
if tx != 0 {
ch.trem_speed = tx;
}
if ty != 0 {
ch.trem_depth = ty;
}
}
0x5 | 0x6 | 0xA if ep != 0 => {
ch.vol_slide_mem = ep;
}
_ => {}
}
match cell.kind() {
StmNoteKind::Note { octave, semitone } if semitone <= 11 => {
let target = note_to_semis(octave, semitone);
if is_tone_porta_cell && ch.voice.active && ch.cur_semis > 0.0 {
ch.note = (octave, semitone);
ch.porta_target_semis = target;
} else {
ch.note = (octave, semitone);
let is_delay = cell.command == 0xE
&& (cell.command_param >> 4) == 0xD
&& (cell.command_param & 0x0F) != 0;
if is_delay {
ch.note_delay_tick = cell.command_param & 0x0F;
ch.pending_note = (octave, semitone);
ch.pending_instrument = cell.instrument;
ch.pending_volume = cell.volume;
ch.has_pending_delay = true;
} else {
let inst_idx = match (ch.instrument as usize).checked_sub(1) {
Some(i) => i,
None => continue,
};
if let Some(body) = self.samples.get(inst_idx) {
let pitch = StmC3Pitch {
c3_hz: body.c3_hz as f32,
};
let freq = pitch.note_to_freq(ch.note);
let vol =
(ch.volume as f32 / 64.0) * (self.global_volume as f32 / 64.0);
ch.voice.trigger(freq, vol);
ch.cur_semis = target;
ch.porta_target_semis = target;
ch.vib_pos = 0;
ch.trem_pos = 0;
}
}
}
}
StmNoteKind::DashNote | StmNoteKind::Dots => {
ch.voice.active = false;
}
_ => {}
}
apply_tick0_effect(ch);
}
for ch in self.channels.iter() {
match ch.effect {
0xB => {
self.pending_order_jump = Some(ch.effect_param);
if self.pending_break_row.is_none() {
self.pending_break_row = Some(0);
}
}
0xD => {
let row = (ch.effect_param >> 4) * 10 + (ch.effect_param & 0x0F);
self.pending_break_row = Some(row.min((PATTERN_ROWS - 1) as u8));
}
0xF => {
if ch.effect_param == 0 {
self.ended = true;
} else if ch.effect_param < 0x20 {
self.speed = ch.effect_param;
} else {
self.tempo = ch.effect_param;
}
}
_ => {}
}
}
}
fn advance_tick(&mut self) {
if self.tick == 0 {
self.enter_row();
} else {
for ch in self.channels.iter_mut() {
apply_tickn_effect(ch);
}
}
let cur_tick = self.tick;
let global_vol = self.global_volume;
for ch_idx in 0..STM_CHANNELS {
let ch = &mut self.channels[ch_idx];
if ch.note_cut_tick > 0 && cur_tick == ch.note_cut_tick {
ch.volume = 0;
}
if ch.has_pending_delay && ch.note_delay_tick > 0 && cur_tick == ch.note_delay_tick {
let inst = ch.pending_instrument;
if inst != 0 {
ch.instrument = inst;
}
let (po, ps) = ch.pending_note;
let idx = ch.instrument.saturating_sub(1) as usize;
if ch.pending_volume > 0 && ch.pending_volume <= 64 {
ch.volume = ch.pending_volume;
}
if let Some(body) = self.samples.get(idx) {
let pitch = StmC3Pitch {
c3_hz: body.c3_hz as f32,
};
let freq = pitch.note_to_freq((po, ps));
let vol = (ch.volume as f32 / 64.0) * (global_vol as f32 / 64.0);
ch.voice.trigger(freq, vol);
ch.note = (po, ps);
ch.cur_semis = note_to_semis(po, ps);
ch.porta_target_semis = ch.cur_semis;
ch.vib_pos = 0;
ch.trem_pos = 0;
}
ch.has_pending_delay = false;
ch.note_delay_tick = 0;
}
let mut semis = ch.cur_semis;
let vib_active = (ch.effect == 0x4 || ch.effect == 0x6) && ch.vib_depth > 0;
if vib_active {
let lfo = SINE_TABLE[(ch.vib_pos & 0x3F) as usize] as f32;
let off_units = (lfo * ch.vib_depth as f32) / 32.0;
let off_semis = off_units / SEMITONE_UNITS;
semis += off_semis;
if cur_tick > 0 {
ch.vib_pos = ch.vib_pos.wrapping_add(ch.vib_speed.wrapping_mul(4)) & 0x3F;
}
}
if ch.effect == 0x0 && ch.effect_param != 0 {
let arp_x = (ch.effect_param >> 4) as f32;
let arp_y = (ch.effect_param & 0x0F) as f32;
semis += match cur_tick % 3 {
0 => 0.0,
1 => arp_x,
_ => arp_y,
};
}
let inst_idx = ch.instrument.saturating_sub(1) as usize;
if let Some(body) = self.samples.get(inst_idx) {
if ch.voice.active && ch.cur_semis > 0.0 {
let new_freq = semis_to_freq(body.c3_hz as f32, semis);
if new_freq > 0.0 {
ch.voice.freq = new_freq;
}
}
}
let trem_active = ch.effect == 0x7 && ch.trem_depth > 0;
let trem_off_units: f32 = if trem_active {
let lfo = SINE_TABLE[(ch.trem_pos & 0x3F) as usize] as f32;
let units = (lfo * ch.trem_depth as f32) / 32.0;
if cur_tick > 0 {
ch.trem_pos = ch.trem_pos.wrapping_add(ch.trem_speed.wrapping_mul(4)) & 0x3F;
}
units
} else {
0.0
};
let modulated = (ch.volume as f32 + trem_off_units).clamp(0.0, 64.0);
ch.voice.volume = (modulated / 64.0) * (global_vol as f32 / 64.0);
}
}
fn next_row(&mut self) {
if let Some(order) = self.pending_order_jump.take() {
self.order_index = order as usize;
self.row = self.pending_break_row.take().unwrap_or(0);
if self.order_index >= self.order.len() {
self.ended = true;
}
return;
}
if let Some(row) = self.pending_break_row.take() {
self.row = row;
self.order_index += 1;
if self.order_index >= self.order.len() {
self.ended = true;
}
return;
}
self.row += 1;
if (self.row as usize) >= PATTERN_ROWS {
self.row = 0;
self.order_index += 1;
if self.order_index >= self.order.len() {
self.ended = true;
}
}
}
pub fn render(&mut self, dst: &mut [i16]) -> usize {
assert!(dst.len() % 2 == 0);
let mut produced = 0usize;
let total_frames = dst.len() / 2;
let out_rate = self.sample_rate as f32;
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 = spt.saturating_sub(self.tick_sample_cursor);
let want = (total_frames - produced).min(remaining as usize);
for _ in 0..want {
let mut l = 0.0f32;
let mut r = 0.0f32;
for (i, ch) in self.channels.iter_mut().enumerate() {
let s = match (ch.instrument as usize).checked_sub(1) {
Some(idx) if idx < self.samples.len() => {
let body = &self.samples[idx];
ch.voice.render_one(body, out_rate)
}
_ => 0.0,
};
if matches!(i % 4, 0 | 3) {
l += s;
} else {
r += s;
}
}
let l = (l / 2.0).clamp(-1.0, 1.0);
let r = (r / 2.0).clamp(-1.0, 1.0);
let off = produced * 2;
dst[off] = (l * 32767.0) as i16;
dst[off + 1] = (r * 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
}
}
fn apply_tick0_effect(ch: &mut StmChannel) {
let ep = ch.effect_param;
let x = ep >> 4;
let y = ep & 0x0F;
match ch.effect {
0xC => {
ch.volume = ep.min(64);
}
0xE => {
match x {
0x1
if y != 0 => {
ch.cur_semis += y as f32 / SEMITONE_UNITS;
}
0x2
if y != 0 => {
ch.cur_semis = (ch.cur_semis - y as f32 / SEMITONE_UNITS).max(0.0);
}
0xA => {
ch.volume = (ch.volume as u16 + y as u16).min(64) as u8;
}
0xB => {
ch.volume = ch.volume.saturating_sub(y);
}
0xC => {
if y == 0 {
ch.volume = 0;
} else {
ch.note_cut_tick = y;
}
}
0xD => {
}
_ => {}
}
}
_ => {}
}
}
fn apply_tickn_effect(ch: &mut StmChannel) {
let effect = ch.effect;
let ep = ch.effect_param;
let x = ep >> 4;
let y = ep & 0x0F;
match effect {
0x1 => {
let p = if ep != 0 { ep } else { ch.porta_updown_mem };
if p != 0 {
ch.cur_semis += p as f32 / SEMITONE_UNITS;
}
}
0x2 => {
let p = if ep != 0 { ep } else { ch.porta_updown_mem };
if p != 0 {
ch.cur_semis = (ch.cur_semis - p as f32 / SEMITONE_UNITS).max(0.0);
}
}
0x3 => {
let speed = (ch.porta_speed as f32) / SEMITONE_UNITS;
if (ch.cur_semis - ch.porta_target_semis).abs() <= speed {
ch.cur_semis = ch.porta_target_semis;
} else if ch.cur_semis < ch.porta_target_semis {
ch.cur_semis += speed;
} else {
ch.cur_semis -= speed;
}
}
0x5 => {
let speed = (ch.porta_speed as f32) / SEMITONE_UNITS;
if (ch.cur_semis - ch.porta_target_semis).abs() <= speed {
ch.cur_semis = ch.porta_target_semis;
} else if ch.cur_semis < ch.porta_target_semis {
ch.cur_semis += speed;
} else {
ch.cur_semis -= speed;
}
apply_vol_slide(ch, ch.vol_slide_mem);
}
0x6 => {
apply_vol_slide(ch, ch.vol_slide_mem);
}
0xA => {
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 apply_vol_slide(ch: &mut StmChannel, mem: u8) {
let hi = mem >> 4;
let lo = mem & 0x0F;
if hi != 0 {
ch.volume = (ch.volume as u16 + hi as u16).min(64) as u8;
} else if lo != 0 {
ch.volume = ch.volume.saturating_sub(lo);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stm::{extract_samples, parse_header, parse_patterns};
pub fn build_ping_stm() -> Vec<u8> {
const HEADER_PREFIX: usize = 0x30;
const ORDER_OFF: usize = 0x3D0;
const ORDER_SIZE: usize = 64;
const PATTERN_OFF: usize = 0x410;
const BYTES_PER_PATTERN: usize = 64 * 4 * 4;
let n_patterns = 1u8;
let mut out = vec![0u8; PATTERN_OFF];
out[0..4].copy_from_slice(b"ping");
out[0x14..0x1C].copy_from_slice(b"!Scream!");
out[0x1C] = 0x1A;
out[0x1D] = 2;
out[0x1E] = 2;
out[0x20] = 0x60;
out[0x21] = n_patterns;
out[0x22] = 64;
let inst_off = HEADER_PREFIX;
out[inst_off..inst_off + 3].copy_from_slice(b"snd");
out[inst_off + 16..inst_off + 18].copy_from_slice(&64u16.to_le_bytes());
out[inst_off + 22] = 64;
out[inst_off + 24..inst_off + 26].copy_from_slice(&8363u16.to_le_bytes());
for i in 0..ORDER_SIZE {
out[ORDER_OFF + i] = if i == 0 { 0 } else { 255 };
}
let mut pattern = vec![0u8; BYTES_PER_PATTERN];
pattern[0] = 0x40; pattern[1] = 1 << 3;
pattern[2] = 0;
pattern[3] = 0;
out.extend(pattern);
for i in 0..64 {
let v: i8 = if i < 32 { 100 } else { -100 };
out.push(v as u8);
}
out
}
#[test]
fn stm_player_emits_nonzero_audio() {
let bytes = build_ping_stm();
let h = parse_header(&bytes).unwrap();
let pats = parse_patterns(&h, &bytes);
let samples = extract_samples(&h, &bytes);
let mut p = StmPlayerState::new(&h, samples, pats, 44_100);
let mut buf = vec![0i16; 4410 * 2];
let produced = p.render(&mut buf);
assert_eq!(produced, 4410);
let nonzero = buf.iter().filter(|&&x| x != 0).count();
assert!(nonzero > 100, "expected audible PCM, got {nonzero} nonzero");
}
fn build_arpeggio_stm() -> Vec<u8> {
let mut out = build_ping_stm();
const PATTERN_OFF: usize = 0x410;
out[PATTERN_OFF + 2] = 0x00;
out[PATTERN_OFF + 3] = 0x37;
out
}
#[test]
fn arpeggio_cycles_note_x_y_half_steps() {
let bytes = build_arpeggio_stm();
let h = parse_header(&bytes).unwrap();
let pats = parse_patterns(&h, &bytes);
let samples = extract_samples(&h, &bytes);
let mut p = StmPlayerState::new(&h, samples, pats, 44_100);
let base = semis_to_freq(8363.0, 48.0);
assert!((base - 16726.0).abs() < 1.0, "base freq = {base}");
let expected = |semis_off: f32| base * 2.0f32.powf(semis_off / 12.0);
let cases = [0.0f32, 3.0, 7.0, 0.0, 3.0, 7.0];
for (tick, &off) in cases.iter().enumerate() {
p.tick = tick as u8;
p.advance_tick();
let got = p.channels[0].voice.freq;
let want = expected(off);
assert!(
(got - want).abs() < 1.0,
"tick {tick}: arpeggio +{off} semis: got {got}, want {want}"
);
}
}
#[test]
fn arpeggio_zero_param_is_inert() {
let mut bytes = build_ping_stm();
const PATTERN_OFF: usize = 0x410;
bytes[PATTERN_OFF + 2] = 0x00; bytes[PATTERN_OFF + 3] = 0x00; let h = parse_header(&bytes).unwrap();
let pats = parse_patterns(&h, &bytes);
let samples = extract_samples(&h, &bytes);
let mut p = StmPlayerState::new(&h, samples, pats, 44_100);
let base = semis_to_freq(8363.0, 48.0);
for tick in 0u8..4 {
p.tick = tick;
p.advance_tick();
let got = p.channels[0].voice.freq;
assert!(
(got - base).abs() < 1.0,
"tick {tick}: zero-param effect-0 must hold base {base}, got {got}"
);
}
}
fn build_tremolo_stm(speed: u8, depth: u8, vol: u8) -> Vec<u8> {
let mut out = build_ping_stm();
const PATTERN_OFF: usize = 0x410;
let v = vol & 0x3F;
out[PATTERN_OFF + 1] = (1 << 3) | (v & 0x07);
out[PATTERN_OFF + 2] = ((v >> 3) << 4) | 0x07; out[PATTERN_OFF + 3] = (speed << 4) | (depth & 0x0F);
out
}
#[test]
fn tremolo_modulates_volume_symmetrically() {
let bytes = build_tremolo_stm(4, 15, 32);
let h = parse_header(&bytes).unwrap();
let pats = parse_patterns(&h, &bytes);
let samples = extract_samples(&h, &bytes);
let mut p = StmPlayerState::new(&h, samples, pats, 44_100);
let mut min_vol = f32::INFINITY;
let mut max_vol = f32::NEG_INFINITY;
for tick in 0u8..32 {
p.tick = tick;
p.advance_tick();
let v = p.channels[0].voice.volume;
if v < min_vol {
min_vol = v;
}
if v > max_vol {
max_vol = v;
}
}
assert!(
min_vol < 0.4,
"tremolo should swing volume below baseline 0.5: min = {min_vol}"
);
assert!(
max_vol > 0.6,
"tremolo should swing volume above baseline 0.5: max = {max_vol}"
);
}
#[test]
fn tremolo_zero_param_uses_memory() {
let mut bytes = build_tremolo_stm(4, 8, 32);
const PATTERN_OFF: usize = 0x410;
const BYTES_PER_ROW: usize = 4 * 4;
let row1 = PATTERN_OFF + BYTES_PER_ROW;
bytes[row1] = 253;
bytes[row1 + 1] = 0;
bytes[row1 + 2] = 0x07;
bytes[row1 + 3] = 0x00; let h = parse_header(&bytes).unwrap();
let pats = parse_patterns(&h, &bytes);
let samples = extract_samples(&h, &bytes);
let mut p = StmPlayerState::new(&h, samples, pats, 44_100);
for tick in 0u8..6 {
p.tick = tick;
p.advance_tick();
}
p.next_row();
let mut saw_swing = false;
for tick in 0u8..6 {
p.tick = tick;
p.advance_tick();
let v = p.channels[0].voice.volume;
if (v - 0.5).abs() > 0.05 {
saw_swing = true;
}
}
assert!(
saw_swing,
"700 must reuse last non-zero 7xy params (depth/speed memory)"
);
}
#[test]
fn tremolo_inert_at_zero_depth() {
let bytes = build_tremolo_stm(0, 0, 32);
let h = parse_header(&bytes).unwrap();
let pats = parse_patterns(&h, &bytes);
let samples = extract_samples(&h, &bytes);
let mut p = StmPlayerState::new(&h, samples, pats, 44_100);
for tick in 0u8..6 {
p.tick = tick;
p.advance_tick();
let v = p.channels[0].voice.volume;
assert!(
(v - 0.5).abs() < 1e-4,
"tick {tick}: 700 with empty memory must leave volume unmodulated, got {v}"
);
}
}
#[test]
fn note_to_semis_places_c3_at_36() {
assert_eq!(note_to_semis(3, 0), 36.0);
assert_eq!(note_to_semis(4, 0), 48.0);
assert_eq!(note_to_semis(4, 7), 55.0);
}
#[test]
fn semis_to_freq_round_trips_c3() {
let f = semis_to_freq(8363.0, 36.0);
assert!((f - 8363.0).abs() < 0.5);
let f = semis_to_freq(8363.0, 48.0);
assert!((f - 16726.0).abs() < 1.0);
}
}