feat(audio): capture transactional fixed-rate stereo samples
This commit is contained in:
parent
f496f12ea3
commit
902c892d3f
11 changed files with 382 additions and 15 deletions
|
|
@ -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;
|
||||
|
|
|
|||
103
crates/gba-core/src/audio/capture.rs
Normal file
103
crates/gba-core/src/audio/capture.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
69
crates/gba-core/src/memory/audio_capture_tests.rs
Normal file
69
crates/gba-core/src/memory/audio_capture_tests.rs
Normal 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);
|
||||
}
|
||||
113
crates/gba-core/tests/audio_capture.rs
Normal file
113
crates/gba-core/tests/audio_capture.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
//! Original fixed-rate sample probes; no host device or external recordings.
|
||||
use gba_core::{
|
||||
audio::{StereoLevel, AUDIO_QUEUE_CAPACITY, AUDIO_SAMPLE_RATE},
|
||||
io::{FIFO_A, HALTCNT, SOUNDBIAS, SOUNDCNT_H, SOUNDCNT_X, TIMER_BASE},
|
||||
memory::Memory,
|
||||
};
|
||||
fn bus() -> Memory {
|
||||
let mut bus = Memory::new(vec![]).unwrap();
|
||||
bus.write16(SOUNDCNT_X, 0x80).unwrap();
|
||||
bus.write16(SOUNDBIAS, 0x200).unwrap();
|
||||
bus.write16(SOUNDCNT_H, 0x0304).unwrap();
|
||||
bus
|
||||
}
|
||||
fn drain(bus: &mut Memory) -> Vec<StereoLevel> {
|
||||
let mut output = vec![StereoLevel::default(); AUDIO_QUEUE_CAPACITY];
|
||||
let count = bus.drain_audio_samples(&mut output);
|
||||
output.truncate(count);
|
||||
output
|
||||
}
|
||||
#[test]
|
||||
fn exact_rate_phase_toggle_and_drain_order() {
|
||||
assert_eq!(AUDIO_SAMPLE_RATE * 512, 16777216);
|
||||
let mut bus = bus();
|
||||
bus.advance_cycles(100);
|
||||
assert!(drain(&mut bus).is_empty());
|
||||
bus.set_audio_capture(true);
|
||||
bus.advance_cycles(511);
|
||||
bus.set_audio_capture(true); // Idempotent; no phase reset.
|
||||
assert!(drain(&mut bus).is_empty());
|
||||
bus.advance_cycles(1);
|
||||
bus.write16(SOUNDBIAS, 0x202).unwrap();
|
||||
bus.advance_cycles(512);
|
||||
let mut one = [StereoLevel::default(); 1];
|
||||
assert_eq!(bus.drain_audio_samples(&mut one), 1);
|
||||
assert_eq!(one[0].left, 0);
|
||||
assert_eq!(bus.drain_audio_samples(&mut one), 1);
|
||||
assert_eq!(one[0].left, 2);
|
||||
assert_eq!(bus.drain_audio_samples(&mut one), 0);
|
||||
bus.advance_cycles(511);
|
||||
bus.set_audio_capture(false);
|
||||
bus.set_audio_capture(true);
|
||||
bus.advance_cycles(1);
|
||||
assert!(drain(&mut bus).is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn timer_overflow_at_sample_boundary_precedes_sample_and_later_stores() {
|
||||
let mut bus = bus();
|
||||
bus.write32(FIFO_A, 0x04030201).unwrap();
|
||||
bus.write32(TIMER_BASE, 0x0080fe00).unwrap(); // 512-clock timer.
|
||||
bus.set_audio_capture(true);
|
||||
bus.advance_cycles(4 * 512);
|
||||
assert_eq!(
|
||||
drain(&mut bus).iter().map(|s| s.left).collect::<Vec<_>>(),
|
||||
[4, 8, 12, 16]
|
||||
);
|
||||
bus.advance_cycles(512);
|
||||
assert_eq!(drain(&mut bus)[0].left, 0);
|
||||
}
|
||||
#[test]
|
||||
fn psg_sample_clock_crosses_many_oscillator_edges_and_respects_halt_stop() {
|
||||
let mut bus = bus();
|
||||
bus.write16(SOUNDCNT_H, 2).unwrap();
|
||||
bus.write16(0x04000080, 0x1177).unwrap();
|
||||
bus.write16(0x04000062, 0xf080).unwrap();
|
||||
bus.write16(0x04000064, 0x87e0).unwrap(); // One duty step every 512 cycles.
|
||||
bus.set_audio_capture(true);
|
||||
bus.write8(HALTCNT, 0).unwrap();
|
||||
bus.advance_cycles(8 * 512);
|
||||
assert_eq!(
|
||||
drain(&mut bus).iter().map(|s| s.left).collect::<Vec<_>>(),
|
||||
[-120, -120, -120, -120, 120, 120, 120, 120]
|
||||
);
|
||||
bus.write8(HALTCNT, 0x80).unwrap();
|
||||
bus.advance_cycles(8192);
|
||||
assert!(drain(&mut bus).is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn bulk_split_capture_and_no_capture_keep_hardware_state_and_samples_equivalent() {
|
||||
let mut a = bus();
|
||||
let mut b = bus();
|
||||
let mut c = bus();
|
||||
for bus in [&mut a, &mut b, &mut c] {
|
||||
bus.write32(FIFO_A, 0x807f01ff).unwrap();
|
||||
bus.write32(TIMER_BASE, 0x0080ff9c).unwrap();
|
||||
}
|
||||
a.set_audio_capture(true);
|
||||
b.set_audio_capture(true);
|
||||
b.set_scanline_rendering(true);
|
||||
a.advance_cycles(100003);
|
||||
c.advance_cycles(100003);
|
||||
for _ in 0..100003 {
|
||||
b.advance_cycles(1);
|
||||
}
|
||||
assert_eq!(drain(&mut a), drain(&mut b));
|
||||
assert_eq!(a.audio_level(), c.audio_level());
|
||||
assert_eq!(a.read32(TIMER_BASE), c.read32(TIMER_BASE));
|
||||
assert_eq!(a.audio_dropped_samples(), 0);
|
||||
}
|
||||
#[test]
|
||||
fn bounded_overflow_counts_drops_and_keeps_earliest_frames_without_clock_stall() {
|
||||
let mut bus = bus();
|
||||
bus.set_audio_capture(true);
|
||||
bus.advance_cycles(u32::MAX);
|
||||
assert_eq!(
|
||||
bus.audio_dropped_samples(),
|
||||
u64::from(u32::MAX) / 512 - AUDIO_QUEUE_CAPACITY as u64
|
||||
);
|
||||
assert_eq!(drain(&mut bus).len(), AUDIO_QUEUE_CAPACITY);
|
||||
bus.advance_cycles(1);
|
||||
assert_eq!(drain(&mut bus).len(), 1); // Overflow did not lose the phase.
|
||||
bus.set_audio_capture(false);
|
||||
assert_eq!(bus.audio_dropped_samples(), 0);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue