This repository has been archived on 2026-10-10. You can view files and clone it, but cannot push or open issues or pull requests.
molotov/docs/superpowers/plans/2026-09-29-molotov-phase2b-audio-pipewire.md

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

// 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::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

// 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::PipeWireStreamController
    • molotov_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_createStreamBuilder
    • AAudioStreamBuilder_openStream
    • AAudioStream_requestStart
    • AAudioStream_requestPause
    • AAudioStream_requestStop
    • AAudioStream_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"