lesavka/client/src/input/microphone.rs

269 lines
9.2 KiB
Rust
Raw Normal View History

2025-06-08 22:24:14 -05:00
// client/src/input/microphone.rs
2025-06-30 19:35:38 -05:00
use anyhow::{Context, Result};
use gst::prelude::*;
2025-06-30 19:35:38 -05:00
use gstreamer as gst;
use gstreamer_app as gst_app;
use lesavka_common::lesavka::AudioPacket;
2025-07-01 10:23:51 -05:00
use shell_escape::unix::escape;
#[cfg(not(coverage))]
2025-07-01 10:23:51 -05:00
use std::sync::atomic::{AtomicU64, Ordering};
2026-04-22 00:56:03 -03:00
use std::{path::Path as StdPath, thread, time::Duration};
use tracing::{debug, warn};
#[cfg(not(coverage))]
use tracing::{error, info, trace};
2025-06-08 22:24:14 -05:00
2026-04-22 00:56:03 -03:00
const MIC_GAIN_ENV: &str = "LESAVKA_MIC_GAIN";
const MIC_GAIN_CONTROL_ENV: &str = "LESAVKA_MIC_GAIN_CONTROL";
2025-06-08 22:24:14 -05:00
pub struct MicrophoneCapture {
#[allow(dead_code)] // kept alive to hold PLAYING state
2025-06-30 19:35:38 -05:00
pipeline: gst::Pipeline,
sink: gst_app::AppSink,
2025-06-08 22:24:14 -05:00
}
impl MicrophoneCapture {
2025-06-30 19:35:38 -05:00
pub fn new() -> Result<Self> {
gst::init().ok(); // idempotent
2025-06-30 19:35:38 -05:00
/* 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(),
2025-07-01 10:23:51 -05:00
};
debug!("🎤 source: {source_desc}");
2025-07-04 01:56:59 -05:00
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"
};
2026-04-22 00:56:03 -03:00
let gain = mic_gain_from_env();
2025-07-01 10:23:51 -05:00
let desc = format!(
"{source_desc} ! \
2025-07-04 01:56:59 -05:00
audio/x-raw,format=S16LE,channels=2,rate=48000 ! \
2026-04-22 00:56:03 -03:00
audioconvert ! audioresample ! \
volume name=mic_input_gain volume={} ! \
{aac} bitrate=128000 ! \
2025-07-04 01:56:59 -05:00
{parser} ! \
queue max-size-buffers=100 leaky=downstream ! \
2026-04-22 00:56:03 -03:00
appsink name=asink emit-signals=true max-buffers=50 drop=true",
format_mic_gain_for_gst(gain)
2025-06-30 19:35:38 -05:00
);
let pipeline: gst::Pipeline = gst::parse::launch(&desc)?.downcast().expect("pipeline");
let sink: gst_app::AppSink = pipeline.by_name("asink").unwrap().downcast().unwrap();
2026-04-22 00:56:03 -03:00
let volume = pipeline
.by_name("mic_input_gain")
.context("missing mic_input_gain volume")?;
2025-06-30 19:35:38 -05:00
#[cfg(not(coverage))]
2025-06-30 19:35:38 -05:00
{
/* ─── bus for diagnostics ───────────────────────────────────────*/
2025-06-30 19:35:38 -05:00
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::<gst::Pipeline>()).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()
),
2025-06-30 19:35:38 -05:00
_ => {}
}
}
});
}
pipeline
.set_state(gst::State::Playing)
2025-06-30 19:35:38 -05:00
.context("start mic pipeline")?;
2026-04-22 00:56:03 -03:00
maybe_spawn_mic_gain_control(volume);
2025-06-30 19:35:38 -05:00
Ok(Self { pipeline, sink })
2025-06-08 22:24:14 -05:00
}
2025-06-30 19:35:38 -05:00
/// Blocking pull; call from an async wrapper
pub fn pull(&self) -> Option<AudioPacket> {
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());
}
2025-07-01 10:23:51 -05:00
}
Some(AudioPacket {
id: 0,
pts,
data: map.as_slice().to_vec(),
})
2025-06-30 19:35:38 -05:00
}
Err(_) => None,
}
2025-06-08 22:24:14 -05:00
}
2025-07-01 10:23:51 -05:00
fn resolve_source_desc(fragment: &str) -> Option<String> {
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<String> {
let out = std::process::Command::new("pw-dump").output().ok()?;
let list = serde_json::from_slice::<serde_json::Value>(&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
}
})
}
2025-07-01 10:23:51 -05:00
fn pulse_source_by_substr(fragment: &str) -> Option<String> {
use std::process::Command;
let out = Command::new("pactl")
.args(["list", "short", "sources"])
.output()
.ok()?;
2025-07-01 10:23:51 -05:00
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)
2025-07-01 10:23:51 -05:00
}
2025-06-08 22:24:14 -05:00
}
2026-04-22 00:56:03 -03:00
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<f64> {
let value = raw.split_ascii_whitespace().next()?.parse::<f64>().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<f64> {
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);
}
}