docs: add Phase 2B Audio PipeWire implementation plan
This commit is contained in:
parent
a9e069ba52
commit
620fe80608
1 changed files with 813 additions and 0 deletions
|
|
@ -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"
|
||||
```
|
||||
|
||||
---
|
||||
Reference in a new issue