1033 lines
31 KiB
Markdown
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"
|
|
```
|