feat(audio): capture transactional fixed-rate stereo samples

This commit is contained in:
0xrsydn 2026-10-07 07:16:42 +07:00
commit 902c892d3f
11 changed files with 382 additions and 15 deletions

View file

@ -1,5 +1,7 @@
//! Nominal Direct Sound and PSG channels, including single-bank wave playback. No host output.
pub(crate) mod capture;
pub use capture::{AUDIO_QUEUE_CAPACITY, AUDIO_SAMPLE_RATE};
mod modulation;
mod noise;
mod pulse;

View file

@ -0,0 +1,103 @@
//! Optional fixed-rate digital capture, separate from hardware channel state.
use super::StereoLevel;
use std::collections::VecDeque;
/// Nominal stereo capture rate. This is not the GBA PWM/resolution model.
pub const AUDIO_SAMPLE_RATE: u32 = 32768;
/// Maximum committed stereo frames retained before the caller drains them.
pub const AUDIO_QUEUE_CAPACITY: usize = 4096;
const PERIOD: u32 = 512;
pub(crate) const STEP_SAMPLES: usize = 8;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SampleClock(Option<u16>);
impl SampleClock {
/// Advance devices at every retained sample boundary. Once capacity is
/// exhausted, advance the remainder in bulk and count discarded samples.
pub(crate) fn advance(
&mut self,
mut cycles: u32,
mut capacity: usize,
mut advance: impl FnMut(u32, bool),
) -> u64 {
let Some(mut phase) = self.0 else {
advance(cycles, false);
return 0;
};
while cycles != 0 && capacity != 0 {
let count = cycles.min(PERIOD - u32::from(phase));
phase += count as u16;
cycles -= count;
let sample = u32::from(phase) == PERIOD;
if sample {
phase = 0;
capacity -= 1;
}
advance(count, sample);
}
let discarded = (u64::from(phase) + u64::from(cycles)) / u64::from(PERIOD);
if cycles != 0 {
advance(cycles, false);
}
self.0 = Some(((u64::from(phase) + u64::from(cycles)) % u64::from(PERIOD)) as u16);
discarded
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SampleBatch {
pub(crate) samples: [StereoLevel; STEP_SAMPLES],
pub(crate) len: usize,
pub(crate) dropped: u64,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct AudioCapture {
pub(crate) clock: SampleClock,
queue: VecDeque<StereoLevel>,
pub(crate) dropped: u64,
}
impl AudioCapture {
pub(crate) fn set_enabled(&mut self, enabled: bool) {
if enabled == self.clock.0.is_some() {
return;
}
self.clock = SampleClock(enabled.then_some(0));
self.queue = if enabled {
VecDeque::with_capacity(AUDIO_QUEUE_CAPACITY)
} else {
VecDeque::new()
};
self.dropped = 0;
}
pub(crate) fn capacity(&self) -> usize {
AUDIO_QUEUE_CAPACITY - self.queue.len()
}
pub(crate) fn push(&mut self, sample: StereoLevel) {
if self.queue.len() < AUDIO_QUEUE_CAPACITY {
self.queue.push_back(sample);
} else {
self.dropped = self.dropped.saturating_add(1);
}
}
pub(crate) fn commit(&mut self, clock: SampleClock, batch: SampleBatch) {
self.clock = clock;
self.dropped = self.dropped.saturating_add(batch.dropped);
for sample in &batch.samples[..batch.len] {
self.push(*sample);
}
}
pub(crate) fn drain(&mut self, output: &mut [StereoLevel]) -> usize {
let count = output.len().min(self.queue.len());
for slot in &mut output[..count] {
*slot = self.queue.pop_front().unwrap();
}
count
}
}

View file

@ -161,6 +161,7 @@ enum PowerState {
pub(crate) struct Io {
pub(crate) serial: Serial,
pub(crate) audio: Audio,
pub(crate) audio_capture: crate::audio::capture::AudioCapture,
timers: [Timer; 4],
// Low ten system-clock bits cover every supported divider. STOP freezes this phase.
timer_phase: u16,
@ -430,6 +431,7 @@ impl Io {
pub(crate) fn commit_timer_step(&mut self, step: TimerStep) {
self.audio = step.audio;
self.audio_capture.commit(step.sample_clock, step.samples);
self.serial = step.serial;
self.dma.trigger_sound(self.audio.take_requests());
self.timers = step.timers;
@ -493,12 +495,20 @@ impl Io {
self.pending |= self.display.advance(cycles);
if advance_timers {
self.pending |= self.serial.advance(cycles);
self.pending |= Self::advance_timer_bank(
&mut self.timers,
&mut self.timer_phase,
&mut self.audio,
cycles,
);
let mut clock = self.audio_capture.clock;
let dropped = clock.advance(cycles, self.audio_capture.capacity(), |count, sample| {
self.pending |= Self::advance_timer_bank(
&mut self.timers,
&mut self.timer_phase,
&mut self.audio,
count,
);
if sample {
self.audio_capture.push(self.audio.level());
}
});
self.audio_capture.clock = clock;
self.audio_capture.dropped = self.audio_capture.dropped.saturating_add(dropped);
self.dma.trigger_sound(self.audio.take_requests());
}
self.wake_if_requested();

View file

@ -7,6 +7,8 @@ pub(super) const STAGED_IRQ_MASK: u16 = 0xf8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct TimerStep {
pub(crate) audio: crate::audio::Audio,
pub(crate) sample_clock: crate::audio::capture::SampleClock,
pub(crate) samples: crate::audio::capture::SampleBatch,
pub(crate) serial: super::Serial,
pub(super) timers: [Timer; 4],
pub(super) phase: u16,
@ -18,6 +20,8 @@ impl TimerStep {
pub(super) fn new(io: &Io) -> Self {
Self {
audio: io.audio,
sample_clock: io.audio_capture.clock,
samples: Default::default(),
serial: io.serial,
timers: io.timers,
phase: io.timer_phase,
@ -29,12 +33,23 @@ impl TimerStep {
pub(crate) fn advance_to(&mut self, elapsed: u32) {
assert!(elapsed >= self.elapsed, "timer step time must be monotonic");
self.pending |= self.serial.advance(elapsed - self.elapsed);
self.pending |= Io::advance_timer_bank(
&mut self.timers,
&mut self.phase,
&mut self.audio,
let dropped = self.sample_clock.advance(
elapsed - self.elapsed,
crate::audio::capture::STEP_SAMPLES - self.samples.len,
|count, sample| {
self.pending |= Io::advance_timer_bank(
&mut self.timers,
&mut self.phase,
&mut self.audio,
count,
);
if sample {
self.samples.samples[self.samples.len] = self.audio.level();
self.samples.len += 1;
}
},
);
self.samples.dropped = self.samples.dropped.saturating_add(dropped);
self.elapsed = elapsed;
}

View file

@ -77,7 +77,7 @@ impl Error for FrameRunError {
}
/// Owns the CPU and memory bus. Use this instead of Cpu::step to advance device
/// clocks, execute DMA, honor HALT/STOP, and deliver interrupts. No audio yet.
/// clocks, execute DMA, honor HALT/STOP, and deliver interrupts. Host audio is external.
pub struct Machine {
cpu: Cpu,
memory: Memory,

View file

@ -1,5 +1,7 @@
use std::{cell::Cell, error::Error, fmt};
#[cfg(test)]
mod audio_capture_tests;
#[cfg(test)]
mod audio_step_tests;
#[cfg(test)]
@ -279,6 +281,28 @@ impl Memory {
self.io.audio.level()
}
/// Enable optional 32768 Hz stereo capture. A toggle clears queued frames,
/// resets its synthetic phase/drop counter, and does not reset sound hardware.
/// Call between machine steps. Repeating the current setting preserves state.
pub fn set_audio_capture(&mut self, enabled: bool) {
assert!(
self.timer_step.get().is_none(),
"audio capture configuration during a step"
);
self.io.audio_capture.set_enabled(enabled);
}
/// Drain committed frames, oldest first. Failed steps never publish samples.
/// Each sample is a signed ten-bit digital level, not normalized host PCM.
pub fn drain_audio_samples(&mut self, output: &mut [StereoLevel]) -> usize {
self.io.audio_capture.drain(output)
}
/// Frames discarded because the bounded capture buffers were full.
pub fn audio_dropped_samples(&self) -> u64 {
self.io.audio_capture.dropped
}
fn serial_state(&self) -> Serial {
self.timer_step
.get()

View file

@ -0,0 +1,69 @@
use super::{timing_event_tests::prepared, *};
use crate::{
audio::StereoLevel,
io::{SOUNDBIAS, SOUNDCNT_X},
machine::Machine,
};
#[test]
fn samples_before_store_completion_keep_old_level_and_publish_only_once() {
let (cpu, mut memory) = prepared(false, &[0xe5810000], &[(0, 0x202), (1, SOUNDBIAS)]);
memory.write16(SOUNDCNT_X, 0x80).unwrap();
memory.write16(SOUNDBIAS, 0x200).unwrap();
memory.set_audio_capture(true);
memory.advance_cycles(505);
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap(); // Sample at cycle 512, then bias write at same bus phase.
let mut output = [StereoLevel::default(); 4];
assert_eq!(machine.memory_mut().drain_audio_samples(&mut output), 1);
assert_eq!(output[0].left, 0);
assert_eq!(machine.memory().audio_level().left, 2);
machine.memory_mut().advance_cycles(512);
assert_eq!(machine.memory_mut().drain_audio_samples(&mut output), 1);
assert_eq!(output[0].left, 2);
}
#[test]
fn failed_cpu_and_dma_steps_discard_speculative_samples_and_phase() {
for dma in [false, true] {
let (cpu, mut memory) = prepared(false, &[0xe8910005], &[(1, SOUNDBIAS)]); // LDM crosses unmapped 8c.
memory.set_audio_capture(true);
memory.advance_cycles(508);
if dma {
memory.write32(DMA_BASE, 0x02000000).unwrap();
memory.write32(DMA_BASE + 4, 0x0400008c).unwrap();
memory.write32(DMA_BASE + 8, 0x84000001).unwrap();
}
let before = memory.io.audio_capture.clone();
let mut machine = Machine::new(cpu, memory);
for _ in 0..2 {
assert!(machine.step().is_err());
assert_eq!(machine.memory().io.audio_capture, before);
assert_eq!(machine.cycles(), 508);
}
machine.memory_mut().advance_cycles(4);
assert_eq!(
machine
.memory_mut()
.drain_audio_samples(&mut [StereoLevel::default(); 2]),
1
);
}
}
#[test]
fn preflight_cannot_publish_samples_or_advance_the_sample_clock() {
let (_, mut memory) = prepared(false, &[0xe1a00000], &[]);
memory.set_audio_capture(true);
memory.advance_cycles(511);
let before = memory.io.audio_capture.clone();
assert!(memory
.write_words(&[(SOUNDBIAS, 0x202), (0x0400008c, 0)])
.is_err());
assert_eq!(memory.io.audio_capture, before);
memory.begin_timer_step();
memory.advance_timer_step(512);
assert_eq!(memory.io.audio_capture, before);
memory.discard_timer_step();
assert_eq!(memory.io.audio_capture, before);
}