External code review on 2026-05-12 rated the codebase 4/10 across audio DSP, error typing, JS injection, env-var safety, ALSA parsing, and async logging. This commit lands the prognosis-level fixes plus three audit follow-ups. Audio/DSP: - StreamingResampler/rubato confirmed in the live capture path - regression test at 12 kHz (rms < 0.01, ~40 dB) catches naive decimation - near-Nyquist test at 9 kHz (rms < 0.05, ~26 dB) exercises transition band Core errors: - Other(String) removed; ProviderNotRegistered introduced - Io variant restructured as struct with kind/message/raw_os_error - FileNotFound display quotes paths - Configuration variant removed (unused) Core types: - ModelId, EngineName backed by Cow<'static, str>; const borrowed ctor - Megabytes::from_gb takes u64 (was f64) - AudioSamples::sample_rate is NonZeroU32; zero-rate defensive branch removed Capture: - /proc/asound/cards parsing rewritten as anchored regex (OnceLock) - regression test covers product names with embedded colons - monitor_pattern_detection test restored alongside the regex test - DEAD_SILENCE_FLOOR promoted to module-level with rationale - DEVICE_VALIDATION_MS, SILENCE_RMS_FLOOR documented with field-observation rationale - RMS validation loop made idiomatic - eprintln! migrated to tracing with structured fields and targets Tauri startup: - unsafe std::env::set_var removed; ensure_x11_on_wayland renamed to warn_if_x11_env_unset_on_wayland (launcher/wrapper owns env-var contract) - DB init + log prune + preferences load collapsed to one block_on - build_preferences_script rewrites JS injection from JSON.parse string to direct object literal plus malformed-JSON guard and unit tests - WebKitGTK microphone auto-grant logs warning at startup - tracing subscriber initialised at top of run() (warn,magnotia=info,... on stderr; honors RUST_LOG); previously eprintln→tracing migration was silent because no subscriber existed Filename counter: - RECORDING_COUNTER uses SeqCst Tests: cargo test --workspace --lib green (322 passed, 0 failed across 10 crates). Three independent audits (original cleanup → Wren → fresh Codex subagent) concur on no critical findings. Deferred to docs/superpowers/plans/2026-05-12-engine-slop-residuals.md: storage-layer typed errors, remaining eprintln→tracing sweep, capture actor-model refactor, property-based DSP testing, frontend/backend error boundary cleanup.
620 lines
22 KiB
Rust
620 lines
22 KiB
Rust
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<f32>,
|
|
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<cpal::Stream>,
|
|
/// 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<AtomicU64>,
|
|
/// Receiver for runtime stream errors (device unplugged, audio server
|
|
/// crash, etc.). The live session calls `error_rx()` once and listens.
|
|
error_rx: Option<mpsc::Receiver<CaptureRuntimeError>>,
|
|
}
|
|
|
|
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<mpsc::Receiver<CaptureRuntimeError>> {
|
|
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<Vec<DeviceInfo>> {
|
|
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(|| "<unnamed>".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<AudioChunk>)> {
|
|
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<AudioChunk>)> {
|
|
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<cpal::Device> = 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<String> {
|
|
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<String, String> {
|
|
#[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<String, String> {
|
|
use std::collections::HashMap;
|
|
|
|
static CARD_LINE: OnceLock<Regex> = 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<AudioChunk>)> {
|
|
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::<AudioChunk>(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::<CaptureRuntimeError>(32);
|
|
let dropped_errors = Arc::new(AtomicU64::new(0));
|
|
|
|
let stream = match format {
|
|
SampleFormat::F32 => build_input_stream::<f32>(
|
|
&device,
|
|
&config,
|
|
sample_rate,
|
|
channels,
|
|
tx,
|
|
dropped_chunks.clone(),
|
|
err_tx.clone(),
|
|
dropped_errors.clone(),
|
|
name.to_string(),
|
|
),
|
|
SampleFormat::I16 => build_input_stream::<i16>(
|
|
&device,
|
|
&config,
|
|
sample_rate,
|
|
channels,
|
|
tx,
|
|
dropped_chunks.clone(),
|
|
err_tx.clone(),
|
|
dropped_errors.clone(),
|
|
name.to_string(),
|
|
),
|
|
SampleFormat::U16 => build_input_stream::<u16>(
|
|
&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<AudioChunk> = 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::<f64>();
|
|
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<T>(
|
|
device: &cpal::Device,
|
|
supported_config: &cpal::SupportedStreamConfig,
|
|
sample_rate: u32,
|
|
channels: u16,
|
|
tx: mpsc::SyncSender<AudioChunk>,
|
|
dropped_chunks: Arc<AtomicU64>,
|
|
err_tx: mpsc::SyncSender<CaptureRuntimeError>,
|
|
dropped_errors: Arc<AtomicU64>,
|
|
device_name: String,
|
|
) -> std::result::Result<cpal::Stream, cpal::BuildStreamError>
|
|
where
|
|
T: Sample + SizedSample,
|
|
f32: FromSample<T>,
|
|
{
|
|
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<f32> = 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")
|
|
);
|
|
}
|
|
}
|