This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
/target
|
/target
|
||||||
/.worktrees/
|
/.worktrees/
|
||||||
|
/docs/superpowers/
|
||||||
|
|||||||
@@ -40,3 +40,12 @@ match_same_arms = "allow"
|
|||||||
module_name_repetitions = "allow"
|
module_name_repetitions = "allow"
|
||||||
too_many_lines = "allow"
|
too_many_lines = "allow"
|
||||||
needless_pass_by_value = "allow"
|
needless_pass_by_value = "allow"
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
opt-level = 1
|
||||||
|
|
||||||
|
[profile.dev.package.nesemu]
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
|
[profile.dev.package.nesemu-desktop]
|
||||||
|
opt-level = 2
|
||||||
|
|||||||
@@ -3,23 +3,25 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
|
use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use gtk::gio;
|
|
||||||
use gtk::gdk;
|
use gtk::gdk;
|
||||||
|
use gtk::gio;
|
||||||
use gtk::glib;
|
use gtk::glib;
|
||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
use gtk4 as gtk;
|
use gtk4 as gtk;
|
||||||
use nesemu::prelude::{EmulationState, HostConfig, RuntimeHostLoop};
|
use nesemu::prelude::{EmulationState, HostConfig, RuntimeHostLoop};
|
||||||
use nesemu::{
|
use nesemu::{
|
||||||
FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, FrameClock, InputProvider, JoypadButton,
|
set_button_pressed, FrameClock, InputProvider, JoypadButton, JoypadButtons, NesRuntime,
|
||||||
JoypadButtons, NesRuntime, RingBuffer, set_button_pressed,
|
RingBuffer, VideoMode, VideoOutput, FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH,
|
||||||
};
|
};
|
||||||
|
|
||||||
const APP_ID: &str = "org.nesemu.desktop";
|
const APP_ID: &str = "org.nesemu.desktop";
|
||||||
const TITLE: &str = "NES Emulator";
|
const TITLE: &str = "NES Emulator";
|
||||||
const SCALE: i32 = 3;
|
const SCALE: i32 = 3;
|
||||||
const SAMPLE_RATE: u32 = 48_000;
|
const SAMPLE_RATE: u32 = 48_000;
|
||||||
|
const AUDIO_RING_CAPACITY: usize = 1536;
|
||||||
|
const AUDIO_CALLBACK_FRAMES: u32 = 256;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
if std::env::var_os("GSK_RENDERER").is_none() {
|
if std::env::var_os("GSK_RENDERER").is_none() {
|
||||||
@@ -28,9 +30,7 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let app = gtk::Application::builder()
|
let app = gtk::Application::builder().application_id(APP_ID).build();
|
||||||
.application_id(APP_ID)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let initial_rom: Rc<RefCell<Option<PathBuf>>> =
|
let initial_rom: Rc<RefCell<Option<PathBuf>>> =
|
||||||
Rc::new(RefCell::new(std::env::args().nth(1).map(PathBuf::from)));
|
Rc::new(RefCell::new(std::env::args().nth(1).map(PathBuf::from)));
|
||||||
@@ -125,16 +125,17 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
|||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
let desktop = Rc::new(RefCell::new(DesktopApp::new(Arc::clone(&volume))));
|
let desktop = Rc::new(RefCell::new(DesktopApp::new(Arc::clone(&volume))));
|
||||||
let frame_for_draw: Rc<RefCell<Vec<u8>>> =
|
let frame_for_draw: Rc<RefCell<Vec<u8>>> = Rc::new(RefCell::new(vec![0u8; FRAME_RGBA_BYTES]));
|
||||||
Rc::new(RefCell::new(vec![0u8; FRAME_RGBA_BYTES]));
|
let scheduler = Rc::new(RefCell::new(DesktopFrameScheduler::new()));
|
||||||
|
|
||||||
// --- Draw function (pixel-perfect nearest-neighbor) ---
|
// --- Draw function (pixel-perfect nearest-neighbor) ---
|
||||||
{
|
{
|
||||||
let frame_for_draw = Rc::clone(&frame_for_draw);
|
let frame_for_draw = Rc::clone(&frame_for_draw);
|
||||||
drawing_area.set_draw_func(move |_da, cr, width, height| {
|
drawing_area.set_draw_func(move |_da, cr, width, height| {
|
||||||
let frame = frame_for_draw.borrow();
|
let frame = frame_for_draw.borrow();
|
||||||
let stride =
|
let stride = cairo::Format::ARgb32
|
||||||
cairo::Format::ARgb32.stride_for_width(FRAME_WIDTH as u32).unwrap();
|
.stride_for_width(FRAME_WIDTH as u32)
|
||||||
|
.unwrap();
|
||||||
let mut argb = vec![0u8; stride as usize * FRAME_HEIGHT];
|
let mut argb = vec![0u8; stride as usize * FRAME_HEIGHT];
|
||||||
for y in 0..FRAME_HEIGHT {
|
for y in 0..FRAME_HEIGHT {
|
||||||
for x in 0..FRAME_WIDTH {
|
for x in 0..FRAME_WIDTH {
|
||||||
@@ -223,6 +224,7 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
|||||||
// --- Open ROM handler ---
|
// --- Open ROM handler ---
|
||||||
let do_open_rom = {
|
let do_open_rom = {
|
||||||
let desktop = Rc::clone(&desktop);
|
let desktop = Rc::clone(&desktop);
|
||||||
|
let scheduler = Rc::clone(&scheduler);
|
||||||
let sync_ui = Rc::clone(&sync_ui);
|
let sync_ui = Rc::clone(&sync_ui);
|
||||||
let window = window.clone();
|
let window = window.clone();
|
||||||
Rc::new(move || {
|
Rc::new(move || {
|
||||||
@@ -244,6 +246,7 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
|||||||
chooser.add_filter(&all_filter);
|
chooser.add_filter(&all_filter);
|
||||||
|
|
||||||
let desktop = Rc::clone(&desktop);
|
let desktop = Rc::clone(&desktop);
|
||||||
|
let scheduler = Rc::clone(&scheduler);
|
||||||
let sync_ui = Rc::clone(&sync_ui);
|
let sync_ui = Rc::clone(&sync_ui);
|
||||||
chooser.connect_response(move |dialog, response| {
|
chooser.connect_response(move |dialog, response| {
|
||||||
if response == gtk::ResponseType::Accept {
|
if response == gtk::ResponseType::Accept {
|
||||||
@@ -252,6 +255,7 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
|||||||
if let Err(err) = app_state.load_rom_from_path(&path) {
|
if let Err(err) = app_state.load_rom_from_path(&path) {
|
||||||
eprintln!("Failed to load ROM '{}': {err}", path.display());
|
eprintln!("Failed to load ROM '{}': {err}", path.display());
|
||||||
} else {
|
} else {
|
||||||
|
scheduler.borrow_mut().reset_timing();
|
||||||
let name = rom_filename(&path);
|
let name = rom_filename(&path);
|
||||||
sync_ui(&app_state, Some(&name));
|
sync_ui(&app_state, Some(&name));
|
||||||
}
|
}
|
||||||
@@ -273,20 +277,24 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let desktop = Rc::clone(&desktop);
|
let desktop = Rc::clone(&desktop);
|
||||||
|
let scheduler = Rc::clone(&scheduler);
|
||||||
let sync_ui = Rc::clone(&sync_ui);
|
let sync_ui = Rc::clone(&sync_ui);
|
||||||
pause_button.connect_clicked(move |_| {
|
pause_button.connect_clicked(move |_| {
|
||||||
let mut app_state = desktop.borrow_mut();
|
let mut app_state = desktop.borrow_mut();
|
||||||
app_state.toggle_pause();
|
app_state.toggle_pause();
|
||||||
|
scheduler.borrow_mut().reset_timing();
|
||||||
sync_ui(&app_state, None);
|
sync_ui(&app_state, None);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let desktop = Rc::clone(&desktop);
|
let desktop = Rc::clone(&desktop);
|
||||||
|
let scheduler = Rc::clone(&scheduler);
|
||||||
let sync_ui = Rc::clone(&sync_ui);
|
let sync_ui = Rc::clone(&sync_ui);
|
||||||
reset_button.connect_clicked(move |_| {
|
reset_button.connect_clicked(move |_| {
|
||||||
let mut app_state = desktop.borrow_mut();
|
let mut app_state = desktop.borrow_mut();
|
||||||
app_state.reset();
|
app_state.reset();
|
||||||
|
scheduler.borrow_mut().reset_timing();
|
||||||
sync_ui(&app_state, None);
|
sync_ui(&app_state, None);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -305,11 +313,13 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
|||||||
let action_pause = gio::SimpleAction::new("toggle-pause", None);
|
let action_pause = gio::SimpleAction::new("toggle-pause", None);
|
||||||
{
|
{
|
||||||
let desktop = Rc::clone(&desktop);
|
let desktop = Rc::clone(&desktop);
|
||||||
|
let scheduler = Rc::clone(&scheduler);
|
||||||
let sync_ui = Rc::clone(&sync_ui);
|
let sync_ui = Rc::clone(&sync_ui);
|
||||||
action_pause.connect_activate(move |_, _| {
|
action_pause.connect_activate(move |_, _| {
|
||||||
let mut app_state = desktop.borrow_mut();
|
let mut app_state = desktop.borrow_mut();
|
||||||
if app_state.is_loaded() {
|
if app_state.is_loaded() {
|
||||||
app_state.toggle_pause();
|
app_state.toggle_pause();
|
||||||
|
scheduler.borrow_mut().reset_timing();
|
||||||
sync_ui(&app_state, None);
|
sync_ui(&app_state, None);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -320,11 +330,13 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
|||||||
let action_reset = gio::SimpleAction::new("reset", None);
|
let action_reset = gio::SimpleAction::new("reset", None);
|
||||||
{
|
{
|
||||||
let desktop = Rc::clone(&desktop);
|
let desktop = Rc::clone(&desktop);
|
||||||
|
let scheduler = Rc::clone(&scheduler);
|
||||||
let sync_ui = Rc::clone(&sync_ui);
|
let sync_ui = Rc::clone(&sync_ui);
|
||||||
action_reset.connect_activate(move |_, _| {
|
action_reset.connect_activate(move |_, _| {
|
||||||
let mut app_state = desktop.borrow_mut();
|
let mut app_state = desktop.borrow_mut();
|
||||||
if app_state.is_loaded() {
|
if app_state.is_loaded() {
|
||||||
app_state.reset();
|
app_state.reset();
|
||||||
|
scheduler.borrow_mut().reset_timing();
|
||||||
sync_ui(&app_state, None);
|
sync_ui(&app_state, None);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -354,6 +366,7 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
|||||||
// --- Drag-and-drop ---
|
// --- Drag-and-drop ---
|
||||||
{
|
{
|
||||||
let desktop = Rc::clone(&desktop);
|
let desktop = Rc::clone(&desktop);
|
||||||
|
let scheduler = Rc::clone(&scheduler);
|
||||||
let sync_ui = Rc::clone(&sync_ui);
|
let sync_ui = Rc::clone(&sync_ui);
|
||||||
let drop_target = gtk::DropTarget::new(gio::File::static_type(), gdk::DragAction::COPY);
|
let drop_target = gtk::DropTarget::new(gio::File::static_type(), gdk::DragAction::COPY);
|
||||||
drop_target.connect_drop(move |_, value, _, _| {
|
drop_target.connect_drop(move |_, value, _, _| {
|
||||||
@@ -364,6 +377,7 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
|||||||
eprintln!("Failed to load ROM '{}': {err}", path.display());
|
eprintln!("Failed to load ROM '{}': {err}", path.display());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
scheduler.borrow_mut().reset_timing();
|
||||||
let name = rom_filename(&path);
|
let name = rom_filename(&path);
|
||||||
sync_ui(&app_state, Some(&name));
|
sync_ui(&app_state, Some(&name));
|
||||||
return true;
|
return true;
|
||||||
@@ -376,23 +390,45 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
|||||||
|
|
||||||
// --- Game loop ---
|
// --- Game loop ---
|
||||||
{
|
{
|
||||||
let desktop = Rc::clone(&desktop);
|
schedule_game_loop(
|
||||||
let drawing_area = drawing_area.clone();
|
Rc::clone(&desktop),
|
||||||
let frame_for_draw = Rc::clone(&frame_for_draw);
|
drawing_area.clone(),
|
||||||
glib::timeout_add_local(Duration::from_millis(16), move || {
|
Rc::clone(&frame_for_draw),
|
||||||
|
Rc::clone(&scheduler),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schedule_game_loop(
|
||||||
|
desktop: Rc<RefCell<DesktopApp>>,
|
||||||
|
drawing_area: gtk::DrawingArea,
|
||||||
|
frame_for_draw: Rc<RefCell<Vec<u8>>>,
|
||||||
|
scheduler: Rc<RefCell<DesktopFrameScheduler>>,
|
||||||
|
) {
|
||||||
|
let interval = desktop.borrow().frame_interval();
|
||||||
|
let delay = scheduler
|
||||||
|
.borrow_mut()
|
||||||
|
.delay_until_next_frame(Instant::now(), interval);
|
||||||
|
|
||||||
|
glib::timeout_add_local_once(delay, move || {
|
||||||
|
{
|
||||||
let mut app_state = desktop.borrow_mut();
|
let mut app_state = desktop.borrow_mut();
|
||||||
|
let now = Instant::now();
|
||||||
|
let interval = app_state.frame_interval();
|
||||||
|
|
||||||
|
scheduler.borrow_mut().mark_frame_complete(now, interval);
|
||||||
app_state.tick();
|
app_state.tick();
|
||||||
|
|
||||||
frame_for_draw
|
frame_for_draw
|
||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.copy_from_slice(app_state.frame_rgba());
|
.copy_from_slice(app_state.frame_rgba());
|
||||||
drawing_area.queue_draw();
|
drawing_area.queue_draw();
|
||||||
|
|
||||||
glib::ControlFlow::Continue
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.present();
|
schedule_game_loop(desktop, drawing_area, frame_for_draw, scheduler);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rom_filename(path: &Path) -> String {
|
fn rom_filename(path: &Path) -> String {
|
||||||
@@ -445,7 +481,7 @@ struct CpalAudioSink {
|
|||||||
|
|
||||||
impl CpalAudioSink {
|
impl CpalAudioSink {
|
||||||
fn new(volume: Arc<AtomicU32>) -> Self {
|
fn new(volume: Arc<AtomicU32>) -> Self {
|
||||||
let ring = Arc::new(RingBuffer::new(4096));
|
let ring = Arc::new(RingBuffer::new(AUDIO_RING_CAPACITY));
|
||||||
let ring_for_cb = Arc::clone(&ring);
|
let ring_for_cb = Arc::clone(&ring);
|
||||||
let vol_for_cb = Arc::clone(&volume);
|
let vol_for_cb = Arc::clone(&volume);
|
||||||
let stream = Self::try_build_stream(ring_for_cb, vol_for_cb);
|
let stream = Self::try_build_stream(ring_for_cb, vol_for_cb);
|
||||||
@@ -456,10 +492,7 @@ impl CpalAudioSink {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn try_build_stream(
|
fn try_build_stream(ring: Arc<RingBuffer>, volume: Arc<AtomicU32>) -> Option<cpal::Stream> {
|
||||||
ring: Arc<RingBuffer>,
|
|
||||||
volume: Arc<AtomicU32>,
|
|
||||||
) -> Option<cpal::Stream> {
|
|
||||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
|
||||||
let host = cpal::default_host();
|
let host = cpal::default_host();
|
||||||
@@ -471,11 +504,7 @@ impl CpalAudioSink {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let config = cpal::StreamConfig {
|
let config = cpal_stream_config();
|
||||||
channels: 1,
|
|
||||||
sample_rate: cpal::SampleRate(SAMPLE_RATE),
|
|
||||||
buffer_size: cpal::BufferSize::Default,
|
|
||||||
};
|
|
||||||
|
|
||||||
let stream = match device.build_output_stream(
|
let stream = match device.build_output_stream(
|
||||||
&config,
|
&config,
|
||||||
@@ -523,6 +552,85 @@ impl nesemu::AudioOutput for CpalAudioSink {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn audio_ring_latency_ms(capacity: usize, sample_rate: u32) -> f64 {
|
||||||
|
((capacity.saturating_sub(1)) as f64 / sample_rate as f64) * 1000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn required_audio_ring_capacity(sample_rate: u32, mode: VideoMode) -> usize {
|
||||||
|
let samples_per_frame = (sample_rate as f64 / mode.frame_hz()).ceil() as usize;
|
||||||
|
samples_per_frame + AUDIO_CALLBACK_FRAMES as usize + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cpal_stream_config() -> cpal::StreamConfig {
|
||||||
|
cpal::StreamConfig {
|
||||||
|
channels: 1,
|
||||||
|
sample_rate: cpal::SampleRate(SAMPLE_RATE),
|
||||||
|
buffer_size: cpal::BufferSize::Fixed(AUDIO_CALLBACK_FRAMES),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DesktopFrameScheduler {
|
||||||
|
next_deadline: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DesktopFrameScheduler {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
next_deadline: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_timing(&mut self) {
|
||||||
|
self.next_deadline = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delay_until_next_frame(&mut self, now: Instant, _interval: Duration) -> Duration {
|
||||||
|
match self.next_deadline {
|
||||||
|
None => {
|
||||||
|
self.next_deadline = Some(now);
|
||||||
|
Duration::ZERO
|
||||||
|
}
|
||||||
|
Some(deadline) if now < deadline => deadline - now,
|
||||||
|
Some(_) => Duration::ZERO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_frame_complete(&mut self, now: Instant, interval: Duration) {
|
||||||
|
let mut next_deadline = self.next_deadline.unwrap_or(now) + interval;
|
||||||
|
while next_deadline <= now {
|
||||||
|
next_deadline += interval;
|
||||||
|
}
|
||||||
|
self.next_deadline = Some(next_deadline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BufferedVideo {
|
||||||
|
frame_rgba: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BufferedVideo {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
frame_rgba: vec![0; FRAME_RGBA_BYTES],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame_rgba(&self) -> &[u8] {
|
||||||
|
&self.frame_rgba
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VideoOutput for BufferedVideo {
|
||||||
|
fn present_rgba(&mut self, frame: &[u8], width: usize, height: usize) {
|
||||||
|
if width != FRAME_WIDTH || height != FRAME_HEIGHT || frame.len() != FRAME_RGBA_BYTES {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.frame_rgba.copy_from_slice(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Application state
|
// Application state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -531,7 +639,7 @@ struct DesktopApp {
|
|||||||
host: Option<RuntimeHostLoop<Box<dyn FrameClock>>>,
|
host: Option<RuntimeHostLoop<Box<dyn FrameClock>>>,
|
||||||
input: InputState,
|
input: InputState,
|
||||||
audio: CpalAudioSink,
|
audio: CpalAudioSink,
|
||||||
frame_rgba: Vec<u8>,
|
video: BufferedVideo,
|
||||||
state: EmulationState,
|
state: EmulationState,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,7 +649,7 @@ impl DesktopApp {
|
|||||||
host: None,
|
host: None,
|
||||||
input: InputState::default(),
|
input: InputState::default(),
|
||||||
audio: CpalAudioSink::new(volume),
|
audio: CpalAudioSink::new(volume),
|
||||||
frame_rgba: vec![0; FRAME_RGBA_BYTES],
|
video: BufferedVideo::new(),
|
||||||
state: EmulationState::Paused,
|
state: EmulationState::Paused,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -589,23 +697,137 @@ impl DesktopApp {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut null_video = nesemu::NullVideo;
|
match host.run_frame_unpaced(&mut self.input, &mut self.video, &mut self.audio) {
|
||||||
if let Err(err) = host.run_frame_unpaced(&mut self.input, &mut null_video, &mut self.audio)
|
Ok(_) => {}
|
||||||
{
|
Err(err) => {
|
||||||
eprintln!("Frame execution error: {err}");
|
eprintln!("Frame execution error: {err}");
|
||||||
self.state = EmulationState::Paused;
|
self.state = EmulationState::Paused;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
self.frame_rgba
|
|
||||||
.copy_from_slice(&host.runtime().frame_rgba());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn frame_rgba(&self) -> &[u8] {
|
fn frame_rgba(&self) -> &[u8] {
|
||||||
&self.frame_rgba
|
self.video.frame_rgba()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame_interval(&self) -> Duration {
|
||||||
|
self.host
|
||||||
|
.as_ref()
|
||||||
|
.map(|host| host.runtime().video_mode().frame_duration())
|
||||||
|
.unwrap_or_else(|| VideoMode::Ntsc.frame_duration())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn input_mut(&mut self) -> &mut InputState {
|
fn input_mut(&mut self) -> &mut InputState {
|
||||||
&mut self.input
|
&mut self.input
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use nesemu::{VideoOutput, FRAME_HEIGHT, FRAME_WIDTH};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_scheduler_waits_until_frame_deadline() {
|
||||||
|
let mut scheduler = DesktopFrameScheduler::new();
|
||||||
|
let start = Instant::now();
|
||||||
|
let interval = Duration::from_micros(16_639);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
scheduler.delay_until_next_frame(start, interval),
|
||||||
|
Duration::ZERO
|
||||||
|
);
|
||||||
|
scheduler.mark_frame_complete(start, interval);
|
||||||
|
assert!(
|
||||||
|
scheduler.delay_until_next_frame(start + Duration::from_millis(1), interval)
|
||||||
|
> Duration::ZERO
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
scheduler.delay_until_next_frame(start + interval, interval),
|
||||||
|
Duration::ZERO
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buffered_video_captures_presented_frame() {
|
||||||
|
let mut video = BufferedVideo::new();
|
||||||
|
let mut frame = vec![0u8; FRAME_RGBA_BYTES];
|
||||||
|
frame[0] = 0x12;
|
||||||
|
frame[1] = 0x34;
|
||||||
|
frame[2] = 0x56;
|
||||||
|
frame[3] = 0x78;
|
||||||
|
|
||||||
|
video.present_rgba(&frame, FRAME_WIDTH, FRAME_HEIGHT);
|
||||||
|
|
||||||
|
assert_eq!(video.frame_rgba(), frame.as_slice());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_scheduler_reset_restarts_from_immediate_tick() {
|
||||||
|
let mut scheduler = DesktopFrameScheduler::new();
|
||||||
|
let start = Instant::now();
|
||||||
|
let interval = Duration::from_micros(16_639);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
scheduler.delay_until_next_frame(start, interval),
|
||||||
|
Duration::ZERO
|
||||||
|
);
|
||||||
|
scheduler.mark_frame_complete(start, interval);
|
||||||
|
assert!(scheduler.delay_until_next_frame(start, interval) > Duration::ZERO);
|
||||||
|
|
||||||
|
scheduler.reset_timing();
|
||||||
|
assert_eq!(
|
||||||
|
scheduler.delay_until_next_frame(start, interval),
|
||||||
|
Duration::ZERO
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_scheduler_reports_zero_delay_when_late() {
|
||||||
|
let mut scheduler = DesktopFrameScheduler::new();
|
||||||
|
let start = Instant::now();
|
||||||
|
let interval = Duration::from_micros(16_639);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
scheduler.delay_until_next_frame(start, interval),
|
||||||
|
Duration::ZERO
|
||||||
|
);
|
||||||
|
scheduler.mark_frame_complete(start, interval);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
scheduler.delay_until_next_frame(start + interval + Duration::from_millis(2), interval),
|
||||||
|
Duration::ZERO
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn desktop_audio_ring_budget_stays_below_25ms() {
|
||||||
|
let latency_ms = audio_ring_latency_ms(AUDIO_RING_CAPACITY, SAMPLE_RATE);
|
||||||
|
let max_budget_ms = 40.0;
|
||||||
|
assert!(
|
||||||
|
latency_ms <= max_budget_ms,
|
||||||
|
"desktop audio ring latency budget too high: {latency_ms:.2}ms"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn desktop_audio_uses_fixed_low_latency_callback_size() {
|
||||||
|
let config = cpal_stream_config();
|
||||||
|
assert_eq!(
|
||||||
|
config.buffer_size,
|
||||||
|
cpal::BufferSize::Fixed(AUDIO_CALLBACK_FRAMES)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn desktop_audio_ring_has_frame_burst_headroom() {
|
||||||
|
let required = required_audio_ring_capacity(SAMPLE_RATE, VideoMode::Ntsc);
|
||||||
|
assert!(
|
||||||
|
AUDIO_RING_CAPACITY >= required,
|
||||||
|
"audio ring too small for frame burst: capacity={}, required={required}",
|
||||||
|
AUDIO_RING_CAPACITY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,245 +0,0 @@
|
|||||||
# Audio Output Design — Full 5-Channel Mixer + cpal Backend
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Add real audio output to the desktop NES emulator client. This involves two independent pieces of work:
|
|
||||||
|
|
||||||
1. **Full APU mixer** — replace the current DMC-only mixer with proper 5-channel mixing (Pulse 1, Pulse 2, Triangle, Noise, DMC) using NES hardware-accurate formulas.
|
|
||||||
2. **cpal audio backend** — replace the stub `AudioSink` in the desktop client with a real audio output using `cpal`, connected via a lock-free ring buffer. Add a volume slider to the GTK4 header bar.
|
|
||||||
|
|
||||||
## 1. Full APU Mixer
|
|
||||||
|
|
||||||
### Current State
|
|
||||||
|
|
||||||
`AudioMixer::push_cycles()` in `src/runtime/audio.rs` reads only `apu_regs[0x11]` (DMC output level) and generates a single-channel signal. All other channels are ignored.
|
|
||||||
|
|
||||||
### Design
|
|
||||||
|
|
||||||
#### 1.1 Channel Outputs Struct
|
|
||||||
|
|
||||||
Add to `src/native_core/apu/`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
|
||||||
pub struct ChannelOutputs {
|
|
||||||
pub pulse1: u8, // 0–15
|
|
||||||
pub pulse2: u8, // 0–15
|
|
||||||
pub triangle: u8, // 0–15
|
|
||||||
pub noise: u8, // 0–15
|
|
||||||
pub dmc: u8, // 0–127
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 1.2 New APU Internal State
|
|
||||||
|
|
||||||
The current `Apu` struct lacks timer counters and sequencer state needed to compute channel outputs. The following fields must be added:
|
|
||||||
|
|
||||||
**Pulse channels (×2):**
|
|
||||||
- `pulse_timer_counter: [u16; 2]` — countdown timer, clocked every other CPU cycle
|
|
||||||
- `pulse_duty_step: [u8; 2]` — position in 8-step duty cycle sequence (0–7)
|
|
||||||
|
|
||||||
**Triangle channel:**
|
|
||||||
- `triangle_timer_counter: u16` — countdown timer, clocked every CPU cycle
|
|
||||||
- `triangle_step: u8` — position in 32-step triangle sequence (0–31)
|
|
||||||
|
|
||||||
**Noise channel:**
|
|
||||||
- `noise_timer_counter: u16` — countdown timer, clocked every other CPU cycle
|
|
||||||
- `noise_lfsr: u16` — 15-bit linear feedback shift register, initialized to 1
|
|
||||||
|
|
||||||
These must be clocked in `Apu::clock_cpu_cycle()`:
|
|
||||||
- Pulse and noise timers decrement every **2** CPU cycles (APU rate, tracked via existing `cpu_cycle_parity`)
|
|
||||||
- Triangle timer decrements every **1** CPU cycle
|
|
||||||
- When a timer reaches 0, it reloads from the period register and advances the corresponding sequencer
|
|
||||||
|
|
||||||
#### 1.3 APU Method
|
|
||||||
|
|
||||||
Add `Apu::channel_outputs(&self) -> ChannelOutputs` that computes the current output level of each channel:
|
|
||||||
|
|
||||||
- **Pulse 1/2:** Output is 0 if length counter is 0, or sweep mutes the channel, or duty cycle sequencer output is 0. Otherwise output is the envelope volume (0–15).
|
|
||||||
- **Triangle:** Output is the value from the 32-step triangle waveform lookup at `triangle_step`. Muted (output 0) if length counter or linear counter is 0.
|
|
||||||
- **Noise:** Output is 0 if length counter is 0 or LFSR bit 0 is 1. Otherwise output is the envelope volume (0–15).
|
|
||||||
- **DMC:** Output is `dmc_output_level` (0–127), already tracked.
|
|
||||||
|
|
||||||
#### 1.4 Save-State Compatibility
|
|
||||||
|
|
||||||
Adding new fields to `Apu` changes the save-state binary format. The `save_state_tail()` and `load_state_tail()` methods must be updated to serialize/deserialize the new fields. This is a **breaking change** to the save-state format — old save states will not be compatible. Since the project is pre-1.0, this is acceptable without a migration strategy.
|
|
||||||
|
|
||||||
#### 1.5 Bus Exposure
|
|
||||||
|
|
||||||
Add `NativeBus::apu_channel_outputs(&self) -> ChannelOutputs` to expose channel outputs alongside the existing `apu_registers()`.
|
|
||||||
|
|
||||||
#### 1.6 Mixer Update
|
|
||||||
|
|
||||||
Change `AudioMixer::push_cycles()` signature:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Before:
|
|
||||||
pub fn push_cycles(&mut self, cpu_cycles: u8, apu_regs: &[u8; 0x20], out: &mut Vec<f32>)
|
|
||||||
|
|
||||||
// After:
|
|
||||||
pub fn push_cycles(&mut self, cpu_cycles: u8, channels: ChannelOutputs, out: &mut Vec<f32>)
|
|
||||||
```
|
|
||||||
|
|
||||||
Mixing formula (nesdev wiki linear approximation):
|
|
||||||
|
|
||||||
```
|
|
||||||
pulse_out = 0.00752 * (pulse1 + pulse2)
|
|
||||||
tnd_out = 0.00851 * triangle + 0.00494 * noise + 0.00335 * dmc
|
|
||||||
output = pulse_out + tnd_out
|
|
||||||
```
|
|
||||||
|
|
||||||
Output range is approximately [0.0, 1.0]. Normalize to [-1.0, 1.0] by: `sample = output * 2.0 - 1.0`.
|
|
||||||
|
|
||||||
**Known simplifications:**
|
|
||||||
- This uses the linear approximation, not the more accurate nonlinear lookup tables from real NES hardware. Nonlinear mixing can be added later as an enhancement.
|
|
||||||
- The current `repeat_n` resampling approach (nearest-neighbor) produces aliasing. A low-pass filter or bandlimited interpolation can be added later.
|
|
||||||
- Real NES hardware applies two first-order high-pass filters (~90Hz and ~440Hz). Without these, channel enable/disable will cause audible pops. Deferred for a future iteration.
|
|
||||||
|
|
||||||
#### 1.7 Runtime Integration
|
|
||||||
|
|
||||||
Update `NesRuntime::run_until_frame_complete_with_audio()` in `src/runtime/core.rs` to pass `ChannelOutputs` (from `self.bus.apu_channel_outputs()`) instead of the register slice to the mixer.
|
|
||||||
|
|
||||||
## 2. Lock-Free Ring Buffer
|
|
||||||
|
|
||||||
### Location
|
|
||||||
|
|
||||||
New file: `src/runtime/ring_buffer.rs`.
|
|
||||||
|
|
||||||
### Design
|
|
||||||
|
|
||||||
SPSC (single-producer, single-consumer) ring buffer using `AtomicUsize` for head/tail indices:
|
|
||||||
|
|
||||||
- **Capacity:** 4096 f32 samples (~85ms at 48kHz) — enough to absorb frame timing jitter
|
|
||||||
- **Producer:** emulation thread writes samples after each frame via `push_samples()`
|
|
||||||
- **Consumer:** cpal audio callback reads samples via `pop_samples()`
|
|
||||||
- **Underrun (buffer empty):** consumer outputs silence (0.0)
|
|
||||||
- **Overrun (buffer full):** producer **drops new samples** (standard SPSC behavior — only the consumer moves the tail pointer)
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct RingBuffer {
|
|
||||||
buffer: Box<[f32]>,
|
|
||||||
capacity: usize,
|
|
||||||
head: AtomicUsize, // write position (producer only)
|
|
||||||
tail: AtomicUsize, // read position (consumer only)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RingBuffer {
|
|
||||||
pub fn new(capacity: usize) -> Self;
|
|
||||||
pub fn push(&self, samples: &[f32]) -> usize; // returns samples actually written
|
|
||||||
pub fn pop(&self, out: &mut [f32]) -> usize; // returns samples actually read
|
|
||||||
pub fn clear(&self); // reset both pointers (call when no concurrent access)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Thread safety: `RingBuffer` is `Send + Sync`. Shared via `Arc<RingBuffer>`.
|
|
||||||
|
|
||||||
## 3. Desktop cpal Audio Backend
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
Add to `crates/nesemu-desktop/Cargo.toml`:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
cpal = "0.15"
|
|
||||||
```
|
|
||||||
|
|
||||||
### CpalAudioSink
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct CpalAudioSink {
|
|
||||||
_stream: cpal::Stream, // keeps the audio stream alive
|
|
||||||
ring: Arc<RingBuffer>,
|
|
||||||
volume: Arc<AtomicU32>, // f32 bits stored atomically
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Implements `nesemu::AudioOutput` — `push_samples()` writes to ring buffer
|
|
||||||
- Created when a ROM is loaded; the ring buffer is cleared on ROM change to prevent stale samples
|
|
||||||
- cpal callback: reads from ring buffer, multiplies each sample by volume, writes to output buffer
|
|
||||||
- On pause: emulation stops producing samples → callback outputs silence (underrun behavior)
|
|
||||||
- On ROM change: old stream is dropped, ring buffer cleared, new stream created
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
If no audio device is available, or the requested format is unsupported, or the stream fails to build:
|
|
||||||
- Log the error to stderr
|
|
||||||
- Fall back to `NullAudio` behavior (discard samples silently)
|
|
||||||
- The emulator continues to work without sound
|
|
||||||
|
|
||||||
The cpal error callback also logs errors to stderr without crashing.
|
|
||||||
|
|
||||||
### Stream Configuration
|
|
||||||
|
|
||||||
- Sample rate: 48,000 Hz
|
|
||||||
- Channels: 1 (mono — NES is mono)
|
|
||||||
- Sample format: f32
|
|
||||||
- Buffer size: let cpal choose (typically 256–1024 frames)
|
|
||||||
|
|
||||||
### Volume
|
|
||||||
|
|
||||||
- `Arc<AtomicU32>` shared between UI and cpal callback
|
|
||||||
- Stored as `f32::to_bits()` / `f32::from_bits()`
|
|
||||||
- Default: 0.75 (75%)
|
|
||||||
- Applied in cpal callback: `sample * volume`
|
|
||||||
|
|
||||||
## 4. UI — Volume Slider
|
|
||||||
|
|
||||||
### Widget
|
|
||||||
|
|
||||||
`gtk::Scale` (horizontal) added to the header bar:
|
|
||||||
|
|
||||||
- Range: 0.0 to 1.0 (displayed as 0–100%)
|
|
||||||
- Default: 0.75
|
|
||||||
- `connect_value_changed` → atomically update volume
|
|
||||||
|
|
||||||
### Placement
|
|
||||||
|
|
||||||
In the header bar, after the existing control buttons (open, pause, reset), with a small speaker icon label.
|
|
||||||
|
|
||||||
## 5. Threading Model
|
|
||||||
|
|
||||||
- **GTK main thread:** runs emulation via `glib::timeout_add_local` (~16ms tick), UI events, volume slider updates
|
|
||||||
- **cpal OS thread:** audio callback reads from ring buffer — this is the only cross-thread boundary
|
|
||||||
- The ring buffer (`Arc<RingBuffer>`) and volume (`Arc<AtomicU32>`) are the only shared state between threads
|
|
||||||
|
|
||||||
## 6. Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
CPU instruction step (GTK main thread)
|
|
||||||
→ APU.clock_cpu_cycle() [updates internal channel state]
|
|
||||||
→ AudioMixer.push_cycles(cycles, apu.channel_outputs())
|
|
||||||
→ mix 5 channels → f32 sample
|
|
||||||
→ append to frame audio buffer (Vec<f32>)
|
|
||||||
|
|
||||||
Per frame (GTK main thread):
|
|
||||||
→ FrameExecutor collects audio_buffer
|
|
||||||
→ CpalAudioSink.push_samples(audio_buffer)
|
|
||||||
→ write to Arc<RingBuffer>
|
|
||||||
|
|
||||||
cpal callback (separate OS thread):
|
|
||||||
→ read from Arc<RingBuffer>
|
|
||||||
→ multiply by volume (Arc<AtomicU32>)
|
|
||||||
→ write to hardware audio buffer
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. Files Changed
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `src/native_core/apu/types.rs` | Add `ChannelOutputs` struct, new timer/sequencer fields to `Apu` and `ApuStateTail` |
|
|
||||||
| `src/native_core/apu/api.rs` | Add `channel_outputs()` method, update `save_state_tail`/`load_state_tail` |
|
|
||||||
| `src/native_core/apu/timing.rs` | Clock new timer/sequencer fields in `clock_cpu_cycle()` |
|
|
||||||
| `src/native_core/bus.rs` | Add `apu_channel_outputs()` |
|
|
||||||
| `src/runtime/audio.rs` | Rewrite mixer with 5-channel formula |
|
|
||||||
| `src/runtime/ring_buffer.rs` (new) | Lock-free SPSC ring buffer |
|
|
||||||
| `src/runtime/core.rs` | Pass `channel_outputs()` to mixer in `run_until_frame_complete_with_audio()` |
|
|
||||||
| `src/runtime/mod.rs` | Export `ring_buffer`, `ChannelOutputs` |
|
|
||||||
| `crates/nesemu-desktop/Cargo.toml` | Add `cpal` dependency |
|
|
||||||
| `crates/nesemu-desktop/src/main.rs` | Replace stub AudioSink with CpalAudioSink, add volume slider |
|
|
||||||
|
|
||||||
## 8. Testing
|
|
||||||
|
|
||||||
- Existing tests in `tests/public_api.rs` must continue to pass (they use NullAudio). **Note:** the regression hash test (`public_api_regression_hashes_for_reference_rom`) will produce a different audio hash due to the mixer change — the expected hash must be updated.
|
|
||||||
- Unit test for ring buffer: push/pop, underrun, overrun, clear
|
|
||||||
- Unit test for mixer: known channel outputs → expected sample values
|
|
||||||
- Manual test: load a ROM, verify audible sound through speakers
|
|
||||||
@@ -23,6 +23,7 @@ pub struct NativeBus {
|
|||||||
odd_frame: bool,
|
odd_frame: bool,
|
||||||
in_vblank: bool,
|
in_vblank: bool,
|
||||||
frame_complete: bool,
|
frame_complete: bool,
|
||||||
|
cpu_cycles_since_poll: u32,
|
||||||
mmc3_a12_prev_high: bool,
|
mmc3_a12_prev_high: bool,
|
||||||
mmc3_a12_low_dots: u16,
|
mmc3_a12_low_dots: u16,
|
||||||
mmc3_last_irq_scanline: u32,
|
mmc3_last_irq_scanline: u32,
|
||||||
@@ -47,6 +48,7 @@ impl NativeBus {
|
|||||||
odd_frame: false,
|
odd_frame: false,
|
||||||
in_vblank: false,
|
in_vblank: false,
|
||||||
frame_complete: false,
|
frame_complete: false,
|
||||||
|
cpu_cycles_since_poll: 0,
|
||||||
mmc3_a12_prev_high: false,
|
mmc3_a12_prev_high: false,
|
||||||
mmc3_a12_low_dots: 8,
|
mmc3_a12_low_dots: 8,
|
||||||
mmc3_last_irq_scanline: u32::MAX,
|
mmc3_last_irq_scanline: u32::MAX,
|
||||||
@@ -84,6 +86,12 @@ impl NativeBus {
|
|||||||
pub fn clock_cpu(&mut self, cycles: u8) {
|
pub fn clock_cpu(&mut self, cycles: u8) {
|
||||||
self.clock_cpu_cycles(cycles as u32);
|
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).
|
// CpuBus trait implementation (memory map + side effects).
|
||||||
|
|||||||
@@ -312,3 +312,23 @@ fn dmc_playback_updates_output_level_from_sample_bits() {
|
|||||||
|
|
||||||
assert!(bus.apu.dmc_output_level < initial);
|
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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use crate::native_core::mapper::Mapper;
|
|||||||
|
|
||||||
impl NativeBus {
|
impl NativeBus {
|
||||||
fn clock_one_cpu_cycle(&mut self) {
|
fn clock_one_cpu_cycle(&mut self) {
|
||||||
|
self.cpu_cycles_since_poll = self.cpu_cycles_since_poll.saturating_add(1);
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
self.clock_ppu_dot();
|
self.clock_ppu_dot();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub struct AudioMixer {
|
|||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
samples_per_cpu_cycle: f64,
|
samples_per_cpu_cycle: f64,
|
||||||
sample_accumulator: f64,
|
sample_accumulator: f64,
|
||||||
|
last_output_sample: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioMixer {
|
impl AudioMixer {
|
||||||
@@ -15,6 +16,7 @@ impl AudioMixer {
|
|||||||
sample_rate,
|
sample_rate,
|
||||||
samples_per_cpu_cycle: sample_rate as f64 / cpu_hz,
|
samples_per_cpu_cycle: sample_rate as f64 / cpu_hz,
|
||||||
sample_accumulator: 0.0,
|
sample_accumulator: 0.0,
|
||||||
|
last_output_sample: 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,10 +26,11 @@ impl AudioMixer {
|
|||||||
|
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
self.sample_accumulator = 0.0;
|
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>) {
|
pub fn push_cycles(&mut self, cpu_cycles: u32, channels: ChannelOutputs, out: &mut Vec<f32>) {
|
||||||
self.sample_accumulator += self.samples_per_cpu_cycle * f64::from(cpu_cycles);
|
self.sample_accumulator += self.samples_per_cpu_cycle * cpu_cycles as f64;
|
||||||
let samples = self.sample_accumulator.floor() as usize;
|
let samples = self.sample_accumulator.floor() as usize;
|
||||||
self.sample_accumulator -= samples as f64;
|
self.sample_accumulator -= samples as f64;
|
||||||
|
|
||||||
@@ -35,10 +38,23 @@ impl AudioMixer {
|
|||||||
let tnd_out = 0.00851 * f32::from(channels.triangle)
|
let tnd_out = 0.00851 * f32::from(channels.triangle)
|
||||||
+ 0.00494 * f32::from(channels.noise)
|
+ 0.00494 * f32::from(channels.noise)
|
||||||
+ 0.00335 * f32::from(channels.dmc);
|
+ 0.00335 * f32::from(channels.dmc);
|
||||||
let mixed = pulse_out + tnd_out;
|
let sample = pulse_out + tnd_out;
|
||||||
let sample = mixed * 2.0 - 1.0;
|
|
||||||
|
|
||||||
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::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mixer_silent_channels_produce_negative_one() {
|
fn mixer_silent_channels_produce_zero() {
|
||||||
let mut mixer = AudioMixer::new(44_100, VideoMode::Ntsc);
|
let mut mixer = AudioMixer::new(44_100, VideoMode::Ntsc);
|
||||||
let channels = ChannelOutputs::default();
|
let channels = ChannelOutputs::default();
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
mixer.push_cycles(50, channels, &mut out);
|
mixer.push_cycles(50, channels, &mut out);
|
||||||
assert!(!out.is_empty());
|
assert!(!out.is_empty());
|
||||||
for &s in &out {
|
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}");
|
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]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use crate::runtime::state::{load_runtime_state, save_runtime_state};
|
use crate::runtime::state::{load_runtime_state, save_runtime_state};
|
||||||
use crate::runtime::{
|
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 {
|
pub struct NesRuntime {
|
||||||
cpu: Cpu6502,
|
cpu: Cpu6502,
|
||||||
@@ -79,11 +79,11 @@ impl NesRuntime {
|
|||||||
self.bus.set_joypad_buttons(buttons);
|
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);
|
self.bus.set_joypad_buttons(self.buttons);
|
||||||
let cycles = self.cpu.step(&mut self.bus).map_err(RuntimeError::Cpu)?;
|
let cpu_cycles = self.cpu.step(&mut self.bus).map_err(RuntimeError::Cpu)?;
|
||||||
self.bus.clock_cpu(cycles);
|
self.bus.clock_cpu(cpu_cycles);
|
||||||
Ok(cycles)
|
Ok(self.bus.take_cpu_cycles_since_poll())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_until_frame_complete(&mut self) -> Result<(), RuntimeError> {
|
pub fn run_until_frame_complete(&mut self) -> Result<(), RuntimeError> {
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
use crate::runtime::{
|
use crate::runtime::{
|
||||||
AudioOutput, ClientRuntime, EmulationState, FRAME_RGBA_BYTES, HostConfig, InputProvider,
|
button_pressed, set_button_pressed, AudioOutput, ClientRuntime, EmulationState, HostConfig,
|
||||||
JOYPAD_BUTTON_ORDER, JOYPAD_BUTTONS_COUNT, JoypadButton, NesRuntime, NoopClock,
|
InputProvider, JoypadButton, NesRuntime, NoopClock, NullAudio, NullInput, NullVideo,
|
||||||
RuntimeHostLoop, VideoMode, VideoOutput, button_pressed, set_button_pressed,
|
RuntimeHostLoop, VideoMode, VideoOutput, FRAME_RGBA_BYTES, JOYPAD_BUTTONS_COUNT,
|
||||||
|
JOYPAD_BUTTON_ORDER,
|
||||||
};
|
};
|
||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
fn nrom_test_rom() -> Vec<u8> {
|
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];
|
let mut rom = vec![0u8; 16 + 16 * 1024 + 8 * 1024];
|
||||||
rom[0..4].copy_from_slice(b"NES\x1A");
|
rom[0..4].copy_from_slice(b"NES\x1A");
|
||||||
rom[4] = 1; // 16 KiB PRG
|
rom[4] = 1; // 16 KiB PRG
|
||||||
@@ -17,11 +22,7 @@ fn nrom_test_rom() -> Vec<u8> {
|
|||||||
rom[reset_vec] = 0x00;
|
rom[reset_vec] = 0x00;
|
||||||
rom[reset_vec + 1] = 0x80;
|
rom[reset_vec + 1] = 0x80;
|
||||||
|
|
||||||
// 0x8000: NOP; JMP $8000
|
rom[prg_offset..prg_offset + program.len()].copy_from_slice(program);
|
||||||
rom[prg_offset] = 0xEA;
|
|
||||||
rom[prg_offset + 1] = 0x4C;
|
|
||||||
rom[prg_offset + 2] = 0x00;
|
|
||||||
rom[prg_offset + 3] = 0x80;
|
|
||||||
rom
|
rom
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +84,30 @@ fn audio_mixer_generates_samples() {
|
|||||||
assert_eq!(mixer.sample_rate(), 48_000);
|
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;
|
struct FixedInput;
|
||||||
|
|
||||||
impl InputProvider for FixedInput {
|
impl InputProvider for FixedInput {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use nesemu::prelude::*;
|
use nesemu::prelude::*;
|
||||||
use nesemu::{
|
use nesemu::{
|
||||||
AudioOutput, HostConfig, InputProvider, JOYPAD_BUTTONS_COUNT, NullAudio, NullInput, NullVideo,
|
AudioOutput, HostConfig, InputProvider, NullAudio, NullInput, NullVideo, RuntimeError,
|
||||||
RuntimeError, VideoOutput,
|
VideoOutput, JOYPAD_BUTTONS_COUNT,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -212,7 +212,7 @@ fn public_api_regression_hashes_for_reference_rom() {
|
|||||||
.expect("run frames");
|
.expect("run frames");
|
||||||
|
|
||||||
let expected_frame_hash = 0x42d1_20e3_54e0_a325_u64;
|
let expected_frame_hash = 0x42d1_20e3_54e0_a325_u64;
|
||||||
let expected_audio_hash = 0xa075_8dd6_adea_e775_u64;
|
let expected_audio_hash = 0x19f5_be12_66f3_37c5_u64;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
video.last_hash, expected_frame_hash,
|
video.last_hash, expected_frame_hash,
|
||||||
|
|||||||
Reference in New Issue
Block a user