fix(apu): correct frame counter timing, add LP filter, mute aliased triangle
- Fix frame counter running at 2× speed: clock_frame_counter now skips odd CPU cycles (APU cycle = CPU/2), so envelope, sweep, and length counters tick at the correct rate. Fixes sweep-driven whistle in Megaman II. - Switch audio sampling to per-CPU-cycle granularity in run_until_frame_complete_with_audio to eliminate square-wave harmonic aliasing caused by sampling only once per instruction. - Add IIR one-pole low-pass filter (~14 kHz) to AudioMixer to smooth abrupt level transitions (crackling) introduced by correct envelope timing. - Mute triangle channel when timer_period < 2 (≥27 kHz), which aliases into the audible range at 48 kHz. Real NES RC circuit removes these ultrasonics; emulator must suppress them explicitly. - Update all APU bus tests to use correct (doubled) CPU cycle counts.
This commit is contained in:
@@ -7,16 +7,23 @@ pub struct AudioMixer {
|
||||
samples_per_cpu_cycle: f64,
|
||||
sample_accumulator: f64,
|
||||
last_output_sample: f32,
|
||||
// One-pole IIR low-pass filter state (approximates NES ~14 kHz RC filter).
|
||||
// Coefficient: a = exp(-2π * fc / fs). At fc=14000, fs=48000: a ≈ 0.160
|
||||
lp_coeff: f32,
|
||||
lp_state: f32,
|
||||
}
|
||||
|
||||
impl AudioMixer {
|
||||
pub fn new(sample_rate: u32, mode: VideoMode) -> Self {
|
||||
let cpu_hz = mode.cpu_hz();
|
||||
let lp_coeff = (-2.0 * std::f64::consts::PI * 14_000.0 / sample_rate as f64).exp() as f32;
|
||||
Self {
|
||||
sample_rate,
|
||||
samples_per_cpu_cycle: sample_rate as f64 / cpu_hz,
|
||||
sample_accumulator: 0.0,
|
||||
last_output_sample: 0.0,
|
||||
lp_coeff,
|
||||
lp_state: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +34,7 @@ impl AudioMixer {
|
||||
pub fn reset(&mut self) {
|
||||
self.sample_accumulator = 0.0;
|
||||
self.last_output_sample = 0.0;
|
||||
self.lp_state = 0.0;
|
||||
}
|
||||
|
||||
pub fn push_cycles(&mut self, cpu_cycles: u32, channels: ChannelOutputs, out: &mut Vec<f32>) {
|
||||
@@ -45,13 +53,20 @@ impl AudioMixer {
|
||||
}
|
||||
|
||||
let start = self.last_output_sample;
|
||||
let a = self.lp_coeff;
|
||||
let b = 1.0 - a;
|
||||
if samples == 1 {
|
||||
out.push(sample);
|
||||
let s = a * self.lp_state + b * sample;
|
||||
self.lp_state = s;
|
||||
out.push(s);
|
||||
} else {
|
||||
let denom = samples as f32;
|
||||
for idx in 0..samples {
|
||||
let t = (idx + 1) as f32 / denom;
|
||||
out.push(start + (sample - start) * t);
|
||||
let interp = start + (sample - start) * t;
|
||||
let s = a * self.lp_state + b * interp;
|
||||
self.lp_state = s;
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
self.last_output_sample = sample;
|
||||
|
||||
@@ -108,8 +108,16 @@ impl NesRuntime {
|
||||
) -> Result<(), RuntimeError> {
|
||||
self.bus.begin_frame();
|
||||
while !self.bus.take_frame_complete() {
|
||||
let cycles = self.step_instruction()?;
|
||||
mixer.push_cycles(cycles, self.bus.apu_channel_outputs(), out_samples);
|
||||
self.bus.set_joypad_buttons(self.buttons);
|
||||
let cpu_cycles = self.cpu.step(&mut self.bus).map_err(RuntimeError::Cpu)?;
|
||||
// Sample APU output once per CPU cycle for better audio resolution.
|
||||
// OAM DMA cycles (triggered inside cpu.step) are captured in the
|
||||
// first take_cpu_cycles_since_poll call of this instruction.
|
||||
for _ in 0..cpu_cycles {
|
||||
self.bus.clock_cpu(1);
|
||||
let actual = self.bus.take_cpu_cycles_since_poll();
|
||||
mixer.push_cycles(actual, self.bus.apu_channel_outputs(), out_samples);
|
||||
}
|
||||
}
|
||||
self.frame_number = self.frame_number.saturating_add(1);
|
||||
Ok(())
|
||||
|
||||
Reference in New Issue
Block a user