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

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 Apu struct

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 ApuStateTail struct

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.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):

        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 ChannelOutputs struct

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.rs import

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 ChannelOutputs from 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_audio in 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 DesktopApp to 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"