feat(audio): implement ephemeral PipeWire capture stream lifecycle
This commit is contained in:
parent
4629456002
commit
370a13c422
3 changed files with 120 additions and 0 deletions
|
|
@ -1,4 +1,5 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
pub mod builder;
|
||||
pub mod buffer;
|
||||
pub mod pipewire;
|
||||
pub mod types;
|
||||
|
|
|
|||
88
crates/molotov-core/src/audio/pipewire.rs
Normal file
88
crates/molotov-core/src/audio/pipewire.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
31
crates/molotov-core/tests/pipewire_lifecycle_test.rs
Normal file
31
crates/molotov-core/tests/pipewire_lifecycle_test.rs
Normal 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");
|
||||
}
|
||||
Reference in a new issue