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
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
pub mod builder;
|
pub mod builder;
|
||||||
pub mod buffer;
|
pub mod buffer;
|
||||||
|
pub mod pipewire;
|
||||||
pub mod types;
|
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