From 370a13c4225302e903e28cd4e62aceaf219b8062 Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Tue, 29 Sep 2026 21:10:53 +0530 Subject: [PATCH] feat(audio): implement ephemeral PipeWire capture stream lifecycle --- crates/molotov-core/src/audio/mod.rs | 1 + crates/molotov-core/src/audio/pipewire.rs | 88 +++++++++++++++++++ .../tests/pipewire_lifecycle_test.rs | 31 +++++++ 3 files changed, 120 insertions(+) create mode 100644 crates/molotov-core/src/audio/pipewire.rs create mode 100644 crates/molotov-core/tests/pipewire_lifecycle_test.rs diff --git a/crates/molotov-core/src/audio/mod.rs b/crates/molotov-core/src/audio/mod.rs index 7337a58..13ad4b2 100644 --- a/crates/molotov-core/src/audio/mod.rs +++ b/crates/molotov-core/src/audio/mod.rs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later pub mod builder; pub mod buffer; +pub mod pipewire; pub mod types; diff --git a/crates/molotov-core/src/audio/pipewire.rs b/crates/molotov-core/src/audio/pipewire.rs new file mode 100644 index 0000000..b6196db --- /dev/null +++ b/crates/molotov-core/src/audio/pipewire.rs @@ -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; + } +} diff --git a/crates/molotov-core/tests/pipewire_lifecycle_test.rs b/crates/molotov-core/tests/pipewire_lifecycle_test.rs new file mode 100644 index 0000000..7b1c25e --- /dev/null +++ b/crates/molotov-core/tests/pipewire_lifecycle_test.rs @@ -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"); +}