docs: add Phase 2B Audio PipeWire implementation plan

This commit is contained in:
katR 2026-09-29 19:39:03 +05:30
parent a9e069ba52
commit 620fe80608

View file

@ -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<AAudioDataCallback>,
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<AAudioDataCallback>, user_data: *mut ()) {
self.data_callback = callback;
self.user_data = user_data;
}
pub fn data_callback(&self) -> Option<AAudioDataCallback> {
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<f32>,
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"
```
---