refactor(desktop): decompose monolithic main.rs into layered modules
Some checks failed
CI / rust (push) Has been cancelled
Some checks failed
CI / rust (push) Has been cancelled
Split DesktopApp into input, audio, video, scheduling, and app modules. Migrate DesktopApp from manual pause/resume logic to library ClientRuntime.
This commit is contained in:
100
crates/nesemu-desktop/src/app.rs
Normal file
100
crates/nesemu-desktop/src/app.rs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::AtomicU32;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use nesemu::prelude::{ClientRuntime, EmulationState, HostConfig};
|
||||||
|
use nesemu::{FrameClock, NesRuntime, VideoMode};
|
||||||
|
|
||||||
|
use crate::audio::CpalAudioSink;
|
||||||
|
use crate::input::InputState;
|
||||||
|
use crate::video::BufferedVideo;
|
||||||
|
use crate::SAMPLE_RATE;
|
||||||
|
|
||||||
|
pub(crate) struct DesktopApp {
|
||||||
|
session: Option<ClientRuntime<Box<dyn FrameClock>>>,
|
||||||
|
input: InputState,
|
||||||
|
audio: CpalAudioSink,
|
||||||
|
video: BufferedVideo,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DesktopApp {
|
||||||
|
pub(crate) fn new(volume: Arc<AtomicU32>) -> Self {
|
||||||
|
Self {
|
||||||
|
session: None,
|
||||||
|
input: InputState::default(),
|
||||||
|
audio: CpalAudioSink::new(volume),
|
||||||
|
video: BufferedVideo::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn load_rom_from_path(
|
||||||
|
&mut self,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let data = std::fs::read(path)?;
|
||||||
|
let runtime = NesRuntime::from_rom_bytes(&data)?;
|
||||||
|
let config = HostConfig::new(SAMPLE_RATE, false);
|
||||||
|
let session = ClientRuntime::with_config(runtime, config);
|
||||||
|
self.session = Some(session);
|
||||||
|
self.audio.clear();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn reset(&mut self) {
|
||||||
|
if let Some(session) = self.session.as_mut() {
|
||||||
|
session.host_mut().runtime_mut().reset();
|
||||||
|
self.audio.clear();
|
||||||
|
session.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_loaded(&self) -> bool {
|
||||||
|
self.session.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn state(&self) -> EmulationState {
|
||||||
|
self.session
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.state())
|
||||||
|
.unwrap_or(EmulationState::Paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn toggle_pause(&mut self) {
|
||||||
|
if let Some(session) = self.session.as_mut() {
|
||||||
|
match session.state() {
|
||||||
|
EmulationState::Running => session.pause(),
|
||||||
|
_ => session.resume(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn tick(&mut self) {
|
||||||
|
let Some(session) = self.session.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
match session.tick(&mut self.input, &mut self.video, &mut self.audio) {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("Frame execution error: {err}");
|
||||||
|
session.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn frame_rgba(&self) -> &[u8] {
|
||||||
|
self.video.frame_rgba()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn frame_interval(&self) -> Duration {
|
||||||
|
self.session
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.host().runtime().video_mode().frame_duration())
|
||||||
|
.unwrap_or_else(|| VideoMode::Ntsc.frame_duration())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn input_mut(&mut self) -> &mut InputState {
|
||||||
|
&mut self.input
|
||||||
|
}
|
||||||
|
}
|
||||||
146
crates/nesemu-desktop/src/audio.rs
Normal file
146
crates/nesemu-desktop/src/audio.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
|
||||||
|
|
||||||
|
use nesemu::RingBuffer;
|
||||||
|
|
||||||
|
use crate::SAMPLE_RATE;
|
||||||
|
|
||||||
|
pub(crate) const AUDIO_RING_CAPACITY: usize = 4096;
|
||||||
|
|
||||||
|
pub(crate) struct CpalAudioSink {
|
||||||
|
_stream: Option<cpal::Stream>,
|
||||||
|
ring: Arc<RingBuffer>,
|
||||||
|
_volume: Arc<AtomicU32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CpalAudioSink {
|
||||||
|
pub(crate) fn new(volume: Arc<AtomicU32>) -> Self {
|
||||||
|
let ring = Arc::new(RingBuffer::new(AUDIO_RING_CAPACITY));
|
||||||
|
Self {
|
||||||
|
_stream: None,
|
||||||
|
ring,
|
||||||
|
_volume: volume,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_stream(&mut self) {
|
||||||
|
if self._stream.is_none() {
|
||||||
|
let ring_for_cb = Arc::clone(&self.ring);
|
||||||
|
let vol_for_cb = Arc::clone(&self._volume);
|
||||||
|
self._stream = Self::try_build_stream(ring_for_cb, vol_for_cb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_build_stream(ring: Arc<RingBuffer>, volume: Arc<AtomicU32>) -> Option<cpal::Stream> {
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
|
||||||
|
let host = cpal::default_host();
|
||||||
|
let device = match host.default_output_device() {
|
||||||
|
Some(d) => d,
|
||||||
|
None => {
|
||||||
|
eprintln!("No audio output device found — running without sound");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = cpal_stream_config();
|
||||||
|
|
||||||
|
let stream = match device.build_output_stream(
|
||||||
|
&config,
|
||||||
|
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||||
|
let read = ring.pop(data);
|
||||||
|
for sample in &mut data[read..] {
|
||||||
|
*sample = 0.0;
|
||||||
|
}
|
||||||
|
let vol = f32::from_bits(volume.load(AtomicOrdering::Relaxed));
|
||||||
|
for sample in &mut data[..read] {
|
||||||
|
*sample *= vol;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
move |err| {
|
||||||
|
eprintln!("Audio stream error: {err}");
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("Failed to build audio stream: {err} — running without sound");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = stream.play() {
|
||||||
|
eprintln!("Failed to start audio stream: {err} — running without sound");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn clear(&self) {
|
||||||
|
self.ring.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl nesemu::AudioOutput for CpalAudioSink {
|
||||||
|
fn push_samples(&mut self, samples: &[f32]) {
|
||||||
|
self.ensure_stream();
|
||||||
|
self.ring.push(samples);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cpal_stream_config() -> cpal::StreamConfig {
|
||||||
|
cpal::StreamConfig {
|
||||||
|
channels: 1,
|
||||||
|
sample_rate: cpal::SampleRate(SAMPLE_RATE),
|
||||||
|
buffer_size: cpal::BufferSize::Default,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use nesemu::VideoMode;
|
||||||
|
|
||||||
|
#[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)]
|
||||||
|
const AUDIO_CALLBACK_FRAMES: u32 = 256;
|
||||||
|
|
||||||
|
#[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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn desktop_audio_ring_budget_stays_below_100ms() {
|
||||||
|
let latency_ms = audio_ring_latency_ms(AUDIO_RING_CAPACITY, SAMPLE_RATE);
|
||||||
|
let max_budget_ms = 100.0;
|
||||||
|
assert!(
|
||||||
|
latency_ms <= max_budget_ms,
|
||||||
|
"desktop audio ring latency budget too high: {latency_ms:.2}ms"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn desktop_audio_uses_default_buffer_size() {
|
||||||
|
let config = cpal_stream_config();
|
||||||
|
assert_eq!(config.buffer_size, cpal::BufferSize::Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
crates/nesemu-desktop/src/input.rs
Normal file
30
crates/nesemu-desktop/src/input.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use gtk4::gdk;
|
||||||
|
use nesemu::{InputProvider, JoypadButton, JoypadButtons, set_button_pressed};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct InputState {
|
||||||
|
buttons: JoypadButtons,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputState {
|
||||||
|
pub(crate) fn set_key_state(&mut self, key: gdk::Key, pressed: bool) {
|
||||||
|
let button = match key {
|
||||||
|
gdk::Key::Up => JoypadButton::Up,
|
||||||
|
gdk::Key::Down => JoypadButton::Down,
|
||||||
|
gdk::Key::Left => JoypadButton::Left,
|
||||||
|
gdk::Key::Right => JoypadButton::Right,
|
||||||
|
gdk::Key::x | gdk::Key::X => JoypadButton::A,
|
||||||
|
gdk::Key::z | gdk::Key::Z => JoypadButton::B,
|
||||||
|
gdk::Key::Return => JoypadButton::Start,
|
||||||
|
gdk::Key::Shift_L | gdk::Key::Shift_R => JoypadButton::Select,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
set_button_pressed(&mut self.buttons, button, pressed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputProvider for InputState {
|
||||||
|
fn poll_buttons(&mut self) -> JoypadButtons {
|
||||||
|
self.buttons
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,32 @@
|
|||||||
|
mod app;
|
||||||
|
mod audio;
|
||||||
|
mod input;
|
||||||
|
mod scheduling;
|
||||||
|
mod video;
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
|
use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Instant;
|
||||||
|
|
||||||
use gtk::gdk;
|
use gtk::gdk;
|
||||||
use gtk::gio;
|
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;
|
||||||
use nesemu::{
|
use nesemu::{FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH};
|
||||||
FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, FrameClock, InputProvider, JoypadButton,
|
|
||||||
JoypadButtons, NesRuntime, RingBuffer, VideoMode, VideoOutput, set_button_pressed,
|
use app::DesktopApp;
|
||||||
};
|
use scheduling::DesktopFrameScheduler;
|
||||||
|
|
||||||
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 = 4096;
|
|
||||||
fn main() {
|
fn main() {
|
||||||
if std::env::var_os("GSK_RENDERER").is_none() {
|
if std::env::var_os("GSK_RENDERER").is_none() {
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -131,48 +137,7 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
|||||||
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 = cairo::Format::ARgb32
|
video::draw_frame(&frame, cr, width, height);
|
||||||
.stride_for_width(FRAME_WIDTH as u32)
|
|
||||||
.unwrap();
|
|
||||||
let mut argb = vec![0u8; stride as usize * FRAME_HEIGHT];
|
|
||||||
for y in 0..FRAME_HEIGHT {
|
|
||||||
for x in 0..FRAME_WIDTH {
|
|
||||||
let src = (y * FRAME_WIDTH + x) * 4;
|
|
||||||
let dst = y * stride as usize + x * 4;
|
|
||||||
let r = frame[src];
|
|
||||||
let g = frame[src + 1];
|
|
||||||
let b = frame[src + 2];
|
|
||||||
let a = frame[src + 3];
|
|
||||||
argb[dst] = b;
|
|
||||||
argb[dst + 1] = g;
|
|
||||||
argb[dst + 2] = r;
|
|
||||||
argb[dst + 3] = a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let surface = cairo::ImageSurface::create_for_data(
|
|
||||||
argb,
|
|
||||||
cairo::Format::ARgb32,
|
|
||||||
FRAME_WIDTH as i32,
|
|
||||||
FRAME_HEIGHT as i32,
|
|
||||||
stride,
|
|
||||||
)
|
|
||||||
.expect("Failed to create Cairo surface");
|
|
||||||
|
|
||||||
// Fill background black
|
|
||||||
cr.set_source_rgb(0.0, 0.0, 0.0);
|
|
||||||
let _ = cr.paint();
|
|
||||||
|
|
||||||
let sx = width as f64 / FRAME_WIDTH as f64;
|
|
||||||
let sy = height as f64 / FRAME_HEIGHT as f64;
|
|
||||||
let scale = sx.min(sy);
|
|
||||||
let offset_x = (width as f64 - FRAME_WIDTH as f64 * scale) / 2.0;
|
|
||||||
let offset_y = (height as f64 - FRAME_HEIGHT as f64 * scale) / 2.0;
|
|
||||||
|
|
||||||
cr.translate(offset_x, offset_y);
|
|
||||||
cr.scale(scale, scale);
|
|
||||||
let _ = cr.set_source_surface(&surface, 0.0, 0.0);
|
|
||||||
cr.source().set_filter(cairo::Filter::Nearest);
|
|
||||||
let _ = cr.paint();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,410 +399,3 @@ fn rom_filename(path: &Path) -> String {
|
|||||||
.map(|n| n.to_string_lossy().into_owned())
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
.unwrap_or_else(|| "Unknown".into())
|
.unwrap_or_else(|| "Unknown".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Input
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct InputState {
|
|
||||||
buttons: JoypadButtons,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InputState {
|
|
||||||
fn set_key_state(&mut self, key: gdk::Key, pressed: bool) {
|
|
||||||
let button = match key {
|
|
||||||
gdk::Key::Up => JoypadButton::Up,
|
|
||||||
gdk::Key::Down => JoypadButton::Down,
|
|
||||||
gdk::Key::Left => JoypadButton::Left,
|
|
||||||
gdk::Key::Right => JoypadButton::Right,
|
|
||||||
gdk::Key::x | gdk::Key::X => JoypadButton::A,
|
|
||||||
gdk::Key::z | gdk::Key::Z => JoypadButton::B,
|
|
||||||
gdk::Key::Return => JoypadButton::Start,
|
|
||||||
gdk::Key::Shift_L | gdk::Key::Shift_R => JoypadButton::Select,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
set_button_pressed(&mut self.buttons, button, pressed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InputProvider for InputState {
|
|
||||||
fn poll_buttons(&mut self) -> JoypadButtons {
|
|
||||||
self.buttons
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Audio (cpal backend)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
struct CpalAudioSink {
|
|
||||||
_stream: Option<cpal::Stream>,
|
|
||||||
ring: Arc<RingBuffer>,
|
|
||||||
_volume: Arc<AtomicU32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CpalAudioSink {
|
|
||||||
fn new(volume: Arc<AtomicU32>) -> Self {
|
|
||||||
let ring = Arc::new(RingBuffer::new(AUDIO_RING_CAPACITY));
|
|
||||||
// Do NOT open the audio device here. Creating a cpal stream at startup
|
|
||||||
// forces the system audio server (PipeWire/PulseAudio) to allocate
|
|
||||||
// resources and may disrupt other running audio applications even when
|
|
||||||
// the emulator is idle. The stream is opened lazily on the first
|
|
||||||
// push_samples call, i.e. only when a ROM is actually playing.
|
|
||||||
Self {
|
|
||||||
_stream: None,
|
|
||||||
ring,
|
|
||||||
_volume: volume,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_stream(&mut self) {
|
|
||||||
if self._stream.is_none() {
|
|
||||||
let ring_for_cb = Arc::clone(&self.ring);
|
|
||||||
let vol_for_cb = Arc::clone(&self._volume);
|
|
||||||
self._stream = Self::try_build_stream(ring_for_cb, vol_for_cb);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_build_stream(ring: Arc<RingBuffer>, volume: Arc<AtomicU32>) -> Option<cpal::Stream> {
|
|
||||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
|
||||||
|
|
||||||
let host = cpal::default_host();
|
|
||||||
let device = match host.default_output_device() {
|
|
||||||
Some(d) => d,
|
|
||||||
None => {
|
|
||||||
eprintln!("No audio output device found — running without sound");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let config = cpal_stream_config();
|
|
||||||
|
|
||||||
let stream = match device.build_output_stream(
|
|
||||||
&config,
|
|
||||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
|
||||||
let read = ring.pop(data);
|
|
||||||
for sample in &mut data[read..] {
|
|
||||||
*sample = 0.0;
|
|
||||||
}
|
|
||||||
let vol = f32::from_bits(volume.load(AtomicOrdering::Relaxed));
|
|
||||||
for sample in &mut data[..read] {
|
|
||||||
*sample *= vol;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
move |err| {
|
|
||||||
eprintln!("Audio stream error: {err}");
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(err) => {
|
|
||||||
eprintln!("Failed to build audio stream: {err} — running without sound");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(err) = stream.play() {
|
|
||||||
eprintln!("Failed to start audio stream: {err} — running without sound");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(stream)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset the ring buffer. Note: the cpal callback may still be calling
|
|
||||||
/// `pop()` concurrently; in practice this is benign — at worst a few stale
|
|
||||||
/// samples are played during the ROM load / reset transition.
|
|
||||||
fn clear(&self) {
|
|
||||||
self.ring.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl nesemu::AudioOutput for CpalAudioSink {
|
|
||||||
fn push_samples(&mut self, samples: &[f32]) {
|
|
||||||
self.ensure_stream();
|
|
||||||
self.ring.push(samples);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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),
|
|
||||||
// Use the audio server's default buffer size to avoid forcing the entire
|
|
||||||
// PipeWire/PulseAudio graph into low-latency mode, which would disturb
|
|
||||||
// other audio applications (browsers, media players, etc.).
|
|
||||||
buffer_size: cpal::BufferSize::Default,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
struct DesktopApp {
|
|
||||||
host: Option<RuntimeHostLoop<Box<dyn FrameClock>>>,
|
|
||||||
input: InputState,
|
|
||||||
audio: CpalAudioSink,
|
|
||||||
video: BufferedVideo,
|
|
||||||
state: EmulationState,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DesktopApp {
|
|
||||||
fn new(volume: Arc<AtomicU32>) -> Self {
|
|
||||||
Self {
|
|
||||||
host: None,
|
|
||||||
input: InputState::default(),
|
|
||||||
audio: CpalAudioSink::new(volume),
|
|
||||||
video: BufferedVideo::new(),
|
|
||||||
state: EmulationState::Paused,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_rom_from_path(&mut self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let data = std::fs::read(path)?;
|
|
||||||
let runtime = NesRuntime::from_rom_bytes(&data)?;
|
|
||||||
let config = HostConfig::new(SAMPLE_RATE, false);
|
|
||||||
self.host = Some(RuntimeHostLoop::with_config(runtime, config));
|
|
||||||
self.audio.clear();
|
|
||||||
self.state = EmulationState::Running;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reset(&mut self) {
|
|
||||||
if let Some(host) = self.host.as_mut() {
|
|
||||||
host.runtime_mut().reset();
|
|
||||||
self.audio.clear();
|
|
||||||
self.state = EmulationState::Running;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_loaded(&self) -> bool {
|
|
||||||
self.host.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state(&self) -> EmulationState {
|
|
||||||
self.state
|
|
||||||
}
|
|
||||||
|
|
||||||
fn toggle_pause(&mut self) {
|
|
||||||
self.state = match self.state {
|
|
||||||
EmulationState::Running => EmulationState::Paused,
|
|
||||||
EmulationState::Paused => EmulationState::Running,
|
|
||||||
_ => EmulationState::Paused,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tick(&mut self) {
|
|
||||||
if self.state != EmulationState::Running {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(host) = self.host.as_mut() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
match host.run_frame_unpaced(&mut self.input, &mut self.video, &mut self.audio) {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(err) => {
|
|
||||||
eprintln!("Frame execution error: {err}");
|
|
||||||
self.state = EmulationState::Paused;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn frame_rgba(&self) -> &[u8] {
|
|
||||||
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 {
|
|
||||||
&mut self.input
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use nesemu::{FRAME_HEIGHT, FRAME_WIDTH, VideoOutput};
|
|
||||||
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_100ms() {
|
|
||||||
let latency_ms = audio_ring_latency_ms(AUDIO_RING_CAPACITY, SAMPLE_RATE);
|
|
||||||
let max_budget_ms = 100.0;
|
|
||||||
assert!(
|
|
||||||
latency_ms <= max_budget_ms,
|
|
||||||
"desktop audio ring latency budget too high: {latency_ms:.2}ms"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn desktop_audio_uses_default_buffer_size() {
|
|
||||||
let config = cpal_stream_config();
|
|
||||||
// Default lets the audio server (PipeWire/PulseAudio) choose the
|
|
||||||
// buffer size, preventing interference with other audio applications.
|
|
||||||
assert_eq!(config.buffer_size, cpal::BufferSize::Default);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
104
crates/nesemu-desktop/src/scheduling.rs
Normal file
104
crates/nesemu-desktop/src/scheduling.rs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
pub(crate) struct DesktopFrameScheduler {
|
||||||
|
next_deadline: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DesktopFrameScheduler {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
next_deadline: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn reset_timing(&mut self) {
|
||||||
|
self.next_deadline = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[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 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
crates/nesemu-desktop/src/video.rs
Normal file
89
crates/nesemu-desktop/src/video.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
use nesemu::{FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, VideoOutput};
|
||||||
|
|
||||||
|
pub(crate) struct BufferedVideo {
|
||||||
|
frame_rgba: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BufferedVideo {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
frame_rgba: vec![0; FRAME_RGBA_BYTES],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn draw_frame(frame: &[u8], cr: &cairo::Context, width: i32, height: i32) {
|
||||||
|
let stride = cairo::Format::ARgb32
|
||||||
|
.stride_for_width(FRAME_WIDTH as u32)
|
||||||
|
.unwrap();
|
||||||
|
let mut argb = vec![0u8; stride as usize * FRAME_HEIGHT];
|
||||||
|
for y in 0..FRAME_HEIGHT {
|
||||||
|
for x in 0..FRAME_WIDTH {
|
||||||
|
let src = (y * FRAME_WIDTH + x) * 4;
|
||||||
|
let dst = y * stride as usize + x * 4;
|
||||||
|
let r = frame[src];
|
||||||
|
let g = frame[src + 1];
|
||||||
|
let b = frame[src + 2];
|
||||||
|
let a = frame[src + 3];
|
||||||
|
argb[dst] = b;
|
||||||
|
argb[dst + 1] = g;
|
||||||
|
argb[dst + 2] = r;
|
||||||
|
argb[dst + 3] = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let surface = cairo::ImageSurface::create_for_data(
|
||||||
|
argb,
|
||||||
|
cairo::Format::ARgb32,
|
||||||
|
FRAME_WIDTH as i32,
|
||||||
|
FRAME_HEIGHT as i32,
|
||||||
|
stride,
|
||||||
|
)
|
||||||
|
.expect("Failed to create Cairo surface");
|
||||||
|
|
||||||
|
cr.set_source_rgb(0.0, 0.0, 0.0);
|
||||||
|
let _ = cr.paint();
|
||||||
|
|
||||||
|
let sx = width as f64 / FRAME_WIDTH as f64;
|
||||||
|
let sy = height as f64 / FRAME_HEIGHT as f64;
|
||||||
|
let scale = sx.min(sy);
|
||||||
|
let offset_x = (width as f64 - FRAME_WIDTH as f64 * scale) / 2.0;
|
||||||
|
let offset_y = (height as f64 - FRAME_HEIGHT as f64 * scale) / 2.0;
|
||||||
|
|
||||||
|
cr.translate(offset_x, offset_y);
|
||||||
|
cr.scale(scale, scale);
|
||||||
|
let _ = cr.set_source_surface(&surface, 0.0, 0.0);
|
||||||
|
cr.source().set_filter(cairo::Filter::Nearest);
|
||||||
|
let _ = cr.paint();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user