feat(audio): implement ephemeral PipeWire capture stream lifecycle

This commit is contained in:
katR 2026-09-29 21:10:53 +05:30
parent 4629456002
commit 370a13c422
3 changed files with 120 additions and 0 deletions

View file

@ -1,4 +1,5 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pub mod builder;
pub mod buffer;
pub mod pipewire;
pub mod types;

View file

@ -0,0 +1,88 @@
// 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;
}
}

View file

@ -0,0 +1,31 @@
// SPDX-License-Identifier: GPL-3.0-or-later
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");
}