31 KiB
Audio Output Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add full 5-channel APU mixing and real audio output via cpal to the desktop NES emulator client, with a volume slider in the header bar.
Architecture: The APU gains timer/sequencer state so it can report per-channel output levels. The AudioMixer uses these to produce properly mixed f32 samples. A lock-free SPSC ring buffer bridges the emulation (GTK main thread) and cpal's audio callback (OS thread). The desktop client replaces its stub AudioSink with a CpalAudioSink that writes to the ring buffer.
Tech Stack: Rust, cpal 0.15, GTK4 0.8, std::sync::atomic
Spec: docs/superpowers/specs/2026-03-13-audio-output-design.md
Chunk 1: APU Channel Output State
Task 1: Add timer/sequencer fields to Apu struct
Files:
-
Modify:
src/native_core/apu/types.rs:19-51(Apu struct) -
Modify:
src/native_core/apu/types.rs:53-84(ApuStateTail struct) -
Step 1: Add new fields to
Apustruct
In src/native_core/apu/types.rs, add these fields after line 50 (pending_frame_irq_inhibit: bool,), before the closing }:
// Pulse channel timers & duty sequencers
pub(crate) pulse_timer_counter: [u16; 2],
pub(crate) pulse_duty_step: [u8; 2],
// Triangle channel timer & sequencer
pub(crate) triangle_timer_counter: u16,
pub(crate) triangle_step: u8,
// Noise channel timer & LFSR
pub(crate) noise_timer_counter: u16,
pub(crate) noise_lfsr: u16,
- Step 2: Add matching fields to
ApuStateTailstruct
In the same file, add matching fields after line 83 (pending_frame_irq_inhibit: bool,), before the closing }:
pub pulse_timer_counter: [u16; 2],
pub pulse_duty_step: [u8; 2],
pub triangle_timer_counter: u16,
pub triangle_step: u8,
pub noise_timer_counter: u16,
pub noise_lfsr: u16,
- Step 3: Initialize new fields in
Apu::new()
In src/native_core/apu/api.rs, add to the Self { ... } block in new() (after line 36, pending_frame_irq_inhibit: false,):
pulse_timer_counter: [0; 2],
pulse_duty_step: [0; 2],
triangle_timer_counter: 0,
triangle_step: 0,
noise_timer_counter: 0,
noise_lfsr: 1, // LFSR initialized to 1 per NES hardware
- Step 4: Update
save_state_tail
In src/native_core/apu/api.rs, at the end of save_state_tail() (before the closing }), add:
out.extend_from_slice(&self.pulse_timer_counter[0].to_le_bytes());
out.extend_from_slice(&self.pulse_timer_counter[1].to_le_bytes());
out.extend_from_slice(&self.pulse_duty_step);
out.extend_from_slice(&self.triangle_timer_counter.to_le_bytes());
out.push(self.triangle_step);
out.extend_from_slice(&self.noise_timer_counter.to_le_bytes());
out.extend_from_slice(&self.noise_lfsr.to_le_bytes());
- Step 5: Update
load_state_tail
In src/native_core/apu/api.rs, at the end of load_state_tail() (before the closing }), add:
self.pulse_timer_counter = state.pulse_timer_counter;
self.pulse_duty_step = [state.pulse_duty_step[0] & 0x07, state.pulse_duty_step[1] & 0x07];
self.triangle_timer_counter = state.triangle_timer_counter;
self.triangle_step = state.triangle_step & 0x1F;
self.noise_timer_counter = state.noise_timer_counter;
self.noise_lfsr = if state.noise_lfsr == 0 { 1 } else { state.noise_lfsr };
- Step 6: Update
bus/state.rsdeserialization
In src/native_core/bus/state.rs, add deserialization of the new fields after line 114 (let pending_frame_irq_inhibit = ...;) and before the self.apu.load_state_tail(ApuStateTail { block (line 115):
let pulse_timer_counter = [
u16::from_le_bytes([
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
]),
u16::from_le_bytes([
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
]),
];
let mut pulse_duty_step = [0u8; 2];
pulse_duty_step.copy_from_slice(sio::take_exact(data, &mut cursor, 2, BUS_STATE_CTX)?);
let triangle_timer_counter = u16::from_le_bytes([
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
]);
let triangle_step = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?;
let noise_timer_counter = u16::from_le_bytes([
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
]);
let noise_lfsr = u16::from_le_bytes([
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
]);
Then add the new fields to the ApuStateTail { ... } constructor (after pending_frame_irq_inhibit, on line 145):
pulse_timer_counter,
pulse_duty_step,
triangle_timer_counter,
triangle_step,
noise_timer_counter,
noise_lfsr,
- Step 7: Bump
SAVE_STATE_VERSION
In src/runtime/constants.rs, change line 4:
pub const SAVE_STATE_VERSION: u32 = 2;
- Step 8: Verify it compiles
Run: cargo build 2>&1 | head -30
Expected: successful build (or warnings only)
- Step 9: Commit
git add src/native_core/apu/types.rs src/native_core/apu/api.rs src/native_core/bus/state.rs src/runtime/constants.rs
git commit -m "feat(apu): add timer/sequencer/LFSR fields for channel output tracking"
Task 2: Clock new timer/sequencer state in APU
Files:
-
Modify:
src/native_core/apu/timing.rs(add clocking logic) -
Modify:
src/native_core/apu/api.rs:137-158(clock_cpu_cycle calls new clocking) -
Step 1: Add pulse timer period helper
The APU already has pulse_timer_period() at timing.rs:213. We need triangle and noise period helpers. Add to the end of src/native_core/apu/timing.rs (before the closing }):
pub(crate) fn triangle_timer_period(&self) -> u16 {
let lo = self.io[0x0A] as u16;
let hi = (self.io[0x0B] as u16 & 0x07) << 8;
hi | lo
}
pub(crate) fn noise_timer_period(&self) -> u16 {
const NOISE_PERIOD_TABLE: [u16; 16] = [
4, 8, 16, 32, 64, 96, 128, 160, 202, 254, 380, 508, 762, 1016, 2034, 4068,
];
let idx = (self.io[0x0E] & 0x0F) as usize;
NOISE_PERIOD_TABLE[idx]
}
- Step 2: Add channel clocking methods
Add to src/native_core/apu/timing.rs, before the closing }:
pub(crate) fn clock_pulse_timers(&mut self) {
if self.cpu_cycle_parity {
return; // pulse timers tick every other CPU cycle
}
for ch in 0..2usize {
if self.pulse_timer_counter[ch] == 0 {
let reg_offset = ch * 4;
let period = self.pulse_timer_period(reg_offset + 2);
self.pulse_timer_counter[ch] = period;
self.pulse_duty_step[ch] = (self.pulse_duty_step[ch] + 1) & 0x07;
} else {
self.pulse_timer_counter[ch] -= 1;
}
}
}
pub(crate) fn clock_triangle_timer(&mut self) {
if self.triangle_timer_counter == 0 {
self.triangle_timer_counter = self.triangle_timer_period();
if self.length_counters[2] > 0 && self.triangle_linear_counter > 0 {
self.triangle_step = (self.triangle_step + 1) & 0x1F;
}
} else {
self.triangle_timer_counter -= 1;
}
}
pub(crate) fn clock_noise_timer(&mut self) {
if self.cpu_cycle_parity {
return; // noise timer ticks every other CPU cycle
}
if self.noise_timer_counter == 0 {
self.noise_timer_counter = self.noise_timer_period();
let mode_flag = (self.io[0x0E] & 0x80) != 0;
let feedback_bit = if mode_flag { 6 } else { 1 };
let feedback = (self.noise_lfsr & 1) ^ ((self.noise_lfsr >> feedback_bit) & 1);
self.noise_lfsr = (self.noise_lfsr >> 1) | (feedback << 14);
} else {
self.noise_timer_counter -= 1;
}
}
- Step 3: Call new clocking from
clock_cpu_cycle()
In src/native_core/apu/api.rs, in the clock_cpu_cycle() method, add three lines before self.cpu_cycle_parity = !self.cpu_cycle_parity; (line 157):
self.clock_pulse_timers();
self.clock_triangle_timer();
self.clock_noise_timer();
- Step 4: Reset sequencers on channel writes
In src/native_core/apu/api.rs, in the write() method, update the 0x4003 arm (line 61-64) to also reset the pulse 1 duty step:
0x4003 => {
self.reload_length_counter(0, value >> 3);
self.envelope_start_flags |= 1 << 0;
self.pulse_duty_step[0] = 0;
self.pulse_timer_counter[0] = self.pulse_timer_period(0x02);
}
And the 0x4007 arm (line 68-71) for pulse 2:
0x4007 => {
self.reload_length_counter(1, value >> 3);
self.envelope_start_flags |= 1 << 1;
self.pulse_duty_step[1] = 0;
self.pulse_timer_counter[1] = self.pulse_timer_period(0x06);
}
- Step 5: Verify it compiles and existing tests pass
Run: cargo test 2>&1 | tail -20
Expected: all existing tests pass
- Step 6: Commit
git add src/native_core/apu/timing.rs src/native_core/apu/api.rs
git commit -m "feat(apu): clock pulse/triangle/noise timers and sequencers"
Task 3: Add ChannelOutputs struct and channel_outputs() method
Files:
-
Modify:
src/native_core/apu/types.rs(add ChannelOutputs) -
Modify:
src/native_core/apu/api.rs(add channel_outputs method) -
Modify:
src/native_core/apu/mod.rs(re-export ChannelOutputs) -
Modify:
src/native_core/bus.rs(expose via bus) -
Modify:
src/lib.rs(re-export from crate root) -
Step 1: Add
ChannelOutputsstruct
At the top of src/native_core/apu/types.rs (before the Apu struct), add:
#[derive(Debug, Clone, Copy, Default)]
pub struct ChannelOutputs {
pub pulse1: u8,
pub pulse2: u8,
pub triangle: u8,
pub noise: u8,
pub dmc: u8,
}
- Step 2: Add
channel_outputs()to Apu
At the end of src/native_core/apu/api.rs (before the closing } of impl Apu), add:
pub fn channel_outputs(&self) -> ChannelOutputs {
const PULSE_DUTY_TABLE: [[u8; 8]; 4] = [
[0, 1, 0, 0, 0, 0, 0, 0], // 12.5%
[0, 1, 1, 0, 0, 0, 0, 0], // 25%
[0, 1, 1, 1, 1, 0, 0, 0], // 50%
[1, 0, 0, 1, 1, 1, 1, 1], // 75% (negated 25%)
];
const TRIANGLE_SEQUENCE: [u8; 32] = [
15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
];
let pulse1 = {
let duty = (self.io[0x00] >> 6) as usize;
let step = self.pulse_duty_step[0] as usize;
let volume = if (self.io[0x00] & 0x10) != 0 {
self.io[0x00] & 0x0F
} else {
self.envelope_decay[0]
};
let active = (self.channel_enable_mask & 0x01) != 0
&& self.length_counters[0] > 0
&& PULSE_DUTY_TABLE[duty][step] != 0
&& !self.sweep_mutes_channel(0, 0x02);
if active { volume } else { 0 }
};
let pulse2 = {
let duty = (self.io[0x04] >> 6) as usize;
let step = self.pulse_duty_step[1] as usize;
let volume = if (self.io[0x04] & 0x10) != 0 {
self.io[0x04] & 0x0F
} else {
self.envelope_decay[1]
};
let active = (self.channel_enable_mask & 0x02) != 0
&& self.length_counters[1] > 0
&& PULSE_DUTY_TABLE[duty][step] != 0
&& !self.sweep_mutes_channel(1, 0x06);
if active { volume } else { 0 }
};
let triangle = {
let active = (self.channel_enable_mask & 0x04) != 0
&& self.length_counters[2] > 0
&& self.triangle_linear_counter > 0;
if active {
TRIANGLE_SEQUENCE[self.triangle_step as usize & 0x1F]
} else {
0
}
};
let noise = {
let volume = if (self.io[0x0C] & 0x10) != 0 {
self.io[0x0C] & 0x0F
} else {
self.envelope_decay[2]
};
let active = (self.channel_enable_mask & 0x08) != 0
&& self.length_counters[3] > 0
&& (self.noise_lfsr & 1) == 0;
if active { volume } else { 0 }
};
let dmc = self.dmc_output_level;
ChannelOutputs { pulse1, pulse2, triangle, noise, dmc }
}
- Step 3: Update
api.rsimport
At the top of src/native_core/apu/api.rs, change:
use super::types::{Apu, ApuStateTail};
to:
use super::types::{Apu, ApuStateTail, ChannelOutputs};
- Step 4: Re-export
ChannelOutputsfrom apu mod
In src/native_core/apu/mod.rs, change:
pub use types::{Apu, ApuStateTail};
to:
pub use types::{Apu, ApuStateTail, ChannelOutputs};
- Step 5: Expose via bus
In src/native_core/bus.rs, add after apu_registers() method (after line 59):
pub fn apu_channel_outputs(&self) -> crate::native_core::apu::ChannelOutputs {
self.apu.channel_outputs()
}
- Step 6: Re-export from crate root
In src/lib.rs, update line 19:
pub use native_core::apu::{Apu, ApuStateTail, ChannelOutputs};
- Step 7: Verify it compiles
Run: cargo build 2>&1 | head -30
Expected: successful build
- Step 8: Commit
git add src/native_core/apu/ src/native_core/bus.rs src/lib.rs
git commit -m "feat(apu): add ChannelOutputs struct and channel_outputs() method"
Task 4: Rewrite AudioMixer with 5-channel mixing
Files:
-
Modify:
src/runtime/audio.rs(rewrite push_cycles) -
Modify:
src/runtime/core.rs:104-116(pass ChannelOutputs) -
Modify:
src/runtime/mod.rs(re-export ChannelOutputs) -
Step 1: Rewrite
AudioMixer::push_cycles()
Replace the entire src/runtime/audio.rs with:
use crate::native_core::apu::ChannelOutputs;
use crate::runtime::VideoMode;
#[derive(Debug)]
pub struct AudioMixer {
sample_rate: u32,
samples_per_cpu_cycle: f64,
sample_accumulator: f64,
}
impl AudioMixer {
pub fn new(sample_rate: u32, mode: VideoMode) -> Self {
let cpu_hz = mode.cpu_hz();
Self {
sample_rate,
samples_per_cpu_cycle: sample_rate as f64 / cpu_hz,
sample_accumulator: 0.0,
}
}
pub fn sample_rate(&self) -> u32 {
self.sample_rate
}
pub fn reset(&mut self) {
self.sample_accumulator = 0.0;
}
pub fn push_cycles(&mut self, cpu_cycles: u8, channels: ChannelOutputs, out: &mut Vec<f32>) {
self.sample_accumulator += self.samples_per_cpu_cycle * f64::from(cpu_cycles);
let samples = self.sample_accumulator.floor() as usize;
self.sample_accumulator -= samples as f64;
let pulse_out = 0.00752 * (f32::from(channels.pulse1) + f32::from(channels.pulse2));
let tnd_out = 0.00851 * f32::from(channels.triangle)
+ 0.00494 * f32::from(channels.noise)
+ 0.00335 * f32::from(channels.dmc);
let mixed = pulse_out + tnd_out;
let sample = mixed * 2.0 - 1.0;
out.extend(std::iter::repeat_n(sample, samples));
}
}
- Step 2: Update
run_until_frame_complete_with_audioin core.rs
In src/runtime/core.rs, change the method (lines 104-116):
pub fn run_until_frame_complete_with_audio(
&mut self,
mixer: &mut AudioMixer,
out_samples: &mut Vec<f32>,
) -> Result<(), RuntimeError> {
self.bus.begin_frame();
while !self.bus.take_frame_complete() {
let cycles = self.step_instruction()?;
mixer.push_cycles(cycles, self.bus.apu_channel_outputs(), out_samples);
}
self.frame_number = self.frame_number.saturating_add(1);
Ok(())
}
- Step 3: Add mixer unit test
Add to the end of src/runtime/audio.rs:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mixer_silent_channels_produce_negative_one() {
let mut mixer = AudioMixer::new(44_100, VideoMode::Ntsc);
let channels = ChannelOutputs::default(); // all zeros
let mut out = Vec::new();
mixer.push_cycles(50, channels, &mut out);
assert!(!out.is_empty());
// All channels at 0 → mixed = 0.0, sample = 0.0 * 2.0 - 1.0 = -1.0
for &s in &out {
assert!((s - (-1.0)).abs() < 1e-6, "expected -1.0, got {s}");
}
}
#[test]
fn mixer_max_channels_produce_positive() {
let mut mixer = AudioMixer::new(44_100, VideoMode::Ntsc);
let channels = ChannelOutputs {
pulse1: 15,
pulse2: 15,
triangle: 15,
noise: 15,
dmc: 127,
};
let mut out = Vec::new();
mixer.push_cycles(50, channels, &mut out);
assert!(!out.is_empty());
for &s in &out {
assert!(s > 0.0, "expected positive sample, got {s}");
}
}
}
- Step 4: Verify it compiles and tests pass
Run: cargo test 2>&1 | tail -30
Expected: mixer tests pass. Regression hash test fails (audio hash changed). Note the new hash.
- Step 5: Update the regression hash
In tests/public_api.rs, update expected_audio_hash (line 215) to the new value printed by the failed test.
- Step 6: Verify all tests pass
Run: cargo test 2>&1 | tail -20
Expected: all tests pass
- Step 7: Commit
git add src/runtime/audio.rs src/runtime/core.rs tests/public_api.rs
git commit -m "feat(mixer): 5-channel APU mixing with linear approximation formula"
Chunk 2: Ring Buffer + cpal Audio Backend + Volume Slider
Task 5: Implement lock-free SPSC ring buffer
Files:
-
Create:
src/runtime/ring_buffer.rs -
Modify:
src/runtime/mod.rs(add module) -
Step 1: Write ring buffer unit tests first
Create src/runtime/ring_buffer.rs with tests:
use std::cell::UnsafeCell;
use std::sync::atomic::{AtomicUsize, Ordering};
pub struct RingBuffer {
buffer: UnsafeCell<Box<[f32]>>,
capacity: usize,
head: AtomicUsize,
tail: AtomicUsize,
}
// SAFETY: RingBuffer is an SPSC queue. The producer (push) only writes to
// positions between head and the next head, while the consumer (pop) only
// reads positions between tail and head. Atomic operations on head/tail
// with Acquire/Release ordering ensure proper synchronization.
unsafe impl Send for RingBuffer {}
unsafe impl Sync for RingBuffer {}
impl RingBuffer {
pub fn new(capacity: usize) -> Self {
assert!(capacity > 0);
Self {
buffer: UnsafeCell::new(vec![0.0; capacity].into_boxed_slice()),
capacity,
head: AtomicUsize::new(0),
tail: AtomicUsize::new(0),
}
}
pub fn push(&self, samples: &[f32]) -> usize {
let head = self.head.load(Ordering::Relaxed);
let tail = self.tail.load(Ordering::Acquire);
let available = self.capacity - self.len_internal(head, tail) - 1;
let to_write = samples.len().min(available);
let buf = self.buffer.get();
for i in 0..to_write {
let idx = (head + i) % self.capacity;
// SAFETY: single producer — only one thread writes to positions
// between current head and new head. Consumer never reads here
// until head is updated with Release ordering below.
unsafe { (*buf)[idx] = samples[i]; }
}
self.head.store((head + to_write) % self.capacity, Ordering::Release);
to_write
}
pub fn pop(&self, out: &mut [f32]) -> usize {
let tail = self.tail.load(Ordering::Relaxed);
let head = self.head.load(Ordering::Acquire);
let available = self.len_internal(head, tail);
let to_read = out.len().min(available);
let buf = self.buffer.get();
for i in 0..to_read {
let idx = (tail + i) % self.capacity;
// SAFETY: single consumer — only one thread reads positions
// between current tail and head. Producer never writes here.
unsafe { out[i] = (*buf)[idx]; }
}
self.tail.store((tail + to_read) % self.capacity, Ordering::Release);
to_read
}
/// Clear the buffer. Must only be called when no concurrent push/pop
/// is in progress (e.g., when the audio stream is paused or dropped).
pub fn clear(&self) {
self.tail.store(0, Ordering::SeqCst);
self.head.store(0, Ordering::SeqCst);
}
fn len_internal(&self, head: usize, tail: usize) -> usize {
if head >= tail {
head - tail
} else {
self.capacity - tail + head
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn push_pop_basic() {
let rb = RingBuffer::new(8);
let input = [1.0, 2.0, 3.0];
assert_eq!(rb.push(&input), 3);
let mut out = [0.0; 3];
assert_eq!(rb.pop(&mut out), 3);
assert_eq!(out, [1.0, 2.0, 3.0]);
}
#[test]
fn underrun_returns_zero_count() {
let rb = RingBuffer::new(8);
let mut out = [0.0; 4];
assert_eq!(rb.pop(&mut out), 0);
}
#[test]
fn overrun_drops_new_samples() {
let rb = RingBuffer::new(4); // usable capacity = 3
let input = [1.0, 2.0, 3.0, 4.0, 5.0];
let written = rb.push(&input);
assert_eq!(written, 3);
let mut out = [0.0; 3];
rb.pop(&mut out);
assert_eq!(out, [1.0, 2.0, 3.0]);
}
#[test]
fn clear_resets() {
let rb = RingBuffer::new(8);
rb.push(&[1.0, 2.0]);
rb.clear();
let mut out = [0.0; 2];
assert_eq!(rb.pop(&mut out), 0);
}
#[test]
fn wraparound() {
let rb = RingBuffer::new(4); // usable = 3
rb.push(&[1.0, 2.0, 3.0]);
let mut out = [0.0; 2];
rb.pop(&mut out);
assert_eq!(out, [1.0, 2.0]);
rb.push(&[4.0, 5.0]);
let mut out2 = [0.0; 3];
let read = rb.pop(&mut out2);
assert_eq!(read, 3);
assert_eq!(out2, [3.0, 4.0, 5.0]);
}
}
- Step 2: Add module to mod.rs
In src/runtime/mod.rs, add after mod audio; (line 3):
pub mod ring_buffer;
And add to the exports (after pub use audio::AudioMixer; on line 14):
pub use ring_buffer::RingBuffer;
- Step 3: Run tests
Run: cargo test ring_buffer 2>&1
Expected: all 5 ring buffer tests pass
- Step 4: Commit
git add src/runtime/ring_buffer.rs src/runtime/mod.rs
git commit -m "feat: add lock-free SPSC ring buffer for audio streaming"
Task 6: Implement CpalAudioSink in desktop client
Files:
-
Modify:
crates/nesemu-desktop/Cargo.toml(add cpal) -
Modify:
crates/nesemu-desktop/src/main.rs(replace AudioSink with CpalAudioSink) -
Step 1: Add cpal dependency
In crates/nesemu-desktop/Cargo.toml, add after cairo-rs = "0.19":
cpal = "0.15"
- Step 2: Replace AudioSink with CpalAudioSink
In crates/nesemu-desktop/src/main.rs, replace lines 411-420 (the audio stub section) with:
use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
use std::sync::Arc;
struct CpalAudioSink {
_stream: Option<cpal::Stream>,
ring: Arc<nesemu::RingBuffer>,
volume: Arc<AtomicU32>,
}
impl CpalAudioSink {
fn new(volume: Arc<AtomicU32>) -> Self {
let ring = Arc::new(nesemu::RingBuffer::new(4096));
let ring_for_cb = Arc::clone(&ring);
let vol = Arc::clone(&volume);
let stream = Self::try_build_stream(ring_for_cb, vol);
Self {
_stream: stream,
ring,
volume,
}
}
fn try_build_stream(
ring: Arc<nesemu::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);
// Fill remainder with silence on underrun
for sample in &mut data[read..] {
*sample = 0.0;
}
// Apply volume to all samples (including silence — no-op on 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)
}
fn clear(&self) {
self.ring.clear();
}
}
impl nesemu::AudioOutput for CpalAudioSink {
fn push_samples(&mut self, samples: &[f32]) {
self.ring.push(samples);
}
}
- Step 3: Update
DesktopAppto use CpalAudioSink
In crates/nesemu-desktop/src/main.rs, update DesktopApp struct (lines 426-432):
struct DesktopApp {
host: Option<RuntimeHostLoop<Box<dyn FrameClock>>>,
input: InputState,
audio: CpalAudioSink,
frame_rgba: Vec<u8>,
state: EmulationState,
}
- Step 4: Update
DesktopApp::new()to accept volume
Change new() (lines 434-443):
fn new(volume: Arc<AtomicU32>) -> Self {
Self {
host: None,
input: InputState::default(),
audio: CpalAudioSink::new(volume),
frame_rgba: vec![0; FRAME_RGBA_BYTES],
state: EmulationState::Paused,
}
}
- Step 5: Clear ring buffer on ROM load and reset
In load_rom_from_path(), add self.audio.clear(); before self.state = EmulationState::Running;.
In reset(), add self.audio.clear(); before self.state = EmulationState::Running;.
- Step 6: Create shared volume atomic in
build_ui()
In build_ui(), after the constants/beginning of the function, add:
let volume = Arc::new(AtomicU32::new(f32::to_bits(0.75)));
And update the state creation (line 102):
let desktop = Rc::new(RefCell::new(DesktopApp::new(Arc::clone(&volume))));
Add necessary imports at the top of the file:
use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
use std::sync::Arc;
(Remove the duplicate use inside the CpalAudioSink section — move it to file level.)
- Step 7: Verify it compiles
Run: cargo build -p nesemu-desktop 2>&1 | head -30
Expected: successful build
- Step 8: Commit
git add crates/nesemu-desktop/
git commit -m "feat(desktop): replace audio stub with cpal backend and ring buffer"
Task 7: Add volume slider to header bar
Files:
-
Modify:
crates/nesemu-desktop/src/main.rs(add Scale widget) -
Step 1: Create the volume slider
In build_ui(), after the reset_button creation and before header.pack_start(&open_button); (line 76), add:
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);
- Step 2: Pack slider into header bar
After header.pack_start(&reset_button); (line 78), add:
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);
- Step 3: Connect volume slider to atomic
After the volume slider creation, connect the signal:
{
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);
});
}
- Step 4: Verify it compiles and runs
Run: cargo build -p nesemu-desktop 2>&1 | head -20
Expected: successful build
- Step 5: Commit
git add crates/nesemu-desktop/src/main.rs
git commit -m "feat(desktop): add volume slider to header bar"
Task 8: Export RingBuffer from crate root and final verification
Files:
-
Modify:
src/lib.rs(export RingBuffer) -
Step 1: Export RingBuffer from lib.rs
In src/lib.rs, add RingBuffer to the runtime re-exports (line 27-33). Add it to the pub use runtime::{...} block:
pub use runtime::{
AudioMixer, AudioOutput, ClientRuntime, EmulationState, FRAME_HEIGHT, FRAME_RGBA_BYTES,
FRAME_WIDTH, FrameClock, FramePacer, HostConfig, InputProvider, JOYPAD_BUTTON_ORDER,
JOYPAD_BUTTONS_COUNT, JoypadButton, JoypadButtons, NesRuntime, NoopClock, NullAudio, NullInput,
NullVideo, PacingClock, RingBuffer, RuntimeError, RuntimeHostLoop, SAVE_STATE_VERSION, VideoMode,
VideoOutput, button_pressed, set_button_pressed,
};
- Step 2: Run full test suite
Run: cargo test 2>&1 | tail -30
Expected: all tests pass
- Step 3: Build desktop client
Run: cargo build -p nesemu-desktop 2>&1 | head -20
Expected: successful build
- Step 4: Commit
git add src/lib.rs
git commit -m "feat: export RingBuffer from crate root"
- Step 5: Final integration commit (if any loose changes)
Run: cargo clippy --all-targets 2>&1 | head -30
Fix any warnings, then:
git add -A
git commit -m "chore: fix clippy warnings after audio implementation"