fix: stabilize desktop audio playback
Some checks failed
CI / rust (push) Has been cancelled

This commit is contained in:
2026-03-13 19:20:33 +03:00
parent f86e3c2284
commit d2be893cfe
12 changed files with 398 additions and 1343 deletions

View File

@@ -23,6 +23,7 @@ pub struct NativeBus {
odd_frame: bool,
in_vblank: bool,
frame_complete: bool,
cpu_cycles_since_poll: u32,
mmc3_a12_prev_high: bool,
mmc3_a12_low_dots: u16,
mmc3_last_irq_scanline: u32,
@@ -47,6 +48,7 @@ impl NativeBus {
odd_frame: false,
in_vblank: false,
frame_complete: false,
cpu_cycles_since_poll: 0,
mmc3_a12_prev_high: false,
mmc3_a12_low_dots: 8,
mmc3_last_irq_scanline: u32::MAX,
@@ -84,6 +86,12 @@ impl NativeBus {
pub fn clock_cpu(&mut self, cycles: u8) {
self.clock_cpu_cycles(cycles as u32);
}
pub fn take_cpu_cycles_since_poll(&mut self) -> u32 {
let cycles = self.cpu_cycles_since_poll;
self.cpu_cycles_since_poll = 0;
cycles
}
}
// CpuBus trait implementation (memory map + side effects).

View File

@@ -312,3 +312,23 @@ fn dmc_playback_updates_output_level_from_sample_bits() {
assert!(bus.apu.dmc_output_level < initial);
}
#[test]
fn pulse_channel_outputs_become_audible_after_setup() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4015, 0x01); // enable pulse1
bus.write(0x4000, 0b0101_1111); // 25% duty, constant volume=15
bus.write(0x4002, 0x08); // low timer period, not sweep-muted
bus.write(0x4003, 0x00); // reload length + reset duty sequencer
let mut saw_non_zero = false;
for _ in 0..64u32 {
bus.clock_cpu(1);
if bus.apu_channel_outputs().pulse1 > 0 {
saw_non_zero = true;
break;
}
}
assert!(saw_non_zero, "pulse1 never produced audible output");
}

View File

@@ -7,6 +7,7 @@ use crate::native_core::mapper::Mapper;
impl NativeBus {
fn clock_one_cpu_cycle(&mut self) {
self.cpu_cycles_since_poll = self.cpu_cycles_since_poll.saturating_add(1);
for _ in 0..3 {
self.clock_ppu_dot();
}

View File

@@ -6,6 +6,7 @@ pub struct AudioMixer {
sample_rate: u32,
samples_per_cpu_cycle: f64,
sample_accumulator: f64,
last_output_sample: f32,
}
impl AudioMixer {
@@ -15,6 +16,7 @@ impl AudioMixer {
sample_rate,
samples_per_cpu_cycle: sample_rate as f64 / cpu_hz,
sample_accumulator: 0.0,
last_output_sample: 0.0,
}
}
@@ -24,10 +26,11 @@ impl AudioMixer {
pub fn reset(&mut self) {
self.sample_accumulator = 0.0;
self.last_output_sample = 0.0;
}
pub fn push_cycles(&mut self, cpu_cycles: u8, channels: ChannelOutputs, out: &mut Vec<f32>) {
self.sample_accumulator += self.samples_per_cpu_cycle * f64::from(cpu_cycles);
pub fn push_cycles(&mut self, cpu_cycles: u32, channels: ChannelOutputs, out: &mut Vec<f32>) {
self.sample_accumulator += self.samples_per_cpu_cycle * cpu_cycles as f64;
let samples = self.sample_accumulator.floor() as usize;
self.sample_accumulator -= samples as f64;
@@ -35,10 +38,23 @@ impl AudioMixer {
let tnd_out = 0.00851 * f32::from(channels.triangle)
+ 0.00494 * f32::from(channels.noise)
+ 0.00335 * f32::from(channels.dmc);
let mixed = pulse_out + tnd_out;
let sample = mixed * 2.0 - 1.0;
let sample = pulse_out + tnd_out;
out.extend(std::iter::repeat_n(sample, samples));
if samples == 0 {
return;
}
let start = self.last_output_sample;
if samples == 1 {
out.push(sample);
} else {
let denom = samples as f32;
for idx in 0..samples {
let t = (idx + 1) as f32 / denom;
out.push(start + (sample - start) * t);
}
}
self.last_output_sample = sample;
}
}
@@ -47,14 +63,14 @@ mod tests {
use super::*;
#[test]
fn mixer_silent_channels_produce_negative_one() {
fn mixer_silent_channels_produce_zero() {
let mut mixer = AudioMixer::new(44_100, VideoMode::Ntsc);
let channels = ChannelOutputs::default();
let mut out = Vec::new();
mixer.push_cycles(50, channels, &mut out);
assert!(!out.is_empty());
for &s in &out {
assert!((s - (-1.0)).abs() < 1e-6, "expected -1.0, got {s}");
assert!(s.abs() < 1e-6, "expected 0.0, got {s}");
}
}
@@ -75,4 +91,34 @@ mod tests {
assert!(s > 0.0, "expected positive sample, got {s}");
}
}
#[test]
fn mixer_smooths_transition_between_batches() {
let mut mixer = AudioMixer::new(44_100, VideoMode::Ntsc);
let mut out = Vec::new();
mixer.push_cycles(200, ChannelOutputs::default(), &mut out);
let before = out.len();
mixer.push_cycles(
200,
ChannelOutputs {
pulse1: 15,
pulse2: 15,
triangle: 15,
noise: 15,
dmc: 127,
},
&mut out,
);
let transition = &out[before..];
assert!(transition.len() > 1);
assert!(transition[0] < *transition.last().expect("transition sample"));
assert!(
transition[0] > 0.0,
"expected smoothed ramp start, got {}",
transition[0]
);
}
}

View File

@@ -1,8 +1,8 @@
use crate::runtime::state::{load_runtime_state, save_runtime_state};
use crate::runtime::{
AudioMixer, FRAME_RGBA_BYTES, FramePacer, JoypadButtons, RuntimeError, VideoMode,
AudioMixer, FramePacer, JoypadButtons, RuntimeError, VideoMode, FRAME_RGBA_BYTES,
};
use crate::{Cpu6502, InesRom, NativeBus, create_mapper, parse_rom};
use crate::{create_mapper, parse_rom, Cpu6502, InesRom, NativeBus};
pub struct NesRuntime {
cpu: Cpu6502,
@@ -79,11 +79,11 @@ impl NesRuntime {
self.bus.set_joypad_buttons(buttons);
}
pub fn step_instruction(&mut self) -> Result<u8, RuntimeError> {
pub fn step_instruction(&mut self) -> Result<u32, RuntimeError> {
self.bus.set_joypad_buttons(self.buttons);
let cycles = self.cpu.step(&mut self.bus).map_err(RuntimeError::Cpu)?;
self.bus.clock_cpu(cycles);
Ok(cycles)
let cpu_cycles = self.cpu.step(&mut self.bus).map_err(RuntimeError::Cpu)?;
self.bus.clock_cpu(cpu_cycles);
Ok(self.bus.take_cpu_cycles_since_poll())
}
pub fn run_until_frame_complete(&mut self) -> Result<(), RuntimeError> {

View File

@@ -1,12 +1,17 @@
use crate::runtime::{
AudioOutput, ClientRuntime, EmulationState, FRAME_RGBA_BYTES, HostConfig, InputProvider,
JOYPAD_BUTTON_ORDER, JOYPAD_BUTTONS_COUNT, JoypadButton, NesRuntime, NoopClock,
RuntimeHostLoop, VideoMode, VideoOutput, button_pressed, set_button_pressed,
button_pressed, set_button_pressed, AudioOutput, ClientRuntime, EmulationState, HostConfig,
InputProvider, JoypadButton, NesRuntime, NoopClock, NullAudio, NullInput, NullVideo,
RuntimeHostLoop, VideoMode, VideoOutput, FRAME_RGBA_BYTES, JOYPAD_BUTTONS_COUNT,
JOYPAD_BUTTON_ORDER,
};
use std::cell::Cell;
use std::rc::Rc;
fn nrom_test_rom() -> Vec<u8> {
nrom_test_rom_with_program(&[0xEA, 0x4C, 0x00, 0x80])
}
fn nrom_test_rom_with_program(program: &[u8]) -> Vec<u8> {
let mut rom = vec![0u8; 16 + 16 * 1024 + 8 * 1024];
rom[0..4].copy_from_slice(b"NES\x1A");
rom[4] = 1; // 16 KiB PRG
@@ -17,11 +22,7 @@ fn nrom_test_rom() -> Vec<u8> {
rom[reset_vec] = 0x00;
rom[reset_vec + 1] = 0x80;
// 0x8000: NOP; JMP $8000
rom[prg_offset] = 0xEA;
rom[prg_offset + 1] = 0x4C;
rom[prg_offset + 2] = 0x00;
rom[prg_offset + 3] = 0x80;
rom[prg_offset..prg_offset + program.len()].copy_from_slice(program);
rom
}
@@ -83,6 +84,30 @@ fn audio_mixer_generates_samples() {
assert_eq!(mixer.sample_rate(), 48_000);
}
#[test]
fn audio_mixer_accounts_for_oam_dma_stall_cycles() {
let rom = nrom_test_rom_with_program(&[
0xA9, 0x00, // LDA #$00
0x8D, 0x14, 0x40, // STA $4014
0x4C, 0x00, 0x80, // JMP $8000
]);
let runtime = NesRuntime::from_rom_bytes(&rom).expect("runtime init");
let mode = runtime.video_mode();
let mut host = RuntimeHostLoop::with_config(runtime, HostConfig::new(48_000, false));
let total_samples = host
.run_frames_unpaced(120, &mut NullInput, &mut NullVideo, &mut NullAudio)
.expect("run frames");
let expected = ((host.runtime().frame_number() as f64) * 48_000.0 / mode.frame_hz()).round();
let drift_pct = ((total_samples as f64 - expected).abs() / expected) * 100.0;
assert!(
drift_pct <= 2.5,
"audio drift too high with OAM DMA: {drift_pct:.3}% (samples={total_samples}, expected={expected:.0})"
);
}
struct FixedInput;
impl InputProvider for FixedInput {