oxideav_mod/player.rs
1//! ProTracker playback engine.
2//!
3//! Drives a `PlayerState` forward one tick at a time. Two output modes
4//! share the same mixing core:
5//!
6//! - [`PlayerState::render`] writes interleaved stereo S16 PCM, applying
7//! the Amiga pan convention (channels 0 & 3 lean LEFT, 1 & 2 lean
8//! RIGHT; repeats every 4 for >4-channel files) and a 1/(N/2)
9//! headroom scale. Pan separation defaults to 70 % rather than
10//! strict 100 % hard pan — see [`PlayerState::set_pan_separation`]
11//! and the citation in the field doc-comment for the rationale
12//! (`Protracker-effects-MODFIL12.txt` §11).
13//! - [`PlayerState::render_per_channel`] writes one S16 plane per MOD
14//! tracker channel, post-volume but pre-pan and pre-mix. Downstream
15//! consumers that need to mix / pan / analyse channels independently
16//! (DAWs, visualisers, per-instrument remastering) drive the player
17//! via this path.
18//!
19//! Terminology:
20//! - **Row**: a line in a pattern. A pattern has 64 rows.
21//! - **Tick**: one row is `speed` ticks long (default 6).
22//! - **BPM**: governs wall-clock tick duration. Samples-per-tick =
23//! `sample_rate * 2.5 / BPM` — 882 at 44.1 kHz / 125 BPM.
24//! - **Period**: the Amiga Paula divider. Output frequency =
25//! PAULA_CLOCK / period.
26//!
27//! Effect coverage follows `docs/audio/trackers/mod/Protracker-v1.1B-mod.txt`
28//! and the concrete tick-level semantics described in
29//! `docs/audio/trackers/mod/FireLight-MOD-Player-Tutorial.txt` §5. All
30//! 16 base effect slots (0..F) plus the 16 Exy sub-commands are wired —
31//! see [`apply_tick0_effect`] / [`apply_tickn_effect`] for the dispatch
32//! tables and the module doc-comment's coverage matrix.
33
34use crate::header::{ModHeader, PATTERN_ROWS};
35use crate::samples::SampleBody;
36
37/// Paula clock (PAL) — classic MOD period→frequency constant. Divide by
38/// the period to get the Amiga's output sample rate for that channel.
39pub const PAULA_CLOCK: f32 = 7_093_789.2 / 2.0;
40
41pub const DEFAULT_SPEED: u8 = 6;
42pub const DEFAULT_BPM: u8 = 125;
43pub const CHANNELS_PER_MOD: usize = 4;
44
45/// Protracker porta-up floor: B-3 at finetune 0 is 113. Effects `1xx`
46/// (porta up) and `E1x` (fine porta up) must not slide below this value
47/// per `Protracker-v1.1B-mod.txt` ("You can NOT slide higher than B-3!
48/// (Period 113)") and `Protracker-effects-MODFIL12.txt` §1
49/// ("usually cannot slide past note B-3 unless you have implemented
50/// octave 4 (NON-STANDARD!)").
51pub const PERIOD_MIN: u16 = 113;
52/// Protracker porta-down ceiling: C-1 at finetune 0 is 856. Effects
53/// `2xx` (porta down) and `E2x` (fine porta down) clamp at this value
54/// per `Protracker-v1.1B-mod.txt` ("You can NOT slide lower than C-1!
55/// (Period 856)").
56pub const PERIOD_MAX: u16 = 856;
57/// Extended period floor — finetune +7 B-3 = 108. Per
58/// `Protracker-effects-MODFIL12.txt` §3.2 the "Normal Minimum Period =
59/// 108", and the period table includes 108 as the lowest legitimate
60/// value (finetune +7 row 35). The mixer must accept this without
61/// further clamping so finetune extremes play cleanly.
62pub const PERIOD_MIN_EXT: u16 = 108;
63/// Extended period ceiling — finetune -8 C-1 = 907. Per
64/// `Protracker-effects-MODFIL12.txt` §3.2 the "Normal Maximum Period =
65/// 907", and the period table's finetune -8 row begins with 907.
66pub const PERIOD_MAX_EXT: u16 = 907;
67
68/// 32-entry Protracker vibrato / tremolo sine table (half-wave positive
69/// quadrant; the sign is applied based on the LFO position register per
70/// spec). From FireLight-MOD-Player-Tutorial.txt §5.5.
71#[rustfmt::skip]
72pub const PROTRACKER_SINE_TABLE: [u8; 32] = [
73 0, 24, 49, 74, 97, 120, 141, 161,
74 180, 197, 212, 224, 235, 244, 250, 253,
75 255, 253, 250, 244, 235, 224, 212, 197,
76 180, 161, 141, 120, 97, 74, 49, 24,
77];
78
79/// 16-finetune × 36-note Protracker period table. Indexed as
80/// `PERIOD_TABLE[finetune_index][note_index]` where
81/// `finetune_index = finetune & 0xF` (0..=7 = +0..+7, 8..=15 = -8..-1)
82/// and `note_index = 0..=35` (C-1 at 0, B-3 at 35).
83///
84/// Transcribed verbatim from FireLight-MOD-Player-Tutorial.txt §3.5
85/// (identified there as a straight dump of the Protracker replayer's
86/// `mt_PeriodTable`, which cross-references the spec's own
87/// "Periodtable for Tuning 0, Normal" block in
88/// Protracker-v1.1B-mod.txt.)
89#[rustfmt::skip]
90pub const PERIOD_TABLE: [[u16; 36]; 16] = [
91 // Tuning 0
92 [856,808,762,720,678,640,604,570,538,508,480,453,
93 428,404,381,360,339,320,302,285,269,254,240,226,
94 214,202,190,180,170,160,151,143,135,127,120,113],
95 // Tuning +1
96 [850,802,757,715,674,637,601,567,535,505,477,450,
97 425,401,379,357,337,318,300,284,268,253,239,225,
98 213,201,189,179,169,159,150,142,134,126,119,113],
99 // Tuning +2
100 [844,796,752,709,670,632,597,563,532,502,474,447,
101 422,398,376,355,335,316,298,282,266,251,237,224,
102 211,199,188,177,167,158,149,141,133,125,118,112],
103 // Tuning +3
104 [838,791,746,704,665,628,592,559,528,498,470,444,
105 419,395,373,352,332,314,296,280,264,249,235,222,
106 209,198,187,176,166,157,148,140,132,125,118,111],
107 // Tuning +4
108 [832,785,741,699,660,623,588,555,524,495,467,441,
109 416,392,370,350,330,312,294,278,262,247,233,220,
110 208,196,185,175,165,156,147,139,131,124,117,110],
111 // Tuning +5
112 [826,779,736,694,655,619,584,551,520,491,463,437,
113 413,390,368,347,328,309,292,276,260,245,232,219,
114 206,195,184,174,164,155,146,138,130,123,116,109],
115 // Tuning +6
116 [820,774,730,689,651,614,580,547,516,487,460,434,
117 410,387,365,345,325,307,290,274,258,244,230,217,
118 205,193,183,172,163,154,145,137,129,122,115,109],
119 // Tuning +7
120 [814,768,725,684,646,610,575,543,513,484,457,431,
121 407,384,363,342,323,305,288,272,256,242,228,216,
122 204,192,181,171,161,152,144,136,128,121,114,108],
123 // Tuning -8 (wrapped as index 8)
124 [907,856,808,762,720,678,640,604,570,538,508,480,
125 453,428,404,381,360,339,320,302,285,269,254,240,
126 226,214,202,190,180,170,160,151,143,135,127,120],
127 // Tuning -7
128 [900,850,802,757,715,675,636,601,567,535,505,477,
129 450,425,401,379,357,337,318,300,284,268,253,238,
130 225,212,200,189,179,169,159,150,142,134,126,119],
131 // Tuning -6
132 [894,844,796,752,709,670,632,597,563,532,502,474,
133 447,422,398,376,355,335,316,298,282,266,251,237,
134 223,211,199,188,177,167,158,149,141,133,125,118],
135 // Tuning -5
136 [887,838,791,746,704,665,628,592,559,528,498,470,
137 444,419,395,373,352,332,314,296,280,264,249,235,
138 222,209,198,187,176,166,157,148,140,132,125,118],
139 // Tuning -4
140 [881,832,785,741,699,660,623,588,555,524,494,467,
141 441,416,392,370,350,330,312,294,278,262,247,233,
142 220,208,196,185,175,165,156,147,139,131,123,117],
143 // Tuning -3
144 [875,826,779,736,694,655,619,584,551,520,491,463,
145 437,413,390,368,347,328,309,292,276,260,245,232,
146 219,206,195,184,174,164,155,146,138,130,123,116],
147 // Tuning -2
148 [868,820,774,730,689,651,614,580,547,516,487,460,
149 434,410,387,365,345,325,307,290,274,258,244,230,
150 217,205,193,183,172,163,154,145,137,129,122,115],
151 // Tuning -1
152 [862,814,768,725,684,646,610,575,543,513,484,457,
153 431,407,384,363,342,323,305,288,272,256,242,228,
154 216,203,192,181,171,161,152,144,136,128,121,114],
155];
156
157/// Convert a signed 4-bit finetune (-8..=7) into the row index of
158/// [`PERIOD_TABLE`]. Positive finetunes map to 0..=7 unchanged; negative
159/// finetunes land in 8..=15 (so -8 is row 8, -1 is row 15), matching the
160/// nibble encoding the spec stores in sample header byte 44.
161#[inline]
162pub fn finetune_row(finetune: i8) -> usize {
163 (finetune as u8 & 0x0F) as usize
164}
165
166/// Find a note index (0..=35, C-1..B-3) for the given period by scanning
167/// all 16 finetune rows. Returns `None` if no row has this exact period —
168/// used only by `E3x` glissando.
169pub fn note_index_for_period(period: u16) -> Option<usize> {
170 for row in PERIOD_TABLE.iter() {
171 for (note_idx, &p) in row.iter().enumerate() {
172 if p == period {
173 return Some(note_idx);
174 }
175 }
176 }
177 None
178}
179
180/// A single decoded pattern row entry for one channel.
181#[derive(Clone, Copy, Debug, Default)]
182pub struct Note {
183 /// Period value (0 means "no new note").
184 pub period: u16,
185 /// Sample index 1..=31 (0 means "no sample change").
186 pub sample: u8,
187 /// Effect command nibble (0..=0xF).
188 pub effect: u8,
189 /// Effect parameter byte.
190 pub effect_param: u8,
191}
192
193impl Note {
194 fn decode(raw: [u8; 4]) -> Self {
195 // Byte 0: ssss pppp (high nibble of sample, high nibble of period)
196 // Byte 1: pppp pppp (low 8 bits of period)
197 // Byte 2: ssss eeee (low nibble of sample, effect nibble)
198 // Byte 3: xxxx xxxx (effect parameter)
199 let period = (((raw[0] & 0x0F) as u16) << 8) | raw[1] as u16;
200 let sample = (raw[0] & 0xF0) | (raw[2] >> 4);
201 let effect = raw[2] & 0x0F;
202 let effect_param = raw[3];
203 Note {
204 period,
205 sample,
206 effect,
207 effect_param,
208 }
209 }
210}
211
212/// A decoded pattern: 64 rows × N channels.
213#[derive(Clone, Debug)]
214pub struct Pattern {
215 pub rows: Vec<Vec<Note>>, // rows[row][channel]
216}
217
218/// Parse all patterns from a MOD bytestream.
219pub fn parse_patterns(header: &ModHeader, bytes: &[u8]) -> Vec<Pattern> {
220 let channels = header.channels as usize;
221 let mut patterns = Vec::with_capacity(header.n_patterns as usize);
222 let base = header.pattern_data_offset();
223
224 for p in 0..header.n_patterns as usize {
225 let mut rows = Vec::with_capacity(PATTERN_ROWS);
226 for r in 0..PATTERN_ROWS {
227 let mut row = Vec::with_capacity(channels);
228 for c in 0..channels {
229 let off = base + (p * PATTERN_ROWS + r) * channels * 4 + c * 4;
230 let raw = if off + 4 <= bytes.len() {
231 [bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]]
232 } else {
233 [0; 4]
234 };
235 row.push(Note::decode(raw));
236 }
237 rows.push(row);
238 }
239 patterns.push(Pattern { rows });
240 }
241 patterns
242}
243
244/// Vibrato / tremolo waveform selector for E4x / E7x.
245///
246/// Per Protracker-v1.1B-mod.txt:
247/// - low bits 0..=2 pick the shape (0 sine, 1 ramp-down, 2 square, 3 random —
248/// random is ignored in PT).
249/// - bit 2 (value 4/5/6/7) disables retrigger on new notes.
250#[derive(Clone, Copy, Debug, Default)]
251pub struct Waveform {
252 /// 0 = sine, 1 = ramp-down, 2 = square, 3 = random (treated as sine).
253 pub shape: u8,
254 /// If false, position is reset to 0 on every new note (default).
255 pub no_retrigger: bool,
256}
257
258impl Waveform {
259 fn set(&mut self, nibble: u8) {
260 self.shape = nibble & 0x3;
261 self.no_retrigger = nibble & 0x4 != 0;
262 }
263}
264
265/// Per-channel playback state.
266#[derive(Clone, Debug, Default)]
267pub struct Channel {
268 /// 1-based sample index (0 = no sample ever triggered).
269 pub sample_index: u8,
270 /// Fractional read position into the sample's pcm buffer.
271 pub sample_pos: f32,
272 /// Current period (0 = silent / not playing).
273 pub period: u16,
274 /// Current volume 0..=64.
275 pub volume: u8,
276 /// Whether this channel is currently sounding.
277 pub active: bool,
278 /// Current finetune for the most recently loaded sample.
279 pub finetune: i8,
280
281 /// Current effect command (0..=0xF).
282 pub effect: u8,
283 pub effect_param: u8,
284
285 // ---- effect memory ----
286 /// Arpeggio base period — the un-modulated period for this row.
287 pub arp_base_period: u16,
288 /// Last portamento-up param (1xx), used when param == 0.
289 pub mem_porta_up: u8,
290 /// Last portamento-down param (2xx), used when param == 0.
291 pub mem_porta_down: u8,
292 /// Tone portamento target period (set by a note on a 3xy / 5xy row).
293 pub tone_porta_target: u16,
294 /// Tone portamento speed (3xy / 5xy param; 0 reuses the last value).
295 pub tone_porta_speed: u8,
296 /// Last vibrato params — nibble format `rate<<4 | depth`.
297 pub mem_vibrato: u8,
298 /// Vibrato LFO position, signed -32..=31.
299 pub vib_pos: i8,
300 /// Vibrato waveform control.
301 pub vib_wave: Waveform,
302 /// Last tremolo params — nibble format `rate<<4 | depth`.
303 pub mem_tremolo: u8,
304 /// Tremolo LFO position, signed -32..=31.
305 pub trem_pos: i8,
306 /// Tremolo waveform control.
307 pub trem_wave: Waveform,
308 /// Last 9xx sample-offset param.
309 pub mem_sample_offset: u8,
310 /// Last volume-slide param (A/5/6).
311 pub mem_volslide: u8,
312 /// E9x retrigger period (ticks between retriggers).
313 pub retrig_ticks: u8,
314 /// ECx note-cut tick (0 = no cut pending).
315 pub cut_tick: u8,
316 /// EDx note-delay state — `Some(delay_tick)` while pending, filled in on
317 /// row entry.
318 pub delay: Option<DelayedTrigger>,
319 /// Glissando flag (E3x): if set, tone portamento snaps to nearest
320 /// semitone each tick rather than sliding smoothly.
321 pub glissando: bool,
322
323 /// Sample number written on a row that did not also trigger a note —
324 /// the swap is deferred until the next note-on per Protracker quirk
325 /// (see `enter_row` for the citation). 0 means no pending swap.
326 pub pending_sample: u8,
327
328 /// Pending LED-filter state from an `E0x` on this channel's row.
329 /// `Some(true)` = filter ON (E00, LED on). `Some(false)` = filter
330 /// OFF (E01, LED off). `None` = no E0 this row. Resolved at the
331 /// end of `enter_row` after all four channels' tick-0 effects are
332 /// processed: like Fxx, a later channel's E0 wins on the same row.
333 pub pending_led: Option<bool>,
334
335 /// Per-channel pan position, 0..=255. `0` = hard LEFT, `255` =
336 /// hard RIGHT, `128` = centre. This is the FT-extension panning
337 /// state set by `8xx` (full 8-bit pan, `Protracker-effects-
338 /// MODFIL12.txt` lines 1201-1207: "Command 8: Set FINE Panning ...
339 /// xxxxyyyy = panning position. (0=Most left, 255=most right.)")
340 /// and by `E8x` (rough nibble pan, `Protracker-effects-
341 /// MODFIL12.txt` lines 1503-1505: "Command $E8: Set (Rough)
342 /// Panning ... yyyy = panning value. $0 = most left, $F = most
343 /// right.") — the E8 nibble is replicated across both halves of
344 /// the byte so `E80` → 0x00, `E8F` → 0xFF, `E87` → 0x77, matching
345 /// the "rough" 16-step interpretation also documented in
346 /// `multimedia-cx-protracker.html` ("$0 is hard left, $F is hard
347 /// right"). Initial values follow the Amiga LRRL hard-pan
348 /// convention (channels 0 & 3 → 0, 1 & 2 → 255, pattern repeats
349 /// every 4) so a MOD with no panning commands renders identically
350 /// to the pre-r75 build.
351 pub pan: u8,
352
353 /// Last post-volume sample emitted by this channel's mixer. Used
354 /// as the starting point for the crossfade ramp on the next
355 /// re-trigger (see `ramp_prev_sample`). Updated by `mix_one`
356 /// every output frame.
357 pub last_mixed_sample: f32,
358
359 /// Volume that the previous note was being mixed at the moment
360 /// this channel was *re-triggered*. Captured by `enter_row` on
361 /// every fresh note-on (and on retrigger paths like E9x and the
362 /// EDx delayed trigger) so the mixer can crossfade from this old
363 /// value to the new note's first sample over a short ramp,
364 /// instead of stepping discontinuously and producing the audible
365 /// pop that a hard re-trigger leaves behind.
366 ///
367 /// Without the ramp, every note-on creates a discontinuity in the
368 /// mixed bus equal to (last sample of the *old* note) → (first
369 /// sample of the *new* note), which is typically a step of a few
370 /// hundred i16 LSBs and accumulates into the per-trigger HF drift
371 /// observed on real-world MODs (`halluc.mod`, `rhmst.mod`).
372 pub ramp_prev_sample: f32,
373 /// Number of output frames remaining in the per-trigger
374 /// crossfade ramp. The ramp linearly interpolates from
375 /// `ramp_prev_sample` to the new note's mixed sample. Set to
376 /// [`PlayerState::RAMP_FRAMES`] on every fresh trigger; counted
377 /// down by `mix_one` once per output frame.
378 pub ramp_remaining_frames: u32,
379}
380
381/// Stores the note/sample/effect details of an EDy-delayed trigger so we
382/// can fire it at the requested tick inside the same row.
383#[derive(Clone, Copy, Debug, Default)]
384pub struct DelayedTrigger {
385 pub tick: u8,
386 pub period: u16,
387 pub sample: u8,
388}
389
390impl Channel {
391 /// Return the period with any per-tick vibrato offset applied. Used
392 /// only for the mixer step calculation; the raw `period` field is the
393 /// un-modulated value (so chained effects like tone-porta keep
394 /// compounding cleanly).
395 ///
396 /// We clamp to the *extended* period range `[108, 907]` here
397 /// (`Protracker-effects-MODFIL12.txt` §3.2) rather than the porta
398 /// limits `[113, 856]`: a vibrato peak / a finetune-extreme note
399 /// must be free to land at e.g. period 108 (FT +7 B-3) without being
400 /// re-clamped to 113. The mixer needs a positive period for the
401 /// PAULA_CLOCK divide.
402 fn effective_period(&self, vib_offset: i16) -> u16 {
403 let p = self.period as i32 + vib_offset as i32;
404 p.clamp(PERIOD_MIN_EXT as i32, PERIOD_MAX_EXT as i32) as u16
405 }
406
407 /// Mix one sample from this channel into the float accumulator.
408 /// Returns the post-volume signal in `-1.0..=1.0`.
409 fn mix_one(
410 &mut self,
411 samples: &[SampleBody],
412 out_rate: f32,
413 vib_offset: i16,
414 trem_offset: i16,
415 ) -> f32 {
416 if !self.active || self.period == 0 {
417 return 0.0;
418 }
419 let idx = self.sample_index as usize;
420 if idx == 0 || idx > samples.len() {
421 return 0.0;
422 }
423 let body = &samples[idx - 1];
424 if body.pcm.is_empty() {
425 return 0.0;
426 }
427
428 // Loop / end-of-sample wrap.
429 //
430 // Protracker quirk (Protracker-effects-MODFIL12.txt §2.2 + §2.8 +
431 // Protracker-2.3A-misc-info.txt "Repeat point/length" notes):
432 // for a looped sample the playable region is exactly
433 // `loop_start..loop_start+loop_length`. Once the cursor reaches
434 // `loop_end`, it wraps back into the loop region — it must NOT
435 // continue reading past `loop_end` into the sample's "tail" (the
436 // tail bytes are an artefact of how trackers store one-shot data
437 // before the loop region starts and the looped tail; PT discards
438 // them). The previous implementation only wrapped when
439 // `pos >= pcm.len()`, which let the player read garbage past the
440 // declared loop and produce audible glitches on real-world MODs
441 // where loop_end < pcm.len().
442 //
443 // For a looped sample, also clamp loop_end to pcm.len() — some
444 // real-world rips have slightly out-of-range repeat metadata.
445 let pcm_len = body.pcm.len();
446 let looped = body.is_looped();
447 let effective_end_f = if looped {
448 ((body.loop_start as usize + body.loop_length as usize).min(pcm_len)) as f32
449 } else {
450 pcm_len as f32
451 };
452
453 let pos = self.sample_pos;
454 if pos >= effective_end_f {
455 if looped {
456 let loop_start = body.loop_start as f32;
457 let span = effective_end_f - loop_start;
458 if span > 0.0 {
459 let over = pos - loop_start;
460 self.sample_pos = loop_start + over.rem_euclid(span);
461 } else {
462 self.active = false;
463 return 0.0;
464 }
465 } else {
466 self.active = false;
467 return 0.0;
468 }
469 }
470
471 // Linear interpolation between two nearest samples. The next-sample
472 // fetch must respect the loop boundary: if `i+1` lands on or past
473 // `loop_end`, wrap to `loop_start` (looped) rather than reading the
474 // tail.
475 let effective_end_idx = effective_end_f as usize;
476 let i = self.sample_pos as usize;
477 let frac = self.sample_pos - i as f32;
478 let s0_idx = i.min(pcm_len.saturating_sub(1));
479 let s0 = body.pcm[s0_idx] as f32 / 128.0;
480 let s1_idx = if i + 1 < effective_end_idx {
481 i + 1
482 } else if looped {
483 body.loop_start as usize
484 } else {
485 s0_idx
486 };
487 let s1 = body.pcm[s1_idx.min(pcm_len.saturating_sub(1))] as f32 / 128.0;
488 let interp = s0 + (s1 - s0) * frac;
489
490 // Apply tremolo to the effective volume; clamp 0..=64.
491 let eff_vol = (self.volume as i16 + trem_offset).clamp(0, 64);
492 let out_raw = interp * (eff_vol as f32 / 64.0);
493
494 // Per-trigger volume ramp (1 ms linear crossfade from
495 // `ramp_prev_sample` to the live mix value). See
496 // `PlayerState::RAMP_FRAMES` for the rationale + measurement
497 // notes.
498 let out = if self.ramp_remaining_frames > 0 {
499 let total = PlayerState::RAMP_FRAMES as f32;
500 let consumed = total - self.ramp_remaining_frames as f32;
501 let t = (consumed / total).clamp(0.0, 1.0);
502 let mixed = self.ramp_prev_sample * (1.0 - t) + out_raw * t;
503 self.ramp_remaining_frames -= 1;
504 if self.ramp_remaining_frames == 0 {
505 // Once the ramp completes, stop tracking the previous
506 // sample so a future re-trigger captures a fresh
507 // baseline rather than this stale value.
508 self.ramp_prev_sample = 0.0;
509 }
510 mixed
511 } else {
512 out_raw
513 };
514
515 // Track the last emitted sample so a future re-trigger can
516 // crossfade from it (see `ramp_prev_sample`).
517 self.last_mixed_sample = out;
518
519 // Advance sample_pos by output-rate-scaled increment, using the
520 // vibrato-modulated period for pitch.
521 let eff_period = self.effective_period(vib_offset) as f32;
522 let chan_rate = PAULA_CLOCK / eff_period;
523 let step = chan_rate / out_rate;
524 self.sample_pos += step;
525
526 out
527 }
528}
529
530/// Pending order/row jump scheduled by Bxx, Dxy, or E6x.
531#[derive(Clone, Copy, Debug)]
532struct Jump {
533 /// Next order index (None = next order + 1).
534 order: Option<u8>,
535 /// Row to start at in the new pattern (default 0).
536 row: u8,
537}
538
539/// Top-level player state. Owns samples, patterns, order, and the
540/// per-channel mixer. Feeds `render(dst)` to fill an interleaved stereo
541/// S16 buffer.
542pub struct PlayerState {
543 pub samples: Vec<SampleBody>,
544 pub patterns: Vec<Pattern>,
545 pub order: Vec<u8>,
546 pub song_length: u8,
547
548 pub channels: Vec<Channel>,
549 pub speed: u8,
550 pub bpm: u8,
551
552 /// Current position in the order table (0..song_length).
553 pub order_index: u8,
554 /// Current row inside the current pattern (0..64).
555 pub row: u8,
556 /// Current tick inside the current row (0..speed).
557 pub tick: u8,
558 /// Samples emitted so far within the current tick.
559 pub tick_sample_cursor: u32,
560
561 pub sample_rate: u32,
562 /// Flag set when the song has wrapped past its last order.
563 pub ended: bool,
564
565 /// Pending pattern break / position jump (consumed on tick advance).
566 pending_jump: Option<Jump>,
567
568 /// Per-pattern loop state for E6x. Four channels each track their own
569 /// start row + remaining count independently (per spec).
570 loop_rows: Vec<u8>,
571 loop_counts: Vec<u8>,
572
573 /// EEx pattern-delay: rows to repeat after the current one completes.
574 pattern_delay: u8,
575
576 /// True while we're inside an EE-induced repeat of the current row.
577 /// Per Pro-Noise-Soundtracker-rev4.txt §[14][14] ("Delay pattern"):
578 /// "all effects and previous notes continue during delay" — i.e. the
579 /// note must NOT re-trigger and tick-0 effects must NOT re-fire on the
580 /// repeated passes; only the per-tick (tick > 0) effect handler keeps
581 /// running. Without this flag, `enter_row` would re-execute on every
582 /// repeat and reset `sample_pos` / fine-volume slides would compound
583 /// once per repeat — both audible regressions on real-world MODs that
584 /// use EE for held-note textures.
585 in_pattern_delay_repeat: bool,
586
587 /// Amiga LED filter state. When ON, the mixer applies a 2-pole
588 /// low-pass to all output streams (`Protracker-v1.1B-mod.txt` Cmd
589 /// E0: "C-300E00 connects filter (turns power LED on)" — i.e.
590 /// LED ON is the *default* on real Amigas, and the filter
591 /// attenuates HF content). E01 disconnects the second (LED-gated
592 /// Sallen-Key) pole at ~3.3 kHz, but the always-on first RC
593 /// pole at ~5 kHz stays in place — that pole models the
594 /// always-on anti-alias RC stage that sits between Paula's DAC
595 /// and the audio jacks on every A500 / A1200 motherboard
596 /// (documented in the MilkyTracker reference doc and the
597 /// Polynominal "Amiga filter test" page — both are documentation,
598 /// not third-party source).
599 led_filter: bool,
600 /// First filter pole (always on). For mixed stereo the first two
601 /// slots hold L/R state; for planar per-channel output one slot
602 /// per MOD channel. Don't mix `render` and `render_per_channel`
603 /// calls on the same instance — the slot semantics differ and the
604 /// filter state would smear across modes.
605 led_filter_state: Vec<f32>,
606 /// Second filter pole (only applied when LED is ON). Same slot
607 /// shape as `led_filter_state`.
608 led_filter_state2: Vec<f32>,
609 /// Pre-computed filter coefficient for the always-on first pole
610 /// (`FIXED_RC_CUTOFF_HZ`). Computed lazily on first render once
611 /// `sample_rate` is known (NaN = "not yet computed").
612 led_filter_alpha: f32,
613 /// Pre-computed filter coefficient for the LED-controlled second
614 /// pole (`LED_FILTER_CUTOFF_HZ`). NaN until first render.
615 led_filter_alpha2: f32,
616
617 /// Stereo pan separation in `0.0..=1.0`.
618 ///
619 /// `1.0` = full Amiga hard pan (channels 0/3 → only LEFT, 1/2 →
620 /// only RIGHT, per `Protracker-effects-MODFIL12.txt` §11
621 /// "Channels 1 and 4 are left, and 2 and 3 are right"). `0.0` =
622 /// full mono (all channels in both speakers, like the legacy
623 /// behaviour of `coreaudio` mono out). Defaults to `0.5` (see
624 /// [`Self::DEFAULT_PAN_SEPARATION`]) — empirically the value
625 /// that minimises cross-correlation drift versus a black-box
626 /// reference render on real-world MODs (`halluc.mod`,
627 /// `rhmst.mod`). `MODFIL12.txt` §11 itself recommends NOT
628 /// pushing balance "all the way over to the left or to the
629 /// right" ("Especially when using headphones"); a real-world
630 /// MOD whose intro uses only the right-panned channels (1 + 2)
631 /// — common in compositions like "hallucinations" — would
632 /// otherwise produce a dead left ear for the entire intro,
633 /// which sounds broken to listeners even though it is
634 /// technically per-spec. Adjust via
635 /// [`PlayerState::set_pan_separation`] if a strict hard-pan
636 /// render (1.0) is required.
637 pan_separation: f32,
638}
639
640impl PlayerState {
641 pub fn new(
642 header: &ModHeader,
643 samples: Vec<SampleBody>,
644 patterns: Vec<Pattern>,
645 sample_rate: u32,
646 ) -> Self {
647 let channels = (0..header.channels)
648 .map(|i| {
649 // Initialise per-channel pan to the Amiga LRRL
650 // hard-pan layout (channels 0 & 3 → 0/LEFT, 1 & 2 →
651 // 255/RIGHT, repeating every 4). This matches
652 // `channel_is_left` and keeps a MOD that never issues
653 // 8xx / E8x rendering identically to the pre-r75
654 // build (the per-channel `pan` simply re-derives the
655 // hard-LRRL mix that `sample_all_channels` used to
656 // compute from `channel_is_left(i)`).
657 let pan = if Self::channel_is_left(i as usize) {
658 0
659 } else {
660 255
661 };
662 Channel {
663 pan,
664 ..Channel::default()
665 }
666 })
667 .collect::<Vec<_>>();
668 let n_ch = channels.len();
669 PlayerState {
670 samples,
671 patterns,
672 order: header.order.clone(),
673 song_length: header.song_length,
674 channels,
675 speed: DEFAULT_SPEED,
676 bpm: DEFAULT_BPM,
677 order_index: 0,
678 row: 0,
679 tick: 0,
680 tick_sample_cursor: 0,
681 sample_rate,
682 ended: false,
683 pending_jump: None,
684 loop_rows: vec![0; n_ch],
685 loop_counts: vec![0; n_ch],
686 pattern_delay: 0,
687 in_pattern_delay_repeat: false,
688 // LED ON is the Amiga default at power-up — and the
689 // ProTracker replayer leaves it on unless an explicit E01
690 // toggles it off. See `Protracker-v1.1B-mod.txt` Cmd E0
691 // ("C-300E00 connects filter (turns power LED on)").
692 led_filter: true,
693 led_filter_state: Vec::new(),
694 led_filter_state2: Vec::new(),
695 led_filter_alpha: f32::NAN,
696 led_filter_alpha2: f32::NAN,
697 pan_separation: Self::DEFAULT_PAN_SEPARATION,
698 }
699 }
700
701 /// Default stereo pan separation (`0.5` = 50 %).
702 ///
703 /// See the `pan_separation` field doc-comment for the rationale.
704 /// We default to **0.5** — half-way between the
705 /// `Protracker-effects-MODFIL12.txt` §11 hard pan recommendation
706 /// (which itself warns against full hard pan "especially when
707 /// using headphones") and full mono. Empirically this is the
708 /// value that minimises cross-correlation drift versus a
709 /// black-box reference render on real-world MODs (`halluc.mod`,
710 /// `rhmst.mod`) — a partial-bleed default is common practice.
711 /// An r14 trial used 0.7 but on these specific fixtures 0.5
712 /// yielded ~3 % better xcorr per 1 s window across the entire
713 /// song. Override with [`PlayerState::set_pan_separation`].
714 pub const DEFAULT_PAN_SEPARATION: f32 = 0.5;
715
716 /// Override the stereo pan separation. `1.0` = full Amiga hard
717 /// pan (channels 0/3 → LEFT only, 1/2 → RIGHT only). `0.0` =
718 /// full mono. Values outside `0.0..=1.0` are clamped.
719 pub fn set_pan_separation(&mut self, sep: f32) {
720 self.pan_separation = sep.clamp(0.0, 1.0);
721 }
722
723 /// Read the current stereo pan separation.
724 pub fn pan_separation(&self) -> f32 {
725 self.pan_separation
726 }
727
728 /// Length of the per-trigger volume ramp, in output frames.
729 /// 44 frames at 44.1 kHz ≈ 1 ms — enough to smooth the
730 /// discontinuity at a fresh note-on without smearing percussive
731 /// transients audibly.
732 ///
733 /// Without this ramp, every note re-trigger emits a single-sample
734 /// step of magnitude `(prev_mixed - new_mixed)` into the post-mix
735 /// L/R bus. On real-world MODs (`halluc.mod`, `rhmst.mod`) those
736 /// steps were measured at ~5775 LSB and were the dominant
737 /// contributor to per-second cross-correlation drift versus a
738 /// black-box reference render. A 1 ms linear crossfade
739 /// corresponds to the `Protracker-effects-MODFIL12.txt` §1.6
740 /// note about Paula taking ~140 µs to settle on a fresh DMA
741 /// fetch (we round up to one full output millisecond so the
742 /// ramp is robust at any reasonable output sample rate).
743 pub const RAMP_FRAMES: u32 = 44;
744
745 /// Cutoff of the always-on first RC stage that sits between
746 /// Paula's DAC and the audio jacks.
747 ///
748 /// Real-hardware schematic R/C values yield 1/(2πRC) ≈ 4400 Hz
749 /// (rounded to **5000 Hz** in r14 of this module to model an
750 /// "authentic" Amiga sound). However, the prevailing modern
751 /// rendering convention effectively bypasses this stage in
752 /// the default render path — only the LED-gated stage is
753 /// modelled — because the real-hardware corner lops off audible
754 /// content that listeners expect to hear back from a MOD
755 /// render. With the strict 5 kHz value we measured
756 /// cross-correlation drift against a black-box reference render
757 /// on `rhmst.mod` of about 5 % (~0.94 vs ~0.99) — i.e. the
758 /// always-on filter was the dominant contributor to per-second
759 /// drift versus the modern reference.
760 ///
761 /// We default to **16000 Hz** to make the always-on stage
762 /// audibly transparent (it still rolls off ultrasonic content
763 /// past the resampler, which is the only PT-faithful purpose
764 /// it serves at modern output rates).
765 ///
766 /// Documentation references: `paula-filter-notes.md`,
767 /// `docs/audio/trackers/mod/openmpt-module-formats.html`
768 /// (Resampling).
769 pub const FIXED_RC_CUTOFF_HZ: f32 = 16_000.0;
770
771 /// Cutoff of the LED-controlled second RC stage (the one
772 /// toggled by `E00` / `E01`).
773 ///
774 /// The real Amiga "Power LED" filter is a 12 dB/oct Sallen-Key
775 /// cell with ~3.275 kHz nominal corner — that's the
776 /// "spec-strict" value previously used here. However,
777 /// `multimedia-cx-protracker.html` documents an
778 /// **11500 Hz 1-pole approximation** that the modern PT
779 /// rendering convention converges on, and every
780 /// cross-correlation measurement against a black-box reference
781 /// render confirms 11500 Hz is much closer to the modern-player
782 /// ground truth than 3300 Hz is.
783 /// The Sallen-Key value lops off so much HF content that
784 /// real-world MOD files render audibly muffled compared to
785 /// what their authors were probably listening to in their
786 /// final mixdown — and this manifests as cross-correlation
787 /// dips of 0.05–0.10 across most of `rhmst.mod` and
788 /// `halluc.mod`.
789 pub const LED_FILTER_CUTOFF_HZ: f32 = 11_500.0;
790
791 /// Compute a 1-pole IIR alpha for the given cutoff at the
792 /// player's output sample rate.
793 /// `y[n] = a*x[n] + (1-a)*y[n-1]` with `a = 1 - exp(-2π·fc/fs)`.
794 fn compute_alpha(sample_rate: u32, fc: f32) -> f32 {
795 let fs = sample_rate as f32;
796 let two_pi = 2.0 * std::f32::consts::PI;
797 1.0 - (-two_pi * fc / fs).exp()
798 }
799
800 /// Ensure both filter state vectors have at least `n_outputs`
801 /// slots and both alphas are initialised.
802 fn ensure_led_filter(&mut self, n_outputs: usize) {
803 if self.led_filter_state.len() < n_outputs {
804 self.led_filter_state.resize(n_outputs, 0.0);
805 }
806 if self.led_filter_state2.len() < n_outputs {
807 self.led_filter_state2.resize(n_outputs, 0.0);
808 }
809 if self.led_filter_alpha.is_nan() {
810 self.led_filter_alpha = Self::compute_alpha(self.sample_rate, Self::FIXED_RC_CUTOFF_HZ);
811 }
812 if self.led_filter_alpha2.is_nan() {
813 self.led_filter_alpha2 =
814 Self::compute_alpha(self.sample_rate, Self::LED_FILTER_CUTOFF_HZ);
815 }
816 }
817
818 /// Apply the two-stage Amiga output filter on slot `idx`. The
819 /// first RC stage is *always* on (it models the always-on
820 /// 4-5 kHz anti-alias post-DAC pole that exists on every
821 /// A500 / A1200 motherboard regardless of LED state). The
822 /// second pole is gated on `led_filter`, modelling the
823 /// `E00 / E01` LED-controlled Sallen-Key corner at ~3.3 kHz.
824 #[inline]
825 fn led_filter_step(&mut self, idx: usize, x: f32) -> f32 {
826 // Stage 1 — always on.
827 let a1 = self.led_filter_alpha;
828 let prev1 = self.led_filter_state[idx];
829 let y1 = a1 * x + (1.0 - a1) * prev1;
830 self.led_filter_state[idx] = y1;
831
832 if !self.led_filter {
833 return y1;
834 }
835
836 // Stage 2 — gated on LED.
837 let a2 = self.led_filter_alpha2;
838 let prev2 = self.led_filter_state2[idx];
839 let y2 = a2 * y1 + (1.0 - a2) * prev2;
840 self.led_filter_state2[idx] = y2;
841 y2
842 }
843
844 /// Samples-per-tick rounded down. Classic formula is
845 /// `sample_rate * 2.5 / BPM`.
846 pub fn samples_per_tick(&self) -> u32 {
847 ((self.sample_rate as f32) * 2.5 / self.bpm as f32) as u32
848 }
849
850 /// Process the row at the current position (called at tick 0).
851 fn enter_row(&mut self) {
852 let pattern_idx = self
853 .order
854 .get(self.order_index as usize)
855 .copied()
856 .unwrap_or(0) as usize;
857 if pattern_idx >= self.patterns.len() {
858 self.ended = true;
859 return;
860 }
861 let row_notes: Vec<Note> = self.patterns[pattern_idx].rows[self.row as usize].clone();
862 for (ch_idx, note) in row_notes.iter().enumerate() {
863 if ch_idx >= self.channels.len() {
864 break;
865 }
866
867 let effect = note.effect;
868 let param = note.effect_param;
869 let x = param >> 4;
870 let y = param & 0x0F;
871
872 let ch = &mut self.channels[ch_idx];
873
874 // Arpeggio cleanup: if the previous row was running arpeggio
875 // (effect 0 with non-zero param), `ch.period` was left at a
876 // semitone-shifted value by the last tick (per FireLight
877 // tutorial §5.1: tick%3==1 → +x, tick%3==2 → +y). At a row
878 // boundary the spec calls for "Tick 0 set frequency to
879 // normal value" (FireLight §5.1) — i.e. the un-modulated
880 // base. Restore `ch.period` to `ch.arp_base_period` before
881 // any new-row decisions so:
882 // 1. The mixer plays tick 0 of the new row at the correct
883 // base pitch instead of whatever leftover modulated
884 // value the prior row's last arp tick left behind.
885 // 2. The "no new note" branch below does not capture the
886 // stale modulated period as a NEW `arp_base_period` —
887 // that would compound the modulation across every row
888 // with continuing arpeggio (audible on `cyber.mod`'s
889 // pat-1 ch-2 lead, rows 32-58, where the effect-0 cell
890 // pattern is "trigger / continue / trigger / trigger
891 // / trigger / continue / ..." — every "continue" row
892 // played at the prior tick's modulated period and
893 // then re-modulated from that, raising the pitch by
894 // ~3 semitones per non-trigger row).
895 // 3. Other effects starting on this row (porta up/down,
896 // tone porta, fine slides) operate on the true base
897 // period rather than a modulated one.
898 // See `Protracker-effects-MODFIL12.txt` 0:Arpeggio and
899 // FireLight-MOD-Player-Tutorial.txt §5.1.
900 if ch.effect == 0x0 && ch.effect_param != 0 && ch.arp_base_period != 0 {
901 ch.period = ch.arp_base_period;
902 }
903
904 ch.effect = effect;
905 ch.effect_param = param;
906 ch.cut_tick = 0;
907 ch.delay = None;
908 ch.retrig_ticks = 0;
909
910 // Sample change.
911 //
912 // Protracker quirk (Protracker-effects-MODFIL12.txt §3.2 +
913 // Pro-Noise-Soundtracker-rev4.txt:113-118): when a sample
914 // number is specified WITHOUT a note, PT loads the new
915 // sample's default volume + finetune immediately, but does
916 // NOT swap the currently-playing sample on the channel —
917 // the swap happens at the next note trigger. Loading the
918 // sample index too early was an audible bug: a row with
919 // "C-2 05 ___ : --- 03" would suddenly play sample 3's PCM
920 // at sample 5's pitch, producing wrong-instrument artefacts
921 // on real-world MODs that use this idiom (e.g. setting up
922 // the next note's volume on the row before the trigger).
923 //
924 // We track the "pending" sample number in `sample_index`
925 // separately from the actively-playing sample, but since the
926 // mixer reads `sample_index`, the safest fix is: only update
927 // `sample_index` when a note also triggers (or on a tone-
928 // portamento / note-delay row, where the trigger is
929 // explicit). Otherwise, defer the swap and only latch the
930 // default volume + finetune (used by the next note).
931 let has_note = note.period != 0;
932 let is_note_delay_pre = note.effect == 0xE && (note.effect_param >> 4) == 0xD;
933 if note.sample != 0 {
934 let idx = note.sample as usize;
935 if idx >= 1 && idx <= self.samples.len() {
936 let body = &self.samples[idx - 1];
937 ch.volume = body.volume;
938 ch.finetune = body.finetune;
939 }
940 if has_note || is_note_delay_pre {
941 // A trigger is happening on this row — latch the
942 // sample index so the new note plays the new sample.
943 ch.sample_index = note.sample;
944 } else {
945 // No trigger: remember the requested swap for the
946 // next note. Volume + finetune are already applied
947 // (PT behaviour); the active sample stays.
948 ch.pending_sample = note.sample;
949 }
950 }
951
952 let is_tone_porta = matches!(effect, 0x3 | 0x5);
953 let is_note_delay = effect == 0xE && x == 0xD;
954
955 // Tone portamento: record target, but DO NOT retrigger.
956 if note.period != 0 && is_tone_porta {
957 ch.tone_porta_target = note.period;
958 if effect == 0x3 && param != 0 {
959 ch.tone_porta_speed = param;
960 }
961 // Speed 0 on Cmd 3 inherits last; Cmd 5 never sets speed.
962 ch.arp_base_period = ch.period;
963 } else if note.period != 0 && is_note_delay {
964 // Delay the trigger to tick y; continue previous note until then.
965 ch.delay = Some(DelayedTrigger {
966 tick: y,
967 period: note.period,
968 sample: note.sample,
969 });
970 ch.arp_base_period = ch.period;
971 } else if note.period != 0 {
972 // Normal note trigger — apply E5 finetune if it lands on this row.
973 let mut note_period = note.period;
974 if effect == 0xE && x == 0x5 {
975 // E5x: set finetune and re-derive the period from the
976 // note index.
977 let new_ft = y as i8;
978 let signed_ft = if new_ft & 0x8 != 0 {
979 new_ft - 16
980 } else {
981 new_ft
982 };
983 ch.finetune = signed_ft;
984 if let Some(note_idx) = note_index_for_period(note.period) {
985 note_period = PERIOD_TABLE[finetune_row(signed_ft)][note_idx];
986 }
987 }
988 ch.period = note_period;
989
990 // Consume any deferred sample swap from a previous row
991 // that wrote a sample number without a note.
992 if note.sample == 0 && ch.pending_sample != 0 {
993 ch.sample_index = ch.pending_sample;
994 }
995 ch.pending_sample = 0;
996
997 // 9xx: start from an offset instead of the sample start.
998 let mut offset_frames: u32 = 0;
999 if effect == 0x9 {
1000 let used = if param == 0 {
1001 ch.mem_sample_offset
1002 } else {
1003 param
1004 };
1005 ch.mem_sample_offset = used;
1006 offset_frames = (used as u32) * 0x100;
1007 }
1008
1009 // 9xx out-of-range quirk: if the offset lands at or past
1010 // the end of the sample body, ProTracker plays NO NOTE at
1011 // all on this channel (Protracker-effects-MODFIL12.txt
1012 // 9:Set-sample-offset, lines 1240-1242: "Note that if the
1013 // effect is out of range (e.g. if it tries to jump beyond
1014 // the end of the sample) NO NOTE WILL BE PLAYED!"). We
1015 // detect this at trigger time and silence the channel
1016 // instead of letting the mixer wrap a looped sample's
1017 // over-range cursor back into the loop region (which would
1018 // audibly play the loop from a fresh trigger — exactly the
1019 // artefact the spec says should not happen). The check
1020 // applies only when a 9xx offset was actually requested;
1021 // a plain note with no 9xx keeps the original semantics.
1022 if effect == 0x9 {
1023 let sample_len = (ch.sample_index as usize)
1024 .checked_sub(1)
1025 .and_then(|i| self.samples.get(i))
1026 .map(|b| b.pcm.len())
1027 .unwrap_or(0);
1028 if offset_frames as usize >= sample_len {
1029 // No note: leave the channel inactive and skip the
1030 // rest of the trigger (position / ramp / LFO
1031 // retrigger). arp_base_period still anchors to the
1032 // intended note period so a following effect-0 row
1033 // has a sane base, matching the "note info is still
1034 // updated, just not played" reading.
1035 ch.active = false;
1036 ch.sample_pos = 0.0;
1037 ch.arp_base_period = note_period;
1038 continue;
1039 }
1040 }
1041
1042 ch.sample_pos = offset_frames as f32;
1043 ch.active = true;
1044 ch.arp_base_period = note_period;
1045
1046 // Per-trigger volume ramp: capture the value the
1047 // mixer last emitted for this channel and arm a short
1048 // crossfade. See `PlayerState::RAMP_FRAMES` for why.
1049 ch.ramp_prev_sample = ch.last_mixed_sample;
1050 ch.ramp_remaining_frames = PlayerState::RAMP_FRAMES;
1051
1052 // Retrigger vibrato / tremolo unless waveform says otherwise.
1053 if !ch.vib_wave.no_retrigger {
1054 ch.vib_pos = 0;
1055 }
1056 if !ch.trem_wave.no_retrigger {
1057 ch.trem_pos = 0;
1058 }
1059 } else {
1060 // No note — keep arp base anchored to the current period.
1061 ch.arp_base_period = ch.period;
1062 }
1063
1064 // Tick-0 effects.
1065 apply_tick0_effect(
1066 ch_idx,
1067 effect,
1068 param,
1069 &mut self.channels,
1070 &mut self.pending_jump,
1071 &mut self.loop_rows,
1072 &mut self.loop_counts,
1073 &mut self.pattern_delay,
1074 self.order_index,
1075 self.row,
1076 );
1077 }
1078
1079 // Fxx applies immediately on tick 0 — handle after per-channel dispatch.
1080 // A later channel's Fxx supersedes an earlier one (PT behaviour, per
1081 // `Pro-Noise-Soundtracker-rev4.txt:375-377`: "the ones on
1082 // higher-numbered channels take precedence over the
1083 // ones on lower-numbered channels").
1084 //
1085 // Boundary check (Pro-Noise-Soundtracker-rev4.txt:362-365): the
1086 // doc reads "If z<=32, then it means 'set ticks/division to z'
1087 // otherwise it means 'set beats/minute to z' (convention says
1088 // that this should read 'If z<32...')". We follow the
1089 // convention (z < 0x20 → speed, else BPM), so 0x20 (= 32) is
1090 // the smallest BPM value, matching `Protracker-v1.1B-mod.txt`
1091 // and the FireLight tutorial. 0x1F is the largest speed value.
1092 for ch in &self.channels {
1093 if ch.effect == 0xF {
1094 let p = ch.effect_param;
1095 if p == 0 {
1096 // Fxx 00 — ProTracker treats this as "halt" (set
1097 // order_index past the end). Safer default: ignore.
1098 } else if p < 0x20 {
1099 self.speed = p;
1100 } else {
1101 self.bpm = p;
1102 }
1103 }
1104 }
1105
1106 // E0x LED filter: same per-row last-channel-wins resolution.
1107 // `Protracker-v1.1B-mod.txt` Cmd E0: E00 connects filter (LED on),
1108 // E01 disconnects (LED off).
1109 let mut new_led: Option<bool> = None;
1110 for ch in &mut self.channels {
1111 if let Some(new_state) = ch.pending_led.take() {
1112 new_led = Some(new_state);
1113 }
1114 }
1115 if let Some(s) = new_led {
1116 self.led_filter = s;
1117 }
1118 }
1119
1120 /// Advance one tick (called at the start of every tick).
1121 fn advance_tick(&mut self) {
1122 if self.tick == 0 {
1123 // Pattern-delay repeat: per Pro-Noise §[14][14] ("all effects
1124 // and previous notes continue during delay") we must NOT
1125 // re-execute `enter_row` on the repeated passes — that would
1126 // re-trigger notes (resetting sample_pos to 0) and re-fire
1127 // tick-0 effects (compounding fine vol slides etc.). The
1128 // currently-playing notes simply continue; tick-N effects on
1129 // the inbetween ticks still run normally below.
1130 if !self.in_pattern_delay_repeat {
1131 self.enter_row();
1132 }
1133 } else {
1134 // Tick-N effects run per channel.
1135 for ch_idx in 0..self.channels.len() {
1136 apply_tickn_effect(ch_idx, self.tick, &mut self.channels, &self.samples);
1137 }
1138 }
1139 }
1140
1141 /// Move to next row (or jump).
1142 fn next_row(&mut self) {
1143 // EEx pattern delay: repeat the current row `pattern_delay` more
1144 // times. Set `in_pattern_delay_repeat` so the next pass through
1145 // tick 0 skips `enter_row` (no note re-trigger, no tick-0 effect
1146 // re-fire) per Pro-Noise-Soundtracker-rev4.txt §[14][14].
1147 if self.pattern_delay > 0 {
1148 self.pattern_delay -= 1;
1149 self.in_pattern_delay_repeat = true;
1150 return;
1151 }
1152 // Real row advance — clear the repeat flag so the next row's
1153 // tick-0 processing runs normally.
1154 self.in_pattern_delay_repeat = false;
1155 if let Some(jump) = self.pending_jump.take() {
1156 if let Some(order) = jump.order {
1157 self.order_index = order;
1158 } else {
1159 self.order_index = self.order_index.saturating_add(1);
1160 }
1161 self.row = jump.row;
1162 } else {
1163 self.row += 1;
1164 if self.row as usize >= PATTERN_ROWS {
1165 self.row = 0;
1166 self.order_index = self.order_index.saturating_add(1);
1167 }
1168 }
1169 if self.order_index >= self.song_length {
1170 self.ended = true;
1171 }
1172 }
1173
1174 /// True if track index `i` is hard-panned left under the classic
1175 /// Amiga convention (channels 0 & 3 left, 1 & 2 right; for >4 channels
1176 /// the pattern repeats every 4).
1177 pub fn channel_is_left(i: usize) -> bool {
1178 matches!(i % 4, 0 | 3)
1179 }
1180
1181 /// Compute the instantaneous vibrato period offset for this channel.
1182 fn vibrato_offset(ch: &Channel) -> i16 {
1183 let rate = ch.mem_vibrato >> 4;
1184 let depth = ch.mem_vibrato & 0x0F;
1185 if depth == 0 || ch.effect != 0x4 && ch.effect != 0x6 {
1186 // Vibrato is only active on ticks where 4xy or 6xy is the current
1187 // effect. A subsequent non-vibrato effect on the same channel
1188 // stops the modulation.
1189 let _ = rate; // keep rate referenced even in this path
1190 return 0;
1191 }
1192 let idx = (ch.vib_pos.unsigned_abs() & 31) as usize;
1193 let base = match ch.vib_wave.shape {
1194 0 | 3 => PROTRACKER_SINE_TABLE[idx] as i32,
1195 1 => {
1196 // Ramp down: |pos|<<3 with 255-x on the negative half.
1197 let raw = (idx << 3) as i32;
1198 if ch.vib_pos < 0 {
1199 255 - raw
1200 } else {
1201 raw
1202 }
1203 }
1204 _ => 255, // square
1205 };
1206 let delta = (base * depth as i32) >> 7;
1207 if ch.vib_pos < 0 {
1208 -(delta as i16)
1209 } else {
1210 delta as i16
1211 }
1212 }
1213
1214 /// Compute the instantaneous tremolo volume offset for this channel.
1215 fn tremolo_offset(ch: &Channel) -> i16 {
1216 let depth = ch.mem_tremolo & 0x0F;
1217 if depth == 0 || ch.effect != 0x7 {
1218 return 0;
1219 }
1220 let idx = (ch.trem_pos.unsigned_abs() & 31) as usize;
1221 let base = match ch.trem_wave.shape {
1222 0 | 3 => PROTRACKER_SINE_TABLE[idx] as i32,
1223 1 => {
1224 let raw = (idx << 3) as i32;
1225 if ch.trem_pos < 0 {
1226 255 - raw
1227 } else {
1228 raw
1229 }
1230 }
1231 _ => 255,
1232 };
1233 // Tremolo divides by 64 (half the vibrato denominator) so the
1234 // peak delta maps to volume units.
1235 let delta = (base * depth as i32) >> 6;
1236 if ch.trem_pos < 0 {
1237 -(delta as i16)
1238 } else {
1239 delta as i16
1240 }
1241 }
1242
1243 /// Sample all channels once, returning per-channel floats in
1244 /// `-1.0..=1.0` range (pre-pan, pre-mix) and a stereo mix scaled so
1245 /// that a fully-saturated 4-channel MOD stays within the -1..1 range.
1246 ///
1247 /// Pan model: with separation `s ∈ [0, 1]`, a hard-left channel
1248 /// contributes `(1 + s) / 2` to L and `(1 - s) / 2` to R; vice
1249 /// versa for hard-right. So `s = 1` reproduces pure Amiga hard
1250 /// pan (channels 0/3 → only L, 1/2 → only R) and `s = 0` is
1251 /// full mono. The default `0.5` (see `pan_separation` field)
1252 /// keeps an intro that uses only right-panned channels (1 + 2,
1253 /// per the convention in `Protracker-effects-MODFIL12.txt` §11)
1254 /// audible on the left speaker too — the modern PT rendering
1255 /// convention applies the same partial bleed for the
1256 /// headphone-fatigue reason called out in MODFIL12.txt itself
1257 /// ("Especially when using headphones").
1258 /// Without it, real-world MODs whose intros use only
1259 /// right-panned voices (e.g. "hallucinations" by ???) sound
1260 /// broken to a listener whose left ear receives no signal at
1261 /// all for several seconds.
1262 fn sample_all_channels(&mut self, per_channel: &mut [f32]) -> (f32, f32) {
1263 let out_rate = self.sample_rate as f32;
1264 let mut l = 0.0f32;
1265 let mut r = 0.0f32;
1266 let n_ch = self.channels.len();
1267 let s = self.pan_separation;
1268 // Per-channel pan derives the L/R gain pair from `ch.pan`
1269 // (0 = hard LEFT, 128 = centre, 255 = hard RIGHT) per the
1270 // 8xx / E8x spec. The `pan_separation` global narrows the L↔R
1271 // gap symmetrically around the centre: a fully panned channel
1272 // contributes `(1 + s) / 2` to its near speaker and
1273 // `(1 - s) / 2` to the far one. A centred channel (pan = 128)
1274 // splits evenly regardless of separation. This collapses to
1275 // the pre-r75 hard-LRRL formula whenever `ch.pan` is 0 or
1276 // 255 (which is the LRRL default initialised by `new`), so
1277 // the libmodplug calibration in the divisor below still
1278 // holds bit-for-bit.
1279 for (i, ch) in self.channels.iter_mut().enumerate() {
1280 let vib = Self::vibrato_offset(ch);
1281 let trem = Self::tremolo_offset(ch);
1282 let smp = ch.mix_one(&self.samples, out_rate, vib, trem);
1283 per_channel[i] = smp;
1284 let (gl, gr) = pan_gains(ch.pan, s);
1285 l += smp * gl;
1286 r += smp * gr;
1287 }
1288 // Mix-bus headroom divisor.
1289 //
1290 // Round 19 calibration vs libmodplug 0.8.9.0 (used as a
1291 // black-box behaviour oracle through the public C API in
1292 // `tests/libmodplug_compare.rs` — see that file's header for
1293 // the dlopen flow + clean-config protocol). With every
1294 // libmodplug colouration disabled (oversampling, megabass,
1295 // surround, noise reduction off; LINEAR resampling; 44100 Hz
1296 // / 16-bit / stereo; master volume default 128/512;
1297 // stereo_separation 128/256 = 50 %), a single max-volume
1298 // hard-left channel triggered on a 4-channel `M.K.` MOD lands
1299 // libmodplug's L peak at 8500 / 32767 = 0.2594 — which only
1300 // works out arithmetically if libmodplug's per-channel
1301 // headroom divisor is **3**, not the **2** we previously
1302 // used. The pan formula itself (near = (1+s)/2,
1303 // far = (1-s)/2) matches across the two engines bit-exact;
1304 // only the headroom divisor differs.
1305 //
1306 // Empirically the libmodplug formula is `n_ch/2 + 1` — i.e.
1307 // 3 for 4-ch, 4 for 6-ch, 5 for 8-ch, etc. That gives a
1308 // ~1.5× lower per-channel max gain than the strict `n_ch/2`
1309 // headroom we shipped before, so a single channel never
1310 // dominates the bus at 84 % of i16 peak the way ours did
1311 // (which the user-reported "scrambled audio at 4.5s" hunt
1312 // bracketed as the most-likely cause of downstream-clipping
1313 // perceived as scrambling on real-world MODs `halluc.mod`
1314 // and `rhmst.mod`). The peak ratio measured against
1315 // libmodplug drops from 1.506× to roughly 1.0× after this
1316 // change — confirmed by the `libmodplug_calibration_*` test
1317 // in `tests/libmodplug_compare.rs`.
1318 //
1319 // We don't read libmodplug source; the divisor is derived
1320 // purely from black-box measurement of the public-API
1321 // output PCM at known input parameters. See
1322 // `docs/audio/trackers/mod/openmpt-module-formats.html`
1323 // ("Resampling and mixing") which documents the same
1324 // "channel count + safety margin" pattern as the modern
1325 // ProTracker rendering convention — though no third-party
1326 // source code is referenced.
1327 let norm = ((n_ch as f32 / 2.0) + 1.0).max(1.0);
1328 (l / norm, r / norm)
1329 }
1330
1331 /// Render one stereo S16 interleaved sample pair by mixing all
1332 /// channels. Channels 0/3 pan toward LEFT, 1/2 toward RIGHT
1333 /// (Amiga convention) — the strength of the L↔R separation is
1334 /// controlled by [`pan_separation`](Self::pan_separation), which
1335 /// defaults to `DEFAULT_PAN_SEPARATION` (`0.5`) rather than full
1336 /// hard pan to keep intros that only use one side audible on
1337 /// both ears. Set to `1.0` for strict spec-faithful Amiga
1338 /// hard pan.
1339 fn render_one(&mut self, out: &mut [i16]) {
1340 let mut per_channel = vec![0.0f32; self.channels.len()];
1341 let (l, r) = self.sample_all_channels(&mut per_channel);
1342 // Amiga LED low-pass filter — applied post-mix on the L/R bus
1343 // (the real Amiga filter sits between Paula's DAC and the audio
1344 // jacks, common to both stereo channels). See
1345 // `multimedia-cx-protracker.html` E0x.
1346 self.ensure_led_filter(2);
1347 let l = self.led_filter_step(0, l);
1348 let r = self.led_filter_step(1, r);
1349 let l = l.clamp(-1.0, 1.0);
1350 let r = r.clamp(-1.0, 1.0);
1351 out[0] = (l * 32767.0) as i16;
1352 out[1] = (r * 32767.0) as i16;
1353 }
1354
1355 /// Render `n_frames` stereo samples into `dst` (interleaved S16,
1356 /// length = n_frames * 2). Returns samples actually rendered (may be
1357 /// less than requested if song ends).
1358 pub fn render(&mut self, dst: &mut [i16]) -> usize {
1359 assert!(dst.len() % 2 == 0);
1360 let mut produced = 0usize;
1361 let total_frames = dst.len() / 2;
1362
1363 while produced < total_frames {
1364 if self.ended {
1365 break;
1366 }
1367 if self.tick_sample_cursor == 0 {
1368 self.advance_tick();
1369 }
1370 let spt = self.samples_per_tick().max(1);
1371 let remaining_in_tick = spt.saturating_sub(self.tick_sample_cursor);
1372 let want = (total_frames - produced).min(remaining_in_tick as usize);
1373
1374 for _ in 0..want {
1375 let off = produced * 2;
1376 self.render_one(&mut dst[off..off + 2]);
1377 produced += 1;
1378 }
1379
1380 self.tick_sample_cursor += want as u32;
1381 if self.tick_sample_cursor >= spt {
1382 self.tick_sample_cursor = 0;
1383 self.tick += 1;
1384 if self.tick >= self.speed {
1385 self.tick = 0;
1386 self.next_row();
1387 }
1388 }
1389 }
1390 produced
1391 }
1392
1393 /// Render into one S16 plane per MOD channel. `planes.len()` must
1394 /// equal `self.channels.len()`; each plane receives the same number
1395 /// of samples, and all planes must be at least `n_frames` long.
1396 pub fn render_per_channel(&mut self, planes: &mut [&mut [i16]], n_frames: usize) -> usize {
1397 assert_eq!(
1398 planes.len(),
1399 self.channels.len(),
1400 "render_per_channel: plane count must equal MOD channel count"
1401 );
1402 for p in planes.iter() {
1403 assert!(
1404 p.len() >= n_frames,
1405 "render_per_channel: every plane must hold at least n_frames samples"
1406 );
1407 }
1408
1409 let mut produced = 0usize;
1410 let mut scratch = vec![0.0f32; self.channels.len()];
1411
1412 while produced < n_frames {
1413 if self.ended {
1414 break;
1415 }
1416 if self.tick_sample_cursor == 0 {
1417 self.advance_tick();
1418 }
1419 let spt = self.samples_per_tick().max(1);
1420 let remaining_in_tick = spt.saturating_sub(self.tick_sample_cursor);
1421 let want = (n_frames - produced).min(remaining_in_tick as usize);
1422
1423 // For per-channel output the Amiga LED filter is applied
1424 // independently on each plane — a downstream consumer that
1425 // re-mixes the planes still sees the same global filter
1426 // shape that mixed playback would produce. We allocate one
1427 // filter slot per plane.
1428 let n_planes = planes.len();
1429 for _ in 0..want {
1430 // Discard the stereo mix; we only need per-channel here.
1431 let _ = self.sample_all_channels(&mut scratch);
1432 self.ensure_led_filter(n_planes);
1433 for (ch_idx, plane) in planes.iter_mut().enumerate() {
1434 let raw = scratch[ch_idx];
1435 let filtered = self.led_filter_step(ch_idx, raw);
1436 let v = filtered.clamp(-1.0, 1.0);
1437 plane[produced] = (v * 32767.0) as i16;
1438 }
1439 produced += 1;
1440 }
1441
1442 self.tick_sample_cursor += want as u32;
1443 if self.tick_sample_cursor >= spt {
1444 self.tick_sample_cursor = 0;
1445 self.tick += 1;
1446 if self.tick >= self.speed {
1447 self.tick = 0;
1448 self.next_row();
1449 }
1450 }
1451 }
1452 produced
1453 }
1454}
1455
1456/// Apply tick-0 (row-start) effects. `ch_idx` identifies the channel
1457/// inside `channels`; shared song-level state (pending_jump, pattern delay
1458/// etc.) is passed by &mut so a single effect can mutate both.
1459#[allow(clippy::too_many_arguments)]
1460fn apply_tick0_effect(
1461 ch_idx: usize,
1462 effect: u8,
1463 param: u8,
1464 channels: &mut [Channel],
1465 pending_jump: &mut Option<Jump>,
1466 loop_rows: &mut [u8],
1467 loop_counts: &mut [u8],
1468 pattern_delay: &mut u8,
1469 order_index: u8,
1470 row: u8,
1471) {
1472 let x = param >> 4;
1473 let y = param & 0x0F;
1474 let ch = &mut channels[ch_idx];
1475 match effect {
1476 0x0 => {
1477 // Arpeggio — parameter memory is just the row's x/y; no state
1478 // needed on tick 0 beyond setting the arp base period (done in
1479 // enter_row) and remembering the param for subsequent ticks.
1480 }
1481 0x1
1482 // Portamento up: remember param for tick-N dispatch.
1483 if param != 0 => {
1484 ch.mem_porta_up = param;
1485 }
1486 0x2
1487 // Portamento down: remember param.
1488 if param != 0 => {
1489 ch.mem_porta_down = param;
1490 }
1491 0x3
1492 // Tone portamento: speed 0 reuses the stored value.
1493 if param != 0 => {
1494 ch.tone_porta_speed = param;
1495 }
1496 0x4 => {
1497 // Vibrato: nibble param memory (0 nibbles reuse previous values).
1498 let mut rate = x;
1499 let mut depth = y;
1500 if rate == 0 {
1501 rate = ch.mem_vibrato >> 4;
1502 }
1503 if depth == 0 {
1504 depth = ch.mem_vibrato & 0x0F;
1505 }
1506 ch.mem_vibrato = (rate << 4) | depth;
1507 }
1508 0x5
1509 // Tone portamento + volume slide. Reuse stored tone-porta speed;
1510 // param here is the volume-slide nibble pair.
1511 if param != 0 => {
1512 ch.mem_volslide = param;
1513 }
1514 0x6
1515 // Vibrato + volume slide. Vibrato params are inherited.
1516 if param != 0 => {
1517 ch.mem_volslide = param;
1518 }
1519 0x7 => {
1520 // Tremolo: nibble param memory.
1521 let mut rate = x;
1522 let mut depth = y;
1523 if rate == 0 {
1524 rate = ch.mem_tremolo >> 4;
1525 }
1526 if depth == 0 {
1527 depth = ch.mem_tremolo & 0x0F;
1528 }
1529 ch.mem_tremolo = (rate << 4) | depth;
1530 }
1531 0x8 => {
1532 // 8xx: Set FINE Panning (FT2 extension).
1533 // `Protracker-effects-MODFIL12.txt` lines 1201-1207:
1534 // "Command 8: Set FINE Panning.
1535 // Even if the AMIGA PROTracker does not support it,
1536 // this effect is used by modern MOD's and supported
1537 // by nearly all modern PC MOD player routines.
1538 // xxxxyyyy = panning position. (0=Most left,
1539 // 255=most right.)"
1540 // The classic Amiga ProTracker hard-pan layout (LRRL,
1541 // see `channel_is_left`) is overridden per channel by
1542 // this command until a new 8xx / E8x sets a different
1543 // value. No memory semantics in the spec (8xx is
1544 // explicitly not memorised between rows).
1545 ch.pan = param;
1546 }
1547 0x9 => {
1548 // 9xx: handled at note-trigger time (enter_row). Nothing to do here
1549 // since we already latched the memory and the position.
1550 }
1551 0xA
1552 // Volume slide — per-tick param memory.
1553 if param != 0 => {
1554 ch.mem_volslide = param;
1555 }
1556 0xB => {
1557 // Position jump.
1558 *pending_jump = Some(Jump {
1559 order: Some(param),
1560 row: 0,
1561 });
1562 }
1563 0xC => {
1564 // Set volume, clamped to 64.
1565 ch.volume = param.min(64);
1566 }
1567 0xD => {
1568 // Pattern break: row x*10 + y in the NEXT order.
1569 let next_row = (x * 10 + y).min(63);
1570 *pending_jump = Some(Jump {
1571 order: None,
1572 row: next_row,
1573 });
1574 }
1575 0xE => apply_extended_tick0(
1576 ch_idx,
1577 x,
1578 y,
1579 channels,
1580 pending_jump,
1581 loop_rows,
1582 loop_counts,
1583 pattern_delay,
1584 order_index,
1585 row,
1586 ),
1587 0xF => {
1588 // Handled at the song level in enter_row (speed vs BPM split).
1589 }
1590 _ => {}
1591 }
1592}
1593
1594#[allow(clippy::too_many_arguments)]
1595fn apply_extended_tick0(
1596 ch_idx: usize,
1597 x: u8,
1598 y: u8,
1599 channels: &mut [Channel],
1600 pending_jump: &mut Option<Jump>,
1601 loop_rows: &mut [u8],
1602 loop_counts: &mut [u8],
1603 pattern_delay: &mut u8,
1604 order_index: u8,
1605 row: u8,
1606) {
1607 let ch = &mut channels[ch_idx];
1608 match x {
1609 0x0 => {
1610 // E0x: set Amiga LED filter on/off. Per
1611 // `Protracker-v1.1B-mod.txt` Cmd E0:
1612 // E00 = "connects filter (turns power LED on)"
1613 // E01 = "disconnects filter (turns power LED off)"
1614 // Real-world MODs use this for tonal contrast (filter ON
1615 // gives warmth via a 1-pole lowpass at ~11.5 kHz). We can't
1616 // reach the song-level state from here, so we annotate the
1617 // channel and the caller propagates it after the per-channel
1618 // dispatch loop completes.
1619 ch.pending_led = Some(y == 0);
1620 }
1621 0x1 => {
1622 // E1x: fine portamento up — one-shot slide on tick 0.
1623 ch.period = ch.period.saturating_sub(y as u16).max(PERIOD_MIN);
1624 }
1625 0x2 => {
1626 // E2x: fine portamento down — clamp at C-1 (period 856) per
1627 // `Protracker-v1.1B-mod.txt` Cmd E2 ("works just like the
1628 // normal portamento down" -> 2xx limit applies).
1629 ch.period = (ch.period + y as u16).min(PERIOD_MAX);
1630 }
1631 0x3 => {
1632 // E3x: glissando control.
1633 ch.glissando = y != 0;
1634 }
1635 0x4 => {
1636 // E4x: set vibrato waveform.
1637 ch.vib_wave.set(y);
1638 }
1639 0x5 => {
1640 // E5x: set finetune (note retrigger path handled in enter_row).
1641 // If no note triggered this row, update finetune for future notes.
1642 let raw = y;
1643 let signed = if raw & 0x8 != 0 {
1644 (raw as i8) - 16
1645 } else {
1646 raw as i8
1647 };
1648 ch.finetune = signed;
1649 }
1650 0x6 => {
1651 // E6x: pattern loop (per-channel). When looping back, schedule
1652 // the jump via `pending_jump` so the rest of the row's ticks
1653 // still complete before we rewind.
1654 if y == 0 {
1655 loop_rows[ch_idx] = row;
1656 } else if loop_counts[ch_idx] == 0 {
1657 loop_counts[ch_idx] = y;
1658 *pending_jump = Some(Jump {
1659 order: Some(order_index),
1660 row: loop_rows[ch_idx],
1661 });
1662 } else {
1663 loop_counts[ch_idx] -= 1;
1664 if loop_counts[ch_idx] > 0 {
1665 *pending_jump = Some(Jump {
1666 order: Some(order_index),
1667 row: loop_rows[ch_idx],
1668 });
1669 }
1670 }
1671 }
1672 0x7 => {
1673 // E7x: set tremolo waveform.
1674 ch.trem_wave.set(y);
1675 }
1676 0x8 => {
1677 // E8x: Set (Rough) Panning.
1678 // `Protracker-effects-MODFIL12.txt` lines 1503-1505:
1679 // "Command $E8: Set (Rough) Panning. (also called
1680 // MTM panning)
1681 // yyyy = panning value. $0 = most left, $F = most
1682 // right."
1683 // The spec doesn't pin a specific 4 → 8 bit upscale, but
1684 // the convention echoed in `multimedia-cx-protracker.html`
1685 // E8x ("Another stereo extension. $0 is hard left, $F is
1686 // hard right.") is for the nibble to span the same range
1687 // as 8xx. Replicating the nibble into both halves of the
1688 // byte (`y << 4 | y`) gives the correct endpoints (0x00,
1689 // 0xFF) and a monotonic centre (0x77/0x88 either side of
1690 // the midpoint), which is the standard "nibble panning"
1691 // mapping used by every modern PT-compatible player.
1692 ch.pan = (y << 4) | y;
1693 }
1694 0x9 => {
1695 // E9x: retrig note — parameter captured here, per-tick handler
1696 // actually replays the sample.
1697 ch.retrig_ticks = y;
1698 }
1699 0xA => {
1700 // EAx: fine volume slide up.
1701 ch.volume = (ch.volume as u16 + y as u16).min(64) as u8;
1702 }
1703 0xB => {
1704 // EBx: fine volume slide down.
1705 ch.volume = ch.volume.saturating_sub(y);
1706 }
1707 0xC => {
1708 // ECx: note cut — the *cut* actually happens at tick x.
1709 ch.cut_tick = y;
1710 }
1711 0xD => { /* EDx: note delay — handled in enter_row (ch.delay). */ }
1712 0xE => {
1713 // EEx: pattern delay — delay row by y more rows' worth of ticks.
1714 *pattern_delay = y;
1715 }
1716 0xF => { /* EFx: invert loop — deliberately not implemented (spec
1717 says "This effect is not supported in any player or
1718 tracker. Don't bother with it"). */
1719 }
1720 _ => {}
1721 }
1722}
1723
1724/// Apply per-tick (tick > 0) effects for one channel.
1725fn apply_tickn_effect(ch_idx: usize, tick: u8, channels: &mut [Channel], samples: &[SampleBody]) {
1726 let ch = &mut channels[ch_idx];
1727 let effect = ch.effect;
1728 let param = ch.effect_param;
1729 let x = param >> 4;
1730 let y = param & 0x0F;
1731
1732 // EDx (note delay): when we reach the stored tick, trigger the note.
1733 if let Some(delayed) = ch.delay {
1734 if tick == delayed.tick {
1735 if delayed.sample != 0 {
1736 let idx = delayed.sample as usize;
1737 ch.sample_index = delayed.sample;
1738 if idx >= 1 && idx <= samples.len() {
1739 let body = &samples[idx - 1];
1740 ch.volume = body.volume;
1741 ch.finetune = body.finetune;
1742 }
1743 } else if ch.pending_sample != 0 {
1744 // Consume any deferred sample swap from a previous row.
1745 ch.sample_index = ch.pending_sample;
1746 }
1747 ch.pending_sample = 0;
1748 ch.period = delayed.period;
1749 ch.sample_pos = 0.0;
1750 ch.active = true;
1751 ch.arp_base_period = delayed.period;
1752 // Per-trigger ramp on the EDx delayed retrigger.
1753 ch.ramp_prev_sample = ch.last_mixed_sample;
1754 ch.ramp_remaining_frames = PlayerState::RAMP_FRAMES;
1755 if !ch.vib_wave.no_retrigger {
1756 ch.vib_pos = 0;
1757 }
1758 if !ch.trem_wave.no_retrigger {
1759 ch.trem_pos = 0;
1760 }
1761 ch.delay = None;
1762 }
1763 }
1764
1765 // ECx: cut — zeros the volume at the specified tick.
1766 if effect == 0xE && x == 0xC && tick == y && y != 0 {
1767 ch.volume = 0;
1768 }
1769
1770 // E9x: retrig every `retrig_ticks` ticks (non-zero).
1771 if effect == 0xE && x == 0x9 && ch.retrig_ticks != 0 && tick % ch.retrig_ticks == 0 {
1772 ch.sample_pos = 0.0;
1773 ch.active = true;
1774 // Per-trigger ramp on the E9x periodic retrigger.
1775 ch.ramp_prev_sample = ch.last_mixed_sample;
1776 ch.ramp_remaining_frames = PlayerState::RAMP_FRAMES;
1777 }
1778
1779 match effect {
1780 0x0
1781 // Arpeggio: tick%3 cycles 0 / +x / +y semitones.
1782 if param != 0 => {
1783 let semis = match tick % 3 {
1784 0 => 0,
1785 1 => x as i32,
1786 2 => y as i32,
1787 _ => 0,
1788 };
1789 if semis == 0 {
1790 ch.period = ch.arp_base_period;
1791 } else {
1792 // Protracker expresses arpeggio via the period table:
1793 // move `semis` semitones up within the current finetune
1794 // row (semitone index + semis). Fallback to the
1795 // equal-temperament approximation when we can't find
1796 // the base note in the table.
1797 let ft_row = finetune_row(ch.finetune);
1798 let mut matched = None;
1799 for (i, &p) in PERIOD_TABLE[ft_row].iter().enumerate() {
1800 if p == ch.arp_base_period {
1801 matched = Some(i);
1802 break;
1803 }
1804 }
1805 if let Some(base_idx) = matched {
1806 let target = (base_idx as i32 + semis).clamp(0, 35) as usize;
1807 ch.period = PERIOD_TABLE[ft_row][target];
1808 } else {
1809 let factor = 2.0f32.powf(semis as f32 / 12.0);
1810 let p = (ch.arp_base_period as f32 / factor) as u16;
1811 ch.period = p.max(PERIOD_MIN);
1812 }
1813 }
1814 }
1815 0x1 => {
1816 let used = if param == 0 { ch.mem_porta_up } else { param };
1817 ch.period = ch.period.saturating_sub(used as u16).max(PERIOD_MIN);
1818 }
1819 0x2 => {
1820 let used = if param == 0 { ch.mem_porta_down } else { param };
1821 ch.period = (ch.period + used as u16).min(PERIOD_MAX);
1822 }
1823 0x3 => {
1824 // Tone portamento: slide toward target at stored speed.
1825 tone_porta_step(ch);
1826 }
1827 0x4 => {
1828 // Vibrato: advance LFO position; period stays un-modulated,
1829 // mixing picks up the delta via `vibrato_offset`.
1830 let rate = ch.mem_vibrato >> 4;
1831 advance_lfo(&mut ch.vib_pos, rate);
1832 }
1833 0x5 => {
1834 // Tone portamento + volume slide: both at once.
1835 tone_porta_step(ch);
1836 volume_slide_step(ch, ch.mem_volslide);
1837 }
1838 0x6 => {
1839 // Vibrato + volume slide.
1840 let rate = ch.mem_vibrato >> 4;
1841 advance_lfo(&mut ch.vib_pos, rate);
1842 volume_slide_step(ch, ch.mem_volslide);
1843 }
1844 0x7 => {
1845 // Tremolo: advance LFO position.
1846 let rate = ch.mem_tremolo >> 4;
1847 advance_lfo(&mut ch.trem_pos, rate);
1848 }
1849 0xA => {
1850 let slide = if param == 0 { ch.mem_volslide } else { param };
1851 volume_slide_step(ch, slide);
1852 }
1853 _ => {}
1854 }
1855}
1856
1857/// Slide `period` toward `tone_porta_target` by `tone_porta_speed` units
1858/// per tick. Clamps on crossing the target. If glissando is enabled,
1859/// additionally snap to the nearest note in the current finetune row.
1860fn tone_porta_step(ch: &mut Channel) {
1861 if ch.tone_porta_target == 0 || ch.tone_porta_speed == 0 {
1862 return;
1863 }
1864 let target = ch.tone_porta_target;
1865 let step = ch.tone_porta_speed as i32;
1866 let cur = ch.period as i32;
1867 let new = if cur < target as i32 {
1868 (cur + step).min(target as i32)
1869 } else if cur > target as i32 {
1870 (cur - step).max(target as i32)
1871 } else {
1872 cur
1873 };
1874 // Tone porta clamps only to the extended period range
1875 // `[108, 907]`. Tightening to `[113, 856]` would break finetune
1876 // extremes — e.g. a slide whose target is FT +7 B-3 (period 108)
1877 // would otherwise clamp short at 113 (`Protracker-effects-MODFIL12.txt`
1878 // §3.2 "Normal Min Period = 108 / Max = 907").
1879 ch.period = new.clamp(PERIOD_MIN_EXT as i32, PERIOD_MAX_EXT as i32) as u16;
1880
1881 if ch.glissando {
1882 // Snap to the nearest note in the current finetune row.
1883 let ft_row = finetune_row(ch.finetune);
1884 let row = &PERIOD_TABLE[ft_row];
1885 let mut best = ch.period;
1886 let mut best_diff = i32::MAX;
1887 for &p in row.iter() {
1888 let d = (p as i32 - ch.period as i32).abs();
1889 if d < best_diff {
1890 best_diff = d;
1891 best = p;
1892 }
1893 }
1894 ch.period = best;
1895 }
1896}
1897
1898/// Apply one tick of an `Axy`-style volume slide. `slide` is the nibble
1899/// pair `x<<4 | y`: x raises, y lowers. If both are non-zero, up wins
1900/// (Protracker behaviour).
1901fn volume_slide_step(ch: &mut Channel, slide: u8) {
1902 let x = slide >> 4;
1903 let y = slide & 0x0F;
1904 if x != 0 {
1905 ch.volume = (ch.volume as u16 + x as u16).min(64) as u8;
1906 } else if y != 0 {
1907 ch.volume = ch.volume.saturating_sub(y);
1908 }
1909}
1910
1911/// Compute the (left, right) gain pair for a channel with FT-extension
1912/// 8-bit pan `p` (0 = hard LEFT, 128 = centre, 255 = hard RIGHT) under
1913/// the player's global `pan_separation` `s ∈ [0, 1]` (1 = full Amiga
1914/// hard pan, 0 = collapse to mono).
1915///
1916/// Construction:
1917/// - Map `p` to a unit position `u = p / 255` in `[0, 1]`.
1918/// - Centre and scale to `[-1, 1]`: `c = 2*u - 1`.
1919/// - Scale by separation `s`: `c' = c * s`.
1920/// - Equal-power-by-cancellation linear pan:
1921/// `left = (1 - c') / 2`
1922/// `right = (1 + c') / 2`
1923///
1924/// At endpoints (`p = 0`, `s = 1` → c' = -1) this collapses to
1925/// `(1, 0)`, identical to the pre-r75 `channel_is_left` hard-pan
1926/// formula via `near = (1 + s) / 2 = 1, far = (1 - s) / 2 = 0`. At
1927/// the centre (`p = 128`, c' ≈ 0) the channel contributes `(0.5,
1928/// 0.5)` regardless of `s`, which is the desired behaviour for a
1929/// centred channel under a global separation narrow. With `p = 0`
1930/// and `s = 0.5` we get `(0.75, 0.25)`, matching the prior
1931/// `near/far = ((1+s)/2, (1-s)/2)` split.
1932pub fn pan_gains(p: u8, s: f32) -> (f32, f32) {
1933 let u = p as f32 / 255.0;
1934 let c = 2.0 * u - 1.0;
1935 let c_eff = c * s.clamp(0.0, 1.0);
1936 let left = (1.0 - c_eff) * 0.5;
1937 let right = (1.0 + c_eff) * 0.5;
1938 (left, right)
1939}
1940
1941/// Advance a vibrato / tremolo LFO position register per the Protracker
1942/// wraparound rule: add `rate`, and when `pos > 31` subtract 64 so the
1943/// signed position stays in `-32..=31`.
1944fn advance_lfo(pos: &mut i8, rate: u8) {
1945 let next = *pos as i32 + rate as i32;
1946 if next > 31 {
1947 *pos = (next - 64) as i8;
1948 } else {
1949 *pos = next as i8;
1950 }
1951}
1952
1953#[cfg(test)]
1954pub mod tests {
1955 use super::*;
1956 use crate::header::parse_header;
1957 use crate::samples::extract_samples;
1958
1959 /// Build a tiny synthetic 4-channel M.K. MOD with one square-wave
1960 /// sample and one pattern that triggers notes on channel 0 across
1961 /// the first 4 rows.
1962 pub fn synth_square_mod() -> Vec<u8> {
1963 let mut out = vec![0u8; crate::header::HEADER_FIXED_SIZE];
1964 out[0..4].copy_from_slice(b"test");
1965 // Sample 1: 32 samples, length-in-words = 16.
1966 out[20 + 22..20 + 24].copy_from_slice(&16u16.to_be_bytes());
1967 // Finetune 0, volume 64.
1968 out[20 + 24] = 0;
1969 out[20 + 25] = 64;
1970 // Loop points: start 0, length 16 words (= 32 samples) — loops full.
1971 out[20 + 26..20 + 28].copy_from_slice(&0u16.to_be_bytes());
1972 out[20 + 28..20 + 30].copy_from_slice(&16u16.to_be_bytes());
1973 // Song length 1, order[0] = 0.
1974 out[950] = 1;
1975 out[951] = 0x7F;
1976 out[952] = 0;
1977 // Signature.
1978 out[1080..1084].copy_from_slice(b"M.K.");
1979 // Pattern 0: 64 rows × 4 channels × 4 bytes = 1024 bytes.
1980 let mut pat = vec![0u8; 64 * 4 * 4];
1981 // Rows 0,16,32,48 — trigger sample 1 on channel 0 with
1982 // descending periods (higher pitch first). Pick periods C-2, D-2,
1983 // E-2, F-2 — classic PT values: 428, 381, 339, 320.
1984 let rows_and_periods = [(0, 428u16), (16, 381), (32, 339), (48, 320)];
1985 for &(row, period) in &rows_and_periods {
1986 let off = row * 4 * 4;
1987 // Sample high nibble (sample = 1, high = 0, low = 1).
1988 // Byte 0 = (sample_hi << 4) | period_hi.
1989 let p_hi = ((period >> 8) & 0x0F) as u8;
1990 let p_lo = (period & 0xFF) as u8;
1991 let sample_hi = 0u8; // high nibble of sample index 1
1992 let sample_lo = 1u8;
1993 pat[off] = (sample_hi << 4) | p_hi;
1994 pat[off + 1] = p_lo;
1995 pat[off + 2] = sample_lo << 4; // effect 0
1996 pat[off + 3] = 0; // param
1997 }
1998 out.extend(pat);
1999 // Sample body: 32-sample square wave (16 hi, 16 lo).
2000 for i in 0..32 {
2001 let v: i8 = if i < 16 { 100 } else { -100 };
2002 out.push(v as u8);
2003 }
2004 out
2005 }
2006
2007 /// Build a MOD with a caller-provided pattern. Sample 1 is a looping
2008 /// 32-byte square wave, volume 64, finetune 0. Only the first row's
2009 /// first channel is meaningful; other rows/channels default to empty.
2010 ///
2011 /// `pattern_rows` is a slice of `(row, channel, Note)` entries; each
2012 /// entry writes its Note into that cell of pattern 0.
2013 pub fn synth_mod_with_pattern(rows: &[(usize, usize, Note)]) -> Vec<u8> {
2014 let mut out = vec![0u8; crate::header::HEADER_FIXED_SIZE];
2015 out[0..4].copy_from_slice(b"test");
2016 out[20 + 22..20 + 24].copy_from_slice(&16u16.to_be_bytes());
2017 out[20 + 24] = 0;
2018 out[20 + 25] = 64;
2019 out[20 + 26..20 + 28].copy_from_slice(&0u16.to_be_bytes());
2020 out[20 + 28..20 + 30].copy_from_slice(&16u16.to_be_bytes());
2021 out[950] = 1;
2022 out[951] = 0x7F;
2023 out[952] = 0;
2024 out[1080..1084].copy_from_slice(b"M.K.");
2025
2026 let mut pat = vec![0u8; 64 * 4 * 4];
2027 for &(row, channel, ref note) in rows {
2028 let off = row * 4 * 4 + channel * 4;
2029 let p_hi = ((note.period >> 8) & 0x0F) as u8;
2030 let p_lo = (note.period & 0xFF) as u8;
2031 let sample_hi = (note.sample & 0xF0) >> 4;
2032 let sample_lo = note.sample & 0x0F;
2033 pat[off] = (sample_hi << 4) | p_hi;
2034 pat[off + 1] = p_lo;
2035 pat[off + 2] = (sample_lo << 4) | note.effect;
2036 pat[off + 3] = note.effect_param;
2037 }
2038 out.extend(pat);
2039
2040 for i in 0..32 {
2041 let v: i8 = if i < 16 { 100 } else { -100 };
2042 out.push(v as u8);
2043 }
2044 out
2045 }
2046
2047 fn make_player(bytes: &[u8]) -> PlayerState {
2048 let header = parse_header(bytes).unwrap();
2049 let samples = extract_samples(&header, bytes);
2050 let patterns = parse_patterns(&header, bytes);
2051 PlayerState::new(&header, samples, patterns, 44_100)
2052 }
2053
2054 #[test]
2055 fn decodes_patterns() {
2056 let bytes = synth_square_mod();
2057 let header = parse_header(&bytes).unwrap();
2058 let patterns = parse_patterns(&header, &bytes);
2059 assert_eq!(patterns.len(), 1);
2060 assert_eq!(patterns[0].rows.len(), 64);
2061 assert_eq!(patterns[0].rows[0].len(), 4);
2062 let n = patterns[0].rows[0][0];
2063 assert_eq!(n.period, 428);
2064 assert_eq!(n.sample, 1);
2065 }
2066
2067 #[test]
2068 fn player_renders_nonzero_audio() {
2069 let bytes = synth_square_mod();
2070 let mut player = make_player(&bytes);
2071
2072 // Render ~0.1 s (4410 frames × 2 channels = 8820 samples).
2073 let mut buf = vec![0i16; 4410 * 2];
2074 let produced = player.render(&mut buf);
2075 assert_eq!(produced, 4410);
2076
2077 // Must have at least some non-zero samples.
2078 let nonzero = buf.iter().filter(|&&x| x != 0).count();
2079 assert!(
2080 nonzero > 100,
2081 "expected non-silent PCM, got {nonzero} non-zero samples"
2082 );
2083 }
2084
2085 #[test]
2086 fn samples_per_tick_default() {
2087 let bytes = synth_square_mod();
2088 let player = make_player(&bytes);
2089 assert_eq!(player.samples_per_tick(), 882);
2090 }
2091
2092 #[test]
2093 fn render_per_channel_isolates_channels() {
2094 // The synth MOD triggers notes exclusively on channel 0, so any
2095 // per-channel stream other than 0 must be pure silence.
2096 let bytes = synth_square_mod();
2097 let mut player = make_player(&bytes);
2098
2099 let n_frames = 4410;
2100 let mut planes: Vec<Vec<i16>> = (0..player.channels.len())
2101 .map(|_| vec![0i16; n_frames])
2102 .collect();
2103 let produced = {
2104 let mut views: Vec<&mut [i16]> = planes.iter_mut().map(|v| v.as_mut_slice()).collect();
2105 player.render_per_channel(&mut views, n_frames)
2106 };
2107 assert_eq!(produced, n_frames);
2108
2109 let ch0_nonzero = planes[0].iter().filter(|&&s| s != 0).count();
2110 assert!(
2111 ch0_nonzero > 100,
2112 "channel 0 should carry audible signal, got {ch0_nonzero} non-zero samples"
2113 );
2114 for (i, plane) in planes.iter().enumerate().skip(1) {
2115 let nonzero = plane.iter().filter(|&&s| s != 0).count();
2116 assert_eq!(
2117 nonzero, 0,
2118 "channel {i} should be silent in synth_square_mod, got {nonzero} non-zero samples"
2119 );
2120 }
2121 }
2122
2123 #[test]
2124 fn render_per_channel_matches_mixed_song_length() {
2125 let bytes = synth_square_mod();
2126 let mut player_mixed = make_player(&bytes);
2127 let mut player_planar = make_player(&bytes);
2128
2129 let n_frames = 2205;
2130 let mut mixed = vec![0i16; n_frames * 2];
2131 let produced_mixed = player_mixed.render(&mut mixed);
2132
2133 let mut planes: Vec<Vec<i16>> = (0..player_planar.channels.len())
2134 .map(|_| vec![0i16; n_frames])
2135 .collect();
2136 let produced_planar = {
2137 let mut views: Vec<&mut [i16]> = planes.iter_mut().map(|v| v.as_mut_slice()).collect();
2138 player_planar.render_per_channel(&mut views, n_frames)
2139 };
2140
2141 assert_eq!(produced_mixed, n_frames);
2142 assert_eq!(produced_planar, n_frames);
2143 }
2144
2145 // ---------- Spec-driven effect tests ----------
2146
2147 #[test]
2148 fn period_table_cross_check_against_spec() {
2149 // Protracker-v1.1B-mod.txt: "Periodtable for Tuning 0, Normal".
2150 // C-1 = 856, B-1 = 453, C-2 = 428, A-3 = 127, B-3 = 113.
2151 let ft0 = &PERIOD_TABLE[0];
2152 assert_eq!(ft0[0], 856, "C-1 @ ft 0");
2153 assert_eq!(ft0[11], 453, "B-1 @ ft 0");
2154 assert_eq!(ft0[12], 428, "C-2 @ ft 0");
2155 assert_eq!(ft0[33], 127, "A-3 @ ft 0");
2156 assert_eq!(ft0[35], 113, "B-3 @ ft 0");
2157 // Finetune +1, C-2 = 425; finetune -1 (row 15), C-2 = 431.
2158 assert_eq!(PERIOD_TABLE[1][12], 425, "C-2 @ ft +1");
2159 assert_eq!(PERIOD_TABLE[15][12], 431, "C-2 @ ft -1");
2160 }
2161
2162 #[test]
2163 fn sine_table_matches_protracker_half_wave() {
2164 // Spot-check: the 32-entry half-wave starts at 0, peaks at 255 at
2165 // index 16, dips back down to 24 at 31. These are spec values
2166 // from FireLight §5.5.
2167 assert_eq!(PROTRACKER_SINE_TABLE[0], 0);
2168 assert_eq!(PROTRACKER_SINE_TABLE[8], 180);
2169 assert_eq!(PROTRACKER_SINE_TABLE[16], 255);
2170 assert_eq!(PROTRACKER_SINE_TABLE[24], 180);
2171 assert_eq!(PROTRACKER_SINE_TABLE[31], 24);
2172 }
2173
2174 /// Step the player forward one full tick. Uses a scratch stereo
2175 /// buffer of exactly one tick's frames.
2176 fn step_one_tick(player: &mut PlayerState) {
2177 let spt = player.samples_per_tick() as usize;
2178 let mut buf = vec![0i16; spt * 2];
2179 player.render(&mut buf);
2180 }
2181
2182 #[test]
2183 fn tone_porta_reaches_target_period_exactly() {
2184 // Row 0: C-2 (period 428), no effect.
2185 // Row 1: A-2 (period 254), tone-porta with speed $10 = 16.
2186 // Initial period 428, target 254, diff 174. After ⌈174/16⌉ = 11
2187 // tick-N steps the period should equal 254. Default speed is 6,
2188 // meaning each row has 5 non-tick-0 updates; so we need 3 rows
2189 // (15 steps) of sustained tone-porta to finish the slide.
2190 let bytes = synth_mod_with_pattern(&[
2191 (
2192 0,
2193 0,
2194 Note {
2195 period: 428,
2196 sample: 1,
2197 effect: 0,
2198 effect_param: 0,
2199 },
2200 ),
2201 (
2202 1,
2203 0,
2204 Note {
2205 period: 254,
2206 sample: 0,
2207 effect: 0x3,
2208 effect_param: 0x10,
2209 },
2210 ),
2211 (
2212 2,
2213 0,
2214 Note {
2215 period: 0,
2216 sample: 0,
2217 effect: 0x3,
2218 effect_param: 0x00,
2219 },
2220 ),
2221 (
2222 3,
2223 0,
2224 Note {
2225 period: 0,
2226 sample: 0,
2227 effect: 0x3,
2228 effect_param: 0x00,
2229 },
2230 ),
2231 ]);
2232 let mut player = make_player(&bytes);
2233 // Walk 4 rows * 6 ticks = 24 ticks worth.
2234 for _ in 0..24 {
2235 step_one_tick(&mut player);
2236 }
2237 assert_eq!(
2238 player.channels[0].period, 254,
2239 "tone porta must clamp at target"
2240 );
2241 }
2242
2243 #[test]
2244 fn vibrato_modulates_period_symmetrically() {
2245 // Trigger a note, then apply 4xy with depth 4 and rate 8. The LFO
2246 // should visit both positive and negative sides of the base period
2247 // over a single row's worth of ticks.
2248 let bytes = synth_mod_with_pattern(&[
2249 (
2250 0,
2251 0,
2252 Note {
2253 period: 428,
2254 sample: 1,
2255 effect: 0x4,
2256 effect_param: 0x84,
2257 },
2258 ),
2259 (
2260 1,
2261 0,
2262 Note {
2263 period: 0,
2264 sample: 0,
2265 effect: 0x4,
2266 effect_param: 0x00,
2267 },
2268 ),
2269 ]);
2270 let mut player = make_player(&bytes);
2271
2272 let mut max_delta = 0i32;
2273 let mut min_delta = 0i32;
2274 // 2 rows × 6 ticks = 12 samples of the offset.
2275 for _ in 0..12 {
2276 step_one_tick(&mut player);
2277 let off = PlayerState::vibrato_offset(&player.channels[0]) as i32;
2278 max_delta = max_delta.max(off);
2279 min_delta = min_delta.min(off);
2280 }
2281 // Depth 4: peak sine*depth/128 = 255*4/128 = 7.9 ≈ 7.
2282 assert!(
2283 max_delta >= 4,
2284 "expected positive vibrato swing, got {max_delta}"
2285 );
2286 assert!(
2287 min_delta <= -4,
2288 "expected negative vibrato swing, got {min_delta}"
2289 );
2290 }
2291
2292 #[test]
2293 fn tremolo_modulates_volume_symmetrically() {
2294 let bytes = synth_mod_with_pattern(&[
2295 // Cxx 20 — set volume to 32 so tremolo has headroom both sides.
2296 (
2297 0,
2298 0,
2299 Note {
2300 period: 428,
2301 sample: 1,
2302 effect: 0xC,
2303 effect_param: 0x20,
2304 },
2305 ),
2306 // 7xy with rate 8 depth 4.
2307 (
2308 1,
2309 0,
2310 Note {
2311 period: 0,
2312 sample: 0,
2313 effect: 0x7,
2314 effect_param: 0x84,
2315 },
2316 ),
2317 (
2318 2,
2319 0,
2320 Note {
2321 period: 0,
2322 sample: 0,
2323 effect: 0x7,
2324 effect_param: 0x00,
2325 },
2326 ),
2327 ]);
2328 let mut player = make_player(&bytes);
2329 let mut max_delta = 0i32;
2330 let mut min_delta = 0i32;
2331 for _ in 0..18 {
2332 step_one_tick(&mut player);
2333 let off = PlayerState::tremolo_offset(&player.channels[0]) as i32;
2334 max_delta = max_delta.max(off);
2335 min_delta = min_delta.min(off);
2336 }
2337 // Depth 4: peak = 255*4/64 = 15.94.
2338 assert!(
2339 max_delta >= 8,
2340 "expected positive tremolo swing, got {max_delta}"
2341 );
2342 assert!(
2343 min_delta <= -8,
2344 "expected negative tremolo swing, got {min_delta}"
2345 );
2346 }
2347
2348 #[test]
2349 fn sample_offset_advances_into_sample() {
2350 // 9xx with param 0x01 -> offset = 0x0100 = 256 frames. We construct
2351 // a MOD with a 512-frame-long sample so the offset lands cleanly
2352 // inside the body. After the first tick the channel's sample_pos
2353 // should be at 256 plus the mixer-advance (a handful of samples).
2354 let mut bytes = synth_mod_with_pattern(&[(
2355 0,
2356 0,
2357 Note {
2358 period: 428,
2359 sample: 1,
2360 effect: 0x9,
2361 effect_param: 0x01,
2362 },
2363 )]);
2364 // Patch sample 1 length to 256 words (512 frames).
2365 bytes[20 + 22..20 + 24].copy_from_slice(&256u16.to_be_bytes());
2366 // Disable the loop (repeat length 0) so the 9xx offset does not
2367 // immediately wrap into the loop region — see the loop-boundary
2368 // PT quirk fix in `mix_one`.
2369 bytes[20 + 28..20 + 30].copy_from_slice(&0u16.to_be_bytes());
2370 // Append 480 more bytes (512 total) to the sample body at the tail.
2371 bytes.extend(std::iter::repeat_n(0u8, 480));
2372
2373 let mut player = make_player(&bytes);
2374 step_one_tick(&mut player);
2375 assert_eq!(
2376 player.channels[0].mem_sample_offset, 0x01,
2377 "9xx memory not latched"
2378 );
2379 // sample_pos should have started at 256 (0x01 * 0x100) plus
2380 // whatever the mixer advanced during the tick.
2381 assert!(
2382 player.channels[0].sample_pos >= 256.0,
2383 "expected sample_pos >= 256, got {}",
2384 player.channels[0].sample_pos
2385 );
2386 }
2387
2388 #[test]
2389 fn sample_offset_out_of_range_plays_no_note() {
2390 // ProTracker quirk (Protracker-effects-MODFIL12.txt 9:Set-sample-
2391 // offset, lines 1240-1242): if a 9xx offset lands at or past the
2392 // end of the sample, NO NOTE is played at all. The synth helper
2393 // builds a 32-frame sample; 901 → offset 0x100 = 256 frames, far
2394 // beyond the end, so the channel must stay inactive and emit
2395 // silence rather than wrapping a looped sample back into its loop.
2396 let bytes = synth_mod_with_pattern(&[(
2397 0,
2398 0,
2399 Note {
2400 period: 428,
2401 sample: 1,
2402 effect: 0x9,
2403 effect_param: 0x01,
2404 },
2405 )]);
2406 let mut player = make_player(&bytes);
2407 // The 9xx memory is still latched even though no note plays
2408 // (the note info is updated; only playback is suppressed).
2409 step_one_tick(&mut player);
2410 assert_eq!(
2411 player.channels[0].mem_sample_offset, 0x01,
2412 "9xx memory should still latch even when the offset is OOR"
2413 );
2414 assert!(
2415 !player.channels[0].active,
2416 "out-of-range 9xx must leave the channel inactive (no note)"
2417 );
2418 assert_eq!(
2419 player.channels[0].sample_pos, 0.0,
2420 "out-of-range 9xx must not advance the sample cursor"
2421 );
2422
2423 // Render a chunk and confirm the channel produced silence (no
2424 // wrapped-loop artefact). A fresh player so we re-enter row 0.
2425 let mut player = make_player(&bytes);
2426 let mut buf = vec![0i16; 2048 * 2];
2427 player.render(&mut buf);
2428 assert!(
2429 buf.iter().all(|&s| s == 0),
2430 "out-of-range 9xx must render silence, found non-zero PCM"
2431 );
2432 }
2433
2434 #[test]
2435 fn sample_offset_at_exact_end_plays_no_note() {
2436 // Boundary: an offset landing EXACTLY at the sample length is
2437 // still "at or past the end" per the spec wording, so it must
2438 // also suppress the note. Build a 256-frame sample and request
2439 // 901 (offset = 256 = the length).
2440 let mut bytes = synth_mod_with_pattern(&[(
2441 0,
2442 0,
2443 Note {
2444 period: 428,
2445 sample: 1,
2446 effect: 0x9,
2447 effect_param: 0x01,
2448 },
2449 )]);
2450 // Sample length 128 words = 256 frames; disable loop so the body
2451 // is a plain one-shot.
2452 bytes[20 + 22..20 + 24].copy_from_slice(&128u16.to_be_bytes());
2453 bytes[20 + 28..20 + 30].copy_from_slice(&0u16.to_be_bytes());
2454 // Append the extra body bytes (synth helper writes 32; need 256).
2455 bytes.extend(std::iter::repeat_n(0u8, 256 - 32));
2456
2457 let mut player = make_player(&bytes);
2458 step_one_tick(&mut player);
2459 assert!(
2460 !player.channels[0].active,
2461 "9xx offset == sample length must suppress the note (>= end)"
2462 );
2463 }
2464
2465 #[test]
2466 fn sample_offset_just_inside_end_plays() {
2467 // Contrast: an offset comfortably inside the sample DOES play.
2468 // Build a 512-frame one-shot so 901 (offset 256) seeks to the
2469 // middle and the note survives a full tick of playback. This
2470 // pins the boundary direction of the OOR check (only >= end
2471 // suppresses; in-range still triggers).
2472 let mut bytes = synth_mod_with_pattern(&[(
2473 0,
2474 0,
2475 Note {
2476 period: 428,
2477 sample: 1,
2478 effect: 0x9,
2479 effect_param: 0x01,
2480 },
2481 )]);
2482 // Sample length 256 words = 512 frames; disable loop.
2483 bytes[20 + 22..20 + 24].copy_from_slice(&256u16.to_be_bytes());
2484 bytes[20 + 28..20 + 30].copy_from_slice(&0u16.to_be_bytes());
2485 bytes.extend(std::iter::repeat_n(7u8, 512 - 32));
2486
2487 let mut player = make_player(&bytes);
2488 step_one_tick(&mut player);
2489 assert!(
2490 player.channels[0].active,
2491 "9xx offset inside the sample must still play the note"
2492 );
2493 assert!(
2494 player.channels[0].sample_pos >= 256.0,
2495 "in-range 9xx should seek to >= 256, got {}",
2496 player.channels[0].sample_pos
2497 );
2498 }
2499
2500 #[test]
2501 fn fine_porta_up_and_down_shift_period_once() {
2502 let bytes = synth_mod_with_pattern(&[
2503 (
2504 0,
2505 0,
2506 Note {
2507 period: 428,
2508 sample: 1,
2509 effect: 0,
2510 effect_param: 0,
2511 },
2512 ),
2513 // E12: fine porta up by 2.
2514 (
2515 1,
2516 0,
2517 Note {
2518 period: 0,
2519 sample: 0,
2520 effect: 0xE,
2521 effect_param: 0x12,
2522 },
2523 ),
2524 // E23: fine porta down by 3.
2525 (
2526 2,
2527 0,
2528 Note {
2529 period: 0,
2530 sample: 0,
2531 effect: 0xE,
2532 effect_param: 0x23,
2533 },
2534 ),
2535 ]);
2536 let mut player = make_player(&bytes);
2537
2538 step_one_tick(&mut player); // row 0, tick 0
2539 assert_eq!(player.channels[0].period, 428);
2540 // Finish row 0 so we reach row 1's tick 0.
2541 for _ in 0..5 {
2542 step_one_tick(&mut player);
2543 }
2544 step_one_tick(&mut player); // row 1, tick 0 — E12 fires.
2545 assert_eq!(player.channels[0].period, 426, "E12 must slide up by 2");
2546 for _ in 0..5 {
2547 step_one_tick(&mut player);
2548 }
2549 step_one_tick(&mut player); // row 2, tick 0 — E23 fires.
2550 assert_eq!(
2551 player.channels[0].period, 429,
2552 "E23 must slide down by 3 from 426"
2553 );
2554 }
2555
2556 #[test]
2557 fn pattern_loop_e6_loops_then_advances() {
2558 // Row 0: trigger note. Row 1: E60 (set loop start). Row 2: E62
2559 // (loop back to row 1 twice). After 2 extra rounds, the player
2560 // must advance to row 3.
2561 //
2562 // Per spec (FireLight §5.22): E6 param != 0 on first visit sets
2563 // loop_count = param and jumps back; subsequent visits decrement
2564 // until zero and advance. So with param=2: visit1 sets counter=2
2565 // jumps back, visit2 counter=1 jumps back, visit3 counter=0
2566 // advances. We should traverse rows 1→2→1→2→1→2→3.
2567 let bytes = synth_mod_with_pattern(&[
2568 (
2569 0,
2570 0,
2571 Note {
2572 period: 428,
2573 sample: 1,
2574 effect: 0,
2575 effect_param: 0,
2576 },
2577 ),
2578 (
2579 1,
2580 0,
2581 Note {
2582 period: 0,
2583 sample: 0,
2584 effect: 0xE,
2585 effect_param: 0x60,
2586 },
2587 ),
2588 (
2589 2,
2590 0,
2591 Note {
2592 period: 0,
2593 sample: 0,
2594 effect: 0xE,
2595 effect_param: 0x62,
2596 },
2597 ),
2598 (
2599 3,
2600 0,
2601 Note {
2602 period: 339,
2603 sample: 1,
2604 effect: 0,
2605 effect_param: 0,
2606 },
2607 ),
2608 ]);
2609 let mut player = make_player(&bytes);
2610
2611 // Visited rows in order so we can inspect. Render one tick per
2612 // step and snapshot the current row number.
2613 let mut visited: Vec<u8> = Vec::new();
2614 for _ in 0..60 {
2615 step_one_tick(&mut player);
2616 if let Some(&last) = visited.last() {
2617 if last != player.row {
2618 visited.push(player.row);
2619 }
2620 } else {
2621 visited.push(player.row);
2622 }
2623 if player.row > 3 || player.ended {
2624 break;
2625 }
2626 }
2627
2628 // Expected sequence: 0,1,2,1,2,1,2,3 (with possible trailing rows)
2629 let prefix = &visited[..visited.len().min(8)];
2630 assert_eq!(
2631 prefix,
2632 &[0, 1, 2, 1, 2, 1, 2, 3][..prefix.len()],
2633 "E62 should loop rows 1..=2 twice before advancing; got {visited:?}"
2634 );
2635 }
2636
2637 #[test]
2638 fn retrig_e9_restarts_sample_cursor() {
2639 // E91: retrig on every tick. Without retrig the sample_pos would
2640 // be cumulatively advanced across ticks; with retrig it resets on
2641 // every tick boundary, so the post-tick-2 position stays
2642 // equal to the post-tick-1 position (both are exactly one tick's
2643 // worth of advance starting from 0).
2644 let bytes = synth_mod_with_pattern(&[(
2645 0,
2646 0,
2647 Note {
2648 period: 428,
2649 sample: 1,
2650 effect: 0xE,
2651 effect_param: 0x91,
2652 },
2653 )]);
2654 let mut player = make_player(&bytes);
2655 // Tick 0: note triggers at pos 0, advances for ~882 samples.
2656 step_one_tick(&mut player);
2657 let pos_after_t0 = player.channels[0].sample_pos;
2658 // Tick 1: E91 resets pos to 0 then advances — should equal t0 value.
2659 step_one_tick(&mut player);
2660 let pos_after_t1 = player.channels[0].sample_pos;
2661 // Tick 2: same behaviour.
2662 step_one_tick(&mut player);
2663 let pos_after_t2 = player.channels[0].sample_pos;
2664 assert!(
2665 (pos_after_t1 - pos_after_t0).abs() < 1.0,
2666 "E91 should retrigger; pos_after_t0={pos_after_t0}, pos_after_t1={pos_after_t1}"
2667 );
2668 assert!(
2669 (pos_after_t2 - pos_after_t1).abs() < 1.0,
2670 "E91 should retrigger again; pos_after_t1={pos_after_t1}, pos_after_t2={pos_after_t2}"
2671 );
2672 }
2673
2674 #[test]
2675 fn note_cut_ec_zeros_volume_at_tick() {
2676 // Cxx 40, EC3: cut at tick 3.
2677 let bytes = synth_mod_with_pattern(&[(
2678 0,
2679 0,
2680 Note {
2681 period: 428,
2682 sample: 1,
2683 effect: 0xE,
2684 effect_param: 0xC3,
2685 },
2686 )]);
2687 let mut player = make_player(&bytes);
2688 // Tick 0: volume loaded (64 from sample), note triggered.
2689 step_one_tick(&mut player);
2690 assert_eq!(player.channels[0].volume, 64);
2691 step_one_tick(&mut player); // tick 1
2692 step_one_tick(&mut player); // tick 2
2693 step_one_tick(&mut player); // tick 3: EC3 fires.
2694 assert_eq!(player.channels[0].volume, 0, "EC3 must cut volume at t=3");
2695 }
2696
2697 #[test]
2698 fn note_delay_ed_postpones_trigger() {
2699 // ED3 with a fresh note. The sample should only start at tick 3.
2700 let bytes = synth_mod_with_pattern(&[(
2701 0,
2702 0,
2703 Note {
2704 period: 428,
2705 sample: 1,
2706 effect: 0xE,
2707 effect_param: 0xD3,
2708 },
2709 )]);
2710 let mut player = make_player(&bytes);
2711 // Tick 0 shouldn't trigger — channel remains inactive (no prior note).
2712 step_one_tick(&mut player);
2713 assert!(!player.channels[0].active, "ED3 must not trigger on tick 0");
2714 step_one_tick(&mut player);
2715 step_one_tick(&mut player);
2716 // Tick 3: note fires.
2717 step_one_tick(&mut player);
2718 assert!(
2719 player.channels[0].active,
2720 "ED3 must trigger at tick 3; state={:?}",
2721 player.channels[0]
2722 );
2723 assert_eq!(player.channels[0].period, 428);
2724 }
2725
2726 #[test]
2727 fn fine_volume_slide_ea_eb_shifts_volume_once() {
2728 let bytes = synth_mod_with_pattern(&[
2729 (
2730 0,
2731 0,
2732 Note {
2733 period: 428,
2734 sample: 1,
2735 effect: 0xC,
2736 effect_param: 0x20,
2737 },
2738 ),
2739 (
2740 1,
2741 0,
2742 Note {
2743 period: 0,
2744 sample: 0,
2745 effect: 0xE,
2746 effect_param: 0xA3,
2747 },
2748 ),
2749 (
2750 2,
2751 0,
2752 Note {
2753 period: 0,
2754 sample: 0,
2755 effect: 0xE,
2756 effect_param: 0xB5,
2757 },
2758 ),
2759 ]);
2760 let mut player = make_player(&bytes);
2761 // Row 0.
2762 for _ in 0..6 {
2763 step_one_tick(&mut player);
2764 }
2765 // After row 0 complete → row 1 tick 0: EA3 fires; volume 32 + 3 = 35.
2766 step_one_tick(&mut player);
2767 assert_eq!(player.channels[0].volume, 0x23);
2768 for _ in 0..5 {
2769 step_one_tick(&mut player);
2770 }
2771 // Row 2 tick 0: EB5 fires; 35 - 5 = 30.
2772 step_one_tick(&mut player);
2773 assert_eq!(player.channels[0].volume, 0x1E);
2774 }
2775
2776 #[test]
2777 fn e5_finetune_applies_on_note_row() {
2778 // Row 0: play C-2 (period 428) with E50 — finetune 0.
2779 // Row 1: play C-2 with E51 — finetune +1. Under finetune +1 the
2780 // C-2 period is 425, so the channel's period should be 425 at tick 0.
2781 let bytes = synth_mod_with_pattern(&[
2782 (
2783 0,
2784 0,
2785 Note {
2786 period: 428,
2787 sample: 1,
2788 effect: 0xE,
2789 effect_param: 0x50,
2790 },
2791 ),
2792 (
2793 1,
2794 0,
2795 Note {
2796 period: 428,
2797 sample: 1,
2798 effect: 0xE,
2799 effect_param: 0x51,
2800 },
2801 ),
2802 ]);
2803 let mut player = make_player(&bytes);
2804 step_one_tick(&mut player);
2805 assert_eq!(player.channels[0].period, 428);
2806 assert_eq!(player.channels[0].finetune, 0);
2807 for _ in 0..5 {
2808 step_one_tick(&mut player);
2809 }
2810 step_one_tick(&mut player);
2811 assert_eq!(player.channels[0].finetune, 1);
2812 assert_eq!(
2813 player.channels[0].period, 425,
2814 "finetune +1 should retune C-2 to 425"
2815 );
2816 }
2817
2818 #[test]
2819 fn pattern_delay_ee_repeats_row_without_retriggering_effects() {
2820 // Per `Pro-Noise-Soundtracker-rev4.txt` §[14][14] ("Delay
2821 // pattern"): "all effects and previous notes continue during
2822 // delay". The row's tick-0 effects (e.g. EAx fine vol slide,
2823 // Cxx set volume, note triggers) must NOT re-fire on the
2824 // repeated passes — only per-tick effects (vol slides, vibrato,
2825 // tone porta) keep ticking. Without this guarantee a held note
2826 // on the row would re-trigger from sample_pos=0 on every repeat
2827 // and EAx would compound, both audible regressions on real-
2828 // world MODs that use EE for held-note textures.
2829 let bytes = synth_mod_with_pattern(&[
2830 (
2831 0,
2832 0,
2833 Note {
2834 period: 428,
2835 sample: 1,
2836 effect: 0xC,
2837 effect_param: 0x00,
2838 },
2839 ),
2840 // Row 1: EE1 pattern delay + EA2 fine vol slide up by 2.
2841 // These are *separate channels* in PT, so put the EE1 on ch1.
2842 (
2843 1,
2844 0,
2845 Note {
2846 period: 0,
2847 sample: 0,
2848 effect: 0xE,
2849 effect_param: 0xA2,
2850 },
2851 ),
2852 (
2853 1,
2854 1,
2855 Note {
2856 period: 0,
2857 sample: 0,
2858 effect: 0xE,
2859 effect_param: 0xE1,
2860 },
2861 ),
2862 ]);
2863 let mut player = make_player(&bytes);
2864 // Walk row 0 (6 ticks).
2865 for _ in 0..6 {
2866 step_one_tick(&mut player);
2867 }
2868 // Row 1 tick 0: EA2 increments volume from 0 → 2. EE1 sets delay=1.
2869 step_one_tick(&mut player);
2870 assert_eq!(player.channels[0].volume, 2);
2871 // Finish row 1 (5 more ticks) — pattern delay counter ticks down.
2872 for _ in 0..5 {
2873 step_one_tick(&mut player);
2874 }
2875 // Row "1 again" tick 0: per spec, EA2 must NOT re-fire — volume
2876 // stays at 2, not 4.
2877 step_one_tick(&mut player);
2878 assert_eq!(
2879 player.channels[0].volume, 2,
2880 "EEx pattern-delay repeat must not re-fire EA2 (volume must stay 2)"
2881 );
2882 }
2883
2884 #[test]
2885 fn pattern_delay_ee_does_not_retrigger_held_note() {
2886 // Trigger a long, looping sample on row 0, then on row 1 emit
2887 // EE2 (delay 2 row passes) with no note. Across the two delay
2888 // repeats, the channel's `sample_pos` must keep advancing
2889 // monotonically (the previous note "continues during delay" per
2890 // spec). If `enter_row` were re-invoked it would not reset the
2891 // sample (no note in row 1) but the tick-0 effect machinery
2892 // would fire again — for the bug we're really chasing, drop a
2893 // note onto row 1 and confirm it does NOT re-trigger across the
2894 // repeats either.
2895 let bytes = synth_mod_with_pattern(&[
2896 // Row 0: trigger.
2897 (
2898 0,
2899 0,
2900 Note {
2901 period: 428,
2902 sample: 1,
2903 effect: 0,
2904 effect_param: 0,
2905 },
2906 ),
2907 // Row 1: a note + EE3. The note must trigger on the first
2908 // pass (and only the first pass).
2909 (
2910 1,
2911 0,
2912 Note {
2913 period: 339,
2914 sample: 1,
2915 effect: 0xE,
2916 effect_param: 0xE3,
2917 },
2918 ),
2919 ]);
2920 let mut player = make_player(&bytes);
2921
2922 // Walk row 0 (6 ticks).
2923 for _ in 0..6 {
2924 step_one_tick(&mut player);
2925 }
2926 // Row 1 tick 0: note triggers (sample_pos resets to 0), EE3
2927 // sets pattern_delay = 3.
2928 step_one_tick(&mut player);
2929 assert_eq!(player.channels[0].period, 339, "row 1 note must trigger");
2930 let pos_after_first_trigger = player.channels[0].sample_pos;
2931
2932 // Walk the rest of row 1's ticks plus all 3 delay repeats. Each
2933 // pass has 6 ticks; we've consumed 1 of the first pass, leaving
2934 // 5 + 3*6 = 23 ticks before the song advances out of row 1.
2935 let mut prev_pos = pos_after_first_trigger;
2936 for tick_idx in 0..23 {
2937 step_one_tick(&mut player);
2938 let cur_pos = player.channels[0].sample_pos;
2939 // Sample is a 32-frame loop; the position wraps inside the
2940 // loop region. We just need to confirm we never reset to 0
2941 // (which would happen if enter_row were re-invoked on the
2942 // repeats and re-triggered the note).
2943 // Allow position == 0 only on the very first iteration (none
2944 // here, so flag any zero immediately). Account for the loop
2945 // wrap by checking we don't drop to a value strictly less
2946 // than the loop_start (which is 0 — so the only invalid
2947 // state is sample_pos == 0.0 followed by another 0.0, i.e.
2948 // an enforced reset, not a wrap).
2949 assert!(
2950 cur_pos != 0.0 || prev_pos == 0.0,
2951 "tick {tick_idx}: sample_pos jumped back to 0 \
2952 (prev={prev_pos}, cur={cur_pos}) — the EE pattern-delay \
2953 repeat must NOT re-trigger a note that was already \
2954 played on the first pass through the row"
2955 );
2956 prev_pos = cur_pos;
2957 }
2958 }
2959
2960 /// Build a 1-channel-style synthetic MOD with a custom sample body so
2961 /// we can probe the mixer's loop-wrap behaviour. The sample is given a
2962 /// loop region from `loop_start` to `loop_start + loop_length`; the
2963 /// PCM tail past `loop_end` is filled with a sentinel value so test
2964 /// code can detect any read past the loop boundary.
2965 fn synth_mod_with_loop_sample(
2966 pcm: &[i8],
2967 loop_start_words: u16,
2968 loop_length_words: u16,
2969 ) -> Vec<u8> {
2970 let mut out = vec![0u8; crate::header::HEADER_FIXED_SIZE];
2971 out[0..4].copy_from_slice(b"loop");
2972 let length_words = (pcm.len() / 2) as u16;
2973 out[20 + 22..20 + 24].copy_from_slice(&length_words.to_be_bytes());
2974 out[20 + 24] = 0;
2975 out[20 + 25] = 64;
2976 out[20 + 26..20 + 28].copy_from_slice(&loop_start_words.to_be_bytes());
2977 out[20 + 28..20 + 30].copy_from_slice(&loop_length_words.to_be_bytes());
2978 out[950] = 1;
2979 out[951] = 0x7F;
2980 out[952] = 0;
2981 out[1080..1084].copy_from_slice(b"M.K.");
2982
2983 let mut pat = vec![0u8; 64 * 4 * 4];
2984 // Row 0, ch 0: trigger sample 1 at C-2 (period 428).
2985 let p_hi = ((428u16 >> 8) & 0x0F) as u8;
2986 let p_lo = (428u16 & 0xFF) as u8;
2987 pat[0] = p_hi;
2988 pat[1] = p_lo;
2989 pat[2] = 1u8 << 4;
2990 pat[3] = 0;
2991 out.extend(pat);
2992 out.extend(pcm.iter().map(|&s| s as u8));
2993 out
2994 }
2995
2996 #[test]
2997 fn loop_wrap_stays_inside_loop_region() {
2998 // Protracker-effects-MODFIL12.txt §2.2: looped samples play only
2999 // the loop_start..loop_start+loop_length region. The "tail" past
3000 // loop_end is decay data that PT discards. Before the fix in
3001 // mix_one, the mixer wrapped only when sample_pos >= pcm.len(),
3002 // producing audible glitches when loop_end < pcm.len(). This
3003 // test sets up loop_end = 64 and pcm.len() = 200, fills the tail
3004 // 64..200 with a distinct sentinel, and verifies that the mixer
3005 // never reads any sample whose magnitude matches the sentinel.
3006 //
3007 // Sample layout (200 bytes total, all i8):
3008 // 0..64 : value +50 (signal in the loop region)
3009 // 64..200 : value -100 (sentinel — must NEVER be read)
3010 // Loop: start=0, length=64.
3011 let mut pcm: Vec<i8> = vec![50; 64];
3012 pcm.extend(std::iter::repeat_n(-100i8, 136));
3013 // pcm.len() must be even (the header stores length in words).
3014 assert!(pcm.len().is_multiple_of(2));
3015 let bytes = synth_mod_with_loop_sample(&pcm, 0, 32);
3016 let mut player = make_player(&bytes);
3017
3018 // Render ~0.05 seconds (2205 frames). At C-2 (period 428) the
3019 // playback rate is PAULA_CLOCK / 428 ≈ 8287 Hz, so this renders
3020 // ~414 sample frames of the source — well past the 64-frame loop
3021 // boundary, exercising several wrap cycles.
3022 let n_frames = 2205;
3023 let mut planes: Vec<Vec<i16>> = (0..player.channels.len())
3024 .map(|_| vec![0i16; n_frames])
3025 .collect();
3026 let _ = {
3027 let mut views: Vec<&mut [i16]> = planes.iter_mut().map(|v| v.as_mut_slice()).collect();
3028 player.render_per_channel(&mut views, n_frames)
3029 };
3030
3031 // Channel 0 carries the signal. Each output frame is a linear
3032 // interpolation of two adjacent sample values; if both are inside
3033 // the loop region they sit around +50/128 ≈ +0.39 (i16 ≈ +12800).
3034 // If the mixer ever reads the sentinel (-100) we would see a
3035 // strongly negative sample. With the 2-pole always-on Amiga
3036 // output filter we need to (1) skip the first ~64 frames while
3037 // the filter accumulators ramp up to steady state, and (2)
3038 // accept any positive value — a sentinel leak would manifest
3039 // as a strongly NEGATIVE excursion (-100/128 → ≈-25600), so
3040 // `v >= 0` is the actual loop-correctness invariant.
3041 for (i, &v) in planes[0].iter().enumerate().skip(64) {
3042 assert!(
3043 v >= 0,
3044 "frame {i}: expected positive +50/128 sample, got {v} \
3045 — mixer leaked past loop boundary into sentinel tail \
3046 (negative value implies the -100 sentinel was read)"
3047 );
3048 }
3049 }
3050
3051 #[test]
3052 fn loop_wrap_handles_loop_end_at_pcm_end() {
3053 // Sanity: the classic case where loop_end == pcm.len() must
3054 // continue to work. Sample is a 32-byte square wave looping for
3055 // its full length.
3056 let mut pcm: Vec<i8> = (0..16).map(|_| 100).collect();
3057 pcm.extend(std::iter::repeat_n(-100i8, 16));
3058 let bytes = synth_mod_with_loop_sample(&pcm, 0, 16);
3059 let mut player = make_player(&bytes);
3060 let mut buf = vec![0i16; 4410 * 2];
3061 let produced = player.render(&mut buf);
3062 assert_eq!(produced, 4410);
3063 let nonzero = buf.iter().filter(|&&x| x != 0).count();
3064 assert!(nonzero > 4000, "expected loud square wave output");
3065 }
3066
3067 #[test]
3068 fn sample_swap_without_note_is_deferred() {
3069 // PT quirk (Protracker-effects-MODFIL12.txt §3.2 +
3070 // Pro-Noise-Soundtracker-rev4.txt:113-118): writing a sample
3071 // number on a row that has NO note must NOT swap the active
3072 // sample on the channel — the swap is deferred until the next
3073 // note-on. The row's volume + finetune are still updated.
3074 //
3075 // Setup: two samples in the file. Row 0 triggers sample 1 at
3076 // C-2. Row 1 writes "sample 2" with no note — the channel must
3077 // continue mixing sample 1 (the active one), with sample 2's
3078 // default volume now applied. Row 2 retriggers (no sample
3079 // number, just a note) — this must consume the pending swap and
3080 // start playing sample 2.
3081 let mut bytes = vec![0u8; crate::header::HEADER_FIXED_SIZE];
3082 bytes[0..4].copy_from_slice(b"swap");
3083 // Sample 1: 32 frames, finetune 0, volume 64, no loop.
3084 bytes[20 + 22..20 + 24].copy_from_slice(&16u16.to_be_bytes());
3085 bytes[20 + 24] = 0;
3086 bytes[20 + 25] = 64;
3087 bytes[20 + 26..20 + 28].copy_from_slice(&0u16.to_be_bytes());
3088 bytes[20 + 28..20 + 30].copy_from_slice(&0u16.to_be_bytes());
3089 // Sample 2: 32 frames, finetune +3, volume 32, no loop.
3090 bytes[50 + 22..50 + 24].copy_from_slice(&16u16.to_be_bytes());
3091 bytes[50 + 24] = 3;
3092 bytes[50 + 25] = 32;
3093 bytes[50 + 26..50 + 28].copy_from_slice(&0u16.to_be_bytes());
3094 bytes[50 + 28..50 + 30].copy_from_slice(&0u16.to_be_bytes());
3095
3096 bytes[950] = 1;
3097 bytes[951] = 0x7F;
3098 bytes[952] = 0;
3099 bytes[1080..1084].copy_from_slice(b"M.K.");
3100
3101 // Pattern.
3102 let mut pat = vec![0u8; 64 * 4 * 4];
3103 // Row 0, ch 0: C-2 (428) sample 1.
3104 let off = 0;
3105 pat[off] = ((428u16 >> 8) & 0x0F) as u8;
3106 pat[off + 1] = (428u16 & 0xFF) as u8;
3107 pat[off + 2] = 1u8 << 4;
3108 // Row 1, ch 0: sample 2, no period.
3109 let off = 4 * 4;
3110 pat[off] = 0; // no period high nibble, no sample high nibble
3111 pat[off + 1] = 0;
3112 pat[off + 2] = 2u8 << 4; // sample lo = 2, effect 0
3113 pat[off + 3] = 0;
3114 // Row 2, ch 0: D-2 (381), no sample number — should consume pending swap.
3115 let off = 2 * 4 * 4;
3116 pat[off] = ((381u16 >> 8) & 0x0F) as u8;
3117 pat[off + 1] = (381u16 & 0xFF) as u8;
3118 pat[off + 2] = 0; // no sample number
3119 pat[off + 3] = 0;
3120 bytes.extend(pat);
3121
3122 // Sample 1 body: 32 bytes of +50.
3123 bytes.extend(std::iter::repeat_n(50i8 as u8, 32));
3124 // Sample 2 body: 32 bytes of -50.
3125 bytes.extend(std::iter::repeat_n((-50i8) as u8, 32));
3126
3127 let mut player = make_player(&bytes);
3128
3129 // After row 0: channel plays sample 1, volume 64.
3130 for _ in 0..6 {
3131 step_one_tick(&mut player);
3132 }
3133 assert_eq!(player.channels[0].sample_index, 1, "row 0: sample 1 active");
3134 assert_eq!(player.channels[0].volume, 64, "row 0: vol from sample 1");
3135
3136 // Row 1: sample 2 written, no note. Active sample stays at 1,
3137 // but volume + finetune update to sample 2's defaults.
3138 for _ in 0..6 {
3139 step_one_tick(&mut player);
3140 }
3141 assert_eq!(
3142 player.channels[0].sample_index, 1,
3143 "row 1: sample 2 written without note — active sample MUST still be 1"
3144 );
3145 assert_eq!(
3146 player.channels[0].volume, 32,
3147 "row 1: sample 2's default volume must apply immediately"
3148 );
3149 assert_eq!(
3150 player.channels[0].finetune, 3,
3151 "row 1: sample 2's finetune must apply immediately"
3152 );
3153 assert_eq!(
3154 player.channels[0].pending_sample, 2,
3155 "row 1: pending sample swap should be queued"
3156 );
3157
3158 // Row 2: D-2 with no sample number — consumes the pending swap.
3159 for _ in 0..6 {
3160 step_one_tick(&mut player);
3161 }
3162 assert_eq!(
3163 player.channels[0].sample_index, 2,
3164 "row 2: pending sample 2 swap must be consumed by note trigger"
3165 );
3166 assert_eq!(
3167 player.channels[0].pending_sample, 0,
3168 "row 2: pending_sample must clear on consumption"
3169 );
3170 }
3171
3172 // ---------- Round-15 PT-fidelity regression tests ----------
3173
3174 #[test]
3175 fn period_clamp_constants_match_pt_spec() {
3176 // Cross-check the four published period limits.
3177 // Standard porta range: B-3 = 113, C-1 = 856 (Protracker-v1.1B-mod.txt
3178 // Cmd 1/2). Extended range covering finetune ±8: 108..907 per
3179 // Protracker-effects-MODFIL12.txt §3.2 + the period table's
3180 // first/last cells.
3181 assert_eq!(PERIOD_MIN, 113);
3182 assert_eq!(PERIOD_MAX, 856);
3183 assert_eq!(PERIOD_MIN_EXT, 108);
3184 assert_eq!(PERIOD_MAX_EXT, 907);
3185 // Period table corners must agree.
3186 assert_eq!(PERIOD_TABLE[7][35], 108, "FT +7 B-3 must equal 108");
3187 assert_eq!(PERIOD_TABLE[8][0], 907, "FT -8 C-1 must equal 907");
3188 }
3189
3190 #[test]
3191 fn porta_up_clamps_at_period_113() {
3192 // Trigger a note near B-3 (period 120, A#3), then porta-up
3193 // aggressively for a couple of rows. The period must clamp at
3194 // 113 (B-3) and not overshoot below it. Per
3195 // `Protracker-v1.1B-mod.txt` Cmd 1: "You can NOT slide higher
3196 // than B-3! (Period 113)".
3197 let bytes = synth_mod_with_pattern(&[
3198 (
3199 0,
3200 0,
3201 Note {
3202 period: 120,
3203 sample: 1,
3204 effect: 0,
3205 effect_param: 0,
3206 },
3207 ),
3208 // 1xx with param 0xFF: tries to slide period down by 255
3209 // every tick — would overshoot massively without the clamp.
3210 (
3211 1,
3212 0,
3213 Note {
3214 period: 0,
3215 sample: 0,
3216 effect: 0x1,
3217 effect_param: 0xFF,
3218 },
3219 ),
3220 ]);
3221 let mut player = make_player(&bytes);
3222 for _ in 0..12 {
3223 step_one_tick(&mut player);
3224 }
3225 assert_eq!(
3226 player.channels[0].period, 113,
3227 "1xx must clamp at period 113 (B-3)"
3228 );
3229 }
3230
3231 #[test]
3232 fn porta_down_clamps_at_period_856() {
3233 // Symmetric test for 2xx — must clamp at 856 (C-1).
3234 let bytes = synth_mod_with_pattern(&[
3235 (
3236 0,
3237 0,
3238 Note {
3239 period: 800,
3240 sample: 1,
3241 effect: 0,
3242 effect_param: 0,
3243 },
3244 ),
3245 (
3246 1,
3247 0,
3248 Note {
3249 period: 0,
3250 sample: 0,
3251 effect: 0x2,
3252 effect_param: 0xFF,
3253 },
3254 ),
3255 ]);
3256 let mut player = make_player(&bytes);
3257 for _ in 0..12 {
3258 step_one_tick(&mut player);
3259 }
3260 assert_eq!(
3261 player.channels[0].period, 856,
3262 "2xx must clamp at period 856 (C-1)"
3263 );
3264 }
3265
3266 #[test]
3267 fn effective_period_accepts_finetune_extreme_below_113() {
3268 // A note at FT +7 B-3 has period 108. Without the extended
3269 // clamp the mixer would force-clamp to 113 and detune the note
3270 // by ~5%. With the new clamp at PERIOD_MIN_EXT = 108 the
3271 // effective_period must pass 108 through unchanged.
3272 let mut ch = Channel {
3273 period: 108,
3274 ..Channel::default()
3275 };
3276 // No vibrato active.
3277 ch.effect = 0;
3278 ch.mem_vibrato = 0;
3279 let eff = ch.effective_period(0);
3280 assert_eq!(eff, 108, "FT +7 B-3 (period 108) must not be clamped");
3281 }
3282
3283 #[test]
3284 fn led_filter_alpha_matches_one_pole_lowpass_at_cutoff() {
3285 // Sanity-check the analytical filter coefficient.
3286 // The LED-controlled second pole's alpha at 44.1 kHz with
3287 // cutoff `LED_FILTER_CUTOFF_HZ`.
3288 let a = PlayerState::compute_alpha(44_100, PlayerState::LED_FILTER_CUTOFF_HZ);
3289 let two_pi = 2.0 * std::f32::consts::PI;
3290 let expected = 1.0 - (-two_pi * PlayerState::LED_FILTER_CUTOFF_HZ / 44_100.0).exp();
3291 let diff = (a - expected).abs();
3292 assert!(
3293 diff < 1e-6,
3294 "LED alpha mismatch: got {a}, expected {expected}"
3295 );
3296 // The 1-pole IIR is stable iff 0 < alpha <= 1.
3297 assert!(a > 0.0 && a <= 1.0, "alpha {a} outside (0, 1]");
3298 }
3299
3300 #[test]
3301 fn led_filter_attenuates_nyquist_input() {
3302 // Drive a +1/-1 alternating signal (the worst-case Nyquist
3303 // content at 44.1 kHz) through the filter directly. The
3304 // 2-pole model: the always-on first RC pole at
3305 // `FIXED_RC_CUTOFF_HZ` attenuates HF whether the LED is on
3306 // or off; the second LED-controlled pole adds further
3307 // attenuation when ON. Both branches therefore reduce the
3308 // alternating signal — but the LED-ON branch must reduce
3309 // it strictly MORE than the LED-OFF branch.
3310 let make_player_with_led = |led: bool| -> PlayerState {
3311 let bytes = synth_square_mod();
3312 let mut player = make_player(&bytes);
3313 player.led_filter = led;
3314 player.led_filter_state.clear();
3315 player.led_filter_state2.clear();
3316 player.led_filter_alpha = f32::NAN;
3317 player.led_filter_alpha2 = f32::NAN;
3318 // Force alpha computation now.
3319 player.ensure_led_filter(2);
3320 player
3321 };
3322
3323 let drive = |player: &mut PlayerState, n: usize| -> Vec<f32> {
3324 let mut out = Vec::with_capacity(n);
3325 for i in 0..n {
3326 let x = if i % 2 == 0 { 1.0 } else { -1.0 };
3327 out.push(player.led_filter_step(0, x));
3328 }
3329 out
3330 };
3331
3332 let mut p_on = make_player_with_led(true);
3333 let mut p_off = make_player_with_led(false);
3334 let on = drive(&mut p_on, 256);
3335 let off = drive(&mut p_off, 256);
3336
3337 // Use the second half of the trace (after transient).
3338 let on_pp: f32 = on[128..].iter().map(|x| x.abs()).fold(0.0f32, f32::max);
3339 let off_pp: f32 = off[128..].iter().map(|x| x.abs()).fold(0.0f32, f32::max);
3340
3341 // Both branches attenuate Nyquist (the always-on first RC
3342 // pole cuts HF unconditionally). LED-ON adds the second
3343 // pole and must attenuate strictly more.
3344 assert!(
3345 off_pp < 1.0,
3346 "filter-off magnitude {off_pp} should attenuate via the \
3347 always-on RC pole even when LED is off"
3348 );
3349 assert!(
3350 on_pp < off_pp,
3351 "filter-on magnitude {on_pp} should be strictly < \
3352 filter-off magnitude {off_pp} (LED adds a second pole)"
3353 );
3354 }
3355
3356 #[test]
3357 fn led_filter_default_is_on_at_song_start() {
3358 // Real Amiga power-on default leaves LED on. PT inherits this
3359 // and assumes the filter is engaged for the very first note.
3360 // See `Protracker-v1.1B-mod.txt` Cmd E0 ("E00 connects filter
3361 // (turns power LED on)").
3362 let bytes = synth_square_mod();
3363 let player = make_player(&bytes);
3364 assert!(
3365 player.led_filter,
3366 "LED filter must default to ON (Amiga power-on state)"
3367 );
3368 }
3369
3370 #[test]
3371 fn e0x_toggles_led_filter() {
3372 // Row 0: trigger a note. Row 1: E01 (LED OFF). Row 2: E00
3373 // (LED ON). The player.led_filter flag should follow.
3374 let bytes = synth_mod_with_pattern(&[
3375 (
3376 0,
3377 0,
3378 Note {
3379 period: 428,
3380 sample: 1,
3381 effect: 0,
3382 effect_param: 0,
3383 },
3384 ),
3385 (
3386 1,
3387 0,
3388 Note {
3389 period: 0,
3390 sample: 0,
3391 effect: 0xE,
3392 effect_param: 0x01,
3393 },
3394 ),
3395 (
3396 2,
3397 0,
3398 Note {
3399 period: 0,
3400 sample: 0,
3401 effect: 0xE,
3402 effect_param: 0x00,
3403 },
3404 ),
3405 ]);
3406 let mut player = make_player(&bytes);
3407 // Row 0 — LED should remain ON (default).
3408 for _ in 0..6 {
3409 step_one_tick(&mut player);
3410 }
3411 assert!(player.led_filter, "row 0: LED still ON");
3412 // Row 1 — E01 fires on tick 0, LED goes OFF.
3413 step_one_tick(&mut player);
3414 assert!(!player.led_filter, "row 1: E01 must clear LED");
3415 for _ in 0..5 {
3416 step_one_tick(&mut player);
3417 }
3418 // Row 2 — E00 fires, LED back ON.
3419 step_one_tick(&mut player);
3420 assert!(player.led_filter, "row 2: E00 must restore LED");
3421 }
3422
3423 #[test]
3424 fn fxx_speed_bpm_split_at_0x20() {
3425 // Per the convention noted in `Pro-Noise-Soundtracker-rev4.txt`
3426 // (lines 362-365) and `Protracker-v1.1B-mod.txt` Cmd F:
3427 // z < 0x20 → set ticks/division (speed)
3428 // z >= 0x20 → set BPM
3429 // 0x1F (= 31) is the largest speed value, 0x20 (= 32) is the
3430 // smallest BPM value. Verify both by running the song-level
3431 // resolution path from `enter_row`.
3432 let bytes_speed = synth_mod_with_pattern(&[(
3433 0,
3434 0,
3435 Note {
3436 period: 0,
3437 sample: 0,
3438 effect: 0xF,
3439 effect_param: 0x1F,
3440 },
3441 )]);
3442 let mut player = make_player(&bytes_speed);
3443 step_one_tick(&mut player);
3444 assert_eq!(player.speed, 0x1F, "F1F must set speed to 31");
3445 assert_eq!(player.bpm, DEFAULT_BPM, "F1F must NOT touch BPM");
3446
3447 let bytes_bpm = synth_mod_with_pattern(&[(
3448 0,
3449 0,
3450 Note {
3451 period: 0,
3452 sample: 0,
3453 effect: 0xF,
3454 effect_param: 0x20,
3455 },
3456 )]);
3457 let mut player = make_player(&bytes_bpm);
3458 step_one_tick(&mut player);
3459 assert_eq!(player.bpm, 0x20, "F20 must set BPM to 32");
3460 assert_eq!(player.speed, DEFAULT_SPEED, "F20 must NOT touch speed");
3461 }
3462
3463 #[test]
3464 fn e6_dxy_same_row_last_channel_wins() {
3465 // Place E6x on channel 0 (would loop) and Dxy on channel 1
3466 // (forces pattern break to specified row). Per the per-channel
3467 // dispatch in `apply_tick0_effect`, both write to
3468 // `pending_jump`; the later channel wins (PT idiom).
3469 // Verify Dxy on a higher channel takes precedence over E6 on
3470 // a lower channel — the song advances to the break-row, not
3471 // to the loop-target.
3472 let bytes = synth_mod_with_pattern(&[
3473 // Row 0: trigger note on ch0.
3474 (
3475 0,
3476 0,
3477 Note {
3478 period: 428,
3479 sample: 1,
3480 effect: 0,
3481 effect_param: 0,
3482 },
3483 ),
3484 // Row 1: E60 on ch0 (set loop start = row 1).
3485 (
3486 1,
3487 0,
3488 Note {
3489 period: 0,
3490 sample: 0,
3491 effect: 0xE,
3492 effect_param: 0x60,
3493 },
3494 ),
3495 // Row 2: E61 on ch0 + D05 on ch1. E61 would loop to row 1
3496 // (jump to row 1 in current order); D05 jumps to row 5 in
3497 // next order. Per PT (channel 1 > channel 0), Dxy wins.
3498 (
3499 2,
3500 0,
3501 Note {
3502 period: 0,
3503 sample: 0,
3504 effect: 0xE,
3505 effect_param: 0x61,
3506 },
3507 ),
3508 (
3509 2,
3510 1,
3511 Note {
3512 period: 0,
3513 sample: 0,
3514 effect: 0xD,
3515 effect_param: 0x05,
3516 },
3517 ),
3518 ]);
3519 let mut player = make_player(&bytes);
3520 // Walk rows 0..=2 (3 rows × 6 ticks).
3521 for _ in 0..18 {
3522 step_one_tick(&mut player);
3523 }
3524 // Then advance one more tick to fire next_row().
3525 step_one_tick(&mut player);
3526 // Pattern-break wins: we should now be on row 5 of the next
3527 // order (and `ended` since there's only one pattern).
3528 // For our 1-pattern synth, advancing past order 0 sets `ended`.
3529 assert!(
3530 player.ended || player.row == 5,
3531 "Dxy must override E6x when on a higher-numbered channel; \
3532 got row={}, order={}, ended={}",
3533 player.row,
3534 player.order_index,
3535 player.ended,
3536 );
3537 }
3538
3539 #[test]
3540 fn vibrato_first_half_lowers_pitch_per_firelight_pseudocode() {
3541 // FireLight §5.5 says the sine-table values are ADDED to the
3542 // "AMIGA frequency" (= the period) in the first half-cycle,
3543 // and SUBTRACTED in the second. Adding to the period LOWERS
3544 // the audible pitch (pitch ∝ PAULA_CLOCK / period). Verify
3545 // our implementation matches this convention.
3546 //
3547 // This test pins down the canonical interpretation we follow,
3548 // disambiguating against the alternative C-snippet reading
3549 // some legacy docs cite.
3550 let mut ch = Channel {
3551 period: 428,
3552 sample_index: 1,
3553 volume: 64,
3554 active: true,
3555 effect: 0x4,
3556 mem_vibrato: 0x84, // rate=8, depth=4
3557 vib_pos: 8, // mid first half-cycle (positive)
3558 ..Channel::default()
3559 };
3560 ch.vib_wave.shape = 0; // sine
3561 let off = PlayerState::vibrato_offset(&ch);
3562 assert!(
3563 off > 0,
3564 "Per FireLight §5.5: positive vib_pos must ADD to period \
3565 (lowering pitch). Got offset {off}."
3566 );
3567 // Effective period should be > base period.
3568 let eff = ch.effective_period(off);
3569 assert!(
3570 eff > ch.period,
3571 "effective_period must rise on positive vib_pos"
3572 );
3573
3574 // Now drive it negative — second half-cycle subtracts.
3575 ch.vib_pos = -8;
3576 let off = PlayerState::vibrato_offset(&ch);
3577 assert!(
3578 off < 0,
3579 "Per FireLight §5.5: negative vib_pos must SUBTRACT from period \
3580 (raising pitch). Got offset {off}."
3581 );
3582 }
3583
3584 #[test]
3585 fn loop_metadata_clamped_when_out_of_range() {
3586 // Defensive: real-world MOD rips sometimes have repeat metadata
3587 // that extends past the actual sample length. extract_samples
3588 // must clamp to keep the mixer from reading past the buffer.
3589 let mut bytes = vec![0u8; crate::header::HEADER_FIXED_SIZE];
3590 bytes[0..4].copy_from_slice(b"clmp");
3591 // Sample length = 16 words = 32 frames.
3592 bytes[20 + 22..20 + 24].copy_from_slice(&16u16.to_be_bytes());
3593 bytes[20 + 25] = 64;
3594 // Repeat start = 100 words (way past the sample). Repeat length = 200.
3595 bytes[20 + 26..20 + 28].copy_from_slice(&100u16.to_be_bytes());
3596 bytes[20 + 28..20 + 30].copy_from_slice(&200u16.to_be_bytes());
3597 bytes[950] = 1;
3598 bytes[951] = 0x7F;
3599 bytes[952] = 0;
3600 bytes[1080..1084].copy_from_slice(b"M.K.");
3601 bytes.extend(std::iter::repeat_n(0u8, 64 * 4 * 4));
3602 bytes.extend(std::iter::repeat_n(0u8, 32));
3603 let header = crate::header::parse_header(&bytes).unwrap();
3604 let samples = crate::samples::extract_samples(&header, &bytes);
3605 // After clamping, loop_start/length must fit within pcm.len() = 32.
3606 let s = &samples[0];
3607 let end = s.loop_start + s.loop_length;
3608 assert!(
3609 end as usize <= s.pcm.len(),
3610 "loop_end ({end}) must be clamped to pcm.len() ({})",
3611 s.pcm.len()
3612 );
3613 }
3614
3615 /// Regression for the round-105 cyber.mod arpeggio bug:
3616 ///
3617 /// When effect 0xy (arpeggio) carries across rows — i.e. row N
3618 /// triggers a note + arpeggio, and row N+1 has no new note but
3619 /// continues the same effect 0xy — the channel's
3620 /// `arp_base_period` MUST stay anchored to the original triggered
3621 /// note. Without the fix, the previous row's last tick left
3622 /// `ch.period` at a semitone-shifted value (FireLight §5.1
3623 /// pseudo: tick%3==2 → +y); the "no note" branch in `enter_row`
3624 /// then captured that modulated period as the NEW arp base, so
3625 /// every continuation row shifted the chord up another (x, y)
3626 /// step. On `cyber.mod` pat-1 ch-2 (rows 32-58, sample 5
3627 /// `st-07:buzzshot`) this produced an audibly out-of-tune lead
3628 /// in the 12-14 s region, which the user reported as "effects a
3629 /// bit off". See `Protracker-effects-MODFIL12.txt` 0:Arpeggio
3630 /// ("This effect means to play the note specified, then the
3631 /// note+xxxx half-steps, then the note+yyyy half-steps, and then
3632 /// return to the original note"; the "original note" is the
3633 /// triggered note, not the previous tick's modulated value) and
3634 /// FireLight-MOD-Player-Tutorial.txt §5.1 ("Tick 0 set frequency
3635 /// to normal value").
3636 #[test]
3637 fn arpeggio_base_persists_across_rows_without_new_note() {
3638 // Row 0: trigger C-2 (period 428) on channel 0 with arpeggio
3639 // x=3, y=4 (effect 034).
3640 // Row 1: NO new note, same arpeggio 034 still active.
3641 // Expected at row 1, tick 1 (after enter_row + 1 tickN): the
3642 // period must equal `PERIOD_TABLE[0][12 + 3]` (C-2 + 3 semis
3643 // = D#-2 = 339), NOT the bug-shifted value
3644 // `PERIOD_TABLE[0][15 + 3]` (which would be a fifth higher).
3645 // Row 1 tick 0: must equal the un-modulated base (period 428),
3646 // not the leftover modulated period from row 0 tick 5
3647 // (which would be 339 = base + x semis).
3648 let bytes = synth_mod_with_pattern(&[
3649 (
3650 0,
3651 0,
3652 Note {
3653 period: 428,
3654 sample: 1,
3655 effect: 0,
3656 effect_param: 0x34,
3657 },
3658 ),
3659 (
3660 1,
3661 0,
3662 Note {
3663 period: 0,
3664 sample: 0,
3665 effect: 0,
3666 effect_param: 0x34,
3667 },
3668 ),
3669 ]);
3670 let mut player = make_player(&bytes);
3671
3672 // Step 8 ticks: ticks 0..5 of row 0 (6 ticks) + ticks 0..1 of
3673 // row 1 (2 ticks). step_one_tick advances state at the end of
3674 // each call; the 7th call's `advance_tick` calls
3675 // `enter_row(row=1)` (where the round-105 fix restores period
3676 // to the un-modulated base), and the 8th call's
3677 // `advance_tick` runs `apply_tickn` for tick 1 of row 1
3678 // (which applies the +x arpeggio offset).
3679 for _ in 0..8 {
3680 step_one_tick(&mut player);
3681 }
3682 // After the round-105 fix, `arp_base_period` must still be
3683 // anchored to the original triggered period (428). Pre-fix
3684 // this would equal the previous row's last-tick modulated
3685 // period (e.g. 320 = C-2 + y=4 semis = E-2), causing the
3686 // chord to shift up by (x, y) on every continuation row.
3687 assert_eq!(
3688 player.channels[0].arp_base_period, 428,
3689 "arpeggio base must persist across rows that have no new \
3690 note — got {}, expected 428 (the original note period). \
3691 Pre-fix this would equal the previous row's last-tick \
3692 modulated period.",
3693 player.channels[0].arp_base_period
3694 );
3695
3696 // After 7 ticks total: tick 1 of row 1 has just been rendered
3697 // (tick=2 in the player's internal counter). Arpeggio sets
3698 // period = base + x semitones = `PERIOD_TABLE[0][12 + 3]`
3699 // = 360 (D#-2 in finetune-0 row). With the bug this would be
3700 // `PERIOD_TABLE[0][15 + 3]` ≈ 285 — a fifth higher than the
3701 // intended +3 semis.
3702 assert_eq!(
3703 player.channels[0].period,
3704 PERIOD_TABLE[0][12 + 3],
3705 "row 1 tick 1 of a continuation arpeggio must land on \
3706 base + x semitones (PERIOD_TABLE[0][15] = {}), not on \
3707 a doubly-shifted value",
3708 PERIOD_TABLE[0][12 + 3]
3709 );
3710 }
3711
3712 /// Default per-channel pan in a freshly-built `PlayerState` follows
3713 /// the Amiga LRRL hard-pan convention (channels 0 & 3 → 0/LEFT,
3714 /// 1 & 2 → 255/RIGHT, repeating every 4) so that a MOD with no
3715 /// 8xx / E8x commands renders identically to the pre-r75 build.
3716 /// Cited in `Protracker-effects-MODFIL12.txt` §11 (the same hard-pan
3717 /// layout that motivates `pan_separation < 1.0` for headphone
3718 /// listeners).
3719 #[test]
3720 fn channel_pan_defaults_to_amiga_lrrl() {
3721 let bytes = synth_square_mod();
3722 let player = make_player(&bytes);
3723 // 4-channel synth: ch 0 & 3 → 0 (LEFT), ch 1 & 2 → 255 (RIGHT).
3724 assert_eq!(player.channels[0].pan, 0, "ch 0 default = LEFT (0)");
3725 assert_eq!(player.channels[1].pan, 255, "ch 1 default = RIGHT (255)");
3726 assert_eq!(player.channels[2].pan, 255, "ch 2 default = RIGHT (255)");
3727 assert_eq!(player.channels[3].pan, 0, "ch 3 default = LEFT (0)");
3728 }
3729
3730 /// `8xx` is the FT-extension Set Fine Panning command:
3731 /// `Protracker-effects-MODFIL12.txt` lines 1201-1207
3732 /// "Command 8: Set FINE Panning. xxxxyyyy = panning position.
3733 /// (0=Most left, 255=most right.)"
3734 /// Verify tick-0 dispatch latches the byte verbatim into `ch.pan`.
3735 #[test]
3736 fn effect_8xx_sets_per_channel_pan() {
3737 let bytes = synth_mod_with_pattern(&[
3738 // Row 0: trigger note on ch 1, then 8xx with param 0x40 on ch 1.
3739 (
3740 0,
3741 1,
3742 Note {
3743 period: 428,
3744 sample: 1,
3745 effect: 0x8,
3746 effect_param: 0x40,
3747 },
3748 ),
3749 ]);
3750 let mut player = make_player(&bytes);
3751 // Sanity — Amiga default for ch 1 = RIGHT (255).
3752 assert_eq!(player.channels[1].pan, 255);
3753 // Tick 0 dispatches the row's tick-0 effects.
3754 step_one_tick(&mut player);
3755 assert_eq!(
3756 player.channels[1].pan, 0x40,
3757 "8xx must overwrite ch.pan with the raw param byte"
3758 );
3759 // Endpoints both directions.
3760 let bytes_left = synth_mod_with_pattern(&[(
3761 0,
3762 2,
3763 Note {
3764 period: 0,
3765 sample: 0,
3766 effect: 0x8,
3767 effect_param: 0x00,
3768 },
3769 )]);
3770 let mut p2 = make_player(&bytes_left);
3771 step_one_tick(&mut p2);
3772 assert_eq!(p2.channels[2].pan, 0x00, "800 = hard LEFT");
3773
3774 let bytes_right = synth_mod_with_pattern(&[(
3775 0,
3776 0,
3777 Note {
3778 period: 0,
3779 sample: 0,
3780 effect: 0x8,
3781 effect_param: 0xFF,
3782 },
3783 )]);
3784 let mut p3 = make_player(&bytes_right);
3785 step_one_tick(&mut p3);
3786 assert_eq!(p3.channels[0].pan, 0xFF, "8FF = hard RIGHT");
3787 }
3788
3789 /// `E8x` is the rough nibble-pan extension:
3790 /// `Protracker-effects-MODFIL12.txt` lines 1503-1505
3791 /// "Command $E8: Set (Rough) Panning. yyyy = panning value.
3792 /// $0 = most left, $F = most right."
3793 /// The nibble is replicated into both halves of the byte
3794 /// (`y << 4 | y`) so E80 → 0x00, E8F → 0xFF, E87 → 0x77, E88 →
3795 /// 0x88 — matching the same endpoint mapping as 8xx and the
3796 /// monotonic "rough" 16-step ramp documented in
3797 /// `multimedia-cx-protracker.html` E8x.
3798 #[test]
3799 fn effect_e8x_sets_rough_pan_from_nibble() {
3800 let table = [
3801 (0x0u8, 0x00u8),
3802 (0x1, 0x11),
3803 (0x7, 0x77),
3804 (0x8, 0x88),
3805 (0xF, 0xFF),
3806 ];
3807 for (nibble, expected) in table {
3808 let bytes = synth_mod_with_pattern(&[(
3809 0,
3810 1,
3811 Note {
3812 period: 0,
3813 sample: 0,
3814 effect: 0xE,
3815 effect_param: 0x80 | nibble,
3816 },
3817 )]);
3818 let mut player = make_player(&bytes);
3819 step_one_tick(&mut player);
3820 assert_eq!(
3821 player.channels[1].pan, expected,
3822 "E8{:X} must set ch.pan to 0x{:02X} (nibble replicated)",
3823 nibble, expected,
3824 );
3825 }
3826 }
3827
3828 /// `pan_gains` must collapse to the pre-r75 hard-pan formula at the
3829 /// LRRL endpoints (pan = 0 or 255) for any `s` — that's the
3830 /// invariant that keeps the libmodplug calibration in
3831 /// `sample_all_channels` valid bit-for-bit on MODs that don't use
3832 /// 8xx / E8x. The centre (pan = 128) must split evenly regardless
3833 /// of `s`; that's the property that lets per-channel pan coexist
3834 /// with the global `pan_separation` narrow.
3835 #[test]
3836 fn pan_gains_matches_legacy_hard_pan_at_endpoints() {
3837 for s_int in 0..=10 {
3838 let s = s_int as f32 / 10.0;
3839 // p = 0: hard LEFT. Legacy formula for a hard-LEFT channel:
3840 // l_gain = (1 + s) / 2, r_gain = (1 - s) / 2.
3841 let (l, r) = pan_gains(0, s);
3842 let expected_l = (1.0 + s) * 0.5;
3843 let expected_r = (1.0 - s) * 0.5;
3844 assert!(
3845 (l - expected_l).abs() < 1e-6 && (r - expected_r).abs() < 1e-6,
3846 "pan=0 s={s}: got ({l},{r}), expected ({expected_l},{expected_r})",
3847 );
3848 // p = 255: hard RIGHT. Symmetric: l = (1 - s) / 2,
3849 // r = (1 + s) / 2.
3850 let (l, r) = pan_gains(255, s);
3851 assert!(
3852 (l - (1.0 - s) * 0.5).abs() < 1e-6 && (r - (1.0 + s) * 0.5).abs() < 1e-6,
3853 "pan=255 s={s}: got ({l},{r}), expected hard-RIGHT mirror"
3854 );
3855 }
3856 // Centre (p = 128 ≈ 0.5 + ε) splits roughly evenly for any s.
3857 for s_int in 0..=10 {
3858 let s = s_int as f32 / 10.0;
3859 let (l, r) = pan_gains(128, s);
3860 // Drift from exact 0.5 is tiny (128/255 ≠ 0.5 exactly).
3861 let drift = (l - r).abs();
3862 assert!(
3863 drift < 0.01,
3864 "centred pan must yield near-equal L/R for s={s}; got ({l},{r}) drift={drift}",
3865 );
3866 }
3867 }
3868
3869 /// End-to-end smoke: a synth MOD with no 8xx / E8x commands must
3870 /// render exactly the same bytes after the per-channel-pan rework
3871 /// as before (the LRRL initialisation + endpoint collapse of
3872 /// `pan_gains` guarantees this). We verify by checking that
3873 /// `render` produces non-trivial signal on BOTH stereo lanes for
3874 /// a 4-ch MOD whose only triggered channel is ch 0 (LEFT-panned)
3875 /// — the `pan_separation = 0.5` default bleeds it to the right
3876 /// at 25 % gain, which is exactly the prior behaviour.
3877 #[test]
3878 fn render_with_no_pan_commands_preserves_legacy_bleed() {
3879 let bytes = synth_square_mod();
3880 let mut player = make_player(&bytes);
3881 let mut buf = vec![0i16; 1024 * 2];
3882 let produced = player.render(&mut buf);
3883 assert!(produced > 0, "must render some samples");
3884 let peak_l = buf
3885 .chunks_exact(2)
3886 .map(|c| c[0].unsigned_abs() as u32)
3887 .max()
3888 .unwrap_or(0);
3889 let peak_r = buf
3890 .chunks_exact(2)
3891 .map(|c| c[1].unsigned_abs() as u32)
3892 .max()
3893 .unwrap_or(0);
3894 assert!(peak_l > 0, "LEFT lane must carry signal");
3895 assert!(
3896 peak_r > 0,
3897 "RIGHT lane must carry bleed signal (default pan_separation = 0.5)"
3898 );
3899 // Bleed ratio: hard-LEFT ch under separation 0.5 → 25 % on R,
3900 // 75 % on L. Tolerate ramp + filter / interpolation slack.
3901 let ratio = peak_r as f32 / peak_l as f32;
3902 assert!(
3903 (0.25..=0.4).contains(&ratio),
3904 "RIGHT-to-LEFT bleed ratio for hard-LEFT ch at s=0.5 must be ~0.33; got {ratio}"
3905 );
3906 }
3907}