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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 16:21:25 +03:00
parent c97bad5551
commit f9b2b05f3f
3 changed files with 793 additions and 21 deletions

View File

@@ -7,3 +7,4 @@ edition = "2024"
nesemu = { path = "../.." }
gtk4 = "0.8"
cairo-rs = "0.19"
cpal = "0.15"

View File

@@ -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: &gtk::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: &gtk::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;
}
}