Skip to main content
← PSoXide SDK guides and examples · SDK f2fec1c301f1 · All Cargo features enabled; check feature gates before use.

psx_sfx/
lib.rs

1//! Sample playback and shared voice management on the SPU.
2//!
3//! Four programs on the PSoXide demo disc grew their own copy of this: the
4//! launcher, VoXide, NitroXide and the Celeste collection. Three of them are
5//! not engine applications, so the helpers in `psx-engine` were out of reach
6//! and each rewrote the same three things. That mattered more than the
7//! duplication itself, because what they were rewriting is the part emulators
8//! get wrong:
9//!
10//! * A one-shot's repeat address has to be written by hand. Silicon latches it
11//!   only from a block carrying the loop-start flag, and a one-shot has none,
12//!   so it otherwise keeps whatever the previous sound left there and the
13//!   voice ends up playing an unrelated sample. [`psx_spu::Voice::
14//!   configure_sample`] does this now; going through it is the point.
15//! * A one-shot does not stop itself. END drops the voice into RELEASE at the
16//!   ADSR's release rate rather than muting it, so a slow release leaves the
17//!   voice running audibly past its own data. A fast release hides that, which
18//!   is why the bug survived so long: the Celeste collection is inaudibly
19//!   wrong rather than right. The honest fix is to silence the voice on a
20//!   clock, which is what [`Player::tick`] does.
21//! * Voices have to be shared out, or a new sound cuts off the one before it.
22//!
23//! ```ignore
24//! let mut bank = Bank::new(SpuAddr::new(0x1010));
25//! let blip = bank.upload_on(&mut spu, include_bytes!("blip.psau"));
26//! let mut player: Player<3> = Player::new([Voice::V0, Voice::V1, Voice::V2], 60);
27//!
28//! // once a frame
29//! player.tick(tick);
30//! if pressed { player.play(&OneShot::new(blip, Volume::HALF), tick); }
31//! ```
32//!
33//! Map ambience whose ADPCM carries loop flags uses [`LoopingSample`].
34
35#![no_std]
36
37use psx_asset::Audio;
38use psx_spu::{Adsr, Pitch, Spu, SpuAddr, Voice, Volume};
39
40/// Decoded samples in one ADPCM block. Fourteen data bytes, two nibbles each.
41const SAMPLES_PER_BLOCK: u32 = 28;
42
43/// Bytes in one ADPCM block: a shift/filter byte, a flags byte, and fourteen
44/// of data.
45const BLOCK_BYTES: u32 = 16;
46
47/// One ADPCM block of silence that loops onto itself, written after every
48/// uploaded sample so a voice running past its own END lands on nothing.
49///
50/// Shift/filter 0, flags `0x07` = LOOP-START | REPEAT | END, then fourteen
51/// bytes of zero nibbles. LOOP-START is the bit that matters: the hardware
52/// latches the repeat address off this block while decoding it, which does not
53/// depend on anything written before key-on.
54/// Self-looping silent ADPCM block appended after each packed one-shot.
55///
56/// Cookers that build a bank for later bulk upload should append this after
57/// every sample. This gives silicon the same loop-start marker as [`Bank`]
58/// without duplicating the hardware-specific byte sequence outside the SDK.
59pub const PARKING_TAIL: [u8; BLOCK_BYTES as usize] =
60    [0x00, 0x07, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
61
62/// Ticks of margin added past a sample's own length before its voice is
63/// silenced, so the cutoff never clips a sound that is still sounding.
64///
65/// This margin is exactly the window the browse blip was audibly wandering in
66/// before SAMPLE_TAIL existed: 0.250 s of sample against a 0.283 s cutoff left
67/// 33 ms in which the voice played the next sample in the bank. The margin is
68/// safe to keep now that the sample parks itself.
69///
70/// The length is computed from the block count, which rounds up to a whole
71/// block, and the key-on itself lands somewhere inside the current tick.
72const TAIL_TICKS: u32 = 2;
73
74/// A sample resident in SPU RAM.
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub struct Sample {
77    addr: SpuAddr,
78    rate_hz: u32,
79    frames: u32,
80}
81
82impl Sample {
83    /// Describe a sample already resident in SPU RAM, for banks uploaded by
84    /// something other than [`Bank`] -- VoXide streams its straight off the
85    /// disc into the SPU, never holding it in main RAM.
86    ///
87    /// `blocks` is the sample's ADPCM block count, or 0 when the bank format
88    /// does not record one. Length is what a cutoff is computed from, so a
89    /// sample without it gets no cutoff and falls back to the envelope: see
90    /// [`Sample::ticks_at`].
91    pub const fn resident(addr: SpuAddr, rate_hz: u32, blocks: u32) -> Self {
92        Self {
93            addr,
94            rate_hz,
95            frames: blocks,
96        }
97    }
98
99    /// Where the sample starts in SPU RAM.
100    pub const fn addr(&self) -> SpuAddr {
101        self.addr
102    }
103
104    /// The rate the sample was cooked at.
105    pub const fn sample_rate_hz(&self) -> u32 {
106        self.rate_hz
107    }
108
109    /// Decoded length in samples.
110    pub const fn len_samples(&self) -> u32 {
111        self.frames * SAMPLES_PER_BLOCK
112    }
113
114    /// How long this sample runs, in ticks of a `ticks_hz` clock, played at
115    /// its own rate, with [`TAIL_TICKS`] of margin. `None` when the block
116    /// count is unknown, which means no cutoff can be computed and the
117    /// envelope has to finish the sound instead.
118    ///
119    /// Pitch-shifted playback stretches this: see [`OneShot::with_pitch`].
120    pub const fn ticks_at(&self, ticks_hz: u32) -> Option<u32> {
121        if self.frames == 0 {
122            return None;
123        }
124        let rate = if self.rate_hz == 0 { 1 } else { self.rate_hz };
125        Some(self.len_samples() * ticks_hz / rate + TAIL_TICKS)
126    }
127}
128
129/// A sequential SPU RAM allocator: uploads cooked `.psau` samples one after
130/// another and hands back where each landed.
131///
132/// Start it at or above [`psx_spu::SILENCE_BLOCK`] plus one block. `psx-spu`
133/// keeps the first usable block of SPU RAM for the silence every finished
134/// one-shot parks on, and [`psx_spu::Spu::upload_adpcm`] rejects a bank laid over
135/// it rather than letting the collision be silent.
136pub struct Bank {
137    next: SpuAddr,
138}
139
140impl Bank {
141    /// Start a bank at `base`.
142    pub const fn new(base: SpuAddr) -> Self {
143        Self { next: base }
144    }
145
146    /// Where the next upload will land, and so the first address free after
147    /// everything uploaded so far.
148    pub const fn next_addr(&self) -> SpuAddr {
149        self.next
150    }
151
152    /// Upload one cooked `.psau` sample through `spu`.
153    ///
154    /// # Panics
155    /// If `psau` is not a valid cooked sample, matching the rest of the SDK's
156    /// asset handling: a bad `include_bytes!` is a build mistake, not a
157    /// runtime condition worth threading a Result through a sound effect for.
158    pub fn upload_on(&mut self, spu: &mut Spu, psau: &[u8]) -> Sample {
159        self.upload_with(psau, |dest, bytes| spu.upload_adpcm(dest, bytes))
160    }
161
162    /// [`upload_on`](Self::upload_on) on a token the caller does not hold.
163    ///
164    /// # Panics
165    /// As [`upload_on`](Self::upload_on).
166    #[deprecated(note = "use `Bank::upload_on` with the `Spu` driver")]
167    #[allow(deprecated)]
168    pub fn upload(&mut self, psau: &[u8]) -> Sample {
169        self.upload_with(psau, psx_spu::upload_adpcm)
170    }
171
172    fn upload_with(&mut self, psau: &[u8], mut put: impl FnMut(SpuAddr, &[u8])) -> Sample {
173        let audio = Audio::from_bytes(psau).expect("cooked psau sample");
174        let adpcm = audio.adpcm_bytes();
175        put(self.next, adpcm);
176        let sample = Sample {
177            addr: self.next,
178            rate_hz: audio.sample_rate_hz(),
179            frames: adpcm.len() as u32 / BLOCK_BYTES,
180        };
181        self.next = SpuAddr::new(self.next.byte_offset() + adpcm.len() as u32);
182        // A parking block after every sample, not just one shared block that
183        // the repeat register points at.
184        //
185        // Pointing the register at psx_spu::SILENCE_BLOCK is not enough on
186        // silicon. The 2026-08-04 launcher capture caught the browse voice
187        // running off the end of jump at 0x1010 straight into pickup_coin at
188        // 0x28B0: the stray 40 ms burst matched the coin's opening spectrum on
189        // all five peaks, 2800/2825/2850/8450/8475 Hz. SB2 had read the repeat
190        // register back as 0x0200, which is that shared block, so the register
191        // was right and the voice went past it anyway.
192        //
193        // VoXide is the counter-example that says what actually works: its
194        // cooker appends a self-looping silent block to every sample and it has
195        // never had the artefact. The difference is the loop-start bit. This
196        // block carries END, REPEAT and LOOP-START, so the hardware latches the
197        // repeat address to the block itself as it decodes it, rather than
198        // relying on a value written before key-on.
199        put(self.next, &PARKING_TAIL);
200        self.next = SpuAddr::new(self.next.byte_offset() + BLOCK_BYTES);
201        sample
202    }
203}
204
205/// A sample with the settings it should be played at.
206#[derive(Clone, Copy)]
207pub struct OneShot {
208    sample: Sample,
209    volume: Volume,
210    adsr: Adsr,
211    pitch: Option<Pitch>,
212}
213
214impl OneShot {
215    /// A one-shot at its own recorded rate, under [`Adsr::default_tone`].
216    ///
217    /// `default_tone` rather than `percussive`: percussive self-fades in about
218    /// 150 ms, which is most of a quarter-second sound. The cutoff, not the
219    /// envelope, is what stops this.
220    pub const fn new(sample: Sample, volume: Volume) -> Self {
221        Self {
222            sample,
223            volume,
224            adsr: Adsr::default_tone(),
225            pitch: None,
226        }
227    }
228
229    /// Play at a different level. For sounds whose loudness depends on what
230    /// happened -- how hard a hit landed, a volume setting -- rather than on
231    /// which sample it is.
232    pub const fn with_volume(mut self, volume: Volume) -> Self {
233        self.volume = volume;
234        self
235    }
236
237    /// Override the envelope.
238    pub const fn with_adsr(mut self, adsr: Adsr) -> Self {
239        self.adsr = adsr;
240        self
241    }
242
243    /// Play at an explicit pitch instead of the sample's own rate. `0x1000` is
244    /// unity; lower stretches the sound and raises how long it lasts, which
245    /// [`OneShot::ticks`] accounts for.
246    pub const fn with_pitch(mut self, pitch: Pitch) -> Self {
247        self.pitch = Some(pitch);
248        self
249    }
250
251    /// The sample behind this one-shot.
252    pub const fn sample(&self) -> Sample {
253        self.sample
254    }
255
256    /// How long this will sound, in ticks of a `ticks_hz` clock, with any
257    /// pitch override applied. `None` when the sample's length is unknown.
258    pub const fn ticks(&self, ticks_hz: u32) -> Option<u32> {
259        let Some(base) = self.sample.ticks_at(ticks_hz) else {
260            return None;
261        };
262        match self.pitch {
263            // Pitch is Q4.12, so unity is 0x1000: half the pitch is twice the
264            // duration. Guard the zero a caller could hand us rather than
265            // dividing by it.
266            //
267            // Clamped, because the hardware clamps. Pitch::raw takes any u16
268            // but silicon caps the register at 0x3FFF: the 2026-08-03 SB2
269            // capture played 0x3FFF and 0x5000 back at the same 15169 Hz. A
270            // cutoff timed off the requested 0x5000 would expect the sound to
271            // finish in four fifths of the time it actually takes, and clip it.
272            Some(p) => {
273                let requested = p.raw_value();
274                let cap = Pitch::MAX.raw_value();
275                let raw = if requested > cap { cap } else { requested };
276                match raw {
277                    0 => Some(base),
278                    raw => Some(base * 0x1000 / raw as u32),
279                }
280            }
281            None => Some(base),
282        }
283    }
284
285    /// Point `voice` at this one-shot without starting it.
286    ///
287    /// Separate from [`OneShot::play`] because a program that keeps one voice
288    /// per sound can set it up once at boot and then only key on. A program
289    /// sharing voices has to do both together.
290    pub fn configure(&self, voice: Voice) {
291        voice.configure_sample(
292            self.sample.addr,
293            self.sample.rate_hz,
294            self.volume,
295            self.adsr,
296        );
297        if let Some(pitch) = self.pitch {
298            voice.set_pitch(pitch);
299        }
300    }
301
302    /// Key this one-shot on `voice`.
303    ///
304    /// Volume is set on every key-on rather than once at upload, because the
305    /// cutoff silences a voice by writing volume 0 and `start` does not
306    /// restore it. Omitting this is how the demo disc's v0.11 pressing ended
307    /// up with a browse blip that played exactly once and never again.
308    pub fn play(&self, voice: Voice) {
309        self.configure(voice);
310        Voice::start(voice.mask());
311    }
312}
313
314/// A resident ADPCM sample whose cooked block flags form a hardware loop.
315///
316/// The sample must carry a LOOP-START block and an END|REPEAT block. This
317/// handle owns the common voice setup and key-on policy, while the caller may
318/// update the voice's left and right volumes each frame for spatial audio.
319#[derive(Clone, Copy)]
320pub struct LoopingSample {
321    sample: Sample,
322    volume: Volume,
323    adsr: Adsr,
324    pitch: Option<Pitch>,
325}
326
327impl LoopingSample {
328    /// A loop at its recorded rate and a sustained full-level envelope.
329    pub const fn new(sample: Sample, volume: Volume) -> Self {
330        Self {
331            sample,
332            volume,
333            adsr: Adsr::default_tone(),
334            pitch: None,
335        }
336    }
337
338    /// Change the initial equal left/right level used at key-on.
339    pub const fn with_volume(mut self, volume: Volume) -> Self {
340        self.volume = volume;
341        self
342    }
343
344    /// Override the sustained envelope.
345    pub const fn with_adsr(mut self, adsr: Adsr) -> Self {
346        self.adsr = adsr;
347        self
348    }
349
350    /// Override playback pitch. `0x1000` is unity.
351    pub const fn with_pitch(mut self, pitch: Pitch) -> Self {
352        self.pitch = Some(pitch);
353        self
354    }
355
356    /// The resident sample behind this loop.
357    pub const fn sample(&self) -> Sample {
358        self.sample
359    }
360
361    /// Configure `voice` without starting it.
362    pub fn configure(&self, voice: Voice) {
363        voice.configure_sample(
364            self.sample.addr,
365            self.sample.rate_hz,
366            self.volume,
367            self.adsr,
368        );
369        if let Some(pitch) = self.pitch {
370            voice.set_pitch(pitch);
371        }
372    }
373
374    /// Configure and start this hardware loop on `voice`.
375    pub fn play(&self, voice: Voice) {
376        self.configure(voice);
377        Voice::start(voice.mask());
378    }
379
380    /// Stop a loop and silence its voice immediately.
381    pub fn stop(voice: Voice) {
382        voice.set_volume(Volume::SILENCE, Volume::SILENCE);
383        Voice::release(voice.mask());
384    }
385}
386
387/// Has `now` reached `deadline`, on a tick counter that wraps?
388///
389/// Split out so the wrap is testable off-hardware. A plain `now >= deadline`
390/// stops working the first time the counter rolls over, which on a 60 Hz clock
391/// is a little over two years of uptime, but the comparison costs the same
392/// either way.
393const fn expired(now: u32, deadline: u32) -> bool {
394    now.wrapping_sub(deadline) < u32::MAX / 2
395}
396
397/// Plays one-shots across a fixed set of voices, silencing each when its
398/// sample has run out.
399///
400/// `N` voices are driven round-robin by [`Player::play`], or addressed
401/// directly by [`Player::play_on`] when a sound wants a voice of its own.
402pub struct Player<const N: usize> {
403    voices: [Voice; N],
404    /// Tick each voice must be silenced on, or `None` when it is idle.
405    off_at: [Option<u32>; N],
406    next: usize,
407    ticks_hz: u32,
408}
409
410impl<const N: usize> Player<N> {
411    /// Drive `voices` from a clock running at `ticks_hz` (60 for NTSC frames,
412    /// 50 for PAL).
413    pub const fn new(voices: [Voice; N], ticks_hz: u32) -> Self {
414        Self {
415            voices,
416            off_at: [None; N],
417            next: 0,
418            ticks_hz,
419        }
420    }
421
422    /// Silence any voice whose sample has ended. Call once per tick, before
423    /// anything that might start a new sound.
424    pub fn tick(&mut self, now: u32) {
425        for (slot, deadline) in self.off_at.iter_mut().enumerate() {
426            if let Some(at) = *deadline {
427                if expired(now, at) {
428                    self.voices[slot].set_volume(Volume::SILENCE, Volume::SILENCE);
429                    *deadline = None;
430                }
431            }
432        }
433    }
434
435    /// Fire `shot` on a specific voice slot.
436    ///
437    /// A shot whose sample has no recorded length gets no cutoff: the voice
438    /// runs until its envelope finishes it, which is what every one-shot did
439    /// before this crate existed.
440    pub fn play_on(&mut self, slot: usize, shot: &OneShot, now: u32) {
441        let voice = self.voices[slot];
442        shot.play(voice);
443        self.off_at[slot] = shot.ticks(self.ticks_hz).map(|t| now.wrapping_add(t));
444    }
445
446    /// Fire `shot` on the next voice in the rotation, and return which slot
447    /// took it.
448    ///
449    /// Round-robin rather than picking an idle voice: finding the idle one
450    /// costs a scan, and a rotation of a few voices means a new sound rarely
451    /// lands on one that is still sounding anyway.
452    pub fn play(&mut self, shot: &OneShot, now: u32) -> usize {
453        let slot = self.next;
454        self.next = (self.next + 1) % N;
455        self.play_on(slot, shot, now);
456        slot
457    }
458
459    /// Silence every voice now, whatever it was doing.
460    pub fn silence_all(&mut self) {
461        for (slot, voice) in self.voices.iter().enumerate() {
462            voice.set_volume(Volume::SILENCE, Volume::SILENCE);
463            self.off_at[slot] = None;
464        }
465    }
466
467    /// The voice in `slot`.
468    pub const fn voice(&self, slot: usize) -> Voice {
469        self.voices[slot]
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    fn sample(rate_hz: u32, frames: u32) -> Sample {
478        Sample {
479            addr: SpuAddr::new(0x1010),
480            rate_hz,
481            frames,
482        }
483    }
484
485    #[test]
486    fn the_parking_block_ends_repeats_and_marks_a_loop_start() {
487        // LOOP-START is the bit that separates this from psx-spu's shared
488        // silence block, which the browse voice ran straight past on silicon
489        // despite the repeat register naming it. The hardware latches the
490        // repeat address off a block carrying 0x04 as it decodes it, which
491        // owes nothing to a register written before key-on.
492        assert_eq!(PARKING_TAIL[1] & 0x01, 0x01, "END");
493        assert_eq!(PARKING_TAIL[1] & 0x02, 0x02, "REPEAT");
494        assert_eq!(PARKING_TAIL[1] & 0x04, 0x04, "LOOP-START");
495        assert_eq!(PARKING_TAIL[0], 0x00, "shift and filter zero");
496        assert!(
497            PARKING_TAIL[2..].iter().all(|&b| b == 0),
498            "decodes to silence"
499        );
500    }
501
502    #[test]
503    fn a_samples_length_follows_its_block_count_and_rate() {
504        // 126 blocks at 44100 Hz is ui_beep: 3528 samples, 0.08 s, so five
505        // ticks of a 60 Hz clock once the margin is on.
506        let s = sample(44100, 126);
507        assert_eq!(s.len_samples(), 3528);
508        assert_eq!(s.ticks_at(60), Some(3528 * 60 / 44100 + TAIL_TICKS));
509    }
510
511    #[test]
512    fn a_zero_rate_sample_does_not_divide_by_zero() {
513        assert_eq!(sample(0, 10).ticks_at(60), Some(10 * 28 * 60 + TAIL_TICKS));
514    }
515
516    #[test]
517    fn stretching_the_pitch_stretches_the_cutoff() {
518        let s = sample(44100, 394); // jump, 0.25 s
519        let plain = OneShot::new(s, Volume::MAX);
520        let half = plain.with_pitch(Pitch::raw(0x0800));
521        // Half the pitch is twice the duration, so the cutoff has to wait
522        // twice as long or it clips the sound it is meant to be following.
523        assert_eq!(half.ticks(60).unwrap(), plain.ticks(60).unwrap() * 2);
524    }
525
526    #[test]
527    fn a_pitch_above_the_hardware_ceiling_is_timed_at_the_ceiling() {
528        // SB2 played 0x3FFF and 0x5000 back at the same 15169 Hz on silicon.
529        // Timing the cutoff off 0x5000 would cut the sound at four fifths.
530        let s = sample(44100, 394);
531        let asked = OneShot::new(s, Volume::MAX).with_pitch(Pitch::raw(0x5000));
532        let capped = OneShot::new(s, Volume::MAX).with_pitch(Pitch::MAX);
533        assert_eq!(asked.ticks(60), capped.ticks(60));
534    }
535
536    #[test]
537    fn a_zero_pitch_falls_back_rather_than_dividing_by_zero() {
538        let s = sample(44100, 394);
539        let shot = OneShot::new(s, Volume::MAX).with_pitch(Pitch::raw(0));
540        assert_eq!(shot.ticks(60), s.ticks_at(60));
541    }
542
543    #[test]
544    fn a_sample_of_unknown_length_gets_no_cutoff() {
545        // VoXide's cooked bank records an offset and a rate but no block
546        // count, so there is nothing to compute a deadline from. Better to
547        // say so than to invent a length and clip the sound.
548        let s = Sample::resident(SpuAddr::new(0x1010), 22050, 0);
549        assert_eq!(s.ticks_at(60), None);
550        assert_eq!(OneShot::new(s, Volume::MAX).ticks(60), None);
551    }
552
553    #[test]
554    fn a_loop_keeps_its_resident_sample() {
555        let resident = sample(11025, 73);
556        assert_eq!(
557            LoopingSample::new(resident, Volume::HALF).sample(),
558            resident
559        );
560    }
561
562    #[test]
563    fn the_cutoff_survives_the_tick_counter_wrapping() {
564        assert!(!expired(10, 20));
565        assert!(expired(20, 20));
566        assert!(expired(21, 20));
567        // Deadline set just before the counter rolls over, checked just
568        // after: the naive `now >= deadline` says "not yet" here and leaves
569        // the voice running for the rest of time.
570        assert!(!expired(u32::MAX - 5, u32::MAX - 2));
571        assert!(expired(2, u32::MAX - 2));
572    }
573}