Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 184 additions & 35 deletions src-tauri/Cargo.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,11 @@ objc2-service-management = "0.3.2"
gtk-layer-shell = { version = "0.8", features = ["v0_6"] }
gtk = "0.18"
libc = "0.2"
# Native PipeWire capture backend (see audio/pipewire_recorder.rs). The
# `v0_3_44` feature unlocks newer property keys such as TARGET_OBJECT used to
# pin capture to a specific source node. Linux-only: the cpal/ALSA path stays
# the fallback and remains the only backend on Windows/macOS.
pipewire = { version = "0.10.0", features = ["v0_3_44"] }
transcribe-cpp = { version = "0.2.0", default-features = false, features = [
"dynamic-backends",
"vulkan",
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/audio_toolkit/audio/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Re-export all audio components
mod device;
#[cfg(target_os = "linux")]
mod pipewire_recorder;
mod recorder;
mod recorder_backend;
mod resampler;
mod utils;
mod visualizer;
Expand All @@ -9,6 +12,9 @@ pub use device::{list_input_devices, list_output_devices, CpalDeviceInfo};
pub use recorder::{
is_microphone_access_denied, is_no_input_device_error, AudioRecorder, VadPolicy,
};
// Shared parts used by the `Recorder` seam / manager to build backends.
pub(crate) use recorder::{AudioFrameCallback, VadConfig};
pub use recorder_backend::Recorder;
pub use resampler::FrameResampler;
pub use utils::{read_wav_samples, save_wav_file, verify_wav_file};
pub use visualizer::AudioVisualiser;
431 changes: 431 additions & 0 deletions src-tauri/src/audio_toolkit/audio/pipewire_recorder.rs

Large diffs are not rendered by default.

72 changes: 65 additions & 7 deletions src-tauri/src/audio_toolkit/audio/recorder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ use crate::audio_toolkit::{
VoiceActivityDetector,
};

enum Cmd {
/// Control protocol shared by every capture backend. The cpal `AudioRecorder`
/// and the native `PipeWireRecorder` both drive the SAME `run_consumer` loop, so
/// this type is `pub(crate)` to let the pipewire backend speak the same protocol
/// instead of duplicating the consumer/VAD/resampler pipeline.
pub(crate) enum Cmd {
/// Begin capturing. Carries the send timestamp so the consumer can log how
/// long the command sat in the channel, plus a one-shot acknowledgement
/// sent only after the first microphone sample chunk is processed.
Expand All @@ -28,7 +32,10 @@ enum Cmd {
Shutdown,
}

enum AudioChunk {
/// Mono `f32` chunk produced by a capture backend and consumed by
/// `run_consumer`. `pub(crate)` so the pipewire backend can push the exact same
/// chunk type the cpal producer does (see `PipeWireRecorder`).
pub(crate) enum AudioChunk {
Samples(Vec<f32>),
EndOfStream,
}
Expand All @@ -48,14 +55,33 @@ pub enum VadPolicy {
/// should use. The offline and streaming policies are never active
/// concurrently, so one detector is reconfigured per session (see `Cmd::Start`)
/// rather than kept as two resident engines.
/// A single VAD engine plus its two hangover-tail lengths, shared by both
/// capture backends. `pub(crate)` + a public `new` so the pipewire backend and
/// the `Recorder` seam can hold and clone the same config (the detector lives
/// behind `Arc<Mutex<..>>`, so one ONNX session is shared, never duplicated).
#[derive(Clone)]
struct VadConfig {
pub(crate) struct VadConfig {
detector: Arc<Mutex<Box<dyn vad::VoiceActivityDetector>>>,
offline_hangover_frames: usize,
streaming_hangover_frames: usize,
}

impl VadConfig {
/// Build a shared VAD config from a detector and the offline/streaming
/// hangover tails. The detector is wrapped in `Arc<Mutex<..>>` so a single
/// engine can back multiple recorder backends without re-instantiating it.
pub(crate) fn new(
detector: Box<dyn VoiceActivityDetector>,
offline_hangover_frames: usize,
streaming_hangover_frames: usize,
) -> Self {
VadConfig {
detector: Arc::new(Mutex::new(detector)),
offline_hangover_frames,
streaming_hangover_frames,
}
}

/// Post-speech hangover tail (in 30 ms frames) for the given policy.
/// `Disabled` never reaches the detector, so it maps to the offline value.
fn hangover_for(&self, policy: VadPolicy) -> usize {
Expand All @@ -70,6 +96,11 @@ impl VadConfig {
/// policy while recording. Used to feed a live streaming transcription as audio arrives.
pub type AudioFrameCallback = Arc<dyn Fn(&[f32]) + Send + Sync + 'static>;

/// Spectrum-level callback type (per-frame frequency buckets forwarded to the
/// UI). Aliased so both capture backends and the `Recorder` seam can pass the
/// exact same boxed callback without re-spelling the signature.
pub(crate) type LevelCallback = Arc<dyn Fn(Vec<f32>) + Send + Sync + 'static>;

pub struct AudioRecorder {
device: Option<Device>,
cmd_tx: Option<mpsc::Sender<Cmd>>,
Expand Down Expand Up @@ -114,14 +145,37 @@ impl AudioRecorder {
offline_hangover_frames: usize,
streaming_hangover_frames: usize,
) -> Self {
self.vad = Some(VadConfig {
detector: Arc::new(Mutex::new(detector)),
self.vad = Some(VadConfig::new(
detector,
offline_hangover_frames,
streaming_hangover_frames,
});
));
self
}

/// Construct a recorder directly from already-built shared parts (VAD +
/// callbacks). This is the seam used by `Recorder` so the cpal backend and
/// the native pipewire backend can share ONE VAD engine and ONE set of
/// callbacks instead of each building its own. Mirrors what the `with_*`
/// builder chain assembles, minus the device (resolved later in `open`).
pub(crate) fn from_parts(
vad: Option<VadConfig>,
level_cb: Option<LevelCallback>,
audio_cb: Option<AudioFrameCallback>,
) -> Self {
AudioRecorder {
device: None,
cmd_tx: None,
worker_handle: None,
vad,
level_cb,
audio_cb,
selected_channel: None,
config_cache: Arc::new(Mutex::new(None)),
stream_error: Arc::new(AtomicBool::new(false)),
}
}

pub fn with_level_callback<F>(mut self, cb: F) -> Self
where
F: Fn(Vec<f32>) + Send + Sync + 'static,
Expand Down Expand Up @@ -661,8 +715,12 @@ mod tests {
}
}

/// Backend-neutral consumer: resample -> VAD -> buffer, driven by the shared
/// `Cmd`/`AudioChunk` protocol. Both the cpal `AudioRecorder` worker and the
/// native `PipeWireRecorder` spawn this on their own thread, so it is
/// `pub(crate)` and must NOT be duplicated per backend.
#[allow(clippy::too_many_arguments)]
fn run_consumer(
pub(crate) fn run_consumer(
in_sample_rate: u32,
vad: Option<VadConfig>,
sample_rx: mpsc::Receiver<AudioChunk>,
Expand Down
184 changes: 184 additions & 0 deletions src-tauri/src/audio_toolkit/audio/recorder_backend.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
//! `Recorder` — the thin seam that lets `AudioRecordingManager` drive either the
//! native PipeWire backend (Linux) or the cpal/ALSA backend (everywhere) through
//! one type with the SAME method surface (`open`/`start`/`stop`/`close`).
//!
//! Selection strategy (Linux): try PipeWire at `open()`; if its
//! connection/stream setup fails (no session, etc.), fall back to the existing
//! cpal path and log it. Both backends are built up front from the SAME shared
//! VAD + callbacks (VAD lives behind `Arc<Mutex<..>>`, so only one ONNX session
//! exists), but only the selected one is ever opened at a time.
//!
//! Non-Linux builds compile the cpal backend ONLY — no PipeWire code, no
//! behavioural change. cpal remains fully compiled and working on Linux too.

use std::sync::mpsc;

use super::recorder::{AudioFrameCallback, LevelCallback, VadConfig};
use super::{AudioRecorder, VadPolicy};

#[cfg(target_os = "linux")]
use super::pipewire_recorder::PipeWireRecorder;

/// Which backend is currently open (Linux only — non-Linux is always cpal).
#[cfg(target_os = "linux")]
#[derive(Clone, Copy, PartialEq, Eq)]
enum Backend {
/// Nothing open yet, or closed.
None,
PipeWire,
Cpal,
}

pub struct Recorder {
/// Always present: the fallback and the only backend off Linux.
cpal: AudioRecorder,
#[cfg(target_os = "linux")]
pipewire: PipeWireRecorder,
#[cfg(target_os = "linux")]
active: Backend,
}

impl Recorder {
/// Build both backends from shared parts. See `AudioRecorder::from_parts`.
pub(crate) fn from_parts(
vad: Option<VadConfig>,
level_cb: Option<LevelCallback>,
audio_cb: Option<AudioFrameCallback>,
) -> Self {
#[cfg(target_os = "linux")]
{
// Share one VAD engine + callbacks across both backends (all cheap
// to clone: VAD is Arc<Mutex<..>>, callbacks are Arc). Only one
// backend is opened at a time, so they never run concurrently.
let cpal =
AudioRecorder::from_parts(vad.clone(), level_cb.clone(), audio_cb.clone());
let pipewire = PipeWireRecorder::from_parts(vad, level_cb, audio_cb);
Recorder {
cpal,
pipewire,
active: Backend::None,
}
}
#[cfg(not(target_os = "linux"))]
{
Recorder {
cpal: AudioRecorder::from_parts(vad, level_cb, audio_cb),
}
}
}

/// Open the microphone. On Linux, prefer native PipeWire and fall back to
/// cpal/ALSA if PipeWire setup fails. The cpal-resolved `device` is used for
/// the cpal path; the PipeWire path currently ignores it and captures the
/// default source (see TODO).
pub fn open(
&mut self,
device: Option<cpal::Device>,
) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(target_os = "linux")]
{
// TODO(pipewire device selection): translate a user-selected mic to
// a PipeWire `node.name` and pass it here instead of `None`. For the
// MVP we capture the default source, which reproduces today's cpal
// "default" behaviour. PipeWire-native device enumeration/selection
// (the de-cpal refactor) would slot in at this call site and in
// `device.rs`/`managers/audio.rs`.
match self.pipewire.open(None) {
Ok(()) => {
self.active = Backend::PipeWire;
log::info!("Microphone capture using native PipeWire backend");
return Ok(());
}
Err(e) => {
log::warn!(
"PipeWire capture unavailable ({e}); falling back to cpal/ALSA backend"
);
}
}
self.cpal.open(device)?;
self.active = Backend::Cpal;
Ok(())
}
#[cfg(not(target_os = "linux"))]
{
self.cpal.open(device)
}
}

/// Begin capturing. Returns the one-shot receiver that fires after the first
/// microphone chunk is processed (the shared `run_consumer` sends it), so the
/// manager can build its `RecordingReadiness` uniformly across backends.
pub fn start(
&self,
vad_policy: VadPolicy,
) -> Result<mpsc::Receiver<()>, Box<dyn std::error::Error>> {
// Expression-block-per-cfg idiom (mirrors `get_cpal_host`): exactly one
// block survives cfg-stripping and becomes the tail expression.
#[cfg(target_os = "linux")]
{
match self.active {
Backend::PipeWire => self.pipewire.start(vad_policy),
_ => self.cpal.start(vad_policy),
}
}
#[cfg(not(target_os = "linux"))]
{
self.cpal.start(vad_policy)
}
}

pub fn stop(&self) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
#[cfg(target_os = "linux")]
{
match self.active {
Backend::PipeWire => self.pipewire.stop(),
_ => self.cpal.stop(),
}
}
#[cfg(not(target_os = "linux"))]
{
self.cpal.stop()
}
}

pub fn close(&mut self) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(target_os = "linux")]
{
let result = match self.active {
Backend::PipeWire => self.pipewire.close(),
_ => self.cpal.close(),
};
self.active = Backend::None;
result
}
#[cfg(not(target_os = "linux"))]
{
self.cpal.close()
}
}

/// Pin capture to a single input channel. Applied to the cpal backend — the
/// only one that selects a channel today. The PipeWire MVP captures the
/// default source, so channel selection there is a no-op until PipeWire
/// device selection lands (see the TODO in `open`).
pub fn set_selected_channel(&mut self, channel: Option<u16>) {
self.cpal.set_selected_channel(channel);
}

/// Whether the open stream has died and must be rebuilt before the next
/// recording. Delegates to the active backend; the PipeWire path has no
/// stream-error mirror yet (MVP), so it never asks for a reopen.
pub fn needs_reopen(&self) -> bool {
#[cfg(target_os = "linux")]
{
match self.active {
Backend::PipeWire => false,
_ => self.cpal.needs_reopen(),
}
}
#[cfg(not(target_os = "linux"))]
{
self.cpal.needs_reopen()
}
}
}
5 changes: 4 additions & 1 deletion src-tauri/src/audio_toolkit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ pub mod vad;

pub use audio::{
is_microphone_access_denied, is_no_input_device_error, list_input_devices, list_output_devices,
read_wav_samples, save_wav_file, verify_wav_file, AudioRecorder, CpalDeviceInfo, VadPolicy,
read_wav_samples, save_wav_file, verify_wav_file, AudioRecorder, CpalDeviceInfo, Recorder,
VadPolicy,
};
pub use lang_id::detect_output_language;
// Shared parts the manager uses to build the `Recorder` backends directly.
pub(crate) use audio::{AudioFrameCallback, VadConfig};
pub use text::{
apply_custom_words, normalize_transcription_output, remove_filler_words, OutputLanguageEvidence,
};
Expand Down
Loading
Loading