oxideav_mod/stm_player.rs
1//! Scream Tracker v1 (`.stm`) playback engine.
2//!
3//! Drives the shared [`crate::mixer::MixerVoice`] core over STM's
4//! 4-channel pattern engine. STM uses per-instrument C3 frequencies
5//! rather than Amiga periods (see [`crate::mixer::StmC3Pitch`]) and
6//! ProTracker-like effect columns.
7//!
8//! Effects implemented (round 9 onward):
9//!
10//! - 0xy: arpeggio — cycle the pitch through note / note+x / note+y
11//! half-steps across the row's ticks (`counter mod 3` per
12//! `Protracker-effects-MODFIL12.txt` 0:Arpeggio), then back to the
13//! note. Pure additive offset so porta / vibrato continue underneath.
14//! - Cxx: set volume.
15//! - Axy: volume slide (+x per tick, else -y).
16//! - Fxx: set speed / tempo (<0x20 = speed, >=0x20 = tempo).
17//! - Bxy: order / position jump.
18//! - Dxy: pattern break (FT2-style decimal `x*10+y` landing row —
19//! matches XM parity; STM files in the wild typically encode row 0
20//! as plain `D00` so the decimal quirk is a non-issue in practice).
21//! - 1xy: porta up — shift pitch up by `p` units per tick.
22//! - 2xy: porta down — shift pitch down.
23//! - 3xy: tone portamento — glide semitone-offset toward the target
24//! note without retriggering; per-channel `porta_target` + memory.
25//! - 4xy: vibrato — sine LFO on pitch in semitone units; per-nibble
26//! speed + depth memory.
27//! - 5xy: tone porta + volume slide (combined).
28//! - 6xy: vibrato + volume slide.
29//! - 7xy: tremolo — sine LFO on output volume (clamped 0..=64); shares
30//! no memory with vibrato (separate `trem_speed` / `trem_depth`
31//! nibble registers per the canonical "if either xxxx or yyyy are 0,
32//! then values from the most recent prior tremolo will be used"
33//! rule in `Protracker-effects-MODFIL12.txt` 7:Tremolo).
34//! - Exy subcommands: E1x / E2x fine porta, EA x / EB x fine volume
35//! slide, EC x note cut, ED x note delay.
36//!
37//! STM lacks period representation (pitch is derived from C3 Hz and a
38//! `(octave, semitone)` token), so pitch effects operate in the
39//! **semitone** domain:
40//!
41//! `freq = base_c3_hz * 2 ^ ((cur_semitone_offset - 3*12) / 12)`
42//!
43//! where `cur_semitone_offset` is a fractional semitone count measured
44//! from C-0. Porta "period units" in STM are very loose; we treat the
45//! ProTracker-style parameter as *centisemitones* (1 unit = 1/16 of a
46//! semitone) which gives musically-plausible slide rates — a 3xy speed
47//! of 0x80 traverses an octave in ~12 ticks.
48
49use crate::mixer::{MixerVoice, PitchModel, StmC3Pitch};
50use crate::stm::{
51 StmCell, StmHeader, StmNoteKind, StmPattern, StmSampleBody, PATTERN_ROWS, STM_CHANNELS,
52};
53
54/// Classic tracker pacing constants. STM's tempo field is related to the
55/// ticks-per-row and BPM-equivalent values; we keep the MOD defaults
56/// unless the file overrides them.
57pub const DEFAULT_SPEED_TICKS: u8 = 6;
58
59/// 64-entry signed sine table for vibrato — one quarter-wave reaches
60/// ±127 at indices 16 and 48. Matches the XM vibrato table so the STM
61/// and XM vibrato implementations behave identically.
62#[rustfmt::skip]
63const SINE_TABLE: [i8; 64] = [
64 0, 12, 25, 37, 49, 60, 71, 81,
65 90, 98, 106, 112, 117, 122, 125, 126,
66 127, 126, 125, 122, 117, 112, 106, 98,
67 90, 81, 71, 60, 49, 37, 25, 12,
68 0, -12, -25, -37, -49, -60, -71, -81,
69 -90, -98,-106,-112,-117,-122,-125,-126,
70 -127,-126,-125,-122,-117,-112,-106, -98,
71 -90, -81, -71, -60, -49, -37, -25, -12,
72];
73
74/// Number of "porta units" per semitone. ProTracker's porta parameters
75/// are in Amiga period units, where one semitone ≈ 38 units near C-4.
76/// STM has no periods, so we pick a scale that matches musical
77/// expectations: `SEMITONE_UNITS = 16` means a 3xy speed of 16 walks
78/// exactly one semitone per tick, so speed 0x08 ≈ half a semitone.
79const SEMITONE_UNITS: f32 = 16.0;
80
81/// Convert a `(octave, semitone)` note into a continuous semitone index
82/// from C-0. `real = octave*12 + semitone`. STM encodes octave in the
83/// high nibble and semitone in the low nibble.
84fn note_to_semis(octave: u8, semitone: u8) -> f32 {
85 (octave as f32) * 12.0 + (semitone as f32)
86}
87
88/// Convert a fractional semitone index from C-0 into a playback
89/// frequency using the given C3 reference Hz. The STM pitch model
90/// maps (octave=3, semitone=0) → c3_hz, so we subtract 3 octaves.
91fn semis_to_freq(c3_hz: f32, semis_from_c0: f32) -> f32 {
92 if c3_hz <= 0.0 {
93 return 0.0;
94 }
95 c3_hz * 2.0f32.powf((semis_from_c0 - 3.0 * 12.0) / 12.0)
96}
97
98/// Per-channel playback state for STM.
99#[derive(Clone, Debug, Default)]
100pub struct StmChannel {
101 /// Currently-loaded instrument index (1..=31, 0 = none).
102 pub instrument: u8,
103 /// Current note (octave, semitone) for the currently playing sample.
104 pub note: (u8, u8),
105 /// Volume 0..=64.
106 pub volume: u8,
107 /// Shared mixer voice.
108 pub voice: MixerVoice,
109 /// Current effect command 0..=0xF.
110 pub effect: u8,
111 /// Current effect parameter.
112 pub effect_param: u8,
113
114 // -------- pitch state (semitone-space) --------
115 /// Current live semitone index (fractional, measured from C-0).
116 /// Porta / vibrato / fine porta all mutate this; the voice
117 /// frequency is derived from it every tick.
118 pub cur_semis: f32,
119 /// Target semitone index for tone portamento (3xy / 5xy).
120 pub porta_target_semis: f32,
121 /// Tone-porta speed memory (shared between 3xy and 5xy).
122 pub porta_speed: u8,
123 /// 1xy / 2xy memory — last non-zero parameter (shared).
124 pub porta_updown_mem: u8,
125 /// Vibrato sine-table position 0..=63.
126 pub vib_pos: u8,
127 /// Vibrato speed memory (last non-zero 4xy `x` nibble).
128 pub vib_speed: u8,
129 /// Vibrato depth memory (last non-zero 4xy `y` nibble).
130 pub vib_depth: u8,
131 /// Axy volume-slide parameter memory (shared with 5xy / 6xy).
132 pub vol_slide_mem: u8,
133
134 // -------- tremolo (7xy) state --------
135 /// Tremolo sine-table position 0..=63. Walks like `vib_pos` but on
136 /// a separate register so 4xy vibrato and 7xy tremolo on the same
137 /// channel don't share phase.
138 pub trem_pos: u8,
139 /// Tremolo speed memory (last non-zero 7xy `x` nibble). PT semantics
140 /// per `Protracker-effects-MODFIL12.txt` 7:Tremolo say the per-nibble
141 /// memory operates independently from vibrato's.
142 pub trem_speed: u8,
143 /// Tremolo depth memory (last non-zero 7xy `y` nibble).
144 pub trem_depth: u8,
145
146 // -------- scheduling --------
147 /// Pending note-cut tick (ECx): if >0, volume forced to 0 on that
148 /// tick.
149 pub note_cut_tick: u8,
150 /// Pending note-delay tick (EDx): if >0, the cell's note is
151 /// triggered on this tick instead of tick 0.
152 pub note_delay_tick: u8,
153 /// Saved trigger data for a pending note-delay.
154 pub pending_note: (u8, u8),
155 pub pending_instrument: u8,
156 pub pending_volume: u8,
157 /// True if the pending note-delay slot is populated.
158 pub has_pending_delay: bool,
159}
160
161/// STM player — owns decoded patterns / samples and a
162/// row/tick/BPM-style state machine.
163pub struct StmPlayerState {
164 pub samples: Vec<StmSampleBody>,
165 pub patterns: Vec<StmPattern>,
166 pub order: Vec<u8>,
167 pub n_patterns: u8,
168 pub channels: [StmChannel; STM_CHANNELS],
169 pub speed: u8,
170 pub tempo: u8,
171 pub sample_rate: u32,
172
173 pub order_index: usize,
174 pub row: u8,
175 pub tick: u8,
176 pub tick_sample_cursor: u32,
177 pub ended: bool,
178 pub global_volume: u8,
179
180 /// Pending pattern jump (Bxy): set on tick 0 of a row, consumed by
181 /// `next_row`.
182 pub pending_order_jump: Option<u8>,
183 /// Pending pattern-break row (Dxy): set on tick 0, consumed by
184 /// `next_row`.
185 pub pending_break_row: Option<u8>,
186}
187
188impl StmPlayerState {
189 pub fn new(
190 header: &StmHeader,
191 samples: Vec<StmSampleBody>,
192 patterns: Vec<StmPattern>,
193 sample_rate: u32,
194 ) -> Self {
195 // Trim order to entries that actually reference a valid pattern.
196 let order: Vec<u8> = header
197 .order
198 .iter()
199 .copied()
200 .take_while(|&b| b != 255)
201 .collect();
202 StmPlayerState {
203 samples,
204 patterns,
205 order,
206 n_patterns: header.n_patterns,
207 channels: Default::default(),
208 speed: DEFAULT_SPEED_TICKS,
209 tempo: header.tempo.max(1),
210 sample_rate,
211 order_index: 0,
212 row: 0,
213 tick: 0,
214 tick_sample_cursor: 0,
215 ended: false,
216 global_volume: header.global_volume.max(1),
217 pending_order_jump: None,
218 pending_break_row: None,
219 }
220 }
221
222 /// Samples-per-tick using the MOD-style formula, with tempo treated
223 /// as a BPM-ish equivalent. Scream Tracker v1's tempo register is
224 /// historically `tempo * 2` compared to the S3M / MOD scale; we
225 /// approximate with `bpm_equiv = tempo * 125 / 0x60`, matching the
226 /// `estimate_duration_micros` heuristic in [`crate::stm`].
227 pub fn samples_per_tick(&self) -> u32 {
228 let bpm_equiv = ((self.tempo as u32) * 125 / 0x60).max(30);
229 ((self.sample_rate as f32) * 2.5 / bpm_equiv as f32).max(1.0) as u32
230 }
231
232 /// Retrieve the cell at (current order → pattern, row, channel).
233 fn cell_at(&self, row: u8, ch: usize) -> Option<StmCell> {
234 let pat_idx = *self.order.get(self.order_index)? as usize;
235 let pattern = self.patterns.get(pat_idx)?;
236 pattern.rows.get(row as usize)?.get(ch).copied()
237 }
238
239 /// Enter a row: load note / volume / effect into each channel.
240 fn enter_row(&mut self) {
241 for ch_idx in 0..STM_CHANNELS {
242 let Some(cell) = self.cell_at(self.row, ch_idx) else {
243 continue;
244 };
245 let ch = &mut self.channels[ch_idx];
246 ch.effect = cell.command;
247 ch.effect_param = cell.command_param;
248 // Reset per-row scheduling.
249 ch.note_cut_tick = 0;
250 ch.note_delay_tick = 0;
251 ch.has_pending_delay = false;
252
253 // Tone-porta detection: 3xy / 5xy turn the note (if any)
254 // into a glide target, not a retrigger.
255 let is_tone_porta_cell = cell.command == 0x3 || cell.command == 0x5;
256
257 // Sample change — update the current instrument and pull
258 // volume from the sample body.
259 if cell.instrument != 0 {
260 ch.instrument = cell.instrument;
261 if let Some(body) = self.samples.get(cell.instrument as usize - 1) {
262 ch.volume = body.volume.min(64);
263 }
264 }
265
266 // Cell volume overrides sample default.
267 if cell.volume > 0 && cell.volume <= 64 {
268 ch.volume = cell.volume;
269 }
270
271 // Memorise effect parameters (zero nibble = reuse last).
272 let ep = ch.effect_param;
273 match ch.effect {
274 0x1 | 0x2 if ep != 0 => {
275 ch.porta_updown_mem = ep;
276 }
277 0x3 if ep != 0 => {
278 ch.porta_speed = ep;
279 }
280 0x4 => {
281 let vx = ep >> 4;
282 let vy = ep & 0x0F;
283 if vx != 0 {
284 ch.vib_speed = vx;
285 }
286 if vy != 0 {
287 ch.vib_depth = vy;
288 }
289 }
290 0x7 => {
291 // 7xy: tremolo — per-nibble memory (`x` speed,
292 // `y` depth) independent of vibrato's. PT spec
293 // §7:Tremolo: "If either xxxx or yyyy are 0, then
294 // values from the most recent prior tremolo will
295 // be used."
296 let tx = ep >> 4;
297 let ty = ep & 0x0F;
298 if tx != 0 {
299 ch.trem_speed = tx;
300 }
301 if ty != 0 {
302 ch.trem_depth = ty;
303 }
304 }
305 0x5 | 0x6 | 0xA if ep != 0 => {
306 ch.vol_slide_mem = ep;
307 }
308 _ => {}
309 }
310
311 // Note trigger.
312 match cell.kind() {
313 StmNoteKind::Note { octave, semitone } if semitone <= 11 => {
314 let target = note_to_semis(octave, semitone);
315 if is_tone_porta_cell && ch.voice.active && ch.cur_semis > 0.0 {
316 // Set glide target without retriggering; keep
317 // current cur_semis so the slide starts from
318 // wherever we are.
319 ch.note = (octave, semitone);
320 ch.porta_target_semis = target;
321 } else {
322 ch.note = (octave, semitone);
323 let is_delay = cell.command == 0xE
324 && (cell.command_param >> 4) == 0xD
325 && (cell.command_param & 0x0F) != 0;
326 if is_delay {
327 ch.note_delay_tick = cell.command_param & 0x0F;
328 ch.pending_note = (octave, semitone);
329 ch.pending_instrument = cell.instrument;
330 ch.pending_volume = cell.volume;
331 ch.has_pending_delay = true;
332 } else {
333 let inst_idx = match (ch.instrument as usize).checked_sub(1) {
334 Some(i) => i,
335 None => continue,
336 };
337 if let Some(body) = self.samples.get(inst_idx) {
338 let pitch = StmC3Pitch {
339 c3_hz: body.c3_hz as f32,
340 };
341 let freq = pitch.note_to_freq(ch.note);
342 let vol =
343 (ch.volume as f32 / 64.0) * (self.global_volume as f32 / 64.0);
344 ch.voice.trigger(freq, vol);
345 ch.cur_semis = target;
346 ch.porta_target_semis = target;
347 // Fresh note resets vibrato + tremolo
348 // phases (PT canonical retrigger:
349 // `Protracker-effects-MODFIL12.txt`
350 // E7:Set-Tremolo-Waveform implies the
351 // default waveform retriggers on a new
352 // note unless E7x sets the no-retrig
353 // bit, which STM's pre-FT2 effect set
354 // doesn't expose).
355 ch.vib_pos = 0;
356 ch.trem_pos = 0;
357 }
358 }
359 }
360 }
361 StmNoteKind::DashNote | StmNoteKind::Dots => {
362 // Per spec: "note off" style markers. Silence the voice.
363 ch.voice.active = false;
364 }
365 _ => {}
366 }
367
368 // Tick-0 effects.
369 apply_tick0_effect(ch);
370 }
371
372 // Row-level song-state effects: Bxy / Dxy / Fxx.
373 for ch in self.channels.iter() {
374 match ch.effect {
375 0xB => {
376 self.pending_order_jump = Some(ch.effect_param);
377 if self.pending_break_row.is_none() {
378 self.pending_break_row = Some(0);
379 }
380 }
381 0xD => {
382 // FT2-style decimal row (matches XM parity).
383 let row = (ch.effect_param >> 4) * 10 + (ch.effect_param & 0x0F);
384 self.pending_break_row = Some(row.min((PATTERN_ROWS - 1) as u8));
385 }
386 0xF => {
387 if ch.effect_param == 0 {
388 self.ended = true;
389 } else if ch.effect_param < 0x20 {
390 self.speed = ch.effect_param;
391 } else {
392 self.tempo = ch.effect_param;
393 }
394 }
395 _ => {}
396 }
397 }
398 }
399
400 fn advance_tick(&mut self) {
401 if self.tick == 0 {
402 self.enter_row();
403 } else {
404 for ch in self.channels.iter_mut() {
405 apply_tickn_effect(ch);
406 }
407 }
408
409 // Per-tick pitch recompute: vibrato + (schedule-based) note
410 // cut / note delay. Runs on every tick (including tick 0).
411 let cur_tick = self.tick;
412 let global_vol = self.global_volume;
413 for ch_idx in 0..STM_CHANNELS {
414 let ch = &mut self.channels[ch_idx];
415
416 // Note cut: force volume to 0 at tick x.
417 if ch.note_cut_tick > 0 && cur_tick == ch.note_cut_tick {
418 ch.volume = 0;
419 }
420
421 // Note delay: trigger voice at tick x.
422 if ch.has_pending_delay && ch.note_delay_tick > 0 && cur_tick == ch.note_delay_tick {
423 let inst = ch.pending_instrument;
424 if inst != 0 {
425 ch.instrument = inst;
426 }
427 let (po, ps) = ch.pending_note;
428 let idx = ch.instrument.saturating_sub(1) as usize;
429 if ch.pending_volume > 0 && ch.pending_volume <= 64 {
430 ch.volume = ch.pending_volume;
431 }
432 if let Some(body) = self.samples.get(idx) {
433 let pitch = StmC3Pitch {
434 c3_hz: body.c3_hz as f32,
435 };
436 let freq = pitch.note_to_freq((po, ps));
437 let vol = (ch.volume as f32 / 64.0) * (global_vol as f32 / 64.0);
438 ch.voice.trigger(freq, vol);
439 ch.note = (po, ps);
440 ch.cur_semis = note_to_semis(po, ps);
441 ch.porta_target_semis = ch.cur_semis;
442 ch.vib_pos = 0;
443 ch.trem_pos = 0;
444 }
445 ch.has_pending_delay = false;
446 ch.note_delay_tick = 0;
447 }
448
449 // Derive the voice frequency from the current semitone state
450 // plus any live vibrato offset.
451 let mut semis = ch.cur_semis;
452 let vib_active = (ch.effect == 0x4 || ch.effect == 0x6) && ch.vib_depth > 0;
453 if vib_active {
454 let lfo = SINE_TABLE[(ch.vib_pos & 0x3F) as usize] as f32;
455 // Peak deviation: depth * 16 / 128 / SEMITONE_UNITS semitones
456 // Simplify: (lfo / 128) * depth * 16 / SEMITONE_UNITS.
457 // With SEMITONE_UNITS=16, this is (lfo/128)*depth — i.e.
458 // depth=15 gives ≈ ±15/128 ≈ ±0.117 semitones peak which
459 // is much too subtle; XM uses a wider depth. Match XM:
460 // treat (depth * sine / 32) as period-unit offset, convert
461 // period-units-to-semitones by dividing by SEMITONE_UNITS.
462 let off_units = (lfo * ch.vib_depth as f32) / 32.0;
463 let off_semis = off_units / SEMITONE_UNITS;
464 semis += off_semis;
465 if cur_tick > 0 {
466 ch.vib_pos = ch.vib_pos.wrapping_add(ch.vib_speed.wrapping_mul(4)) & 0x3F;
467 }
468 }
469
470 // Arpeggio (0xy): with at least one non-zero nibble, cycle the
471 // pitch through note / note+x / note+y half-steps across the
472 // ticks of the row, evenly spaced, then back to the note.
473 // STM uses ProTracker-format effects, so we follow the
474 // `Protracker-effects-MODFIL12.txt` 0:Arpeggio algorithm
475 // ("if (counter mod 3) = 0/1/2 then play note / note+x /
476 // note+y"). The offset is a pure addition to the live
477 // semitone position so porta / vibrato continue underneath
478 // and `cur_semis` is left unmodified (no permanent drift).
479 if ch.effect == 0x0 && ch.effect_param != 0 {
480 let arp_x = (ch.effect_param >> 4) as f32;
481 let arp_y = (ch.effect_param & 0x0F) as f32;
482 semis += match cur_tick % 3 {
483 0 => 0.0,
484 1 => arp_x,
485 _ => arp_y,
486 };
487 }
488
489 // If the channel is actively playing, recompute the voice
490 // frequency from the (possibly-modulated) semitone position.
491 let inst_idx = ch.instrument.saturating_sub(1) as usize;
492 if let Some(body) = self.samples.get(inst_idx) {
493 if ch.voice.active && ch.cur_semis > 0.0 {
494 let new_freq = semis_to_freq(body.c3_hz as f32, semis);
495 if new_freq > 0.0 {
496 ch.voice.freq = new_freq;
497 }
498 }
499 }
500
501 // Tremolo (7xy): oscillate the *output* volume with a sine
502 // LFO. Per `Protracker-effects-MODFIL12.txt` 7:Tremolo, the
503 // peak amplitude is `depth * (speed - 1)` volume units, and
504 // the effect is "Like vibrato, except we modify the output
505 // volume" per `multimedia-cx-protracker.html` 7xy with the
506 // result clamped to `0 <= vol <= 64`. We mirror the STM
507 // vibrato divisor (`/ 32`) so depth 15 yields a peak swing
508 // of ~60 volume units (full-range at the strongest setting)
509 // — the same ratio MOD's `tremolo_offset` produces via its
510 // `>> 6` shift on the 32-entry table. The offset is computed
511 // first (before the volume scalar update) and added to the
512 // base `ch.volume` so Axy / Cxx etc. set the baseline that
513 // tremolo modulates around (per the "stored volume isn't
514 // modified by this effect" reading shared by the S3M Ixy
515 // doc, which is the same family of effects).
516 let trem_active = ch.effect == 0x7 && ch.trem_depth > 0;
517 let trem_off_units: f32 = if trem_active {
518 let lfo = SINE_TABLE[(ch.trem_pos & 0x3F) as usize] as f32;
519 // Same shape as STM vibrato: (lfo * depth) / 32 → peak
520 // ≈ depth * 4 volume units. At depth 15 → ~±60 units.
521 let units = (lfo * ch.trem_depth as f32) / 32.0;
522 if cur_tick > 0 {
523 // Walk the sine table at `speed * 4` (matches the
524 // STM vibrato stepping and gives the same per-line
525 // oscillation rate the 4xy / 7xy parameters share
526 // by spec construction).
527 ch.trem_pos = ch.trem_pos.wrapping_add(ch.trem_speed.wrapping_mul(4)) & 0x3F;
528 }
529 units
530 } else {
531 0.0
532 };
533
534 // Update the voice volume scalar (in case Axy / Exx modified
535 // `ch.volume` this tick). Tremolo adds an offset, then the
536 // result is clamped to [0, 64] per the PT spec before the
537 // global-volume scale is folded in.
538 let modulated = (ch.volume as f32 + trem_off_units).clamp(0.0, 64.0);
539 ch.voice.volume = (modulated / 64.0) * (global_vol as f32 / 64.0);
540 }
541 }
542
543 fn next_row(&mut self) {
544 // Consume pending Bxy / Dxy.
545 if let Some(order) = self.pending_order_jump.take() {
546 self.order_index = order as usize;
547 self.row = self.pending_break_row.take().unwrap_or(0);
548 if self.order_index >= self.order.len() {
549 self.ended = true;
550 }
551 return;
552 }
553 if let Some(row) = self.pending_break_row.take() {
554 self.row = row;
555 self.order_index += 1;
556 if self.order_index >= self.order.len() {
557 self.ended = true;
558 }
559 return;
560 }
561
562 self.row += 1;
563 if (self.row as usize) >= PATTERN_ROWS {
564 self.row = 0;
565 self.order_index += 1;
566 if self.order_index >= self.order.len() {
567 self.ended = true;
568 }
569 }
570 }
571
572 /// Render interleaved stereo S16 PCM into `dst` (length must be
573 /// even). STM uses hard-pan LRRL like MOD.
574 pub fn render(&mut self, dst: &mut [i16]) -> usize {
575 assert!(dst.len() % 2 == 0);
576 let mut produced = 0usize;
577 let total_frames = dst.len() / 2;
578 let out_rate = self.sample_rate as f32;
579
580 while produced < total_frames {
581 if self.ended {
582 break;
583 }
584 if self.tick_sample_cursor == 0 {
585 self.advance_tick();
586 }
587 let spt = self.samples_per_tick().max(1);
588 let remaining = spt.saturating_sub(self.tick_sample_cursor);
589 let want = (total_frames - produced).min(remaining as usize);
590
591 for _ in 0..want {
592 let mut l = 0.0f32;
593 let mut r = 0.0f32;
594 for (i, ch) in self.channels.iter_mut().enumerate() {
595 let s = match (ch.instrument as usize).checked_sub(1) {
596 Some(idx) if idx < self.samples.len() => {
597 let body = &self.samples[idx];
598 ch.voice.render_one(body, out_rate)
599 }
600 _ => 0.0,
601 };
602 // Hard-pan LRRL (channels 0 & 3 → left).
603 if matches!(i % 4, 0 | 3) {
604 l += s;
605 } else {
606 r += s;
607 }
608 }
609 // Headroom scale for 4-channel STM → divide by 2.
610 let l = (l / 2.0).clamp(-1.0, 1.0);
611 let r = (r / 2.0).clamp(-1.0, 1.0);
612 let off = produced * 2;
613 dst[off] = (l * 32767.0) as i16;
614 dst[off + 1] = (r * 32767.0) as i16;
615 produced += 1;
616 }
617
618 self.tick_sample_cursor += want as u32;
619 if self.tick_sample_cursor >= spt {
620 self.tick_sample_cursor = 0;
621 self.tick += 1;
622 if self.tick >= self.speed {
623 self.tick = 0;
624 self.next_row();
625 }
626 }
627 }
628 produced
629 }
630}
631
632/// Run tick-0 portion of effects: set-volume, Exy subcommands (fine
633/// porta, fine volume slide, note cut/delay scheduling).
634fn apply_tick0_effect(ch: &mut StmChannel) {
635 let ep = ch.effect_param;
636 let x = ep >> 4;
637 let y = ep & 0x0F;
638 match ch.effect {
639 0xC => {
640 // Cxx: set volume.
641 ch.volume = ep.min(64);
642 }
643 0xE => {
644 // Exy subcommands.
645 match x {
646 0x1
647 // E1x: fine porta up (tick 0 only) — shift
648 // semitone-position up by `y / SEMITONE_UNITS`.
649 if y != 0 => {
650 ch.cur_semis += y as f32 / SEMITONE_UNITS;
651 }
652 0x2
653 // E2x: fine porta down.
654 if y != 0 => {
655 ch.cur_semis = (ch.cur_semis - y as f32 / SEMITONE_UNITS).max(0.0);
656 }
657 0xA => {
658 // EAx: fine volume slide up.
659 ch.volume = (ch.volume as u16 + y as u16).min(64) as u8;
660 }
661 0xB => {
662 // EBx: fine volume slide down.
663 ch.volume = ch.volume.saturating_sub(y);
664 }
665 0xC => {
666 // ECx: note cut at tick x (0 = immediate).
667 if y == 0 {
668 ch.volume = 0;
669 } else {
670 ch.note_cut_tick = y;
671 }
672 }
673 0xD => {
674 // EDx: note delay — handled in enter_row by routing
675 // the trigger through `pending_*` + `note_delay_tick`.
676 }
677 _ => {}
678 }
679 }
680 _ => {}
681 }
682}
683
684/// Run per-tick effects (ticks > 0): continuous pitch slides + volume
685/// slides. Vibrato motion is applied in `advance_tick` alongside the
686/// pitch recompute so it can stack with tone-porta.
687fn apply_tickn_effect(ch: &mut StmChannel) {
688 let effect = ch.effect;
689 let ep = ch.effect_param;
690 let x = ep >> 4;
691 let y = ep & 0x0F;
692 match effect {
693 0x1 => {
694 // 1xy: porta up.
695 let p = if ep != 0 { ep } else { ch.porta_updown_mem };
696 if p != 0 {
697 ch.cur_semis += p as f32 / SEMITONE_UNITS;
698 }
699 }
700 0x2 => {
701 // 2xy: porta down.
702 let p = if ep != 0 { ep } else { ch.porta_updown_mem };
703 if p != 0 {
704 ch.cur_semis = (ch.cur_semis - p as f32 / SEMITONE_UNITS).max(0.0);
705 }
706 }
707 0x3 => {
708 // 3xy: tone porta — glide toward target.
709 let speed = (ch.porta_speed as f32) / SEMITONE_UNITS;
710 if (ch.cur_semis - ch.porta_target_semis).abs() <= speed {
711 ch.cur_semis = ch.porta_target_semis;
712 } else if ch.cur_semis < ch.porta_target_semis {
713 ch.cur_semis += speed;
714 } else {
715 ch.cur_semis -= speed;
716 }
717 }
718 0x5 => {
719 // 5xy: tone porta + volume slide.
720 let speed = (ch.porta_speed as f32) / SEMITONE_UNITS;
721 if (ch.cur_semis - ch.porta_target_semis).abs() <= speed {
722 ch.cur_semis = ch.porta_target_semis;
723 } else if ch.cur_semis < ch.porta_target_semis {
724 ch.cur_semis += speed;
725 } else {
726 ch.cur_semis -= speed;
727 }
728 apply_vol_slide(ch, ch.vol_slide_mem);
729 }
730 0x6 => {
731 // 6xy: vibrato + volume slide. Vibrato motion is in
732 // advance_tick; the vol-slide piece is applied here.
733 apply_vol_slide(ch, ch.vol_slide_mem);
734 }
735 0xA => {
736 // Axy: volume slide.
737 if x != 0 {
738 ch.volume = (ch.volume as u16 + x as u16).min(64) as u8;
739 } else if y != 0 {
740 ch.volume = ch.volume.saturating_sub(y);
741 }
742 }
743 _ => {}
744 }
745}
746
747fn apply_vol_slide(ch: &mut StmChannel, mem: u8) {
748 let hi = mem >> 4;
749 let lo = mem & 0x0F;
750 if hi != 0 {
751 ch.volume = (ch.volume as u16 + hi as u16).min(64) as u8;
752 } else if lo != 0 {
753 ch.volume = ch.volume.saturating_sub(lo);
754 }
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760 use crate::stm::{extract_samples, parse_header, parse_patterns};
761
762 /// Build a tiny STM file with a single note-on on row 0, channel 0.
763 pub fn build_ping_stm() -> Vec<u8> {
764 const HEADER_PREFIX: usize = 0x30;
765 const ORDER_OFF: usize = 0x3D0;
766 const ORDER_SIZE: usize = 64;
767 const PATTERN_OFF: usize = 0x410;
768 const BYTES_PER_PATTERN: usize = 64 * 4 * 4;
769 let n_patterns = 1u8;
770 let mut out = vec![0u8; PATTERN_OFF];
771 out[0..4].copy_from_slice(b"ping");
772 out[0x14..0x1C].copy_from_slice(b"!Scream!");
773 out[0x1C] = 0x1A;
774 out[0x1D] = 2;
775 out[0x1E] = 2;
776 out[0x20] = 0x60;
777 out[0x21] = n_patterns;
778 out[0x22] = 64;
779 // Instrument 0: 64-byte sample, volume 64, C3 = 8363 Hz.
780 let inst_off = HEADER_PREFIX;
781 out[inst_off..inst_off + 3].copy_from_slice(b"snd");
782 out[inst_off + 16..inst_off + 18].copy_from_slice(&64u16.to_le_bytes());
783 out[inst_off + 22] = 64;
784 out[inst_off + 24..inst_off + 26].copy_from_slice(&8363u16.to_le_bytes());
785 // Order table: pattern 0, then 255-terminated.
786 for i in 0..ORDER_SIZE {
787 out[ORDER_OFF + i] = if i == 0 { 0 } else { 255 };
788 }
789 // Pattern 0: row 0 / ch 0 = note C-4 (octave 4, semitone 0), instrument 1.
790 let mut pattern = vec![0u8; BYTES_PER_PATTERN];
791 pattern[0] = 0x40; // octave 4, semitone 0
792 // vol_lo 0, instrument 1.
793 pattern[1] = 1 << 3;
794 pattern[2] = 0;
795 pattern[3] = 0;
796 out.extend(pattern);
797 // 64-sample square wave body.
798 for i in 0..64 {
799 let v: i8 = if i < 32 { 100 } else { -100 };
800 out.push(v as u8);
801 }
802 out
803 }
804
805 #[test]
806 fn stm_player_emits_nonzero_audio() {
807 let bytes = build_ping_stm();
808 let h = parse_header(&bytes).unwrap();
809 let pats = parse_patterns(&h, &bytes);
810 let samples = extract_samples(&h, &bytes);
811 let mut p = StmPlayerState::new(&h, samples, pats, 44_100);
812
813 // Render ~0.1s.
814 let mut buf = vec![0i16; 4410 * 2];
815 let produced = p.render(&mut buf);
816 assert_eq!(produced, 4410);
817 let nonzero = buf.iter().filter(|&&x| x != 0).count();
818 assert!(nonzero > 100, "expected audible PCM, got {nonzero} nonzero");
819 }
820
821 /// Build an STM with a C-4 note on row 0 plus arpeggio `037`
822 /// (effect 0, x=3, y=7) on channel 0.
823 fn build_arpeggio_stm() -> Vec<u8> {
824 let mut out = build_ping_stm();
825 const PATTERN_OFF: usize = 0x410;
826 // Cell byte 2 = (vol_hi << 4) | command; command 0 = arpeggio.
827 out[PATTERN_OFF + 2] = 0x00;
828 // Cell byte 3 = command param 0x37 (x=3 half-steps, y=7).
829 out[PATTERN_OFF + 3] = 0x37;
830 out
831 }
832
833 #[test]
834 fn arpeggio_cycles_note_x_y_half_steps() {
835 let bytes = build_arpeggio_stm();
836 let h = parse_header(&bytes).unwrap();
837 let pats = parse_patterns(&h, &bytes);
838 let samples = extract_samples(&h, &bytes);
839 let mut p = StmPlayerState::new(&h, samples, pats, 44_100);
840
841 // Base note C-4 (48 semis from C0) at c3_hz=8363 →
842 // 8363 * 2^((48-36)/12) = 8363 * 2 = 16726 Hz.
843 let base = semis_to_freq(8363.0, 48.0);
844 assert!((base - 16726.0).abs() < 1.0, "base freq = {base}");
845
846 // Step the engine tick by tick (advance_tick recomputes
847 // ch.voice.freq each tick). On the row's ticks the arpeggio
848 // walks 0 / +3 / +7 / 0 / +3 / +7 half-steps via `tick % 3`.
849 let expected = |semis_off: f32| base * 2.0f32.powf(semis_off / 12.0);
850 let cases = [0.0f32, 3.0, 7.0, 0.0, 3.0, 7.0];
851 for (tick, &off) in cases.iter().enumerate() {
852 p.tick = tick as u8;
853 p.advance_tick();
854 let got = p.channels[0].voice.freq;
855 let want = expected(off);
856 assert!(
857 (got - want).abs() < 1.0,
858 "tick {tick}: arpeggio +{off} semis: got {got}, want {want}"
859 );
860 }
861 }
862
863 #[test]
864 fn arpeggio_zero_param_is_inert() {
865 // Effect 0 with a zero parameter is NOT an arpeggio (per
866 // `Protracker-effects-MODFIL12.txt`: "only an arpeggio if there
867 // is at least one non-zero argument"). The pitch must stay on
868 // the base note across all ticks.
869 let mut bytes = build_ping_stm();
870 const PATTERN_OFF: usize = 0x410;
871 bytes[PATTERN_OFF + 2] = 0x00; // command 0
872 bytes[PATTERN_OFF + 3] = 0x00; // zero param → no arpeggio
873 let h = parse_header(&bytes).unwrap();
874 let pats = parse_patterns(&h, &bytes);
875 let samples = extract_samples(&h, &bytes);
876 let mut p = StmPlayerState::new(&h, samples, pats, 44_100);
877
878 let base = semis_to_freq(8363.0, 48.0);
879 for tick in 0u8..4 {
880 p.tick = tick;
881 p.advance_tick();
882 let got = p.channels[0].voice.freq;
883 assert!(
884 (got - base).abs() < 1.0,
885 "tick {tick}: zero-param effect-0 must hold base {base}, got {got}"
886 );
887 }
888 }
889
890 /// Build an STM with a C-4 note on row 0 plus tremolo `7xy`
891 /// (effect 7, x=speed nibble, y=depth nibble) on channel 0.
892 /// `vol` (1..=64) sets the cell's starting volume so we have
893 /// headroom for tremolo to swing both above and below.
894 fn build_tremolo_stm(speed: u8, depth: u8, vol: u8) -> Vec<u8> {
895 let mut out = build_ping_stm();
896 const PATTERN_OFF: usize = 0x410;
897 // Cell volume override (STM encodes vol bits as bit 0..2 of
898 // byte 1 (low bit) + bits 4..6 of byte 2 (upper bits); vol-1 is
899 // stored — see `StmCell::volume` decoding). For simplicity,
900 // patch the existing ping STM's volume to the requested value:
901 // bit 0..2 of byte 1 (instrument is in bits 3..7, value 1).
902 let v = vol & 0x3F;
903 out[PATTERN_OFF + 1] = (1 << 3) | (v & 0x07);
904 out[PATTERN_OFF + 2] = ((v >> 3) << 4) | 0x07; // upper-vol nibble + effect 7
905 out[PATTERN_OFF + 3] = (speed << 4) | (depth & 0x0F);
906 out
907 }
908
909 #[test]
910 fn tremolo_modulates_volume_symmetrically() {
911 // Tremolo with mid-range volume (32/64) has +32 headroom up and
912 // -32 down so we can see swings both directions. Use a moderate
913 // speed (4) and large depth (15) → peak swing ≈ ±60 vol units,
914 // clamped to [0, 64].
915 let bytes = build_tremolo_stm(4, 15, 32);
916 let h = parse_header(&bytes).unwrap();
917 let pats = parse_patterns(&h, &bytes);
918 let samples = extract_samples(&h, &bytes);
919 let mut p = StmPlayerState::new(&h, samples, pats, 44_100);
920
921 // Walk many ticks to cover at least one full sine cycle.
922 let mut min_vol = f32::INFINITY;
923 let mut max_vol = f32::NEG_INFINITY;
924 for tick in 0u8..32 {
925 p.tick = tick;
926 p.advance_tick();
927 let v = p.channels[0].voice.volume;
928 if v < min_vol {
929 min_vol = v;
930 }
931 if v > max_vol {
932 max_vol = v;
933 }
934 }
935
936 // Baseline (no tremolo) would be (32 / 64) * (global / 64).
937 // Global is 64 from build_ping_stm. So baseline = 0.5.
938 // Depth 15 + STM scaler 4 gives peak ≈ ±60 vol units, clamped
939 // into [0, 64]. The sine LFO with `speed * 4 = 16` steps per
940 // tick covers a full cycle in 4 ticks, so within 32 ticks we
941 // should see both the lower clamp (0.0) and a high value near
942 // (64 / 64) = 1.0 scaled by global = 1.0.
943 assert!(
944 min_vol < 0.4,
945 "tremolo should swing volume below baseline 0.5: min = {min_vol}"
946 );
947 assert!(
948 max_vol > 0.6,
949 "tremolo should swing volume above baseline 0.5: max = {max_vol}"
950 );
951 }
952
953 #[test]
954 fn tremolo_zero_param_uses_memory() {
955 // First a 7xy with non-zero nibbles to seed memory, then a 700
956 // continuation must keep modulating using the stored values.
957 let mut bytes = build_tremolo_stm(4, 8, 32);
958 // Patch row 1 channel 0 to be effect 7 with param 0 (memory).
959 const PATTERN_OFF: usize = 0x410;
960 const BYTES_PER_ROW: usize = 4 * 4;
961 let row1 = PATTERN_OFF + BYTES_PER_ROW;
962 // Note: 253 (Dots) so no retrigger / no fresh phase reset.
963 bytes[row1] = 253;
964 bytes[row1 + 1] = 0;
965 bytes[row1 + 2] = 0x07;
966 bytes[row1 + 3] = 0x00; // 700 → reuse memory
967 let h = parse_header(&bytes).unwrap();
968 let pats = parse_patterns(&h, &bytes);
969 let samples = extract_samples(&h, &bytes);
970 let mut p = StmPlayerState::new(&h, samples, pats, 44_100);
971
972 // Walk row 0 to seed memory (speed=4, depth=8).
973 for tick in 0u8..6 {
974 p.tick = tick;
975 p.advance_tick();
976 }
977 // Now move to row 1 (which uses 700 — memory) and confirm
978 // the tremolo offset is non-zero on at least one tick.
979 p.next_row();
980 let mut saw_swing = false;
981 for tick in 0u8..6 {
982 p.tick = tick;
983 p.advance_tick();
984 let v = p.channels[0].voice.volume;
985 // Baseline would be 32/64 = 0.5. Tremolo with depth 8 must
986 // move it off that value on at least one tick.
987 if (v - 0.5).abs() > 0.05 {
988 saw_swing = true;
989 }
990 }
991 assert!(
992 saw_swing,
993 "700 must reuse last non-zero 7xy params (depth/speed memory)"
994 );
995 }
996
997 #[test]
998 fn tremolo_inert_at_zero_depth() {
999 // 700 on a row with no prior 7xy seed → memory still zero →
1000 // volume must hold the baseline across all ticks.
1001 let bytes = build_tremolo_stm(0, 0, 32);
1002 let h = parse_header(&bytes).unwrap();
1003 let pats = parse_patterns(&h, &bytes);
1004 let samples = extract_samples(&h, &bytes);
1005 let mut p = StmPlayerState::new(&h, samples, pats, 44_100);
1006
1007 for tick in 0u8..6 {
1008 p.tick = tick;
1009 p.advance_tick();
1010 let v = p.channels[0].voice.volume;
1011 // Baseline = 32/64 * 64/64 = 0.5 exactly.
1012 assert!(
1013 (v - 0.5).abs() < 1e-4,
1014 "tick {tick}: 700 with empty memory must leave volume unmodulated, got {v}"
1015 );
1016 }
1017 }
1018
1019 #[test]
1020 fn note_to_semis_places_c3_at_36() {
1021 assert_eq!(note_to_semis(3, 0), 36.0);
1022 assert_eq!(note_to_semis(4, 0), 48.0);
1023 assert_eq!(note_to_semis(4, 7), 55.0);
1024 }
1025
1026 #[test]
1027 fn semis_to_freq_round_trips_c3() {
1028 let f = semis_to_freq(8363.0, 36.0);
1029 assert!((f - 8363.0).abs() < 0.5);
1030 let f = semis_to_freq(8363.0, 48.0);
1031 assert!((f - 16726.0).abs() < 1.0);
1032 }
1033}