use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc; use std::sync::Arc; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{FromSample, Sample, SampleFormat, SizedSample}; use regex::Regex; use serde::{Deserialize, Serialize}; use std::sync::OnceLock; use magnotia_core::error::{MagnotiaError, Result}; const AUDIO_CHANNEL_CAPACITY: usize = 32; /// Validation window. 350ms is long enough to collect several cpal callback /// buffers at common 44.1/48kHz rates while keeping Settings/UI device /// switching perceptibly sub-second. const DEVICE_VALIDATION_MS: u64 = 350; /// Below this RMS amplitude (peak ±1.0 scale) the input is treated as /// silence. Field dogfooding on PipeWire/PulseAudio showed idle monitor /// sources at exact or near-zero RMS, while connected microphones in quiet /// rooms stayed around 5e-4+; 1e-5 keeps a 50x safety margin below that. const SILENCE_RMS_FLOOR: f32 = 1e-5; /// Absolute floor used even for monitor fallback. Values below this are /// effectively digital zero on normalized f32 PCM, so accepting them only /// records silence and hides device-routing failures. const DEAD_SILENCE_FLOOR: f32 = 1e-7; /// A chunk of captured audio from the microphone. pub struct AudioChunk { pub samples: Vec, pub sample_rate: u32, pub channels: u16, } /// Public-facing description of an audio input device. /// Returned by `list_devices()` and used by the UI device picker. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeviceInfo { /// Device name as reported by cpal/the host. pub name: String, /// Default sample rate in Hz. pub sample_rate: u32, /// Default channel count. pub channels: u16, /// True if the device name matches a known monitor-source pattern /// (PulseAudio/PipeWire loopback of speaker output). pub is_likely_monitor: bool, /// True if cpal reports this as the host's default input device. pub is_default: bool, /// Human-readable product description, if known (Linux: from /// `/proc/asound/cards`). Empty string when unavailable or on /// platforms that don't expose one. #[serde(default)] pub description: String, } /// A non-fatal capture-time error emitted by the cpal stream callback after /// `start()` has already returned. The live session subscribes to these via /// `error_rx()` so the frontend can show a toast when the mic vanishes /// mid-recording. #[derive(Debug, Clone)] pub struct CaptureRuntimeError { pub device_name: String, pub message: String, } /// Manages microphone capture via cpal. pub struct MicrophoneCapture { stream: Option, /// Name of the device that is actually capturing. pub device_name: String, /// Counter incremented every time the capture callback drops a chunk /// because the channel was full. Read via `dropped_chunks()`. dropped_chunks: Arc, /// Receiver for runtime stream errors (device unplugged, audio server /// crash, etc.). The live session calls `error_rx()` once and listens. error_rx: Option>, } impl MicrophoneCapture { /// Number of audio chunks dropped because the downstream channel was full /// since this capture started. Should stay at 0 in normal use; non-zero /// indicates downstream backpressure or a stuck consumer. pub fn dropped_chunks(&self) -> u64 { self.dropped_chunks.load(Ordering::Relaxed) } /// Take the runtime-error receiver. Can be called once per capture; the /// caller (live session manager) drains it on its own cadence and surfaces /// errors to the frontend. Returns None on the second call. pub fn take_error_rx(&mut self) -> Option> { self.error_rx.take() } /// Enumerate every input device the host knows about, with the metadata /// needed by the device-picker UI. pub fn list_devices() -> Result> { let host = cpal::default_host(); let default_name = host .default_input_device() .and_then(|d| device_display_name(&d)) .unwrap_or_default(); let devices = host .input_devices() .map_err(|e| MagnotiaError::AudioCaptureFailed(format!("input_devices: {e}")))?; // Load ALSA card descriptions once per enumeration. These are the // "real" product names (e.g. "Blue Microphones") that cpal's // short card name (e.g. "Microphones") alone can't convey. Empty // map on non-Linux or if the file is missing. let card_descriptions = load_alsa_card_descriptions(); let mut out = Vec::new(); for device in devices { let name = device_display_name(&device).unwrap_or_else(|| "".to_string()); let (sample_rate, channels) = match device.default_input_config() { Ok(cfg) => (cfg.sample_rate(), cfg.channels()), Err(_) => (0, 0), }; let is_likely_monitor = is_monitor_name(&name); let is_default = !default_name.is_empty() && name == default_name; let description = extract_card_id(&name) .and_then(|card| card_descriptions.get(card).cloned()) .unwrap_or_default(); out.push(DeviceInfo { name, sample_rate, channels, is_likely_monitor, is_default, description, }); } Ok(out) } /// Start capturing from the device whose name matches `device_name` exactly. /// If no match is found, returns an error rather than silently falling back. pub fn start_with_device(device_name: &str) -> Result<(Self, mpsc::Receiver)> { let host = cpal::default_host(); let devices = host .input_devices() .map_err(|e| MagnotiaError::AudioCaptureFailed(format!("input_devices: {e}")))?; for device in devices { let name = device_display_name(&device).unwrap_or_default(); if name == device_name { tracing::info!(target: "magnotia_audio", "start_with_device: opening explicit device '{name}'"); return open_and_validate(device, &name, /* require_audio = */ true); } } Err(MagnotiaError::AudioCaptureFailed(format!( "Selected device '{device_name}' not found in current host enumeration. \ It may have been disconnected. Open Settings → Audio to pick another." ))) } /// Start capturing audio with auto-selection. /// /// Selection rules: /// 1. Try the host default input device first if it exists AND is not a monitor source. /// 2. Otherwise, try non-monitor devices in enumeration order. /// 3. Validate the chosen device by RMS energy (not just receipt of bytes) over /// a short window — this is what defeats the "silent monitor source wins" bug. /// 4. If no non-monitor device produces real audio, fall back to monitor sources /// as a last resort (with a clear log line). Never accept dead silence. pub fn start() -> Result<(Self, mpsc::Receiver)> { let host = cpal::default_host(); let default_name = host .default_input_device() .and_then(|d| device_display_name(&d)) .unwrap_or_default(); let mut all_devices: Vec = host .input_devices() .map_err(|e| MagnotiaError::AudioCaptureFailed(format!("input_devices: {e}")))? .collect(); // Sort: default first, then non-monitor, then monitor-as-last-resort. all_devices.sort_by_key(|d| { let n = device_display_name(d).unwrap_or_default(); let is_default = !default_name.is_empty() && n == default_name; let is_monitor = is_monitor_name(&n); // Smaller key = tried first. match (is_default, is_monitor) { (true, false) => 0, // default, real input (false, false) => 1, // any other real input (true, true) => 2, // default but is a monitor (very rare) (false, true) => 3, // monitor source — last resort } }); tracing::info!( target: "magnotia_audio", device_count = all_devices.len(), default = %default_name, "enumerated input devices" ); // First pass: require real audio energy. for device in &all_devices { let name = device_display_name(device).unwrap_or_default(); if is_monitor_name(&name) { continue; // Save monitor sources for second pass. } match open_and_validate(device.clone(), &name, true) { Ok(result) => return Ok(result), Err(e) => { tracing::warn!(target: "magnotia_audio", device = %name, error = %e, "candidate device rejected"); } } } // Second pass: accept anything that delivers bytes (monitor sources // included). Better to capture from a monitor than fail entirely. tracing::warn!( target: "magnotia_audio", "no non-monitor mic produced audio; falling back to monitor/loopback sources" ); for device in &all_devices { let name = device_display_name(device).unwrap_or_default(); match open_and_validate(device.clone(), &name, false) { Ok(result) => { tracing::warn!( target: "magnotia_audio", device = %name, "capturing from likely monitor source; recordings may be silent or contain system audio" ); return Ok(result); } Err(_) => continue, } } Err(MagnotiaError::AudioCaptureFailed( "No working microphone found. Check that an input device is connected, \ that PulseAudio/PipeWire is running, and that the app has microphone permission. \ Then open Settings → Audio to pick a device explicitly." .into(), )) } /// Stop capturing audio. pub fn stop(&mut self) { if let Some(stream) = self.stream.take() { let _ = stream.pause(); } } } impl Drop for MicrophoneCapture { fn drop(&mut self) { self.stop(); } } /// Heuristic: identify a PulseAudio/PipeWire monitor source by name. /// Common patterns: /// - ".monitor" suffix (PulseAudio convention) /// - "Monitor of " prefix (longer human-readable name) /// - "Loopback" anywhere (some PipeWire configurations) fn is_monitor_name(name: &str) -> bool { let lower = name.to_lowercase(); lower.ends_with(".monitor") || lower.starts_with("monitor of ") || lower.contains("monitor of ") || lower.contains("loopback") } fn device_display_name(device: &cpal::Device) -> Option { device .description() .ok() .map(|description| description.name().to_string()) } /// Pull the CARD= value from an ALSA device string. /// /// `sysdefault:CARD=Microphones` → `Some("Microphones")` /// `hw:CARD=C920,DEV=0` → `Some("C920")` /// `pipewire` / `default` → `None` fn extract_card_id(name: &str) -> Option<&str> { let rest = name.split("CARD=").nth(1)?; Some(rest.split([',', ';']).next().unwrap_or(rest)) } /// Read `/proc/asound/cards` and return a map from ALSA card short name /// (e.g. "Microphones") to the richer product string (e.g. "Blue /// Microphones"). Empty map on non-Linux or if the file is missing. /// /// Format of `/proc/asound/cards`: /// ```text /// 2 [Microphones ]: USB-Audio - Blue Microphones /// Blue Microphones at usb-... /// 3 [C920 ]: USB-Audio - HD Pro Webcam C920 /// HD Pro Webcam C920 at usb-... /// ``` /// The bracket contains the short name that cpal reports; the text /// after the colon on that same line is the description we want. The /// next indented line is a longer location string we ignore. fn load_alsa_card_descriptions() -> std::collections::HashMap { #[cfg(target_os = "linux")] { let Ok(contents) = std::fs::read_to_string("/proc/asound/cards") else { return std::collections::HashMap::new(); }; parse_alsa_card_descriptions(&contents) } #[cfg(not(target_os = "linux"))] { std::collections::HashMap::new() } } fn parse_alsa_card_descriptions(contents: &str) -> std::collections::HashMap { use std::collections::HashMap; static CARD_LINE: OnceLock = OnceLock::new(); let card_line = CARD_LINE.get_or_init(|| { Regex::new(r"^\s*\d+\s+\[([^\]]+)\]\s*:\s*(.+?)\s*$").expect("valid ALSA card-line regex") }); let mut map = HashMap::new(); for line in contents.lines() { let Some(captures) = card_line.captures(line) else { continue; }; let Some(short_name) = captures.get(1).map(|m| m.as_str().trim()) else { continue; }; if short_name.is_empty() { continue; } let raw = captures .get(2) .map(|m| m.as_str().trim()) .unwrap_or_default(); let description = raw .split_once(" - ") .map(|(_, product)| product.trim()) .unwrap_or(raw); if !description.is_empty() { map.insert(short_name.to_string(), description.to_string()); } } map } /// Open the given device and validate it produces non-silent audio. /// If `require_audio` is false, accept any data (used for monitor fallback). fn open_and_validate( device: cpal::Device, name: &str, require_audio: bool, ) -> Result<(MicrophoneCapture, mpsc::Receiver)> { let config = device .default_input_config() .map_err(|e| MagnotiaError::AudioCaptureFailed(format!("default_input_config: {e}")))?; let sample_rate = config.sample_rate(); let channels = config.channels(); let format = config.sample_format(); tracing::info!( target: "magnotia_audio", device = %name, sample_rate, channels, format = ?format, "trying audio input device" ); let (tx, rx) = mpsc::sync_channel::(AUDIO_CHANNEL_CAPACITY); let requeue_tx = tx.clone(); let dropped_chunks = Arc::new(AtomicU64::new(0)); // Bounded channel for runtime stream errors. Capacity 32 = plenty for // the rare error case; if it ever fills, drops are reported via stderr // and counted in `dropped_errors` so the symptom is visible in the // diagnostic bundle even when the listener has gone away. Errors // beyond the cap are by definition redundant noise in a stream that // is already failing. let (err_tx, err_rx) = mpsc::sync_channel::(32); let dropped_errors = Arc::new(AtomicU64::new(0)); let stream = match format { SampleFormat::F32 => build_input_stream::( &device, &config, sample_rate, channels, tx, dropped_chunks.clone(), err_tx.clone(), dropped_errors.clone(), name.to_string(), ), SampleFormat::I16 => build_input_stream::( &device, &config, sample_rate, channels, tx, dropped_chunks.clone(), err_tx.clone(), dropped_errors.clone(), name.to_string(), ), SampleFormat::U16 => build_input_stream::( &device, &config, sample_rate, channels, tx, dropped_chunks.clone(), err_tx.clone(), dropped_errors.clone(), name.to_string(), ), other => { return Err(MagnotiaError::AudioCaptureFailed(format!( "unsupported sample format {other:?}" ))) } } .map_err(|e| MagnotiaError::AudioCaptureFailed(format!("build_input_stream: {e}")))?; stream .play() .map_err(|e| MagnotiaError::AudioCaptureFailed(format!("stream.play: {e}")))?; // Validation window: collect chunks for DEVICE_VALIDATION_MS, compute RMS. let deadline = std::time::Instant::now() + std::time::Duration::from_millis(DEVICE_VALIDATION_MS); let mut collected: Vec = Vec::new(); let mut total_samples = 0_usize; let mut sum_sq: f64 = 0.0; while std::time::Instant::now() < deadline { let remaining = deadline.saturating_duration_since(std::time::Instant::now()); if remaining.is_zero() { break; } match rx.recv_timeout(remaining) { Ok(chunk) => { sum_sq += chunk .samples .iter() .map(|&s| (s as f64).powi(2)) .sum::(); total_samples += chunk.samples.len(); collected.push(chunk); } Err(_) => break, } } if total_samples == 0 { return Err(MagnotiaError::AudioCaptureFailed( "device delivered zero samples in validation window".into(), )); } let rms = (sum_sq / total_samples as f64).sqrt() as f32; tracing::info!( target: "magnotia_audio", device = %name, samples = total_samples, rms, "audio input validation complete" ); if require_audio && rms < SILENCE_RMS_FLOOR { return Err(MagnotiaError::AudioCaptureFailed(format!( "device produced silence (rms={rms:.6} below floor {SILENCE_RMS_FLOOR:.6})" ))); } // Even in the fallback pass (require_audio=false), reject completely // dead-zero audio. PulseAudio/PipeWire will sometimes happily emit a // long stream of f32 zeros from a borked device — that is worse than // failing fast. if rms < DEAD_SILENCE_FLOOR { return Err(MagnotiaError::AudioCaptureFailed(format!( "device produced dead silence (rms={rms:.6e} below absolute floor {DEAD_SILENCE_FLOOR:.6e})" ))); } // Re-queue the collected chunks so downstream gets them. Count any // drops here against the same `dropped_chunks` counter so the live // session sees them and can warn the user. for chunk in collected { if requeue_tx.try_send(chunk).is_err() { dropped_chunks.fetch_add(1, Ordering::Relaxed); } } tracing::info!(target: "magnotia_audio", device = %name, "selected microphone"); Ok(( MicrophoneCapture { stream: Some(stream), device_name: name.to_string(), dropped_chunks, error_rx: Some(err_rx), }, rx, )) } #[allow(clippy::too_many_arguments)] fn build_input_stream( device: &cpal::Device, supported_config: &cpal::SupportedStreamConfig, sample_rate: u32, channels: u16, tx: mpsc::SyncSender, dropped_chunks: Arc, err_tx: mpsc::SyncSender, dropped_errors: Arc, device_name: String, ) -> std::result::Result where T: Sample + SizedSample, f32: FromSample, { let config: cpal::StreamConfig = supported_config.clone().into(); let err_device_name = device_name.clone(); device.build_input_stream( &config, move |data: &[T], _| { let samples: Vec = data.iter().copied().map(f32::from_sample).collect(); let chunk = AudioChunk { samples, sample_rate, channels, }; // try_send fails if the channel is full. Track that explicitly; // otherwise backpressure looks like clean transcription silence. if tx.try_send(chunk).is_err() { dropped_chunks.fetch_add(1, Ordering::Relaxed); } }, move |err| { // Surface stream errors to the live session via err_tx so the // frontend can show a toast. tracing::error!(target: "magnotia_audio", error = %err, "capture stream error"); if err_tx .try_send(CaptureRuntimeError { device_name: err_device_name.clone(), message: err.to_string(), }) .is_err() { // Channel full — listener has stalled or detached. Keep a // counter so the diagnostic bundle still shows the symptom // even if the frontend never received the typed event. let prior = dropped_errors.fetch_add(1, Ordering::Relaxed); tracing::warn!( target: "magnotia_audio", device = %err_device_name, dropped_error = prior + 1, "capture error channel full; dropping runtime error" ); } }, None, ) } #[cfg(test)] mod tests { use super::*; #[test] fn monitor_pattern_detection() { for name in [ "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor", "Monitor of Built-in Audio Analog Stereo", "PipeWire Loopback Source", "Built-in Audio Monitor of Analog Stereo", ] { assert!(is_monitor_name(name), "expected monitor source: {name}"); } for name in [ "Built-in Audio Analog Stereo", "Blue Microphones", "HD Pro Webcam C920", "sysdefault:CARD=Microphones", ] { assert!(!is_monitor_name(name), "expected physical input: {name}"); } } #[test] fn parses_alsa_cards_with_regex() { let contents = r#" 2 [Microphones ]: USB-Audio - Blue Microphones Blue Microphones at usb-0000:04:00.3-2.1, full speed 3 [C920 ]: USB-Audio - HD Pro Webcam C920: With Colon HD Pro Webcam C920 at usb-0000:04:00.3-2.2, high speed "#; let parsed = parse_alsa_card_descriptions(contents); assert_eq!( parsed.get("Microphones").map(String::as_str), Some("Blue Microphones") ); assert_eq!( parsed.get("C920").map(String::as_str), Some("HD Pro Webcam C920: With Colon") ); } }