feat(desktop): replace audio stub with cpal backend and volume slider
- CpalAudioSink writes to SPSC ring buffer, cpal callback reads - Graceful fallback to silent operation on audio device errors - Volume slider (0-100%) in header bar with speaker icon - Ring buffer cleared on ROM load and reset
This commit is contained in:
@@ -7,3 +7,4 @@ edition = "2024"
|
||||
nesemu = { path = "../.." }
|
||||
gtk4 = "0.8"
|
||||
cairo-rs = "0.19"
|
||||
cpal = "0.15"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use gtk::gio;
|
||||
@@ -11,7 +13,7 @@ use gtk4 as gtk;
|
||||
use nesemu::prelude::{EmulationState, HostConfig, RuntimeHostLoop};
|
||||
use nesemu::{
|
||||
FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, FrameClock, InputProvider, JoypadButton,
|
||||
JoypadButtons, NesRuntime, set_button_pressed,
|
||||
JoypadButtons, NesRuntime, RingBuffer, set_button_pressed,
|
||||
};
|
||||
|
||||
const APP_ID: &str = "org.nesemu.desktop";
|
||||
@@ -73,10 +75,33 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
.sensitive(false)
|
||||
.build();
|
||||
|
||||
let volume = Arc::new(AtomicU32::new(f32::to_bits(0.75)));
|
||||
|
||||
let volume_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 0.05);
|
||||
volume_scale.set_value(0.75);
|
||||
volume_scale.set_draw_value(false);
|
||||
volume_scale.set_width_request(100);
|
||||
volume_scale.set_tooltip_text(Some("Volume"));
|
||||
volume_scale.set_focusable(false);
|
||||
|
||||
{
|
||||
let volume = Arc::clone(&volume);
|
||||
volume_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value() as f32;
|
||||
volume.store(f32::to_bits(val), AtomicOrdering::Relaxed);
|
||||
});
|
||||
}
|
||||
|
||||
header.pack_start(&open_button);
|
||||
header.pack_start(&pause_button);
|
||||
header.pack_start(&reset_button);
|
||||
|
||||
let volume_box = gtk::Box::new(gtk::Orientation::Horizontal, 4);
|
||||
let volume_icon = gtk::Image::from_icon_name("audio-volume-high-symbolic");
|
||||
volume_box.append(&volume_icon);
|
||||
volume_box.append(&volume_scale);
|
||||
header.pack_end(&volume_box);
|
||||
|
||||
window.set_titlebar(Some(&header));
|
||||
|
||||
// --- Drawing area ---
|
||||
@@ -99,7 +124,7 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
window.set_child(Some(&overlay));
|
||||
|
||||
// --- State ---
|
||||
let desktop = Rc::new(RefCell::new(DesktopApp::new()));
|
||||
let desktop = Rc::new(RefCell::new(DesktopApp::new(Arc::clone(&volume))));
|
||||
let frame_for_draw: Rc<RefCell<Vec<u8>>> =
|
||||
Rc::new(RefCell::new(vec![0u8; FRAME_RGBA_BYTES]));
|
||||
|
||||
@@ -409,14 +434,93 @@ impl InputProvider for InputState {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audio (stub)
|
||||
// Audio (cpal backend)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct AudioSink;
|
||||
struct CpalAudioSink {
|
||||
_stream: Option<cpal::Stream>,
|
||||
ring: Arc<RingBuffer>,
|
||||
_volume: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl nesemu::AudioOutput for AudioSink {
|
||||
fn push_samples(&mut self, _samples: &[f32]) {}
|
||||
impl CpalAudioSink {
|
||||
fn new(volume: Arc<AtomicU32>) -> Self {
|
||||
let ring = Arc::new(RingBuffer::new(4096));
|
||||
let ring_for_cb = Arc::clone(&ring);
|
||||
let vol_for_cb = Arc::clone(&volume);
|
||||
let stream = Self::try_build_stream(ring_for_cb, vol_for_cb);
|
||||
Self {
|
||||
_stream: stream,
|
||||
ring,
|
||||
_volume: volume,
|
||||
}
|
||||
}
|
||||
|
||||
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::StreamConfig {
|
||||
channels: 1,
|
||||
sample_rate: cpal::SampleRate(SAMPLE_RATE),
|
||||
buffer_size: cpal::BufferSize::Default,
|
||||
};
|
||||
|
||||
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.ring.push(samples);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -426,17 +530,17 @@ impl nesemu::AudioOutput for AudioSink {
|
||||
struct DesktopApp {
|
||||
host: Option<RuntimeHostLoop<Box<dyn FrameClock>>>,
|
||||
input: InputState,
|
||||
audio: AudioSink,
|
||||
audio: CpalAudioSink,
|
||||
frame_rgba: Vec<u8>,
|
||||
state: EmulationState,
|
||||
}
|
||||
|
||||
impl DesktopApp {
|
||||
fn new() -> Self {
|
||||
fn new(volume: Arc<AtomicU32>) -> Self {
|
||||
Self {
|
||||
host: None,
|
||||
input: InputState::default(),
|
||||
audio: AudioSink,
|
||||
audio: CpalAudioSink::new(volume),
|
||||
frame_rgba: vec![0; FRAME_RGBA_BYTES],
|
||||
state: EmulationState::Paused,
|
||||
}
|
||||
@@ -447,6 +551,7 @@ impl DesktopApp {
|
||||
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(())
|
||||
}
|
||||
@@ -454,6 +559,7 @@ impl DesktopApp {
|
||||
fn reset(&mut self) {
|
||||
if let Some(host) = self.host.as_mut() {
|
||||
host.runtime_mut().reset();
|
||||
self.audio.clear();
|
||||
self.state = EmulationState::Running;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user