Skip to main content

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}