24 KiB
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 onrequestPause,requestStop, orclose. - No Faking Success: All unhandled stream operations return explicit error codes (
AAUDIO_ERROR_UNIMPLEMENTEDorAAUDIO_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
- Microphone Indicator Leak: Calling
requestPausemust drop the capture stream and transition the PipeWire state to destroyed rather than merely muting, avoiding desktop tray privacy indicators. - 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.
- Double Start / Double Pause Calls: Calling
requestStartwhile already started, orrequestPausewhile already paused, must return the expected AAudio result state without panicking or leaking resources. - Data Callback Re-entrancy & Thread Safety: Data callbacks invoked from the audio thread must safely access the user data pointer without data races.
- 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
// 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
// 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,
}
// 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
}
}
// 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
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::AudioRingBuffermolotov_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
// 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
// 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
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::PipeWireStreamControllermolotov_core::audio::pipewire::PipeWireStreamController::is_stream_active()
-
Step 1: Write the failing test for ephemeral microphone destruction on pause
// 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
// 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
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_createStreamBuilderAAudioStreamBuilder_openStreamAAudioStream_requestStartAAudioStream_requestPauseAAudioStream_requestStopAAudioStream_close
-
Step 1: Write the failing test for AAudio C-ABI function dispatch
// 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
// 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
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
// 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
git add tests/ci_audio_loopback.rs
git commit -m "test(audio): implement automated 440 Hz audio loopback CI verification test"