Files
nesemu/docs/superpowers/plans/2026-03-13-audio-output.md
2026-03-13 16:21:30 +03:00

1033 lines
31 KiB
Markdown

# 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<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_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<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`:
```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<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):
```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<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 `DesktopApp` to use CpalAudioSink**
In `crates/nesemu-desktop/src/main.rs`, update `DesktopApp` struct (lines 426-432):
```rust
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):
```rust
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:
```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"
```