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.
|
//! 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 modulation;
|
||||||
mod noise;
|
mod noise;
|
||||||
mod pulse;
|
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) struct Io {
|
||||||
pub(crate) serial: Serial,
|
pub(crate) serial: Serial,
|
||||||
pub(crate) audio: Audio,
|
pub(crate) audio: Audio,
|
||||||
|
pub(crate) audio_capture: crate::audio::capture::AudioCapture,
|
||||||
timers: [Timer; 4],
|
timers: [Timer; 4],
|
||||||
// Low ten system-clock bits cover every supported divider. STOP freezes this phase.
|
// Low ten system-clock bits cover every supported divider. STOP freezes this phase.
|
||||||
timer_phase: u16,
|
timer_phase: u16,
|
||||||
|
|
@ -430,6 +431,7 @@ impl Io {
|
||||||
|
|
||||||
pub(crate) fn commit_timer_step(&mut self, step: TimerStep) {
|
pub(crate) fn commit_timer_step(&mut self, step: TimerStep) {
|
||||||
self.audio = step.audio;
|
self.audio = step.audio;
|
||||||
|
self.audio_capture.commit(step.sample_clock, step.samples);
|
||||||
self.serial = step.serial;
|
self.serial = step.serial;
|
||||||
self.dma.trigger_sound(self.audio.take_requests());
|
self.dma.trigger_sound(self.audio.take_requests());
|
||||||
self.timers = step.timers;
|
self.timers = step.timers;
|
||||||
|
|
@ -493,12 +495,20 @@ impl Io {
|
||||||
self.pending |= self.display.advance(cycles);
|
self.pending |= self.display.advance(cycles);
|
||||||
if advance_timers {
|
if advance_timers {
|
||||||
self.pending |= self.serial.advance(cycles);
|
self.pending |= self.serial.advance(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(
|
self.pending |= Self::advance_timer_bank(
|
||||||
&mut self.timers,
|
&mut self.timers,
|
||||||
&mut self.timer_phase,
|
&mut self.timer_phase,
|
||||||
&mut self.audio,
|
&mut self.audio,
|
||||||
cycles,
|
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.dma.trigger_sound(self.audio.take_requests());
|
||||||
}
|
}
|
||||||
self.wake_if_requested();
|
self.wake_if_requested();
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ pub(super) const STAGED_IRQ_MASK: u16 = 0xf8;
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) struct TimerStep {
|
pub(crate) struct TimerStep {
|
||||||
pub(crate) audio: crate::audio::Audio,
|
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(crate) serial: super::Serial,
|
||||||
pub(super) timers: [Timer; 4],
|
pub(super) timers: [Timer; 4],
|
||||||
pub(super) phase: u16,
|
pub(super) phase: u16,
|
||||||
|
|
@ -18,6 +20,8 @@ impl TimerStep {
|
||||||
pub(super) fn new(io: &Io) -> Self {
|
pub(super) fn new(io: &Io) -> Self {
|
||||||
Self {
|
Self {
|
||||||
audio: io.audio,
|
audio: io.audio,
|
||||||
|
sample_clock: io.audio_capture.clock,
|
||||||
|
samples: Default::default(),
|
||||||
serial: io.serial,
|
serial: io.serial,
|
||||||
timers: io.timers,
|
timers: io.timers,
|
||||||
phase: io.timer_phase,
|
phase: io.timer_phase,
|
||||||
|
|
@ -29,12 +33,23 @@ impl TimerStep {
|
||||||
pub(crate) fn advance_to(&mut self, elapsed: u32) {
|
pub(crate) fn advance_to(&mut self, elapsed: u32) {
|
||||||
assert!(elapsed >= self.elapsed, "timer step time must be monotonic");
|
assert!(elapsed >= self.elapsed, "timer step time must be monotonic");
|
||||||
self.pending |= self.serial.advance(elapsed - self.elapsed);
|
self.pending |= self.serial.advance(elapsed - self.elapsed);
|
||||||
|
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(
|
self.pending |= Io::advance_timer_bank(
|
||||||
&mut self.timers,
|
&mut self.timers,
|
||||||
&mut self.phase,
|
&mut self.phase,
|
||||||
&mut self.audio,
|
&mut self.audio,
|
||||||
elapsed - self.elapsed,
|
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;
|
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
|
/// 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 {
|
pub struct Machine {
|
||||||
cpu: Cpu,
|
cpu: Cpu,
|
||||||
memory: Memory,
|
memory: Memory,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
use std::{cell::Cell, error::Error, fmt};
|
use std::{cell::Cell, error::Error, fmt};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod audio_capture_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod audio_step_tests;
|
mod audio_step_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -279,6 +281,28 @@ impl Memory {
|
||||||
self.io.audio.level()
|
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 {
|
fn serial_state(&self) -> Serial {
|
||||||
self.timer_step
|
self.timer_step
|
||||||
.get()
|
.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);
|
||||||
|
}
|
||||||
|
|
@ -65,6 +65,9 @@ The repository is a Cargo workspace with three crates:
|
||||||
| `crates/gba-core/src/cpu/fetch_boundary_tests.rs` | Actual fetch-region snapshots, split refills, IWRAM lane requirements, ROM-window boundaries, and nominal timing |
|
| `crates/gba-core/src/cpu/fetch_boundary_tests.rs` | Actual fetch-region snapshots, split refills, IWRAM lane requirements, ROM-window boundaries, and nominal timing |
|
||||||
| `crates/gba-core/src/io.rs` | I/O registers, timers, HALT/STOP wake-up, next-event bounds, and IRQ latches |
|
| `crates/gba-core/src/io.rs` | I/O registers, timers, HALT/STOP wake-up, next-event bounds, and IRQ latches |
|
||||||
| `crates/gba-core/src/audio.rs` | Direct Sound state, PSG sequencer, wave banks, and instantaneous stereo mixing |
|
| `crates/gba-core/src/audio.rs` | Direct Sound state, PSG sequencer, wave banks, and instantaneous stereo mixing |
|
||||||
|
| `crates/gba-core/src/audio/capture.rs` | Optional fixed-rate sample clock, bounded committed queue, transaction batches, and drop accounting |
|
||||||
|
| `crates/gba-core/tests/audio_capture.rs` | Sample rate/order, timer and pulse samples, HALT/STOP, batching, and bounded overflow |
|
||||||
|
| `crates/gba-core/src/memory/audio_capture_tests.rs` | Sample/store bus ordering, speculative capture, failed-step rollback, and preflight isolation |
|
||||||
| `crates/gba-core/src/audio/pulse.rs` | Shared pulse oscillator; sweep is mapped only for channel 1 |
|
| `crates/gba-core/src/audio/pulse.rs` | Shared pulse oscillator; sweep is mapped only for channel 1 |
|
||||||
| `crates/gba-core/src/audio/modulation.rs` | Shared PSG length, envelope, logical DAC gate, and activity state |
|
| `crates/gba-core/src/audio/modulation.rs` | Shared PSG length, envelope, logical DAC gate, and activity state |
|
||||||
| `crates/gba-core/src/audio/wave.rs` | Single-bank wave samples, rotating RAM, length/gate state, and fractional gain |
|
| `crates/gba-core/src/audio/wave.rs` | Single-bank wave samples, rotating RAM, length/gate state, and fractional gain |
|
||||||
|
|
|
||||||
|
|
@ -226,8 +226,8 @@ This centered mixer follows GBATEK's signed range and the NanoBoyAdvance compari
|
||||||
The mixer adds all supported channels and the ten-bit bias, clips each side to `0..1023`, and subtracts 512.
|
The mixer adds all supported channels and the ten-bit bias, clips each side to `0..1023`, and subtracts 512.
|
||||||
`StereoLevel` therefore contains signed levels in `-512..511`. Master disable returns zero.
|
`StereoLevel` therefore contains signed levels in `-512..511`. Master disable returns zero.
|
||||||
Bias resolution bits retain their values but do not yet drive pulse-width modulation (PWM) sampling or quantization.
|
Bias resolution bits retain their values but do not yet drive pulse-width modulation (PWM) sampling or quantization.
|
||||||
There is no resampling, analog filtering, sample queue, or host output backend.
|
The optional capture stream below samples this mixer. Hardware PWM sampling and analog filtering remain unimplemented.
|
||||||
Polling once per video frame will not reconstruct audio.
|
Polling `audio_level()` once per video frame will not reconstruct audio.
|
||||||
|
|
||||||
Timer and sound state share the staged CPU/DMA clock.
|
Timer and sound state share the staged CPU/DMA clock.
|
||||||
Reads and writes observe their nominal bus-completion phase, after earlier timer overflows.
|
Reads and writes observe their nominal bus-completion phase, after earlier timer overflows.
|
||||||
|
|
@ -235,6 +235,33 @@ Successful steps commit once. Failed steps discard FIFO consumption, held sample
|
||||||
Block-store validation simulates sound enable/disable in a temporary state before committing any RAM or I/O writes.
|
Block-store validation simulates sound enable/disable in a temporary state before committing any RAM or I/O writes.
|
||||||
Other devices retain their existing scheduling limits.
|
Other devices retain their existing scheduling limits.
|
||||||
|
|
||||||
|
## Fixed-rate digital capture
|
||||||
|
|
||||||
|
`Memory::set_audio_capture(true)` enables optional stereo capture at `AUDIO_SAMPLE_RATE` (32,768 Hz).
|
||||||
|
The first sample occurs after 512 system clocks. Later samples use the same interval, independent of video frames.
|
||||||
|
This is a nominal digital stream, not the hardware PWM/resolution model selected by SOUNDBIAS.
|
||||||
|
|
||||||
|
Each sample observes PSG advancement and timer-driven Direct Sound consumption through its clock boundary.
|
||||||
|
When a CPU write completes on that same boundary, sampling precedes the write under the existing bus-phase ordering.
|
||||||
|
No host time, window code, device dependency, resampling, or file operation enters the core.
|
||||||
|
Master sound disable produces silence while capture continues. HALT continues capture; STOP freezes its phase and produces no frames.
|
||||||
|
|
||||||
|
`drain_audio_samples` copies committed stereo frames, oldest first, into a caller-provided slice.
|
||||||
|
Values use the same signed ten-bit scale as `StereoLevel`, not normalized host PCM.
|
||||||
|
CPU/DMA steps stage samples alongside timer/audio state. A failed step discards its samples and sample-clock changes.
|
||||||
|
Block-store preflight never publishes samples. Row capture does not clock sound a second time.
|
||||||
|
CPU-only stepping retains its existing no-device-clock behavior.
|
||||||
|
|
||||||
|
The committed queue holds at most `AUDIO_QUEUE_CAPACITY` (4,096) stereo frames.
|
||||||
|
A transaction stages at most eight frames. Ordinary instruction/DMA timing fits within this budget.
|
||||||
|
Full buffers discard new frames, retain older frames, and increment `audio_dropped_samples` without blocking emulation.
|
||||||
|
Large device-only clock advances bound work by available capacity, then advance remaining hardware state in bulk.
|
||||||
|
Draining often avoids loss; callers must inspect the drop counter rather than assuming lossless capture.
|
||||||
|
|
||||||
|
Capture is disabled by default. Changing enable state clears queued frames, phase, and the drop counter, but does not reset audio hardware.
|
||||||
|
Repeating the current enable state preserves capture state. Configure capture between machine steps.
|
||||||
|
Original tests cover exact sample boundaries, timer consumption, pulse phases, batching, queue overflow, HALT/STOP, and transactional rollback.
|
||||||
|
|
||||||
## Validation and remaining work
|
## Validation and remaining work
|
||||||
|
|
||||||
Original tests cover signed samples, byte order, queue capacity, partial writes, resets, underflow, stereo routing, and clipping.
|
Original tests cover signed samples, byte order, queue capacity, partial writes, resets, underflow, stereo routing, and clipping.
|
||||||
|
|
@ -251,7 +278,7 @@ CPU/DMA tests include noise register writes at bus completion and failed-step ro
|
||||||
BIOS reset tests verify that selected sound reset stops all supported PSG channels, while unselected sound state remains active.
|
BIOS reset tests verify that selected sound reset stops all supported PSG channels, while unselected sound state remains active.
|
||||||
|
|
||||||
These tests validate the documented nominal model, not hardware audio fidelity.
|
These tests validate the documented nominal model, not hardware audio fidelity.
|
||||||
Remaining audio work: two-bank wave playback, verified live bank changes, a continuous output stream, PWM/mixer sampling, and a Darwin host backend.
|
Remaining audio work: two-bank wave playback, verified live bank changes, hardware PWM sampling, filtering/resampling, and a Darwin host backend.
|
||||||
A local Emerald input probe exposed a wave trigger with the gate disabled. This is now accepted without activating playback.
|
A local Emerald input probe exposed a wave trigger with the gate disabled. This is now accepted without activating playback.
|
||||||
The same scheduled-input probe passes that point and completes its extended frame budget; audible output and gameplay remain unverified.
|
The same scheduled-input probe passes that point and completes its extended frame budget; audible output and gameplay remain unverified.
|
||||||
See [the local runtime result](../research/emerald-reset.md).
|
See [the local runtime result](../research/emerald-reset.md).
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,8 @@ The optional [cartridge GPIO/RTC subset](hardware/cartridge-gpio.md) supports ca
|
||||||
RTC persistence, interrupts, invalid-date correction, physical subsecond timing, GPIO aliases, and byte writes remain unsupported or unverified.
|
RTC persistence, interrupts, invalid-date correction, physical subsecond timing, GPIO aliases, and byte writes remain unsupported or unverified.
|
||||||
The core uses deterministic caller time. Desktop RTC runs use host UTC/elapsed time and are not fully deterministic.
|
The core uses deterministic caller time. Desktop RTC runs use host UTC/elapsed time and are not fully deterministic.
|
||||||
The [audio subset](hardware/audio.md) supports Direct Sound, both PSG pulse channels, single-bank wave channel 3, noise channel 4, and instantaneous digital mixing.
|
The [audio subset](hardware/audio.md) supports Direct Sound, both PSG pulse channels, single-bank wave channel 3, noise channel 4, and instantaneous digital mixing.
|
||||||
It does not yet provide a continuous sample stream. Two-bank wave playback, active wave-bank changes, and unverified sound-DMA pairings remain diagnostic.
|
Optional 32,768 Hz digital capture provides bounded, transactional stereo samples with explicit drop counts.
|
||||||
|
Two-bank wave playback, active wave-bank changes, and unverified sound-DMA pairings remain diagnostic.
|
||||||
PSG timing and signed mixing are nominal; hardware startup edges and analog behavior remain unverified.
|
PSG timing and signed mixing are nominal; hardware startup edges and analog behavior remain unverified.
|
||||||
The [serial subset](hardware/serial.md) supports disconnected normal 8/32-bit transfers, external-clock waiting, idle multiplayer configuration, and general-purpose pins.
|
The [serial subset](hardware/serial.md) supports disconnected normal 8/32-bit transfers, external-clock waiting, idle multiplayer configuration, and general-purpose pins.
|
||||||
Internal clocks shift high input at nominal rates, clear busy, and request completion IRQs. External requests retain busy without clock edges.
|
Internal clocks shift high input at nominal rates, clear busy, and request completion IRQs. External requests retain busy without clock edges.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue