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

psx_io/cd/
audio.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2//! CD-DA playback helpers: a start-handshake state machine and an
3//! audio-anchored clock.
4//!
5//! Both are extractions of code that at least three projects grew
6//! independently (gh-psx, the magikarp example, the engine's private
7//! `CddaPlayer`), and both encode silicon findings that are expensive to
8//! rediscover:
9//!
10//! * Starting CD-DA after the no-BIOS fast boot needs the drive PRIMED:
11//!   SetMode -> Demute -> Play, with a GetStat drain AFTER each command
12//!   (a GetStat immediately *before* a command wedges the controller
13//!   instead), a cold-drive delay before the first command, and retry
14//!   pacing between attempts. [`PlaybackStarter`] is that machine.
15//! * A song clock read from the vblank counter drifts off the audio (the
16//!   display and the CD run on different oscillators), while GetlocP alone
17//!   is jittery and coarse (75 Hz). [`PlaybackClock`] anchors on GetlocP and
18//!   interpolates with ticks, with a deadband, spike confirmation, and
19//!   monotonic output.
20//!
21//! Both are poll-driven: call them once per frame/tick with your tick
22//! counter. Neither takes an interrupt or blocks beyond the bounded
23//! command spins.
24
25use crate::cd::PlayPosition;
26use crate::periph::Cd;
27
28/// Ticks to wait before the very first command: hammering CD commands while
29/// the drive is still cold from boot can wedge it (observed on silicon).
30pub const COLD_DRIVE_DELAY_TICKS: u32 = 45;
31/// Per-command response spin budget. Generous; the drive answers far sooner.
32pub const DEFAULT_COMMAND_SPINS: u32 = crate::cd::DEFAULT_COMMAND_SPINS;
33const RETRY_AFTER_OK_TICKS: u32 = 2;
34const RETRY_AFTER_NAK_TICKS: u32 = 4;
35
36#[derive(Copy, Clone, Debug, PartialEq, Eq)]
37enum StartStep {
38    SetMode,
39    Demute,
40    Play,
41    Done,
42}
43
44/// Poll-driven CD-DA start handshake (SetMode -> Demute -> Play).
45///
46/// ```ignore
47/// let mut starter = PlaybackStarter::new();
48/// starter.begin(tick); // arms the cold-drive delay
49/// // then, once per tick:
50/// if starter.tick(tick, track) {
51///     // playback accepted this tick; e.g. clock.start(tick)
52/// }
53/// ```
54#[derive(Copy, Clone, Debug)]
55pub struct PlaybackStarter {
56    step: StartStep,
57    next_try_tick: u32,
58    spins: u32,
59    retry_ticks: u32,
60}
61
62impl PlaybackStarter {
63    /// A starter in the Done state; call [`PlaybackStarter::begin`] to arm it.
64    pub const fn new() -> Self {
65        Self {
66            step: StartStep::Done,
67            next_try_tick: 0,
68            spins: DEFAULT_COMMAND_SPINS,
69            retry_ticks: RETRY_AFTER_NAK_TICKS,
70        }
71    }
72
73    /// Override the per-command spin budget (emulators answer instantly;
74    /// silicon wants the default).
75    pub const fn with_spins(mut self, spins: u32) -> Self {
76        self.spins = spins;
77        self
78    }
79
80    /// Override the delay after a rejected command. Successful steps retain
81    /// their two-tick controller settling gap.
82    pub const fn with_retry_ticks(mut self, ticks: u32) -> Self {
83        self.retry_ticks = ticks;
84        self
85    }
86
87    /// Arm with an explicit initial delay (zero for an already warm drive).
88    pub fn begin_after(&mut self, now_tick: u32, delay: u32) {
89        self.step = StartStep::SetMode;
90        self.next_try_tick = now_tick.wrapping_add(delay);
91    }
92
93    /// Arm the handshake: first command fires [`COLD_DRIVE_DELAY_TICKS`]
94    /// after `now_tick`.
95    pub fn begin(&mut self, now_tick: u32) {
96        self.begin_after(now_tick, COLD_DRIVE_DELAY_TICKS);
97    }
98
99    /// Whether Play has been accepted (the handshake is finished).
100    pub fn has_started(&self) -> bool {
101        self.step == StartStep::Done
102    }
103
104    /// Which handshake step is pending, for on-screen diagnostics:
105    /// 0 SetMode, 1 Demute, 2 Play, 3 Done. Note that Done means Play was
106    /// ACCEPTED, not that the head has arrived; the drive may still be
107    /// seeking when this reads 3.
108    pub fn step_code(&self) -> u8 {
109        match self.step {
110            StartStep::SetMode => 0,
111            StartStep::Demute => 1,
112            StartStep::Play => 2,
113            StartStep::Done => 3,
114        }
115    }
116
117    /// Drive one attempt if due, issuing its command through `cd`. Returns
118    /// true exactly once, on the tick the Play command is accepted. Safe to
119    /// keep calling afterwards (no-op).
120    pub fn tick_on(&mut self, cd: &mut Cd, now_tick: u32, track: u8) -> bool {
121        if self.step == StartStep::Done || now_tick.wrapping_sub(self.next_try_tick) > u32::MAX / 2
122        {
123            return false;
124        }
125        self.tick_with(now_tick, track, |step, track, spins| {
126            let ok = match step {
127                StartStep::SetMode => cd.try_set_mode(psx_hw::cd::MODE_CDDA, spins).is_some(),
128                StartStep::Demute => cd.try_unmute(spins).is_some(),
129                StartStep::Play => cd.try_play_track(track, spins).is_some(),
130                StartStep::Done => unreachable!(),
131            };
132            let _ = cd.try_status(spins);
133            ok
134        })
135    }
136
137    fn tick_with(
138        &mut self,
139        now_tick: u32,
140        track: u8,
141        mut command: impl FnMut(StartStep, u8, u32) -> bool,
142    ) -> bool {
143        if self.step == StartStep::Done || now_tick.wrapping_sub(self.next_try_tick) > u32::MAX / 2
144        {
145            return false;
146        }
147        let ok = command(self.step, track, self.spins);
148        // Settle the controller AFTER each command. The no-BIOS fast boot
149        // leaves the drive touchy: without this drain between steps the next
150        // command silently NAKs. (Issued *before* a command it wedges instead,
151        // so it lives here.)
152        if ok {
153            self.step = match self.step {
154                StartStep::SetMode => StartStep::Demute,
155                StartStep::Demute => StartStep::Play,
156                StartStep::Play | StartStep::Done => StartStep::Done,
157            };
158            self.next_try_tick = now_tick.wrapping_add(RETRY_AFTER_OK_TICKS);
159            self.step == StartStep::Done
160        } else {
161            self.next_try_tick = now_tick.wrapping_add(self.retry_ticks);
162            false
163        }
164    }
165}
166
167impl Default for PlaybackStarter {
168    fn default() -> Self {
169        Self::new()
170    }
171}
172
173/// Track-end detection that survives real drive behaviour.
174///
175/// The naive check ("GetStat shows neither playing nor seeking N times in
176/// a row, so the track ended") advances spuriously on hardware: after a
177/// Stop-then-Play restart the drive reports status `0x00` for one to two
178/// seconds while the motor stops and re-spins, before the seek even
179/// engages. An emulator answers instantly and never shows that window,
180/// which is how the bug reached a burned disc.
181///
182/// This detector only ARMS once it has seen the drive actually playing the
183/// current track; call [`rearm`](Self::rearm) whenever a new Play
184/// handshake begins. Feed it every GetStat poll; it answers `true` exactly
185/// once per genuine end of track.
186#[derive(Copy, Clone, Debug)]
187pub struct EndDetector {
188    armed: bool,
189    quiet_polls: u8,
190    threshold: u8,
191}
192
193impl EndDetector {
194    /// `threshold` is how many consecutive quiet polls mean "ended".
195    pub const fn new(threshold: u8) -> Self {
196        Self {
197            armed: false,
198            quiet_polls: 0,
199            threshold,
200        }
201    }
202
203    /// A new Play handshake is starting: disarm until the drive is seen
204    /// playing again, so its stop/spin-up window cannot read as an end.
205    pub fn rearm(&mut self) {
206        self.armed = false;
207        self.quiet_polls = 0;
208    }
209
210    /// Feed one GetStat status byte (`None` for a command timeout).
211    /// Returns `true` when the current track has genuinely ended, once;
212    /// the detector disarms itself for the caller's restart.
213    pub fn poll(&mut self, status: Option<u8>) -> bool {
214        let Some(status) = status else {
215            self.quiet_polls = 0;
216            return false;
217        };
218        if status & psx_hw::cd::STAT_PLAYING != 0 {
219            self.armed = true;
220            self.quiet_polls = 0;
221            return false;
222        }
223        let quiet = status & (psx_hw::cd::STAT_PLAYING | psx_hw::cd::STAT_SEEKING) == 0;
224        if !(self.armed && quiet) {
225            self.quiet_polls = 0;
226            return false;
227        }
228        self.quiet_polls += 1;
229        if self.quiet_polls >= self.threshold {
230            self.rearm();
231            return true;
232        }
233        false
234    }
235
236    /// Whether the drive has been seen playing the current track.
237    pub fn is_armed(&self) -> bool {
238        self.armed
239    }
240
241    /// Consecutive quiet polls so far, for debug overlays.
242    pub fn quiet_polls(&self) -> u8 {
243        self.quiet_polls
244    }
245}
246
247/// Readings within this of the prediction are noise; ignore them.
248const DEADBAND_MS: i32 = 5;
249/// A single reading farther out than this is a suspected jitter spike (or a
250/// real loop/seek): hold it, accept only if the next poll confirms.
251const SPIKE_MS: i32 = 150;
252/// Poll the drive every this many ticks (~0.5 s at 60 Hz): rare enough to be
253/// cheap, often enough that interpolation drift stays under an audio frame.
254const RESYNC_TICKS: u32 = 30;
255
256/// Audio-anchored song clock: GetlocP anchor + tick interpolation.
257///
258/// Feed it your per-display-frame tick counter (the same one you pass to
259/// [`PlaybackStarter`]); construct with that counter's rate (60 for NTSC, 50 for
260/// PAL). Output is milliseconds into the playing track, smooth every tick,
261/// monotonic (never steps backwards), and immune to single-sample GetlocP
262/// jitter while still following a genuine seek or track loop.
263pub struct PlaybackClock {
264    ticks_hz: u32,
265    anchor_ms: u32,
266    anchor_tick: u32,
267    last_now_ms: u32,
268    last_poll_tick: u32,
269    pending_spike_ms: Option<u32>,
270    playing: bool,
271}
272
273impl PlaybackClock {
274    /// A stopped clock counting `ticks_hz` ticks per second (60 NTSC, 50 PAL).
275    pub const fn new(ticks_hz: u32) -> Self {
276        Self {
277            ticks_hz,
278            anchor_ms: 0,
279            anchor_tick: 0,
280            last_now_ms: 0,
281            last_poll_tick: 0,
282            pending_spike_ms: None,
283            playing: false,
284        }
285    }
286
287    /// Call once CD-DA playback has actually started (e.g. on the tick
288    /// [`PlaybackStarter::tick`] returns true).
289    pub fn start(&mut self, now_tick: u32) {
290        self.anchor_ms = 0;
291        self.anchor_tick = now_tick;
292        self.last_now_ms = 0;
293        self.last_poll_tick = now_tick;
294        self.pending_spike_ms = None;
295        self.playing = true;
296    }
297
298    /// Whether [`PlaybackClock::start`] has been called.
299    pub fn is_playing(&self) -> bool {
300        self.playing
301    }
302
303    /// Advance and return the current song time in ms. Polls the drive through
304    /// `cd` at most once per [`RESYNC_TICKS`]; interpolates otherwise.
305    pub fn tick_on(&mut self, cd: &mut Cd, now_tick: u32) -> u32 {
306        if !self.playing {
307            return 0;
308        }
309        if now_tick.wrapping_sub(self.last_poll_tick) >= RESYNC_TICKS {
310            self.last_poll_tick = now_tick;
311            if let Some(drive_ms) = poll_drive_ms(cd) {
312                self.consider_anchor(now_tick, drive_ms);
313            }
314        }
315        let now = self.predict(now_tick);
316        if now > self.last_now_ms {
317            self.last_now_ms = now;
318        }
319        self.last_now_ms
320    }
321
322    fn predict(&self, now_tick: u32) -> u32 {
323        let elapsed = now_tick.wrapping_sub(self.anchor_tick);
324        self.anchor_ms + elapsed * 1000 / self.ticks_hz
325    }
326
327    /// Decide whether a fresh drive reading should move the anchor. Public
328    /// within the crate so the policy is host-testable without a drive.
329    fn consider_anchor(&mut self, now_tick: u32, drive_ms: u32) {
330        let predicted = self.predict(now_tick);
331        let delta = drive_ms as i32 - predicted as i32;
332        let mag = delta.abs();
333
334        if mag <= DEADBAND_MS {
335            self.pending_spike_ms = None; // in sync; noise, ignore
336            return;
337        }
338        if mag > SPIKE_MS {
339            // Suspected jitter spike (or a loop/seek). Accept only on a
340            // second consecutive reading near the same spot.
341            match self.pending_spike_ms {
342                Some(prev) if (drive_ms as i32 - prev as i32).abs() <= SPIKE_MS => {
343                    self.set_anchor(now_tick, drive_ms);
344                    // A confirmed backwards jump (track loop) must also reset
345                    // the monotonic floor or the clock would freeze at the
346                    // old maximum.
347                    self.last_now_ms = drive_ms;
348                    self.pending_spike_ms = None;
349                }
350                _ => self.pending_spike_ms = Some(drive_ms),
351            }
352            return;
353        }
354        // Genuine drift: re-anchor. Forward-only so a slightly-early reading
355        // cannot rewind notes.
356        self.pending_spike_ms = None;
357        if drive_ms >= self.last_now_ms {
358            self.set_anchor(now_tick, drive_ms);
359        }
360    }
361
362    fn set_anchor(&mut self, now_tick: u32, drive_ms: u32) {
363        self.anchor_ms = drive_ms;
364        self.anchor_tick = now_tick;
365    }
366}
367
368/// Poll GetlocP and convert the track-relative MSF to milliseconds. `None`
369/// when the drive is not ready (short response / no disc).
370fn poll_drive_ms(cd: &mut Cd) -> Option<u32> {
371    let resp = cd.try_play_position(16_384)?;
372    PlayPosition::parse(&resp).map(|p| p.relative_millis())
373}
374
375impl PlaybackStarter {
376    /// [`tick_on`](Self::tick_on) on a token the caller does not hold.
377    #[deprecated(note = "use `tick_on` with the `Cd` token")]
378    pub fn tick(&mut self, now_tick: u32, track: u8) -> bool {
379        self.tick_on(&mut steal(), now_tick, track)
380    }
381
382    /// Renamed to [`Self::has_started`].
383    #[deprecated(note = "renamed to `has_started`")]
384    pub fn started(&self) -> bool {
385        self.has_started()
386    }
387}
388
389impl EndDetector {}
390
391impl PlaybackClock {}
392
393/// A token for a deprecated forwarder that never took one.
394fn steal() -> Cd {
395    // SAFETY: a token is a logic guard, not a memory-safety one (see
396    // `crate::periph`), and the old methods never took one.
397    unsafe { Cd::steal() }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn configurable_start_and_retry_keep_success_gap_and_wrap() {
406        let mut starter = PlaybackStarter::new()
407            .with_spins(16384)
408            .with_retry_ticks(60);
409        let start = u32::MAX - 10;
410        starter.begin_after(start, 30);
411        let due = start.wrapping_add(30);
412        assert!(!starter.tick_with(due - 1, 2, |_, _, _| panic!("early command")));
413        assert!(!starter.tick_with(due, 2, |step, track, spins| {
414            assert_eq!(step, StartStep::SetMode);
415            assert_eq!((track, spins), (2, 16384));
416            false
417        }));
418        assert!(!starter.tick_with(due + 59, 2, |_, _, _| panic!("early retry")));
419        for (t, step, result) in [
420            (due + 60, StartStep::SetMode, false),
421            (due + 62, StartStep::Demute, false),
422            (due + 64, StartStep::Play, true),
423        ] {
424            assert_eq!(
425                starter.tick_with(t, 2, |s, _, _| {
426                    assert_eq!(s, step);
427                    true
428                }),
429                result
430            );
431        }
432        assert!(!starter.tick_with(due + 66, 2, |_, _, _| panic!("repeated Play")));
433        starter.begin_after(100, 0);
434        assert!(!starter.tick_with(100, 2, |s, _, _| {
435            assert_eq!(s, StartStep::SetMode);
436            true
437        }));
438    }
439
440    fn started() -> PlaybackClock {
441        let mut c = PlaybackClock::new(60);
442        c.start(0);
443        c
444    }
445
446    #[test]
447    fn interpolates_between_anchors() {
448        let mut c = started();
449        c.set_anchor(0, 1000);
450        assert_eq!(c.predict(30), 1500); // 30 ticks at 60 Hz = 500 ms
451        assert_eq!(c.predict(60), 2000);
452    }
453
454    #[test]
455    fn deadband_readings_are_ignored() {
456        let mut c = started();
457        c.set_anchor(0, 1000);
458        c.consider_anchor(30, 1503); // predicted 1500, within 5 ms
459        assert_eq!(c.anchor_ms, 1000); // unmoved
460    }
461
462    #[test]
463    fn moderate_drift_reanchors_forward_only() {
464        let mut c = started();
465        c.set_anchor(0, 1000);
466        c.last_now_ms = 1500;
467        c.consider_anchor(30, 1540); // +40 ms: genuine drift
468        assert_eq!((c.anchor_ms, c.anchor_tick), (1540, 30));
469        // A drift reading BELOW the emitted floor must not rewind.
470        c.last_now_ms = 1600;
471        c.consider_anchor(33, 1560);
472        assert_eq!(c.anchor_ms, 1540); // rejected
473    }
474
475    #[test]
476    fn spike_needs_confirmation() {
477        let mut c = started();
478        c.set_anchor(0, 1000);
479        c.consider_anchor(30, 9000); // wild jump: held
480        assert_eq!(c.anchor_ms, 1000);
481        c.consider_anchor(60, 9010); // confirmed near the same spot
482        assert_eq!(c.anchor_ms, 9010);
483    }
484
485    #[test]
486    fn confirmed_loop_resets_the_monotonic_floor() {
487        let mut c = started();
488        c.set_anchor(0, 60_000);
489        c.last_now_ms = 60_000;
490        c.consider_anchor(30, 100); // track looped: held
491        c.consider_anchor(60, 120); // confirmed
492        assert_eq!(c.anchor_ms, 120);
493        assert_eq!(c.last_now_ms, 120); // floor reset, clock free to move
494    }
495
496    #[test]
497    fn stopped_clock_reads_zero() {
498        let mut c = PlaybackClock::new(60);
499        // A stopped clock never touches the drive, so the host token is safe to
500        // pass.
501        assert_eq!(c.tick_on(&mut steal(), 100), 0);
502    }
503
504    /// The exact sequence the demo-disc debug burn photographed: after a
505    /// skip the drive reports 00 00 (motor transition), then seeks, then
506    /// plays. An unarmed detector must sit through all of it.
507    #[test]
508    fn spin_up_window_is_not_an_end_of_track() {
509        let mut d = EndDetector::new(3);
510        d.rearm();
511        for s in [0x00, 0x00, 0x00, 0x00, 0x42, 0x42, 0x82] {
512            assert!(!d.poll(Some(s)), "advanced on status {s:#04x}");
513        }
514        assert!(d.is_armed());
515    }
516
517    #[test]
518    fn a_real_end_fires_once_after_the_threshold() {
519        let mut d = EndDetector::new(3);
520        d.rearm();
521        assert!(!d.poll(Some(0x82)));
522        assert!(!d.poll(Some(0x02)));
523        assert!(!d.poll(Some(0x02)));
524        assert!(d.poll(Some(0x02)), "third quiet poll after playing");
525        // Disarmed for the restart: more quiet polls stay silent.
526        assert!(!d.poll(Some(0x02)));
527        assert!(!d.poll(Some(0x02)));
528        assert!(!d.poll(Some(0x02)));
529    }
530
531    #[test]
532    fn timeouts_and_seeks_reset_the_quiet_streak() {
533        let mut d = EndDetector::new(2);
534        d.rearm();
535        assert!(!d.poll(Some(0x82)));
536        assert!(!d.poll(Some(0x02)));
537        assert!(!d.poll(None), "timeout must not count as quiet");
538        assert!(!d.poll(Some(0x02)));
539        assert!(!d.poll(Some(0x42)), "a seek is activity, not quiet");
540        assert!(!d.poll(Some(0x02)));
541        assert!(d.poll(Some(0x02)));
542    }
543}