Skip to main content

pixel8_runtime/
audio.rs

1//! Audio runtime: a 4-channel chip-tune synthesizer.
2//!
3//! The synth core is pure (samples in, samples out) so it can be tested
4//! headless; `AudioOutput` hooks it to a real device via cpal when the
5//! `audio` feature is enabled and an output device exists. On machines
6//! with no audio device Pixel8 stays silent but fully functional.
7
8use crate::assets::{MusicPattern, Sfx, SfxEffect, Waveform, CHANNELS, SFX_LEN};
9use std::sync::{Arc, Mutex};
10
11/// PICO-8 synthesizes at this fixed internal rate; the synth core runs here
12/// and `next_sample` resamples up to the device rate.
13const INTERNAL_RATE: f32 = 22050.0;
14
15/// PICO-8: one speed-unit tick is 183 samples at the internal rate.
16const SAMPLES_PER_TICK: f32 = 183.0;
17
18/// Anti-click: a voice's amplitude ramps toward its target volume at a
19/// fixed rate (full 0..1 scale in this many seconds), and starts from zero
20/// on onset, matching PICO-8's smooth note-change/onset transitions.
21const ANTICLICK_RAMP_SECONDS: f32 = 0.0025;
22
23/// PICO-8's noise low-pass scale (= internal rate / frequency of key 63);
24/// the noise cutoff tracks the note frequency through this. (zepto8.)
25const NOISE_CUTOFF_SCALE: f32 = 8.858923;
26
27/// The noise voice plays below its PICO-8 nominal amplitude: at Pixel8's
28/// output level the broadband noise (and its resampling images) would
29/// otherwise read as crackle, so it is held down to sit smoothly in the mix.
30const NOISE_GAIN: f32 = 0.3;
31
32fn pitch_to_freq(pitch: f32) -> f32 {
33    // Pitch 33 = A-4 = 440 Hz, 12 steps per octave.
34    440.0 * ((pitch - 33.0) / 12.0).exp2()
35}
36
37/// True when the SFX loops (a real loop range, not a LEN marker).
38fn sfx_loops(sfx: &Sfx) -> bool {
39    sfx.loop_end > sfx.loop_start
40}
41
42/// Steps the SFX occupies for music timing: its loop end when looping, its
43/// LEN marker (`loop_start` with no loop end), otherwise the full 32.
44fn sfx_steps(sfx: &Sfx) -> usize {
45    if sfx.loop_end > sfx.loop_start {
46        sfx.loop_end as usize
47    } else if sfx.loop_start > 0 {
48        sfx.loop_start as usize
49    } else {
50        SFX_LEN
51    }
52}
53
54/// One play-through of the SFX in seconds, used to time music patterns.
55fn sfx_duration(sfx: &Sfx) -> f32 {
56    sfx_steps(sfx) as f32 * sfx.speed.max(1) as f32 * SAMPLES_PER_TICK / INTERNAL_RATE
57}
58
59/// One sample of a deterministic (non-noise) waveform, matching PICO-8's exact
60/// shapes and per-waveform amplitudes. `t` is the phase in `[0, 1)`; `buzz`
61/// selects the buzz-filter variant; `t_phaser` is the phase of the phaser's
62/// slightly-detuned second oscillator (ignored by the other waveforms).
63fn tonal_wave(wave: Waveform, t: f32, buzz: bool, t_phaser: f32) -> f32 {
64    match wave {
65        Waveform::Triangle => {
66            let mut ret = 1.0 - (4.0 * t - 2.0).abs();
67            if buzz {
68                let a = 0.875;
69                let bret = if t < a {
70                    2.0 * t / a - 1.0
71                } else {
72                    2.0 * (1.0 - t) / (1.0 - a) - 1.0
73                };
74                ret = ret * 0.75 + bret * 0.25;
75            }
76            ret * 0.5
77        }
78        Waveform::TiltedSaw => {
79            let a = if buzz { 0.975 } else { 0.875 };
80            let ret = if t < a {
81                2.0 * t / a - 1.0
82            } else {
83                2.0 * (1.0 - t) / (1.0 - a) - 1.0
84            };
85            ret * 0.5
86        }
87        Waveform::Saw => {
88            // PICO-8's buzz adds a tiny per-period DC offset that needs
89            // cross-period state; we keep its 0.83 scale and omit that offset.
90            let base = if t < 0.5 { t } else { t - 1.0 };
91            let ret = if buzz { base * 0.83 } else { base };
92            0.653 * ret
93        }
94        Waveform::Square => {
95            if t < if buzz { 0.4 } else { 0.5 } {
96                0.25
97            } else {
98                -0.25
99            }
100        }
101        Waveform::Pulse => {
102            if t < if buzz { 0.255 } else { 0.316 } {
103                0.25
104            } else {
105                -0.25
106            }
107        }
108        Waveform::Organ => {
109            let mut ret = if t < 0.5 {
110                3.0 - (24.0 * t - 6.0).abs()
111            } else {
112                1.0 - (16.0 * t - 12.0).abs()
113            };
114            if buzz {
115                ret = if t < 0.5 { ret * 2.0 + 3.0 } else { ret };
116                ret = if t < 0.5 && ret > -1.875 {
117                    ret * 0.2 - 1.0
118                } else {
119                    ret + 0.5
120                };
121            }
122            ret / 9.0
123        }
124        Waveform::Phaser => {
125            let mut ret = 2.0 - (8.0 * t - 4.0).abs();
126            ret += 1.0 - (4.0 * t_phaser - 2.0).abs();
127            if buzz {
128                ret += 0.25 - ((2.0 * t + 0.5).fract() - 0.5).abs();
129                ret += 0.125 - (0.5 * (4.0 * t).fract() - 0.25).abs();
130            }
131            ret / 6.0
132        }
133        // Noise is stateful; handled directly in `Voice::sample`.
134        Waveform::Noise => 0.0,
135    }
136}
137
138/// One sample of a drawn waveform-instrument table at `phase` in `[0, 1)`,
139/// linearly interpolated. Samples are signed (`-16..=15`); normalized to
140/// roughly `[-1, 1)`.
141fn drawn_wave(w: &crate::assets::CustomWave, phase: f32) -> f32 {
142    let n = w.samples.len();
143    let fpos = phase * n as f32;
144    let i0 = (fpos as usize) % n;
145    let i1 = (i0 + 1) % n;
146    let frac = fpos - fpos.floor();
147    let a = w.samples[i0] as f32 / 16.0;
148    let b = w.samples[i1] as f32 / 16.0;
149    a + (b - a) * frac
150}
151
152/// One playing voice on a channel.
153struct Voice {
154    sfx_index: usize,
155    sfx: Sfx,
156    /// Current step in `0..SFX_LEN`.
157    step: usize,
158    /// Seconds elapsed within the current step.
159    t_in_step: f32,
160    /// Current, slewed output amplitude; ramps toward the note's target
161    /// volume to avoid clicks at onsets and note changes (anti-click).
162    amp: f32,
163    /// Oscillator phase in `[0, 1)`.
164    phase: f32,
165    /// Phase of the detuned second oscillator (`detune` filter).
166    phase2: f32,
167    /// Phase of the phaser waveform's slightly-detuned (109/110) oscillator.
168    phase_b: f32,
169    /// Pitch of the previous step, for slides.
170    prev_pitch: f32,
171    /// True when this voice was started by the music sequencer.
172    from_music: bool,
173    /// Noise generator state.
174    noise: u32,
175    noise_level: f32,
176    /// One-pole low-pass state (`dampen` filter).
177    lp: f32,
178    /// Echo delay ring buffer and write cursor (`reverb` filter); empty when
179    /// reverb is off.
180    echo: Vec<f32>,
181    echo_pos: usize,
182}
183
184impl Voice {
185    fn new(sfx_index: usize, sfx: Sfx, from_music: bool) -> Self {
186        let first_pitch = sfx.notes[0].pitch as f32;
187        // Reverb delays by 2 or 4 ticks; size the ring buffer to suit. The
188        // delay is in internal-sample units (independent of the device rate).
189        let echo_ticks = match sfx.reverb {
190            1 => 2.0,
191            2 => 4.0,
192            _ => 0.0,
193        };
194        let echo_len = (echo_ticks * SAMPLES_PER_TICK).round() as usize;
195        Self {
196            sfx_index,
197            sfx,
198            step: 0,
199            t_in_step: 0.0,
200            // Start silent so the first note ramps up from zero (anti-click).
201            amp: 0.0,
202            phase: 0.0,
203            phase2: 0.0,
204            phase_b: 0.0,
205            prev_pitch: first_pitch,
206            from_music,
207            noise: 0x1234_5678,
208            noise_level: 0.0,
209            lp: 0.0,
210            echo: vec![0.0; echo_len],
211            echo_pos: 0,
212        }
213    }
214
215    fn step_duration(&self) -> f32 {
216        self.sfx.speed.max(1) as f32 * SAMPLES_PER_TICK / INTERNAL_RATE
217    }
218
219    /// Render one sample; returns `None` when the voice has finished.
220    ///
221    /// `inst_waves` carries the timbre of each of the eight SFX slots usable
222    /// as custom instruments (its note-0 waveform), so a note flagged as a
223    /// custom instrument plays through that waveform at its own pitch.
224    /// `inst_drawn` carries those slots' drawn waveform tables, when any; a
225    /// custom-instrument note whose slot has one plays it instead of a built-in.
226    fn sample(
227        &mut self,
228        dt: f32,
229        total_t: f32,
230        inst_waves: &[u8; 8],
231        inst_drawn: &[Option<crate::assets::CustomWave>; 8],
232    ) -> Option<f32> {
233        if self.step >= SFX_LEN {
234            return None;
235        }
236        let note = self.sfx.notes[self.step];
237        let frac = self.t_in_step / self.step_duration();
238
239        // Resolve effect-modified pitch and volume.
240        let base_pitch = note.pitch as f32;
241        let mut pitch = base_pitch;
242        let mut vol = note.volume as f32 / 7.0;
243        match SfxEffect::from_u8(note.effect) {
244            SfxEffect::None => {}
245            SfxEffect::Slide => pitch = self.prev_pitch + (base_pitch - self.prev_pitch) * frac,
246            SfxEffect::Vibrato => {
247                pitch += 0.25 * (total_t * 2.0 * std::f32::consts::PI * 8.0).sin()
248            }
249            SfxEffect::Drop => pitch = base_pitch * (1.0 - frac),
250            SfxEffect::FadeIn => vol *= frac,
251            SfxEffect::FadeOut => vol *= 1.0 - frac,
252            SfxEffect::ArpFast | SfxEffect::ArpSlow => {
253                let rate = if note.effect == 6 { 32.0 } else { 16.0 };
254                let group = self.step / 4 * 4;
255                let idx = (total_t * rate) as usize % 4;
256                pitch = self.sfx.notes[(group + idx).min(SFX_LEN - 1)].pitch as f32;
257            }
258        }
259
260        // A custom-instrument note borrows the timbre of another SFX: its
261        // drawn waveform table when it has one, else that slot's note-0 built-in
262        // waveform. A plain note names a built-in waveform directly.
263        let drawn = note.instrument().and_then(|slot| inst_drawn[slot as usize]);
264        let bass = drawn.is_some_and(|w| w.bass);
265        let freq = pitch_to_freq(pitch) * if bass { 0.5 } else { 1.0 };
266        let wave = match note.instrument() {
267            Some(slot) => Waveform::from_u8(inst_waves[slot as usize]),
268            None => Waveform::from_u8(note.wave),
269        };
270
271        // Advance oscillator.
272        self.phase = (self.phase + freq * dt).fract();
273        // The phaser's second oscillator runs slightly detuned (109/110).
274        self.phase_b = (self.phase_b + freq * (109.0 / 110.0) * dt).fract();
275        let raw = if let Some(w) = &drawn {
276            drawn_wave(w, self.phase) * 0.5
277        } else if wave == Waveform::Noise {
278            // PICO-8's noise is a one-pole low-pass of white noise whose cutoff
279            // tracks the note frequency (a leaky integrator), so it stays smooth
280            // instead of the hard sample-and-hold steps that crackle. (zepto8.)
281            self.noise = self.noise.wrapping_mul(1664525).wrapping_add(1013904223);
282            let white = (self.noise >> 16) as f32 / 32768.0 - 1.0;
283            let scale = freq * dt * NOISE_CUTOFF_SCALE;
284            self.noise_level = (self.noise_level + scale * white) / (1.0 + scale);
285            let factor = 1.0 - pitch / 63.0;
286            let mut n = self.noise_level * 1.5 * (1.0 + factor * factor) * NOISE_GAIN;
287            if self.sfx.noiz {
288                // `noiz` brightens the noise: amplitude-modulate by a triangle of
289                // the phase.
290                n *= 2.0
291                    * if self.phase < 0.5 {
292                        self.phase
293                    } else {
294                        self.phase - 1.0
295                    };
296            }
297            n
298        } else {
299            let mut s = tonal_wave(wave, self.phase, self.sfx.buzz, self.phase_b);
300            // `detune` mixes in a second oscillator a little (or an octave)
301            // off the first.
302            if self.sfx.detune > 0 {
303                let ratio = if self.sfx.detune == 1 { 1.0073 } else { 2.0 };
304                self.phase2 = (self.phase2 + freq * ratio * dt).fract();
305                s = (s + tonal_wave(wave, self.phase2, self.sfx.buzz, self.phase_b)) * 0.5;
306            }
307            s
308        };
309
310        // Anti-click: ramp the amplitude toward the target instead of jumping,
311        // so note onsets and volume changes between steps don't click.
312        let max_step = dt / ANTICLICK_RAMP_SECONDS;
313        self.amp += (vol - self.amp).clamp(-max_step, max_step);
314
315        let mut out = raw * self.amp;
316
317        // `dampen` is a one-pole low-pass at one of two cutoffs.
318        if self.sfx.dampen > 0 {
319            let fc = if self.sfx.dampen == 1 { 2200.0 } else { 900.0 };
320            let rc = 1.0 / (2.0 * std::f32::consts::PI * fc);
321            let alpha = dt / (rc + dt);
322            self.lp += alpha * (out - self.lp);
323            out = self.lp;
324        }
325
326        // `reverb` is a feedback echo through the delay ring buffer.
327        if !self.echo.is_empty() {
328            let delayed = self.echo[self.echo_pos];
329            self.echo[self.echo_pos] = (out + delayed * 0.45).clamp(-1.0, 1.0);
330            self.echo_pos = (self.echo_pos + 1) % self.echo.len();
331            out = (out + delayed * 0.5).clamp(-1.0, 1.0);
332        }
333
334        // Advance step clock.
335        self.t_in_step += dt;
336        if self.t_in_step >= self.step_duration() {
337            self.t_in_step = 0.0;
338            self.prev_pitch = base_pitch;
339            self.step += 1;
340            let (ls, le) = (self.sfx.loop_start as usize, self.sfx.loop_end as usize);
341            if le > ls {
342                // Looping SFX wrap at the loop end — for music voices too, so
343                // a short looping part repeats to fill its pattern (the
344                // sequencer replaces the voice when the pattern advances).
345                if self.step >= le {
346                    self.step = ls;
347                }
348            } else if ls > 0 && self.step >= ls {
349                // A "LEN" marker (loop start set, no loop end) shortens the
350                // SFX to `loop_start` steps.
351                self.step = SFX_LEN;
352            }
353        }
354        // PICO-8 clamps each channel before mixing.
355        Some(out.clamp(-1.0, 1.0))
356    }
357}
358
359/// Music sequencer state.
360struct MusicState {
361    pattern: usize,
362    /// Seconds remaining in the current pattern.
363    remaining: f32,
364}
365
366/// The synthesizer: voices, sequencer and a copy of the cart's audio data.
367pub struct Synth {
368    sample_rate: f32,
369    t: f32,
370    sfx: Vec<Sfx>,
371    music: Vec<MusicPattern>,
372    voices: [Option<Voice>; CHANNELS],
373    music_state: Option<MusicState>,
374    /// Monotonic counter; each start mints the next play-token.
375    token_counter: i32,
376    /// The current song's play-token (`0` when nothing is playing).
377    current_token: i32,
378    /// Gain applied to music voices (`0.0`..=`1.0`), for fades.
379    music_gain: f32,
380    /// Where `music_gain` is heading.
381    music_gain_target: f32,
382    /// Per-sample step toward the target (`0.0` once settled).
383    music_gain_step: f32,
384    /// True while fading out: stop the music when the gain reaches zero.
385    stop_when_silent: bool,
386    /// Channels reserved for music (bit i = channel i); auto-routed sfx skip them.
387    reserved_channels: u8,
388    /// Resampler position between `prev_internal` and `cur_internal`.
389    resample_frac: f32,
390    /// Previous and current internal-rate samples bracketing the output.
391    prev_internal: f32,
392    cur_internal: f32,
393    /// Two cascaded one-pole low-pass states for reconstruction filtering.
394    lp1: f32,
395    lp2: f32,
396}
397
398impl Synth {
399    pub fn new(sample_rate: f32) -> Self {
400        Self {
401            sample_rate,
402            t: 0.0,
403            sfx: Vec::new(),
404            music: Vec::new(),
405            voices: [None, None, None, None],
406            music_state: None,
407            token_counter: 0,
408            current_token: 0,
409            music_gain: 1.0,
410            music_gain_target: 1.0,
411            music_gain_step: 0.0,
412            stop_when_silent: false,
413            reserved_channels: 0,
414            // Start at 1.0 so the first call renders an internal sample.
415            resample_frac: 1.0,
416            prev_internal: 0.0,
417            cur_internal: 0.0,
418            lp1: 0.0,
419            lp2: 0.0,
420        }
421    }
422
423    /// Replace the audio data (called when a cart starts or assets change).
424    pub fn load(&mut self, sfx: Vec<Sfx>, music: Vec<MusicPattern>) {
425        self.sfx = sfx;
426        self.music = music;
427    }
428
429    /// Stop all voices and the sequencer.
430    pub fn stop_all(&mut self) {
431        self.voices = [None, None, None, None];
432        self.music_state = None;
433        self.current_token = 0;
434        self.music_gain = 1.0;
435        self.music_gain_target = 1.0;
436        self.music_gain_step = 0.0;
437        self.stop_when_silent = false;
438        self.reserved_channels = 0;
439    }
440
441    /// Play SFX `n`. `channel < 0` picks a free channel (preferring ones not
442    /// used by music); `n < 0` with a valid channel stops that channel.
443    pub fn play_sfx(&mut self, n: i32, channel: i32) {
444        if n < 0 {
445            if (0..CHANNELS as i32).contains(&channel) {
446                self.voices[channel as usize] = None;
447            }
448            return;
449        }
450        let Some(sfx) = self.sfx.get(n as usize).cloned() else {
451            return;
452        };
453        let ch = if (0..CHANNELS as i32).contains(&channel) {
454            channel as usize
455        } else {
456            // Prefer an idle non-reserved channel, then one playing a one-shot
457            // SFX, then any non-reserved channel; steal a reserved one only when
458            // every channel is reserved.
459            let reserved = self.reserved_channels;
460            let free = |i: usize| reserved & (1 << i) == 0;
461            let idle = (0..CHANNELS).find(|&i| self.voices[i].is_none() && free(i));
462            let non_music = (0..CHANNELS)
463                .find(|&i| free(i) && self.voices[i].as_ref().is_some_and(|v| !v.from_music));
464            let any_free = (0..CHANNELS).rev().find(|&i| free(i));
465            idle.or(non_music).or(any_free).unwrap_or(CHANNELS - 1)
466        };
467        self.voices[ch] = Some(Voice::new(n as usize, sfx, false));
468    }
469
470    /// Start music at pattern `n` (mints and returns a nonzero play-token) or,
471    /// when `n < 0`, stop. A start is refused — returns `0` — while a song is
472    /// already playing and not fading out. A stop acts only when `token <= 0`
473    /// (unconditional) or `token` equals the current song's play-token.
474    /// `channel_mask` bits 0-3 mark which channels are reserved for music;
475    /// auto-routed sfx will skip those channels while music is playing.
476    pub fn play_music(&mut self, n: i32, fade_duration: i32, channel_mask: i32, token: i32) -> i32 {
477        if n < 0 {
478            let matches = token <= 0 || (self.current_token != 0 && token == self.current_token);
479            if matches {
480                self.begin_stop(fade_duration);
481            }
482            return 0;
483        }
484        // Refuse a second start only while a song is live (not already fading out).
485        if self.music_state.is_some() && !self.stop_when_silent {
486            return 0;
487        }
488        self.reserved_channels = (channel_mask & 0x0F) as u8;
489        self.start_pattern(n as usize);
490        self.setup_fade_in(fade_duration);
491        self.token_counter = self.token_counter.wrapping_add(1);
492        if self.token_counter == 0 {
493            self.token_counter = 1;
494        }
495        self.current_token = self.token_counter;
496        self.current_token
497    }
498
499    /// Arm the fade-in (or instant full volume) for a freshly started song.
500    fn setup_fade_in(&mut self, fade_duration: i32) {
501        self.stop_when_silent = false;
502        if fade_duration <= 0 {
503            self.music_gain = 1.0;
504            self.music_gain_target = 1.0;
505            self.music_gain_step = 0.0;
506        } else {
507            let fade_seconds = fade_duration as f32 / 1000.0;
508            self.music_gain = 0.0;
509            self.music_gain_target = 1.0;
510            self.music_gain_step = 1.0 / (fade_seconds * INTERNAL_RATE);
511        }
512    }
513
514    /// Stop now, or ramp to silence over `fade_duration` ms then stop.
515    fn begin_stop(&mut self, fade_duration: i32) {
516        if self.music_state.is_none() {
517            return;
518        }
519        if fade_duration <= 0 {
520            self.stop_music();
521            return;
522        }
523        let fade_seconds = fade_duration as f32 / 1000.0;
524        self.music_gain_target = 0.0;
525        self.music_gain_step = -1.0 / (fade_seconds * INTERNAL_RATE);
526        self.stop_when_silent = true;
527    }
528
529    /// Advance the music-gain envelope one sample; stop the song if a fade-out
530    /// has reached silence.
531    fn advance_music_gain(&mut self) {
532        if self.music_gain_step == 0.0 {
533            return;
534        }
535        self.music_gain += self.music_gain_step;
536        let reached = if self.music_gain_step > 0.0 {
537            self.music_gain >= self.music_gain_target
538        } else {
539            self.music_gain <= self.music_gain_target
540        };
541        if reached {
542            self.music_gain = self.music_gain_target;
543            self.music_gain_step = 0.0;
544            if self.stop_when_silent {
545                self.stop_music();
546            }
547        }
548    }
549
550    pub fn stop_music(&mut self) {
551        for v in &mut self.voices {
552            if v.as_ref().is_some_and(|v| v.from_music) {
553                *v = None;
554            }
555        }
556        self.music_state = None;
557        self.current_token = 0;
558        self.music_gain = 1.0;
559        self.music_gain_target = 1.0;
560        self.music_gain_step = 0.0;
561        self.stop_when_silent = false;
562        self.reserved_channels = 0;
563    }
564
565    /// Index of the playing music pattern, if any.
566    pub fn playing_pattern(&self) -> Option<usize> {
567        self.music_state.as_ref().map(|m| m.pattern)
568    }
569
570    fn start_pattern(&mut self, n: usize) {
571        let Some(pat) = self.music.get(n).copied() else {
572            self.music_state = None;
573            return;
574        };
575        // PICO-8 sets a pattern's length from the left-most non-looping active
576        // channel (the "timekeeper"); if every active channel loops, fall back
577        // to the longest. SFX shortened by a LEN marker count as that length.
578        let mut timekeeper: Option<f32> = None;
579        let mut longest = 0.0f32;
580        for (ch, slot) in pat.channels.iter().enumerate() {
581            // Music takes ownership of its channels; others keep playing SFX.
582            if let Some(sfx_idx) = slot {
583                if let Some(sfx) = self.sfx.get(*sfx_idx as usize).cloned() {
584                    let dur = sfx_duration(&sfx);
585                    longest = longest.max(dur);
586                    if timekeeper.is_none() && !sfx_loops(&sfx) {
587                        timekeeper = Some(dur);
588                    }
589                    self.voices[ch] = Some(Voice::new(*sfx_idx as usize, sfx, true));
590                }
591            } else if self.voices[ch].as_ref().is_some_and(|v| v.from_music) {
592                self.voices[ch] = None;
593            }
594        }
595        let length = timekeeper.unwrap_or(longest);
596        if length == 0.0 {
597            self.music_state = None;
598            return;
599        }
600        self.music_state = Some(MusicState {
601            pattern: n,
602            remaining: length,
603        });
604    }
605
606    fn advance_music(&mut self) {
607        let Some(state) = &self.music_state else {
608            return;
609        };
610        let cur = state.pattern;
611        let pat = self.music.get(cur).copied().unwrap_or_default();
612        if pat.stop_at_end {
613            self.stop_music();
614            return;
615        }
616        if pat.loop_back {
617            // Jump back to the nearest loop_start at or before this pattern.
618            let target = (0..=cur)
619                .rev()
620                .find(|&i| self.music.get(i).is_some_and(|p| p.loop_start))
621                .unwrap_or(0);
622            self.start_pattern(target);
623            return;
624        }
625        let next = cur + 1;
626        if self.music.get(next).is_some_and(|p| !p.is_empty()) {
627            self.start_pattern(next);
628        } else {
629            self.stop_music();
630        }
631    }
632
633    /// Render one mono sample at the device rate.
634    ///
635    /// The synth core runs at `INTERNAL_RATE`; this resamples up to the
636    /// device rate with linear interpolation, then applies a two-pole
637    /// reconstruction low-pass to suppress interpolation imaging and match
638    /// PICO-8's clean top end. Calling it N times advances device time by
639    /// `N / sample_rate` seconds.
640    pub fn next_sample(&mut self) -> f32 {
641        // Internal samples consumed per output sample (< 1 when upsampling).
642        let ratio = INTERNAL_RATE / self.sample_rate;
643        self.resample_frac += ratio;
644        while self.resample_frac >= 1.0 {
645            self.prev_internal = self.cur_internal;
646            self.cur_internal = self.render_internal();
647            self.resample_frac -= 1.0;
648        }
649        let mut out =
650            self.prev_internal + (self.cur_internal - self.prev_internal) * self.resample_frac;
651        // Two-pole reconstruction low-pass at ~11 kHz on the device-rate
652        // stream: lp1 filters `out`, then lp2 filters lp1.
653        let fc = 11_000.0;
654        let dt_dev = 1.0 / self.sample_rate;
655        let alpha = dt_dev / (1.0 / (2.0 * std::f32::consts::PI * fc) + dt_dev);
656        self.lp1 += alpha * (out - self.lp1);
657        self.lp2 += alpha * (self.lp1 - self.lp2);
658        out = self.lp2;
659        out
660    }
661
662    /// Render one mono sample at the internal rate.
663    fn render_internal(&mut self) -> f32 {
664        let dt = 1.0 / INTERNAL_RATE;
665        self.t += dt;
666
667        if let Some(state) = &mut self.music_state {
668            state.remaining -= dt;
669            if state.remaining <= 0.0 {
670                self.advance_music();
671            }
672        }
673
674        // Timbre of the eight SFX slots usable as custom instruments: each
675        // slot's note-0 built-in waveform and its drawn waveform table (if any).
676        let mut inst_waves = [0u8; 8];
677        let mut inst_drawn: [Option<crate::assets::CustomWave>; 8] = Default::default();
678        for i in 0..8 {
679            if let Some(s) = self.sfx.get(i) {
680                inst_waves[i] = s.notes[0].wave_index();
681                inst_drawn[i] = s.custom_wave;
682            }
683        }
684
685        let mut music_mix = 0.0;
686        let mut sfx_mix = 0.0;
687        for v in &mut self.voices {
688            if let Some(voice) = v {
689                let from_music = voice.from_music;
690                match voice.sample(dt, self.t, &inst_waves, &inst_drawn) {
691                    Some(s) => {
692                        if from_music {
693                            music_mix += s;
694                        } else {
695                            sfx_mix += s;
696                        }
697                    }
698                    None => *v = None,
699                }
700            }
701        }
702        self.advance_music_gain();
703        (sfx_mix + music_mix * self.music_gain).clamp(-1.0, 1.0)
704    }
705
706    /// Which SFX index is playing on each channel (for editor UI).
707    pub fn channel_sfx(&self) -> [Option<usize>; CHANNELS] {
708        let mut out = [None; CHANNELS];
709        for (i, v) in self.voices.iter().enumerate() {
710            out[i] = v.as_ref().map(|v| v.sfx_index);
711        }
712        out
713    }
714
715    /// Which step each channel's voice is currently sounding (for editor
716    /// playheads); `None` when the channel is idle.
717    pub fn channel_step(&self) -> [Option<usize>; CHANNELS] {
718        let mut out = [None; CHANNELS];
719        for (i, v) in self.voices.iter().enumerate() {
720            out[i] = v.as_ref().map(|v| v.step);
721        }
722        out
723    }
724}
725
726/// Clonable handle the VM and editors use to poke the synth.
727#[derive(Clone)]
728pub struct AudioHandle {
729    synth: Arc<Mutex<Synth>>,
730}
731
732impl AudioHandle {
733    pub fn new(synth: Arc<Mutex<Synth>>) -> Self {
734        Self { synth }
735    }
736
737    /// A handle with no device attached — still fully functional for logic.
738    pub fn dummy() -> Self {
739        Self {
740            synth: Arc::new(Mutex::new(Synth::new(44100.0))),
741        }
742    }
743
744    pub fn with_synth<R>(&self, f: impl FnOnce(&mut Synth) -> R) -> R {
745        // Recover from a poisoned lock instead of cascading the panic:
746        // a one-off hiccup in the audio callback shouldn't permanently
747        // silence the synth or take down the next caller.
748        let mut guard = self.synth.lock().unwrap_or_else(|e| e.into_inner());
749        f(&mut guard)
750    }
751
752    pub fn play_sfx(&self, n: i32, channel: i32) {
753        self.with_synth(|s| s.play_sfx(n, channel));
754    }
755
756    /// The step each channel's voice is sounding (for editor playheads).
757    pub fn channel_step(&self) -> [Option<usize>; CHANNELS] {
758        self.with_synth(|s| s.channel_step())
759    }
760
761    pub fn play_music(&self, n: i32, fade_duration: i32, channel_mask: i32, token: i32) -> i32 {
762        self.with_synth(|s| s.play_music(n, fade_duration, channel_mask, token))
763    }
764
765    pub fn stop_all(&self) {
766        self.with_synth(|s| s.stop_all());
767    }
768
769    pub fn load(&self, sfx: Vec<Sfx>, music: Vec<MusicPattern>) {
770        self.with_synth(|s| s.load(sfx, music));
771    }
772}
773
774/// Real audio output via cpal. Owns the stream; dropping it stops audio.
775#[cfg(feature = "audio")]
776pub struct AudioOutput {
777    _stream: cpal::Stream,
778    handle: AudioHandle,
779}
780
781#[cfg(feature = "audio")]
782impl AudioOutput {
783    /// Try to open the default output device. Returns `None` (silently)
784    /// when no device is available, e.g. on headless machines.
785    pub fn start() -> Option<Self> {
786        use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
787        let host = cpal::default_host();
788        let device = host.default_output_device()?;
789        let config = device.default_output_config().ok()?;
790        let sample_rate = config.sample_rate() as f32;
791        let channels = config.channels() as usize;
792        let synth = Arc::new(Mutex::new(Synth::new(sample_rate)));
793        let cb_synth = synth.clone();
794        let stream = device
795            .build_output_stream(
796                config.into(),
797                move |data: &mut [f32], _| {
798                    let mut synth = cb_synth.lock().unwrap();
799                    for frame in data.chunks_mut(channels) {
800                        let s = synth.next_sample();
801                        for out in frame {
802                            *out = s;
803                        }
804                    }
805                },
806                |err| eprintln!("Pixel8 audio error: {err}"),
807                None,
808            )
809            .ok()?;
810        stream.play().ok()?;
811        Some(Self {
812            _stream: stream,
813            handle: AudioHandle::new(synth),
814        })
815    }
816
817    pub fn handle(&self) -> AudioHandle {
818        self.handle.clone()
819    }
820}
821
822#[cfg(test)]
823mod tests {
824    use super::*;
825    use crate::assets::{Note, SFX_COUNT};
826
827    fn test_sfx() -> Vec<Sfx> {
828        let mut sfx = vec![Sfx::default(); SFX_COUNT];
829        for note in sfx[0].notes.iter_mut() {
830            *note = Note {
831                pitch: 33,
832                wave: 0,
833                volume: 5,
834                effect: 0,
835            };
836        }
837        sfx
838    }
839
840    #[test]
841    fn pitch_33_is_a440() {
842        assert!((pitch_to_freq(33.0) - 440.0).abs() < 0.01);
843        assert!((pitch_to_freq(45.0) - 880.0).abs() < 0.01);
844    }
845
846    #[test]
847    fn sfx_produces_sound_then_ends() {
848        let mut synth = Synth::new(44100.0);
849        synth.load(test_sfx(), vec![MusicPattern::default(); 64]);
850        synth.play_sfx(0, 0);
851        let mut peak = 0.0f32;
852        for _ in 0..1000 {
853            peak = peak.max(synth.next_sample().abs());
854        }
855        assert!(peak > 0.01, "voice should be audible");
856        // Default speed 16 -> 32 steps * 16 * 183 / 22050 s ~= 4.25 s; play 5 s.
857        for _ in 0..(44100 * 5) {
858            synth.next_sample();
859        }
860        assert_eq!(synth.channel_sfx()[0], None, "voice should end");
861    }
862
863    #[test]
864    fn custom_instrument_borrows_its_waveform() {
865        use crate::assets::NOTE_CUSTOM_FLAG;
866        // SFX 1 is the instrument: a noise (waveform 6) tone.
867        let mut sfx = vec![Sfx::default(); SFX_COUNT];
868        for note in sfx[1].notes.iter_mut() {
869            *note = Note {
870                pitch: 33,
871                wave: 6,
872                volume: 5,
873                effect: 0,
874            };
875        }
876        // SFX 0 plays using SFX 1 as a custom instrument.
877        for note in sfx[0].notes.iter_mut() {
878            *note = Note {
879                pitch: 33,
880                wave: NOTE_CUSTOM_FLAG | 1,
881                volume: 5,
882                effect: 0,
883            };
884        }
885        let mut synth = Synth::new(44100.0);
886        synth.load(sfx, vec![MusicPattern::default(); 64]);
887        synth.play_sfx(0, 0);
888        let mut peak = 0.0f32;
889        for _ in 0..1000 {
890            peak = peak.max(synth.next_sample().abs());
891        }
892        assert!(peak > 0.01, "a custom-instrument note should be audible");
893    }
894
895    #[test]
896    fn sfx_filters_stay_audible_and_bounded() {
897        // Every filter switch on at once must still produce a clean, bounded
898        // signal (no NaNs, no runaway feedback).
899        let mut sfx = test_sfx();
900        sfx[0].noiz = true;
901        sfx[0].buzz = true;
902        sfx[0].detune = 2;
903        sfx[0].reverb = 2;
904        sfx[0].dampen = 1;
905        let mut synth = Synth::new(44100.0);
906        synth.load(sfx, vec![MusicPattern::default(); 64]);
907        synth.play_sfx(0, 0);
908        let mut peak = 0.0f32;
909        for _ in 0..44100 {
910            let s = synth.next_sample();
911            assert!(s.is_finite() && s.abs() <= 1.0, "sample out of range: {s}");
912            peak = peak.max(s.abs());
913        }
914        assert!(peak > 0.01, "filtered voice should still be audible");
915    }
916
917    #[test]
918    fn noise_is_smooth_not_crackly() {
919        // Mirror airwolf's percussion: every step a wave-6 (noise) note at a
920        // fixed pitch, full speed, with buzz on. The old hard sample-and-hold
921        // noise (resampled LFSR + tanh overdrive) slams steps into the rails,
922        // crackling; PICO-8's leaky-integrator noise stays smooth.
923        let mut sfx = vec![Sfx::default(); SFX_COUNT];
924        for note in sfx[0].notes.iter_mut() {
925            *note = Note {
926                pitch: 17,
927                wave: 6,
928                volume: 7,
929                effect: 0,
930            };
931        }
932        sfx[0].speed = 16;
933        sfx[0].buzz = true;
934        sfx[0].noiz = false;
935
936        let mut synth = Synth::new(48000.0);
937        synth.load(sfx, vec![MusicPattern::default(); 64]);
938        synth.play_sfx(0, 0);
939
940        // Render ~0.5 s; skip the first 256 samples (anti-click onset ramp).
941        let mut buf = Vec::with_capacity(24000);
942        for _ in 0..24000 {
943            buf.push(synth.next_sample());
944        }
945        let mut max_jump = 0.0f32;
946        for i in 257..buf.len() {
947            max_jump = max_jump.max((buf[i] - buf[i - 1]).abs());
948        }
949        let peak = buf[256..].iter().fold(0.0f32, |m, s| m.max(s.abs()));
950
951        // Measured max sample-to-sample jump at PICO-8 gain (volume/7, no 0.25
952        // master, so noise is ~4x louder than the old `* 0.25` staging): the
953        // leaky integrator is smooth at ~0.070, while the old hard
954        // sample-and-hold (~0.146 at the old gain) would be ~0.58 here. 0.15
955        // sits cleanly between, so this still distinguishes crackle from smooth.
956        assert!(peak > 0.01, "noise should be audible: peak {peak}");
957        assert!(
958            max_jump < 0.15,
959            "noise should be smooth, not crackly: max jump {max_jump}"
960        );
961    }
962
963    #[test]
964    fn note_transitions_do_not_click() {
965        // A hard amplitude transition (volume 7 -> 0 between steps) on a
966        // click-free triangle wave: the triangle has no in-waveform
967        // discontinuity, so any large sample-to-sample jump can only come
968        // from an un-ramped amplitude boundary (onset or note change).
969        let mut sfx = vec![Sfx::default(); SFX_COUNT];
970        sfx[0].speed = 16;
971        sfx[0].notes[0] = Note {
972            pitch: 33,
973            wave: 0,
974            volume: 7,
975            effect: 0,
976        };
977        sfx[0].notes[1] = Note {
978            pitch: 33,
979            wave: 0,
980            volume: 0,
981            effect: 0,
982        };
983        let mut synth = Synth::new(48000.0);
984        synth.load(sfx, vec![MusicPattern::default(); 64]);
985        synth.play_sfx(0, 0);
986
987        // Step length = 16 * 183 / 22050 ~= 0.133 s; render ~0.3 s so we
988        // cross both the onset and the note0 -> note1 boundary.
989        let mut buf = Vec::with_capacity(14400);
990        for _ in 0..14400 {
991            buf.push(synth.next_sample());
992        }
993        let mut max_jump = 0.0f32;
994        for i in 1..buf.len() {
995            max_jump = max_jump.max((buf[i] - buf[i - 1]).abs());
996        }
997        let peak = buf.iter().fold(0.0f32, |m, s| m.max(s.abs()));
998
999        // Measured at this device rate and at PICO-8 gain (triangle peaks ~0.5,
1000        // ~2x louder than the old `* 0.25` staging): an un-ramped amplitude
1001        // jump (onset and the note0 -> note1 boundary, smeared by the
1002        // 22050 -> 48000 resampler) would be ~0.134, while the 2.5 ms ramp
1003        // leaves max_jump ~= 0.020, dominated by the ramp's own per-sample step
1004        // near peak rather than a discontinuity. The 0.04 threshold sits
1005        // cleanly between (3x below the un-ramped, 2x above the ramped).
1006        assert!(
1007            max_jump < 0.04,
1008            "amplitude jump should be smooth: {max_jump}"
1009        );
1010        assert!(peak > 0.01, "the note should still be audible: {peak}");
1011    }
1012
1013    #[test]
1014    fn empty_sfx_slot_is_ignored() {
1015        let mut synth = Synth::new(44100.0);
1016        synth.load(test_sfx(), vec![]);
1017        synth.play_sfx(63, -1);
1018        for _ in 0..100 {
1019            assert_eq!(synth.next_sample(), 0.0);
1020        }
1021    }
1022
1023    #[test]
1024    fn music_plays_and_stops() {
1025        let mut synth = Synth::new(44100.0);
1026        let mut music = vec![MusicPattern::default(); 64];
1027        music[0].channels[0] = Some(0);
1028        music[0].stop_at_end = true;
1029        synth.load(test_sfx(), music);
1030        synth.play_music(0, 0, 0, 0);
1031        assert_eq!(synth.playing_pattern(), Some(0));
1032        for _ in 0..(44100 * 5) {
1033            synth.next_sample();
1034        }
1035        assert_eq!(synth.playing_pattern(), None);
1036    }
1037
1038    #[test]
1039    fn music_loops_back() {
1040        let mut synth = Synth::new(44100.0);
1041        let mut music = vec![MusicPattern::default(); 64];
1042        music[0].channels[0] = Some(0);
1043        music[0].loop_start = true;
1044        music[1].channels[0] = Some(0);
1045        music[1].loop_back = true;
1046        synth.load(test_sfx(), music);
1047        synth.play_music(1, 0, 0, 0);
1048        for _ in 0..(44100 * 5) {
1049            synth.next_sample();
1050        }
1051        assert_eq!(synth.playing_pattern(), Some(0), "should loop to start");
1052    }
1053
1054    #[test]
1055    fn pattern_length_follows_first_non_looping_channel() {
1056        // ch0 is the timekeeper at speed 4 (32*4*183/22050 ~= 1.062s); ch1 is
1057        // four times longer. The pattern must end with ch0, not stretch to ch1.
1058        let mut sfx = vec![Sfx::default(); SFX_COUNT];
1059        for (i, &spd) in [4u8, 16].iter().enumerate() {
1060            sfx[i].speed = spd;
1061            for n in sfx[i].notes.iter_mut() {
1062                *n = Note {
1063                    pitch: 33,
1064                    wave: 0,
1065                    volume: 5,
1066                    effect: 0,
1067                };
1068            }
1069        }
1070        let mut music = vec![MusicPattern::default(); 64];
1071        music[0].channels = [Some(0), Some(1), None, None];
1072        music[0].stop_at_end = true;
1073        let mut synth = Synth::new(44100.0);
1074        synth.load(sfx, music);
1075        synth.play_music(0, 0, 0, 0);
1076        let mut n = 0;
1077        while synth.playing_pattern().is_some() && n < 44100 * 5 {
1078            synth.next_sample();
1079            n += 1;
1080        }
1081        let secs = n as f32 / 44100.0;
1082        assert!(
1083            (secs - 1.062).abs() < 0.03,
1084            "pattern should track ch0, got {secs}s"
1085        );
1086    }
1087
1088    #[test]
1089    fn auto_channel_avoids_music() {
1090        let mut synth = Synth::new(44100.0);
1091        let mut sfx = test_sfx();
1092        sfx[1] = sfx[0].clone();
1093        let mut music = vec![MusicPattern::default(); 64];
1094        music[0].channels[0] = Some(0);
1095        synth.load(sfx, music);
1096        synth.play_music(0, 0, 0, 0);
1097        synth.play_sfx(1, -1);
1098        let chans = synth.channel_sfx();
1099        assert_eq!(chans[0], Some(0), "music keeps channel 0");
1100        assert!(chans[1..].contains(&Some(1)), "sfx lands elsewhere");
1101    }
1102
1103    #[test]
1104    fn drawn_waveform_instrument_drives_output() {
1105        use crate::assets::{CustomWave, Note, NOTE_CUSTOM_FLAG, SFX_COUNT, SFX_LEN};
1106        let mut sfx = vec![Sfx::default(); SFX_COUNT];
1107        // SFX 1 is a drawn-waveform instrument held at the maximum positive
1108        // sample: this produces a constant positive (DC) signal, which no
1109        // built-in (zero-mean) waveform could ever produce — so a nonzero
1110        // positive mean proves the drawn samples are what's being played.
1111        sfx[1].custom_wave = Some(CustomWave {
1112            samples: [15; SFX_LEN],
1113            bass: false,
1114        });
1115        for note in sfx[0].notes.iter_mut() {
1116            *note = Note {
1117                pitch: 33,
1118                wave: NOTE_CUSTOM_FLAG | 1,
1119                volume: 5,
1120                effect: 0,
1121            };
1122        }
1123        let mut synth = Synth::new(44100.0);
1124        synth.load(sfx, vec![MusicPattern::default(); 64]);
1125        synth.play_sfx(0, 0);
1126        let mut sum = 0.0f32;
1127        let n = 2000;
1128        for _ in 0..n {
1129            let s = synth.next_sample();
1130            assert!(s.is_finite() && s.abs() <= 1.0, "sample out of range: {s}");
1131            sum += s;
1132        }
1133        assert!(
1134            sum / n as f32 > 0.05,
1135            "drawn samples should drive the output"
1136        );
1137    }
1138
1139    #[test]
1140    fn channel_step_tracks_playback() {
1141        let mut synth = Synth::new(44100.0);
1142        synth.load(test_sfx(), vec![MusicPattern::default(); 64]);
1143        assert_eq!(synth.channel_step(), [None, None, None, None]);
1144        synth.play_sfx(0, 0);
1145        // After starting, channel 0 is on step 0.
1146        assert_eq!(synth.channel_step()[0], Some(0));
1147        // Default speed 16 -> 16*183/22050 ~= 0.133 s/step; advance ~0.2 s,
1148        // expect step 1.
1149        for _ in 0..(44100 / 5) {
1150            synth.next_sample();
1151        }
1152        assert_eq!(synth.channel_step()[0], Some(1));
1153    }
1154
1155    #[test]
1156    fn second_start_is_refused_while_playing() {
1157        let mut synth = Synth::new(44100.0);
1158        let mut music = vec![MusicPattern::default(); 64];
1159        music[0].channels[0] = Some(0);
1160        music[1].channels[0] = Some(0);
1161        synth.load(test_sfx(), music);
1162        let token = synth.play_music(0, 0, 0, 0);
1163        assert!(token != 0, "first start mints a nonzero token");
1164        // A second start while a song plays is refused.
1165        assert_eq!(synth.play_music(1, 0, 0, 0), 0);
1166        assert_eq!(synth.playing_pattern(), Some(0), "first song keeps playing");
1167    }
1168
1169    #[test]
1170    fn stale_token_does_not_stop_a_later_song() {
1171        let mut synth = Synth::new(44100.0);
1172        let mut music = vec![MusicPattern::default(); 64];
1173        music[0].channels[0] = Some(0);
1174        music[0].stop_at_end = true; // one-shot: ends on its own
1175        music[1].channels[0] = Some(0);
1176        synth.load(test_sfx(), music);
1177        let stale = synth.play_music(0, 0, 0, 0);
1178        for _ in 0..(44100 * 5) {
1179            synth.next_sample(); // let song 0 finish
1180        }
1181        assert_eq!(synth.playing_pattern(), None, "one-shot ended on its own");
1182        let fresh = synth.play_music(1, 0, 0, 0);
1183        assert!(fresh != 0 && fresh != stale, "new song gets a fresh token");
1184        // A stop carrying the stale token must NOT stop the new song.
1185        synth.play_music(-1, 0, 0, stale);
1186        assert_eq!(synth.playing_pattern(), Some(1), "stale token is a no-op");
1187        // The fresh token stops it.
1188        synth.play_music(-1, 0, 0, fresh);
1189        assert_eq!(synth.playing_pattern(), None);
1190    }
1191
1192    #[test]
1193    fn music_fades_in_from_silence() {
1194        let mut synth = Synth::new(44100.0);
1195        let mut music = vec![MusicPattern::default(); 64];
1196        // Loop the song so it never ends on its own during the measurement window.
1197        music[0].channels[0] = Some(0);
1198        music[0].loop_start = true;
1199        music[1].channels[0] = Some(0);
1200        music[1].loop_back = true;
1201        synth.load(test_sfx(), music);
1202        synth.play_music(0, 1000, 0, 0); // 1s fade-in
1203        assert!(
1204            synth.music_gain < 0.05,
1205            "starts near silent: {}",
1206            synth.music_gain
1207        );
1208        for _ in 0..(44100 / 2) {
1209            synth.next_sample();
1210        }
1211        assert!(
1212            synth.music_gain > 0.4 && synth.music_gain < 0.6,
1213            "~half after 0.5s: {}",
1214            synth.music_gain
1215        );
1216        for _ in 0..44100 {
1217            synth.next_sample();
1218        }
1219        assert!(
1220            (synth.music_gain - 1.0).abs() < 1e-3,
1221            "reaches full: {}",
1222            synth.music_gain
1223        );
1224    }
1225
1226    #[test]
1227    fn music_fades_out_then_stops() {
1228        let mut synth = Synth::new(44100.0);
1229        let mut music = vec![MusicPattern::default(); 64];
1230        music[0].channels[0] = Some(0);
1231        music[0].loop_start = true; // loops, so it never ends on its own
1232        music[1].channels[0] = Some(0);
1233        music[1].loop_back = true;
1234        synth.load(test_sfx(), music);
1235        let token = synth.play_music(0, 0, 0, 0);
1236        synth.play_music(-1, 1000, 0, token); // 1s fade-out
1237        assert!(synth.stop_when_silent, "fading out");
1238        assert_eq!(
1239            synth.playing_pattern(),
1240            Some(0),
1241            "still playing while fading"
1242        );
1243        for _ in 0..(44100 / 2) {
1244            synth.next_sample();
1245        }
1246        assert!(synth.playing_pattern().is_some(), "still fading at 0.5s");
1247        for _ in 0..(44100 / 2 + 200) {
1248            synth.next_sample();
1249        }
1250        assert_eq!(synth.playing_pattern(), None, "stops once silent");
1251    }
1252
1253    #[test]
1254    fn reserved_channel_is_not_auto_selected_for_sfx() {
1255        let mut synth = Synth::new(44100.0);
1256        let mut music = vec![MusicPattern::default(); 64];
1257        music[0].channels[0] = Some(0); // music plays on channel 0
1258        synth.load(test_sfx(), music);
1259        // Reserve channel 1, which is IDLE — so only the reservation (not mere
1260        // occupancy) can keep an auto-routed sfx off it. Without reservation the
1261        // router would pick idle channel 1 first.
1262        synth.play_music(0, 0, 0b0010, 0);
1263        synth.play_sfx(1, -1); // auto-routed
1264        let chans = synth.channel_sfx();
1265        assert_ne!(
1266            chans[1],
1267            Some(1),
1268            "sfx must avoid the reserved idle channel 1"
1269        );
1270        assert!(
1271            chans[2..].contains(&Some(1)),
1272            "sfx landed on a free channel"
1273        );
1274    }
1275
1276    #[test]
1277    fn explicit_channel_overrides_reservation() {
1278        let mut synth = Synth::new(44100.0);
1279        let mut music = vec![MusicPattern::default(); 64];
1280        music[0].channels[0] = Some(0);
1281        synth.load(test_sfx(), music);
1282        synth.play_music(0, 0, 0b0001, 0); // reserve channel 0
1283        synth.play_sfx(1, 0); // explicit channel 0
1284        assert_eq!(synth.channel_sfx()[0], Some(1), "explicit request wins");
1285    }
1286
1287    /// Goertzel single-bin DFT magnitude of `freq` (Hz) in `samples` at rate
1288    /// `fs`. Used to measure spectral content without a full FFT.
1289    fn goertzel(samples: &[f32], freq: f32, fs: f32) -> f32 {
1290        let omega = 2.0 * std::f32::consts::PI * freq / fs;
1291        let coeff = 2.0 * omega.cos();
1292        let mut s_prev = 0.0f32;
1293        let mut s_prev2 = 0.0f32;
1294        for &x in samples {
1295            let s = x + coeff * s_prev - s_prev2;
1296            s_prev2 = s_prev;
1297            s_prev = s;
1298        }
1299        let real = s_prev - s_prev2 * omega.cos();
1300        let imag = s_prev2 * omega.sin();
1301        (real * real + imag * imag).sqrt()
1302    }
1303
1304    #[test]
1305    fn tick_duration_matches_pico8() {
1306        // PICO-8 times a speed-unit tick as 183 samples at 22050 Hz, not
1307        // 1/128 s. A 32-note, speed-16, non-looping SFX should last exactly
1308        // 32 * 16 * 183 / 22050 seconds. This fails on the old 1/128 timing.
1309        let mut sfx = Sfx {
1310            speed: 16,
1311            ..Default::default()
1312        };
1313        for n in sfx.notes.iter_mut() {
1314            *n = Note {
1315                pitch: 33,
1316                wave: 0,
1317                volume: 5,
1318                effect: 0,
1319            };
1320        }
1321        let expected = 32.0 * 16.0 * 183.0 / 22050.0;
1322        assert!((sfx_duration(&sfx) - expected).abs() < 1e-4);
1323    }
1324
1325    #[test]
1326    fn no_aliasing_above_internal_nyquist() {
1327        // A sustained max-pitch (pitch 63) saw has its fundamental near
1328        // 2490 Hz; its harmonics 5-8 sit at ~12.4/14.9/17.4/19.9 kHz, well
1329        // above the 11025 Hz internal Nyquist. Rendered pointwise at 48 kHz
1330        // those harmonics ring loudly; synthesizing at 22050 Hz and
1331        // reconstruction-filtering on the way up must crush them.
1332        //
1333        // The high band probes those four harmonics. Threshold:
1334        // high-band/fundamental ratio < 0.30. Measured with this fixture:
1335        // the naive 48 kHz code gives ~0.70 (high=580, fund=835); after the
1336        // fix it drops to ~0.034 (high=25, fund=747). 0.30 sits cleanly
1337        // between the two — FAILS before / PASSES after (verified both ways).
1338        let mut sfx = Sfx {
1339            speed: 1,
1340            ..Default::default()
1341        };
1342        for n in sfx.notes.iter_mut() {
1343            *n = Note {
1344                pitch: 63,
1345                wave: 2,
1346                volume: 7,
1347                effect: 0,
1348            };
1349        }
1350        let mut all = vec![Sfx::default(); SFX_COUNT];
1351        all[0] = sfx;
1352        let fs = 48000.0;
1353        let mut synth = Synth::new(fs);
1354        synth.load(all, vec![MusicPattern::default(); 64]);
1355        synth.play_sfx(0, 0);
1356        let mut buf = Vec::with_capacity(24000);
1357        for i in 0..24000 {
1358            let s = synth.next_sample();
1359            if i >= 512 {
1360                buf.push(s);
1361            }
1362        }
1363        // Pitch-63 saw fundamental, and its harmonics 5-8 (above the internal
1364        // Nyquist) as the high-band probes.
1365        let fund = goertzel(&buf, 2490.0, fs);
1366        let high: f32 = [12445.0, 14934.0, 17423.0, 19912.0]
1367            .iter()
1368            .map(|&f| goertzel(&buf, f, fs))
1369            .sum();
1370        let ratio = high / fund;
1371        assert!(
1372            ratio < 0.30,
1373            "high-band/fundamental ratio {ratio} should be small (fund={fund}, high={high})"
1374        );
1375    }
1376
1377    #[test]
1378    fn start_is_allowed_while_fading_out() {
1379        let mut synth = Synth::new(44100.0);
1380        let mut music = vec![MusicPattern::default(); 64];
1381        music[0].channels[0] = Some(0);
1382        music[0].loop_start = true;
1383        music[1].channels[0] = Some(0);
1384        music[1].loop_back = true;
1385        synth.load(test_sfx(), music);
1386        let a = synth.play_music(0, 0, 0, 0);
1387        synth.play_music(-1, 1000, 0, a); // fade A out
1388        let b = synth.play_music(0, 0, 0, 0); // start during the fade
1389        assert!(b != 0 && b != a, "took over during fade-out");
1390        assert!(
1391            !synth.stop_when_silent,
1392            "the new song plays at full, not fading"
1393        );
1394    }
1395
1396    #[test]
1397    fn waveform_amplitudes_match_pico8() {
1398        // Each non-noise waveform must peak at PICO-8's per-waveform amplitude
1399        // (baked into `tonal_wave` directly), not the old uniform +-1.0.
1400        let cases = [
1401            (Waveform::Triangle, 0.5),
1402            (Waveform::TiltedSaw, 0.5),
1403            (Waveform::Saw, 0.327),
1404            (Waveform::Square, 0.25),
1405            (Waveform::Pulse, 0.25),
1406            (Waveform::Organ, 0.333),
1407        ];
1408        for (wave, expected) in cases {
1409            let mut peak = 0.0f32;
1410            for i in 0..10000 {
1411                let t = i as f32 / 10000.0;
1412                peak = peak.max(tonal_wave(wave, t, false, t).abs());
1413            }
1414            assert!(
1415                (peak - expected).abs() <= 0.02,
1416                "{wave:?} peak {peak} should match {expected}"
1417            );
1418        }
1419        // Phaser's peak depends on the two oscillators' alignment, so just
1420        // bound it rather than asserting an exact value.
1421        let mut peak = 0.0f32;
1422        for i in 0..10000 {
1423            let t = i as f32 / 10000.0;
1424            peak = peak.max(tonal_wave(Waveform::Phaser, t, false, t).abs());
1425        }
1426        assert!(
1427            (0.25..=0.85).contains(&peak),
1428            "Phaser peak {peak} should be in 0.25..=0.85"
1429        );
1430    }
1431
1432    #[test]
1433    fn buzz_changes_duty_cycle() {
1434        // Buzz narrows the square and pulse duty cycles, flipping the sign at a
1435        // phase that straddles the old vs. new duty edge.
1436        assert!(tonal_wave(Waveform::Square, 0.45, false, 0.0) > 0.0);
1437        assert!(tonal_wave(Waveform::Square, 0.45, true, 0.0) < 0.0);
1438        assert!(tonal_wave(Waveform::Pulse, 0.28, false, 0.0) > 0.0);
1439        assert!(tonal_wave(Waveform::Pulse, 0.28, true, 0.0) < 0.0);
1440    }
1441}