// 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, 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"; pub struct MicrophoneCapture { #[allow(dead_code)] // kept alive to hold PLAYING state pipeline: gst::Pipeline, sink: gst_app::AppSink, } 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 = if aac.contains("opus") { // opusenc already outputs raw Opus frames – just state the caps "capsfilter caps=audio/x-opus,rate=48000,channels=2" } else { // AAC → ADTS frames "aacparse ! capsfilter caps=audio/mpeg,stream-format=adts,rate=48000,channels=2" }; let gain = mic_gain_from_env(); let desc = format!( "{source_desc} ! \ audio/x-raw,format=S16LE,channels=2,rate=48000 ! \ audioconvert ! audioresample ! \ volume name=mic_input_gain volume={} ! \ {aac} bitrate=128000 ! \ {parser} ! \ queue max-size-buffers=100 leaky=downstream ! \ appsink name=asink emit-signals=true max-buffers=50 drop=true", format_mic_gain_for_gst(gain) ); 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() ), _ => {} } } }); } pipeline .set_state(gst::State::Playing) .context("start mic pipeline")?; maybe_spawn_mic_gain_control(volume); Ok(Self { pipeline, sink }) } /// 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 pts = buf.pts().unwrap_or(gst::ClockTime::ZERO).nseconds() / 1_000; #[cfg(not(coverage))] { static CNT: AtomicU64 = AtomicU64::new(0); let n = CNT.fetch_add(1, Ordering::Relaxed); if n < 10 || n % 300 == 0 { trace!("🎤⇧ cli pkt#{n} {} bytes", map.len()); } } Some(AudioPacket { id: 0, pts, data: map.as_slice().to_vec(), }) } Err(_) => None, } } fn resolve_source_desc(fragment: &str) -> Option { 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 { 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 { if Self::pipewire_source_available() { return Self::pipewire_source_desc(None); } Self::pulse_source_desc(None) } } 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 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) { let _ = self.pipeline.set_state(gst::State::Null); } }