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}