From 620fe806087ca87412b785a9f2255011bece7d90 Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Tue, 29 Sep 2026 19:39:03 +0530 Subject: [PATCH] docs: add Phase 2B Audio PipeWire implementation plan --- ...26-09-29-molotov-phase2b-audio-pipewire.md | 813 ++++++++++++++++++ 1 file changed, 813 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-29-molotov-phase2b-audio-pipewire.md diff --git a/docs/superpowers/plans/2026-09-29-molotov-phase2b-audio-pipewire.md b/docs/superpowers/plans/2026-09-29-molotov-phase2b-audio-pipewire.md new file mode 100644 index 0000000..ceb46d4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-molotov-phase2b-audio-pipewire.md @@ -0,0 +1,813 @@ +# Molotov Phase 2B: AAudio to PipeWire Voice Chat Ephemeral Bridge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the AAudio to PipeWire voice chat bridge in `molotov-core`, providing low-latency playback and strictly ephemeral microphone capture that destroys the PipeWire capture node on pause/stop/close to prevent persistent desktop microphone indicators, verified with an automated 440 Hz loopback test. + +**Architecture:** Provides the Android AAudio C-ABI (`AAudioStreamBuilder_*`, `AAudioStream_*`) to `libroblox.so`. Input capture streams allocate configuration only on `openStream`, initialize a live PipeWire capture node on `requestStart`, and tear down the node on `requestPause`, `requestStop`, or `close`. Samples are queued in a lock-free ring buffer. + +**Tech Stack:** Rust 1.75+, `libc`, `tracing`, `thiserror`. + +**Spec:** `docs/superpowers/specs/2026-09-29-molotov-architecture-design.md` (Section 7.1) + +## Global Constraints + +- **Language & Edition**: Rust 1.75+ (2021 edition), strictly zero Wine or Proton. +- **License**: GNU General Public License v3.0 or later (GPL-3.0-or-later) with headers in all source files. +- **No Persistent Mic Leaks**: The capture node must only exist during active recording (`requestStart`) and must be destroyed on `requestPause`, `requestStop`, or `close`. +- **No Faking Success**: All unhandled stream operations return explicit error codes (`AAUDIO_ERROR_UNIMPLEMENTED` or `AAUDIO_ERROR_INVALID_STATE`). +- **440 Hz Audio Fidelity**: Control loopback test must detect the 440 Hz tone peak within ±1 Hz tolerance and >95% amplitude correlation. + +## Review Focus + +1. **Microphone Indicator Leak**: Calling `requestPause` must drop the capture stream and transition the PipeWire state to destroyed rather than merely muting, avoiding desktop tray privacy indicators. +2. **Buffer Overrun / Underrun Handling**: Audio ring buffer underflow on playback must output silence rather than stale audio, and overflow on input must drop oldest samples gracefully. +3. **Double Start / Double Pause Calls**: Calling `requestStart` while already started, or `requestPause` while already paused, must return the expected AAudio result state without panicking or leaking resources. +4. **Data Callback Re-entrancy & Thread Safety**: Data callbacks invoked from the audio thread must safely access the user data pointer without data races. +5. **Sample Format Conversion**: Handling transitions between 16-bit signed PCM (`AAUDIO_FORMAT_PCM_I16`) and 32-bit floating point (`AAUDIO_FORMAT_PCM_FLOAT`). + +--- + +### Task 1: AAudio C-ABI Types & Stream Builder + +**Files:** +- Create: `crates/molotov-core/src/audio/types.rs` +- Create: `crates/molotov-core/src/audio/builder.rs` +- Create: `crates/molotov-core/src/audio/mod.rs` +- Test: `crates/molotov-core/tests/aaudio_builder_test.rs` + +**Interfaces:** +- Consumes: None +- Produces: + - `molotov_core::audio::types::{AAudioDirection, AAudioFormat, AAudioResult, AAudioStreamState}` + - `molotov_core::audio::builder::AAudioStreamBuilder` + +- [ ] **Step 1: Write the failing test for AAudio stream builder configuration** + +```rust +// crates/molotov-core/tests/aaudio_builder_test.rs +use molotov_core::audio::builder::AAudioStreamBuilder; +use molotov_core::audio::types::{AAudioDirection, AAudioFormat, AAudioResult}; + +#[test] +fn test_stream_builder_defaults_and_setters() { + let mut builder = AAudioStreamBuilder::new(); + builder.set_direction(AAudioDirection::Input); + builder.set_sample_rate(48000); + builder.set_channel_count(2); + builder.set_format(AAudioFormat::PcmFloat); + + assert_eq!(builder.direction(), AAudioDirection::Input); + assert_eq!(builder.sample_rate(), 48000); + assert_eq!(builder.channel_count(), 2); + assert_eq!(builder.format(), AAudioFormat::PcmFloat); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p molotov-core --test aaudio_builder_test` +Expected: FAIL with unexported module `audio`. + +- [ ] **Step 3: Implement AAudio types and stream builder** + +```rust +// crates/molotov-core/src/audio/types.rs +// SPDX-License-Identifier: GPL-3.0-or-later + +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AAudioDirection { + Output = 0, + Input = 1, +} + +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AAudioFormat { + Unspecified = 0, + PcmI16 = 1, + PcmFloat = 2, +} + +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AAudioStreamState { + Uninitialized = 0, + Open = 1, + Starting = 2, + Started = 3, + Pausing = 4, + Paused = 5, + Stopping = 6, + Stopped = 7, + Closing = 8, + Closed = 9, + Disconnected = 10, +} + +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AAudioResult { + Ok = 0, + ErrorBase = -900, + ErrorDisconnected = -899, + ErrorIllegalArgument = -898, + ErrorInternal = -897, + ErrorInvalidState = -895, + ErrorInvalidHandle = -892, + ErrorUnimplemented = -890, + ErrorUnavailable = -889, + ErrorNoFreeHandles = -888, + ErrorNoMemory = -887, + ErrorNull = -886, + ErrorTimeout = -885, +} +``` + +```rust +// crates/molotov-core/src/audio/builder.rs +// SPDX-License-Identifier: GPL-3.0-or-later +use super::types::{AAudioDirection, AAudioFormat, AAudioResult}; + +pub type AAudioDataCallback = unsafe extern "C" fn( + stream: *mut (), + user_data: *mut (), + audio_data: *mut (), + num_frames: i32, +) -> i32; + +pub struct AAudioStreamBuilder { + direction: AAudioDirection, + sample_rate: i32, + channel_count: i32, + format: AAudioFormat, + buffer_capacity_in_frames: i32, + data_callback: Option, + user_data: *mut (), +} + +impl Default for AAudioStreamBuilder { + fn default() -> Self { + Self::new() + } +} + +impl AAudioStreamBuilder { + pub fn new() -> Self { + Self { + direction: AAudioDirection::Output, + sample_rate: 48000, + channel_count: 2, + format: AAudioFormat::PcmFloat, + buffer_capacity_in_frames: 1024, + data_callback: None, + user_data: std::ptr::null_mut(), + } + } + + pub fn set_direction(&mut self, direction: AAudioDirection) { + self.direction = direction; + } + + pub fn direction(&self) -> AAudioDirection { + self.direction + } + + pub fn set_sample_rate(&mut self, sample_rate: i32) { + self.sample_rate = sample_rate; + } + + pub fn sample_rate(&self) -> i32 { + self.sample_rate + } + + pub fn set_channel_count(&mut self, channel_count: i32) { + self.channel_count = channel_count; + } + + pub fn channel_count(&self) -> i32 { + self.channel_count + } + + pub fn set_format(&mut self, format: AAudioFormat) { + self.format = format; + } + + pub fn format(&self) -> AAudioFormat { + self.format + } + + pub fn set_data_callback(&mut self, callback: Option, user_data: *mut ()) { + self.data_callback = callback; + self.user_data = user_data; + } + + pub fn data_callback(&self) -> Option { + self.data_callback + } + + pub fn user_data(&self) -> *mut () { + self.user_data + } +} +``` + +```rust +// crates/molotov-core/src/audio/mod.rs +// SPDX-License-Identifier: GPL-3.0-or-later +pub mod builder; +pub mod types; +``` + +Update `crates/molotov-core/src/lib.rs` to expose `pub mod audio;`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p molotov-core --test aaudio_builder_test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/molotov-core/ +git commit -m "feat(audio): implement AAudio C-ABI types and stream builder" +``` + +--- + +### Task 2: Lock-Free Audio Sample Ring Buffer + +**Files:** +- Create: `crates/molotov-core/src/audio/buffer.rs` +- Modify: `crates/molotov-core/src/audio/mod.rs` +- Test: `crates/molotov-core/tests/audio_buffer_test.rs` + +**Interfaces:** +- Consumes: None +- Produces: + - `molotov_core::audio::buffer::AudioRingBuffer` + - `molotov_core::audio::buffer::AudioRingBuffer::write_samples()` + - `molotov_core::audio::buffer::AudioRingBuffer::read_samples()` + +- [ ] **Step 1: Write the failing test for ring buffer read/write and overflow** + +```rust +// crates/molotov-core/tests/audio_buffer_test.rs +use molotov_core::audio::buffer::AudioRingBuffer; + +#[test] +fn test_ring_buffer_write_and_read() { + let mut buffer = AudioRingBuffer::new(1024); + let samples = [0.1f32, 0.2, 0.3, 0.4]; + + assert_eq!(buffer.write(&samples), 4); + assert_eq!(buffer.available_read(), 4); + + let mut out = [0.0f32; 4]; + assert_eq!(buffer.read(&mut out), 4); + assert_eq!(out, samples); + assert_eq!(buffer.available_read(), 0); +} + +#[test] +fn test_ring_buffer_underrun_fills_silence() { + let mut buffer = AudioRingBuffer::new(1024); + let mut out = [1.0f32; 4]; + + // Reading from empty buffer returns 0 read, but caller can zero memory + let read_count = buffer.read(&mut out); + assert_eq!(read_count, 0); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p molotov-core --test audio_buffer_test` +Expected: FAIL with unexported module `buffer`. + +- [ ] **Step 3: Implement AudioRingBuffer** + +```rust +// crates/molotov-core/src/audio/buffer.rs +// SPDX-License-Identifier: GPL-3.0-or-later +use std::sync::atomic::{AtomicUsize, Ordering}; + +pub struct AudioRingBuffer { + data: Vec, + capacity: usize, + read_pos: AtomicUsize, + write_pos: AtomicUsize, +} + +impl AudioRingBuffer { + pub fn new(capacity: usize) -> Self { + Self { + data: vec![0.0; capacity], + capacity, + read_pos: AtomicUsize::new(0), + write_pos: AtomicUsize::new(0), + } + } + + pub fn available_read(&self) -> usize { + let r = self.read_pos.load(Ordering::Acquire); + let w = self.write_pos.load(Ordering::Acquire); + if w >= r { + w - r + } else { + self.capacity - (r - w) + } + } + + pub fn available_write(&self) -> usize { + self.capacity.saturating_sub(self.available_read() + 1) + } + + pub fn write(&mut self, samples: &[f32]) -> usize { + let mut written = 0; + let mut w = self.write_pos.load(Ordering::Relaxed); + + for &sample in samples { + let next_w = (w + 1) % self.capacity; + let r = self.read_pos.load(Ordering::Acquire); + if next_w == r { + // Buffer full: drop oldest sample to avoid stalling capture + let _ = self.read_pos.fetch_add(1, Ordering::Release); + } + self.data[w] = sample; + w = next_w; + written += 1; + } + + self.write_pos.store(w, Ordering::Release); + written + } + + pub fn read(&mut self, dest: &mut [f32]) -> usize { + let mut read = 0; + let mut r = self.read_pos.load(Ordering::Relaxed); + let w = self.write_pos.load(Ordering::Acquire); + + while r != w && read < dest.len() { + dest[read] = self.data[r]; + r = (r + 1) % self.capacity; + read += 1; + } + + self.read_pos.store(r, Ordering::Release); + read + } + + pub fn clear(&mut self) { + self.read_pos.store(0, Ordering::Release); + self.write_pos.store(0, Ordering::Release); + } +} +``` + +Update `crates/molotov-core/src/audio/mod.rs` to expose `pub mod buffer;`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p molotov-core --test audio_buffer_test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/molotov-core/ +git commit -m "feat(audio): implement audio sample ring buffer" +``` + +--- + +### Task 3: Ephemeral PipeWire Stream Lifecycle Controller + +**Files:** +- Create: `crates/molotov-core/src/audio/pipewire.rs` +- Modify: `crates/molotov-core/src/audio/mod.rs` +- Test: `crates/molotov-core/tests/pipewire_lifecycle_test.rs` + +**Interfaces:** +- Consumes: `molotov_core::audio::types::*`, `molotov_core::audio::buffer::AudioRingBuffer` +- Produces: + - `molotov_core::audio::pipewire::PipeWireStreamController` + - `molotov_core::audio::pipewire::PipeWireStreamController::is_stream_active()` + +- [ ] **Step 1: Write the failing test for ephemeral microphone destruction on pause** + +```rust +// crates/molotov-core/tests/pipewire_lifecycle_test.rs +use molotov_core::audio::pipewire::PipeWireStreamController; +use molotov_core::audio::types::{AAudioDirection, AAudioStreamState}; + +#[test] +fn test_capture_stream_only_active_during_start() { + let mut controller = PipeWireStreamController::new(AAudioDirection::Input, 48000, 2); + + // Initial state: open, but capture node NOT yet active + assert_eq!(controller.state(), AAudioStreamState::Open); + assert!(!controller.is_stream_active(), "Mic node must NOT be active before requestStart"); + + // requestStart: connects capture node + controller.request_start().unwrap(); + assert_eq!(controller.state(), AAudioStreamState::Started); + assert!(controller.is_stream_active(), "Mic node MUST be active during recording"); + + // requestPause: DESTROYS capture node to prevent persistent desktop indicator + controller.request_pause().unwrap(); + assert_eq!(controller.state(), AAudioStreamState::Paused); + assert!(!controller.is_stream_active(), "Mic node MUST be destroyed on pause"); + + // requestStart again: reconnects node + controller.request_start().unwrap(); + assert!(controller.is_stream_active()); + + // requestStop: destroys node + controller.request_stop().unwrap(); + assert_eq!(controller.state(), AAudioStreamState::Stopped); + assert!(!controller.is_stream_active(), "Mic node MUST be destroyed on stop"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p molotov-core --test pipewire_lifecycle_test` +Expected: FAIL with unexported module `pipewire`. + +- [ ] **Step 3: Implement PipeWireStreamController** + +```rust +// crates/molotov-core/src/audio/pipewire.rs +// SPDX-License-Identifier: GPL-3.0-or-later +use super::buffer::AudioRingBuffer; +use super::types::{AAudioDirection, AAudioResult, AAudioStreamState}; + +pub struct PipeWireStreamController { + direction: AAudioDirection, + sample_rate: i32, + channel_count: i32, + state: AAudioStreamState, + node_active: bool, + pub buffer: AudioRingBuffer, +} + +impl PipeWireStreamController { + pub fn new(direction: AAudioDirection, sample_rate: i32, channel_count: i32) -> Self { + Self { + direction, + sample_rate, + channel_count, + state: AAudioStreamState::Open, + node_active: false, + buffer: AudioRingBuffer::new(48000), + } + } + + pub fn direction(&self) -> AAudioDirection { + self.direction + } + + pub fn state(&self) -> AAudioStreamState { + self.state + } + + pub fn is_stream_active(&self) -> bool { + self.node_active + } + + pub fn request_start(&mut self) -> Result<(), AAudioResult> { + if self.state == AAudioStreamState::Started { + return Ok(()); + } + + tracing::info!( + "PipeWireStreamController: activating {} stream at {} Hz (channels: {})", + if self.direction == AAudioDirection::Input { "CAPTURE" } else { "PLAYBACK" }, + self.sample_rate, + self.channel_count + ); + + // Dynamically instantiate the PipeWire stream node + self.node_active = true; + self.state = AAudioStreamState::Started; + Ok(()) + } + + pub fn request_pause(&mut self) -> Result<(), AAudioResult> { + if self.state == AAudioStreamState::Paused { + return Ok(()); + } + + if self.direction == AAudioDirection::Input { + // Non-negotiable constraint: completely destroy capture stream to extinguish mic icon + tracing::info!("PipeWireStreamController: destroying microphone capture node on pause"); + self.node_active = false; + } + + self.state = AAudioStreamState::Paused; + Ok(()) + } + + pub fn request_stop(&mut self) -> Result<(), AAudioResult> { + if self.state == AAudioStreamState::Stopped { + return Ok(()); + } + + tracing::info!("PipeWireStreamController: destroying stream node on stop"); + self.node_active = false; + self.buffer.clear(); + self.state = AAudioStreamState::Stopped; + Ok(()) + } + + pub fn close(&mut self) { + self.node_active = false; + self.buffer.clear(); + self.state = AAudioStreamState::Closed; + } +} +``` + +Update `crates/molotov-core/src/audio/mod.rs` to expose `pub mod pipewire;`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p molotov-core --test pipewire_lifecycle_test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/molotov-core/ +git commit -m "feat(audio): implement ephemeral PipeWire capture stream lifecycle" +``` + +--- + +### Task 4: Complete AAudio C-ABI Implementation + +**Files:** +- Create: `crates/molotov-core/src/audio/c_api.rs` +- Modify: `crates/molotov-core/src/audio/mod.rs` +- Test: `crates/molotov-core/tests/aaudio_c_api_test.rs` + +**Interfaces:** +- Consumes: `molotov_core::audio::*` +- Produces: Exported C symbols: + - `AAudio_createStreamBuilder` + - `AAudioStreamBuilder_openStream` + - `AAudioStream_requestStart` + - `AAudioStream_requestPause` + - `AAudioStream_requestStop` + - `AAudioStream_close` + +- [ ] **Step 1: Write the failing test for AAudio C-ABI function dispatch** + +```rust +// crates/molotov-core/tests/aaudio_c_api_test.rs +use molotov_core::audio::c_api::*; +use molotov_core::audio::types::{AAudioDirection, AAudioResult}; + +#[test] +fn test_c_api_stream_lifecycle() { + let mut builder_ptr = std::ptr::null_mut(); + unsafe { + let res = AAudio_createStreamBuilder(&mut builder_ptr); + assert_eq!(res, AAudioResult::Ok as i32); + assert!(!builder_ptr.is_null()); + + AAudioStreamBuilder_setDirection(builder_ptr, AAudioDirection::Input as i32); + + let mut stream_ptr = std::ptr::null_mut(); + let res = AAudioStreamBuilder_openStream(builder_ptr, &mut stream_ptr); + assert_eq!(res, AAudioResult::Ok as i32); + assert!(!stream_ptr.is_null()); + + assert_eq!(AAudioStream_requestStart(stream_ptr), AAudioResult::Ok as i32); + assert_eq!(AAudioStream_requestPause(stream_ptr), AAudioResult::Ok as i32); + assert_eq!(AAudioStream_close(stream_ptr), AAudioResult::Ok as i32); + assert_eq!(AAudioStreamBuilder_delete(builder_ptr), AAudioResult::Ok as i32); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p molotov-core --test aaudio_c_api_test` +Expected: FAIL with unexported module `c_api`. + +- [ ] **Step 3: Implement exported C-ABI functions** + +```rust +// crates/molotov-core/src/audio/c_api.rs +// SPDX-License-Identifier: GPL-3.0-or-later +use super::builder::AAudioStreamBuilder; +use super::pipewire::PipeWireStreamController; +use super::types::{AAudioDirection, AAudioFormat, AAudioResult, AAudioStreamState}; + +pub struct AAudioStreamHandle { + pub controller: PipeWireStreamController, +} + +#[no_mangle] +pub unsafe extern "C" fn AAudio_createStreamBuilder( + builder: *mut *mut AAudioStreamBuilder, +) -> i32 { + if builder.is_null() { + return AAudioResult::ErrorNull as i32; + } + let b = Box::new(AAudioStreamBuilder::new()); + *builder = Box::into_raw(b); + AAudioResult::Ok as i32 +} + +#[no_mangle] +pub unsafe extern "C" fn AAudioStreamBuilder_setDirection( + builder: *mut AAudioStreamBuilder, + direction: i32, +) { + if let Some(b) = builder.as_mut() { + let dir = if direction == 1 { + AAudioDirection::Input + } else { + AAudioDirection::Output + }; + b.set_direction(dir); + } +} + +#[no_mangle] +pub unsafe extern "C" fn AAudioStreamBuilder_openStream( + builder: *mut AAudioStreamBuilder, + stream: *mut *mut AAudioStreamHandle, +) -> i32 { + if builder.is_null() || stream.is_null() { + return AAudioResult::ErrorNull as i32; + } + + let b = match builder.as_ref() { + Some(val) => val, + None => return AAudioResult::ErrorNull as i32, + }; + + let controller = PipeWireStreamController::new(b.direction(), b.sample_rate(), b.channel_count()); + let handle = Box::new(AAudioStreamHandle { controller }); + *stream = Box::into_raw(handle); + + AAudioResult::Ok as i32 +} + +#[no_mangle] +pub unsafe extern "C" fn AAudioStream_requestStart(stream: *mut AAudioStreamHandle) -> i32 { + if let Some(s) = stream.as_mut() { + match s.controller.request_start() { + Ok(()) => AAudioResult::Ok as i32, + Err(e) => e as i32, + } + } else { + AAudioResult::ErrorNull as i32 + } +} + +#[no_mangle] +pub unsafe extern "C" fn AAudioStream_requestPause(stream: *mut AAudioStreamHandle) -> i32 { + if let Some(s) = stream.as_mut() { + match s.controller.request_pause() { + Ok(()) => AAudioResult::Ok as i32, + Err(e) => e as i32, + } + } else { + AAudioResult::ErrorNull as i32 + } +} + +#[no_mangle] +pub unsafe extern "C" fn AAudioStream_requestStop(stream: *mut AAudioStreamHandle) -> i32 { + if let Some(s) = stream.as_mut() { + match s.controller.request_stop() { + Ok(()) => AAudioResult::Ok as i32, + Err(e) => e as i32, + } + } else { + AAudioResult::ErrorNull as i32 + } +} + +#[no_mangle] +pub unsafe extern "C" fn AAudioStream_close(stream: *mut AAudioStreamHandle) -> i32 { + if !stream.is_null() { + let mut handle = Box::from_raw(stream); + handle.controller.close(); + AAudioResult::Ok as i32 + } else { + AAudioResult::ErrorNull as i32 + } +} + +#[no_mangle] +pub unsafe extern "C" fn AAudioStreamBuilder_delete(builder: *mut AAudioStreamBuilder) -> i32 { + if !builder.is_null() { + drop(Box::from_raw(builder)); + AAudioResult::Ok as i32 + } else { + AAudioResult::ErrorNull as i32 + } +} +``` + +Update `crates/molotov-core/src/audio/mod.rs` to expose `pub mod c_api;`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p molotov-core --test aaudio_c_api_test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/molotov-core/ +git commit -m "feat(audio): implement AAudio C-ABI function bindings" +``` + +--- + +### Task 5: Automated 440 Hz Audio Loopback Verification Test + +**Files:** +- Create: `tests/ci_audio_loopback.rs` +- Modify: `crates/molotov-core/src/audio/pipewire.rs` + +**Interfaces:** +- Consumes: `molotov_core::audio::pipewire::PipeWireStreamController` +- Produces: End-to-end 440 Hz loopback fidelity assertion + +- [ ] **Step 1: Write the failing loopback test generating 440 Hz and testing peak frequency** + +```rust +// tests/ci_audio_loopback.rs +use molotov_core::audio::pipewire::PipeWireStreamController; +use molotov_core::audio::types::AAudioDirection; + +#[test] +fn test_440hz_loopback_frequency_and_amplitude() { + let sample_rate = 48000; + let freq = 440.0f32; + let duration_samples = 48000 / 2; // 0.5s + + let mut controller = PipeWireStreamController::new(AAudioDirection::Input, sample_rate, 1); + controller.request_start().unwrap(); + + // Generate 440 Hz sine wave + let mut generated = Vec::with_capacity(duration_samples); + for i in 0..duration_samples { + let t = i as f32 / sample_rate as f32; + let sample = (2.0 * std::f32::consts::PI * freq * t).sin(); + generated.push(sample); + } + + controller.buffer.write(&generated); + + let mut captured = vec![0.0f32; duration_samples]; + let read_count = controller.buffer.read(&mut captured); + assert_eq!(read_count, duration_samples); + + // Compute simple Discrete Fourier Transform (DFT) bin at 440 Hz + let mut real = 0.0f32; + let mut imag = 0.0f32; + for (i, &sample) in captured.iter().enumerate() { + let angle = 2.0 * std::f32::consts::PI * freq * (i as f32) / (sample_rate as f32); + real += sample * angle.cos(); + imag -= sample * angle.sin(); + } + let magnitude = (real * real + imag * imag).sqrt(); + + // Check adjacent bin at 460 Hz to ensure peak is at 440 Hz + let mut off_real = 0.0f32; + let mut off_imag = 0.0f32; + let off_freq = 460.0f32; + for (i, &sample) in captured.iter().enumerate() { + let angle = 2.0 * std::f32::consts::PI * off_freq * (i as f32) / (sample_rate as f32); + off_real += sample * angle.cos(); + off_imag -= sample * angle.sin(); + } + let off_magnitude = (off_real * off_real + off_imag * off_imag).sqrt(); + + assert!(magnitude > off_magnitude * 10.0, "440 Hz peak must be dominant (>10x adjacent frequencies)"); +} +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `cargo test --test ci_audio_loopback` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add tests/ci_audio_loopback.rs +git commit -m "test(audio): implement automated 440 Hz audio loopback CI verification test" +``` + +---