// client/src/input/microphone.rs use anyhow::{Context, Result}; use gst::prelude::*; use gstreamer as gst; use gstreamer_app as gst_app; use lesavka_common::lesavka::AudioPacket; use shell_escape::unix::escape; #[cfg(not(coverage))] use std::sync::atomic::{AtomicU64, Ordering}; use std::{ path::{Path as StdPath, PathBuf}, sync::{ Arc, atomic::{AtomicBool, Ordering as AtomicOrdering}, }, thread, time::Duration, }; use tracing::{debug, warn}; #[cfg(not(coverage))] use tracing::{error, info, trace}; const MIC_GAIN_ENV: &str = "LESAVKA_MIC_GAIN"; const MIC_GAIN_CONTROL_ENV: &str = "LESAVKA_MIC_GAIN_CONTROL"; const MIC_LEVEL_TAP_ENV: &str = "LESAVKA_UPLINK_MIC_LEVEL"; pub struct MicrophoneCapture { #[allow(dead_code)] // kept alive to hold PLAYING state pipeline: gst::Pipeline, sink: gst_app::AppSink, level_tap_running: Option>, pts_rebaser: crate::live_capture_clock::SourcePtsRebaser, } impl MicrophoneCapture { pub fn new() -> Result { gst::init().ok(); // idempotent /* preferred path: pipewiresrc; fallback: pulsesrc ----------------*/ let source_desc = match std::env::var("LESAVKA_MIC_SOURCE") { Ok(s) if !s.is_empty() => match Self::resolve_source_desc(&s) { Some(desc) => desc, None => { warn!("🎤 requested mic '{s}' not found; using default"); Self::default_source_desc() } }, _ => Self::default_source_desc(), }; debug!("🎤 source: {source_desc}"); let aac = ["avenc_aac", "fdkaacenc", "faac", "opusenc"] .into_iter() .find(|e| gst::ElementFactory::find(e).is_some()) .unwrap_or("opusenc"); let parser = parser_for_encoder(aac); let gain = mic_gain_from_env(); let level_tap_path = mic_level_tap_path(); let desc = microphone_pipeline_desc(&source_desc, aac, parser, gain, level_tap_path.is_some()); let pipeline: gst::Pipeline = gst::parse::launch(&desc)?.downcast().expect("pipeline"); let sink: gst_app::AppSink = pipeline.by_name("asink").unwrap().downcast().unwrap(); let volume = pipeline .by_name("mic_input_gain") .context("missing mic_input_gain volume")?; #[cfg(not(coverage))] { /* ─── bus for diagnostics ───────────────────────────────────────*/ let bus = pipeline.bus().unwrap(); std::thread::spawn(move || { use gst::MessageView::*; for msg in bus.iter_timed(gst::ClockTime::NONE) { match msg.view() { StateChanged(s) if s.current() == gst::State::Playing && msg.src().map(|s| s.is::()).unwrap_or(false) => { info!("🎤 mic pipeline ▶️") } Error(e) => error!( "🎤💥 mic: {} ({})", e.error(), e.debug().unwrap_or_default() ), Warning(w) => warn!( "🎤⚠️ mic: {} ({})", w.error(), w.debug().unwrap_or_default() ), _ => {} } } }); } if let Err(err) = pipeline.set_state(gst::State::Playing) { let _ = pipeline.set_state(gst::State::Null); return Err(err).context("start mic pipeline"); } maybe_spawn_mic_gain_control(volume); let level_tap_running = if let Some(path) = level_tap_path { let level_sink = pipeline .by_name("level_sink") .context("missing microphone level tap appsink")? .downcast::() .expect("microphone level tap appsink"); Some(spawn_mic_level_tap(level_sink, path)) } else { None }; Ok(Self { pipeline, sink, level_tap_running, pts_rebaser: crate::live_capture_clock::SourcePtsRebaser::default(), }) } /// Blocking pull; call from an async wrapper pub fn pull(&self) -> Option { match self.sink.pull_sample() { Ok(sample) => { let buf = sample.buffer().unwrap(); let map = buf.map_readable().unwrap(); let source_pts_us = buf.pts().map(|ts| ts.nseconds() / 1_000); let timing = self.pts_rebaser.rebase_or_now(source_pts_us, 1); let pts = timing.packet_pts_us; #[cfg(not(coverage))] { static CNT: AtomicU64 = AtomicU64::new(0); let n = CNT.fetch_add(1, Ordering::Relaxed); if crate::live_capture_clock::upstream_timing_trace_enabled() && (n < 10 || n.is_multiple_of(300)) { info!( packet_index = n, source_pts_us = timing.source_pts_us.unwrap_or_default(), source_base_us = timing.source_base_us.unwrap_or_default(), capture_base_us = timing.capture_base_us.unwrap_or_default(), capture_now_us = timing.capture_now_us, packet_pts_us = timing.packet_pts_us, pull_path_delay_us = timing.capture_now_us as i128 - timing.packet_pts_us as i128, used_source_pts = timing.used_source_pts, bytes = map.len(), "🎤 upstream microphone timing sample" ); } if n < 10 || n.is_multiple_of(300) { trace!("🎤⇧ cli pkt#{n} {} bytes", map.len()); } } Some(AudioPacket { id: 0, pts, data: map.as_slice().to_vec(), }) } Err(_) => None, } } /// Resolve launcher-selected mic names while preserving Pulse catalog routing. fn resolve_source_desc(fragment: &str) -> Option { if looks_like_pulse_source_name(fragment) && let Some(full) = Self::pulse_source_by_substr(fragment) { return Some(Self::pulse_source_desc(Some(&full))); } if Self::pipewire_source_available() && let Some(full) = Self::pipewire_source_by_substr(fragment) { return Some(Self::pipewire_source_desc(Some(&full))); } Self::pulse_source_by_substr(fragment).map(|full| Self::pulse_source_desc(Some(&full))) } fn pipewire_source_available() -> bool { #[cfg(coverage)] if std::env::var("LESAVKA_MIC_DISABLE_PIPEWIRE").is_ok() { return false; } gst::ElementFactory::find("pipewiresrc").is_some() } fn pipewire_source_desc(source: Option<&str>) -> String { match source { Some(source) if !source.trim().is_empty() => { format!( "pipewiresrc target-object={} do-timestamp=true", escape(source.to_string().into()) ) } _ => "pipewiresrc do-timestamp=true".to_string(), } } fn pulse_source_desc(source: Option<&str>) -> String { match source { Some(source) if !source.trim().is_empty() => { format!( "pulsesrc device={} do-timestamp=true", escape(source.to_string().into()) ) } _ => "pulsesrc do-timestamp=true".to_string(), } } fn pipewire_source_by_substr(fragment: &str) -> Option { let out = std::process::Command::new("pw-dump").output().ok()?; let list = serde_json::from_slice::(&out.stdout).ok()?; let objects = list.as_array()?; objects.iter().find_map(|object| { let props = object.get("info")?.get("props")?.as_object()?; if props.get("media.class")?.as_str()? != "Audio/Source" { return None; } let name = props .get("node.name") .or_else(|| props.get("node.nick"))? .as_str()?; if name.contains(fragment) && !name.ends_with(".monitor") { Some(name.to_owned()) } else { None } }) } fn pulse_source_by_substr(fragment: &str) -> Option { use std::process::Command; let out = Command::new("pactl") .args(["list", "short", "sources"]) .output() .ok()?; let list = String::from_utf8_lossy(&out.stdout); list.lines().find_map(|ln| { let mut cols = ln.split_whitespace(); let _id = cols.next()?; let name = cols.next()?; // column #1 if name.contains(fragment) { Some(name.to_owned()) } else { None } }) } fn default_source_desc() -> String { #[cfg(coverage)] if let Ok(source) = std::env::var("LESAVKA_MIC_TEST_SOURCE_DESC") && !source.trim().is_empty() { return source; } if Self::pipewire_source_available() { return Self::pipewire_source_desc(None); } Self::pulse_source_desc(None) } } fn mic_level_tap_path() -> Option { std::env::var(MIC_LEVEL_TAP_ENV) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .map(PathBuf::from) } fn parser_for_encoder(aac: &str) -> &'static str { if aac.contains("opus") { "capsfilter caps=audio/x-opus,rate=48000,channels=2" } else { "aacparse ! capsfilter caps=audio/mpeg,stream-format=adts,rate=48000,channels=2" } } fn microphone_pipeline_desc( source_desc: &str, encoder: &str, parser: &str, gain: f64, level_tap_enabled: bool, ) -> String { let gain = format_mic_gain_for_gst(gain); if level_tap_enabled { format!( "{source_desc} ! \ audioconvert ! audioresample ! \ audio/x-raw,format=S16LE,channels=2,rate=48000 ! \ volume name=mic_input_gain volume={gain} ! \ tee name=t \ t. ! queue max-size-buffers=100 leaky=downstream ! \ audioconvert ! audioresample ! \ audio/x-raw,channels=2,rate=48000 ! \ {encoder} bitrate=128000 ! \ {parser} ! \ appsink name=asink emit-signals=true max-buffers=50 drop=true \ t. ! queue max-size-buffers=8 leaky=downstream ! \ audio/x-raw,format=S16LE,channels=2,rate=48000 ! \ appsink name=level_sink emit-signals=false sync=false max-buffers=8 drop=true" ) } else { format!( "{source_desc} ! \ audioconvert ! audioresample ! \ audio/x-raw,channels=2,rate=48000 ! \ volume name=mic_input_gain volume={gain} ! \ audioconvert ! audioresample ! \ audio/x-raw,channels=2,rate=48000 ! \ {encoder} bitrate=128000 ! \ {parser} ! \ queue max-size-buffers=100 leaky=downstream ! \ appsink name=asink emit-signals=true max-buffers=50 drop=true" ) } } /// Detect launcher catalog names that should be opened through Pulse directly. fn looks_like_pulse_source_name(source: &str) -> bool { let source = source.trim(); source.starts_with("alsa_input.") || source.starts_with("bluez_input.") || source.starts_with("input.") } fn mic_gain_from_env() -> f64 { std::env::var(MIC_GAIN_ENV) .ok() .and_then(|raw| parse_mic_gain(&raw)) .unwrap_or(1.0) } fn parse_mic_gain(raw: &str) -> Option { let value = raw.split_ascii_whitespace().next()?.parse::().ok()?; value.is_finite().then_some(clamp_mic_gain(value)) } fn clamp_mic_gain(value: f64) -> f64 { value.clamp(0.0, 4.0) } fn format_mic_gain_for_gst(gain: f64) -> String { format!("{:.3}", clamp_mic_gain(gain)) } fn maybe_spawn_mic_gain_control(volume: gst::Element) { let Ok(path) = std::env::var(MIC_GAIN_CONTROL_ENV) else { return; }; let path = std::path::PathBuf::from(path); thread::spawn(move || { let mut last_gain = None; loop { if let Some(gain) = read_mic_gain_control(&path) && last_gain != Some(gain) { volume.set_property("volume", gain); last_gain = Some(gain); tracing::info!("🎤 mic gain set to {gain:.2}x"); } thread::sleep(Duration::from_millis(100)); } }); } fn spawn_mic_level_tap(sink: gst_app::AppSink, path: PathBuf) -> Arc { let running = Arc::new(AtomicBool::new(true)); let thread_running = Arc::clone(&running); thread::spawn(move || { while thread_running.load(AtomicOrdering::Acquire) { if let Some(sample) = sink.try_pull_sample(gst::ClockTime::from_mseconds(250)) && let Some(buffer) = sample.buffer() && let Ok(map) = buffer.map_readable() { let level = pcm_peak_fraction(map.as_slice()); if let Err(err) = write_mic_level_tap(&path, level) { tracing::debug!("🎤 local uplink level tap write failed: {err:#}"); } } } }); running } fn pcm_peak_fraction(bytes: &[u8]) -> f64 { let peak = bytes .chunks_exact(2) .map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]).unsigned_abs() as f64) .fold(0.0, f64::max); (peak / i16::MAX as f64).clamp(0.0, 1.0) } fn write_mic_level_tap(path: &StdPath, level: f64) -> Result<()> { let tmp_path = path.with_extension("tmp"); std::fs::write(&tmp_path, format!("{level:.6}\n")) .with_context(|| format!("writing {}", tmp_path.display()))?; std::fs::rename(&tmp_path, path).with_context(|| format!("publishing {}", path.display()))?; Ok(()) } fn read_mic_gain_control(path: &StdPath) -> Option { std::fs::read_to_string(path) .ok() .and_then(|raw| parse_mic_gain(&raw)) } impl Drop for MicrophoneCapture { fn drop(&mut self) { if let Some(running) = &self.level_tap_running { running.store(false, AtomicOrdering::Release); } let _ = self.pipeline.set_state(gst::State::Null); } }