# 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 `Apu` struct** In `src/native_core/apu/types.rs`, add these fields after line 50 (`pending_frame_irq_inhibit: bool,`), before the closing `}`: ```rust // 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 `ApuStateTail` struct** In the same file, add matching fields after line 83 (`pending_frame_irq_inhibit: bool,`), before the closing `}`: ```rust 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,`): ```rust 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: ```rust 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: ```rust 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.rs` deserialization** 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): ```rust 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): ```rust 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: ```rust 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** ```bash 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 `}`): ```rust 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 `}`: ```rust 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): ```rust 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: ```rust 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: ```rust 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** ```bash 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 `ChannelOutputs` struct** At the top of `src/native_core/apu/types.rs` (before the Apu struct), add: ```rust #[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: ```rust 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.rs` import** At the top of `src/native_core/apu/api.rs`, change: ```rust use super::types::{Apu, ApuStateTail}; ``` to: ```rust use super::types::{Apu, ApuStateTail, ChannelOutputs}; ``` - [ ] **Step 4: Re-export `ChannelOutputs` from apu mod** In `src/native_core/apu/mod.rs`, change: ```rust pub use types::{Apu, ApuStateTail}; ``` to: ```rust 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): ```rust 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: ```rust 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** ```bash 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: ```rust 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) { 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_audio` in core.rs** In `src/runtime/core.rs`, change the method (lines 104-116): ```rust pub fn run_until_frame_complete_with_audio( &mut self, mixer: &mut AudioMixer, out_samples: &mut Vec, ) -> 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`: ```rust #[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** ```bash 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: ```rust use std::cell::UnsafeCell; use std::sync::atomic::{AtomicUsize, Ordering}; pub struct RingBuffer { buffer: UnsafeCell>, 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): ```rust pub mod ring_buffer; ``` And add to the exports (after `pub use audio::AudioMixer;` on line 14): ```rust 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** ```bash 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"`: ```toml 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: ```rust use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering}; use std::sync::Arc; struct CpalAudioSink { _stream: Option, ring: Arc, volume: Arc, } impl CpalAudioSink { fn new(volume: Arc) -> 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, volume: Arc, ) -> Option { 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 `DesktopApp` to use CpalAudioSink** In `crates/nesemu-desktop/src/main.rs`, update `DesktopApp` struct (lines 426-432): ```rust struct DesktopApp { host: Option>>, input: InputState, audio: CpalAudioSink, frame_rgba: Vec, state: EmulationState, } ``` - [ ] **Step 4: Update `DesktopApp::new()` to accept volume** Change `new()` (lines 434-443): ```rust fn new(volume: Arc) -> 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: ```rust let volume = Arc::new(AtomicU32::new(f32::to_bits(0.75))); ``` And update the state creation (line 102): ```rust let desktop = Rc::new(RefCell::new(DesktopApp::new(Arc::clone(&volume)))); ``` Add necessary imports at the top of the file: ```rust 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** ```bash 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: ```rust 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: ```rust 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: ```rust { 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** ```bash 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: ```rust 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** ```bash 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: ```bash git add -A git commit -m "chore: fix clippy warnings after audio implementation" ```