416 lines
14 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// client/src/output/audio.rs
use anyhow::{Context, Result};
use gst::MessageView::*;
use gst::prelude::*;
use gstreamer as gst;
use gstreamer_app as gst_app;
use std::{
fs as std_fs,
path::{Path as StdPath, PathBuf},
sync::Mutex,
thread,
time::Duration,
};
use tracing::{debug, error, info, warn};
use lesavka_common::lesavka::AudioPacket;
const AUDIO_GAIN_ENV: &str = "LESAVKA_AUDIO_GAIN";
const AUDIO_GAIN_CONTROL_ENV: &str = "LESAVKA_AUDIO_GAIN_CONTROL";
const DEFAULT_AUDIO_GAIN: f64 = 2.0;
const MAX_AUDIO_GAIN: f64 = 8.0;
pub struct AudioOut {
pipeline: gst::Pipeline,
src: gst_app::AppSrc,
timeline: Mutex<AudioTimeline>,
}
#[derive(Default)]
struct AudioTimeline {
last_remote_pts_us: Option<u64>,
packets: u64,
}
impl AudioOut {
pub fn new() -> anyhow::Result<Self> {
Self::new_with_sink_and_env(None, true)
}
pub fn new_with_sink(sink_override: Option<&str>) -> anyhow::Result<Self> {
Self::new_with_sink_and_env(sink_override, true)
}
pub fn new_default_sink() -> anyhow::Result<Self> {
Self::new_with_sink_and_env(None, false)
}
fn new_with_sink_and_env(
sink_override: Option<&str>,
allow_env_sink: bool,
) -> anyhow::Result<Self> {
gst::init().context("initialising GStreamer")?;
let sink = pick_sink_element(sink_override, allow_env_sink)?;
let tee_dump = std::env::var("LESAVKA_TAP_AUDIO")
.ok()
.as_deref()
.is_some_and(|v| v == "1");
let gain = audio_gain_from_env();
let pipe = audio_output_pipeline_desc(&sink, gain, tee_dump);
if tee_dump {
warn!("💾 tee to /tmp/lesavka-audio.aac enabled (LESAVKA_TAP_AUDIO=1)");
}
let pipeline: gst::Pipeline = gst::parse::launch(&pipe)?
.downcast::<gst::Pipeline>()
.expect("not a pipeline");
let src: gst_app::AppSrc = pipeline
.by_name("src")
.expect("no src element")
.downcast::<gst_app::AppSrc>()
.expect("src not an AppSrc");
let volume = pipeline
.by_name("remote_audio_gain")
.expect("remote_audio_gain");
src.set_caps(Some(
&gst::Caps::builder("audio/mpeg")
.field("mpegversion", 4i32) // AAC
.field("stream-format", "adts") // ADTS frames
.field("rate", 48_000i32) // 48kHz
.field("channels", 2i32) // stereo
.build(),
));
src.set_format(gst::Format::Time);
maybe_spawn_audio_gain_control(volume);
#[cfg(not(coverage))]
{
let bus = pipeline.bus().unwrap();
std::thread::spawn(move || {
for msg in bus.iter_timed(gst::ClockTime::NONE) {
match msg.view() {
Error(e) => error!(
"💥 gst error from {:?}: {} ({})",
msg.src().map(gst::prelude::GstObjectExt::path_string),
e.error(),
e.debug().unwrap_or_default()
),
Warning(w) => warn!(
"⚠️ gst warning from {:?}: {} ({})",
msg.src().map(gst::prelude::GstObjectExt::path_string),
w.error(),
w.debug().unwrap_or_default()
),
Element(e) => {
if let Some(structure) = e.structure() {
if structure.name() == "level" {
info!("🔊 decoded audio level {}", structure);
} else {
debug!("🔎 gst element message: {}", structure);
}
}
}
StateChanged(s) if s.current() == gst::State::Playing => {
if msg.src().is_some_and(|s| s.is::<gst::Pipeline>()) {
info!("🔊 audio pipeline ▶️ (sink='{sink}' gain={gain:.2}x)");
} else {
debug!(
"🔊 element {} now ▶️",
msg.src()
.map(gst::prelude::GstObjectExt::name)
.unwrap_or_default()
);
}
}
_ => {}
}
}
});
}
pipeline
.set_state(gst::State::Playing)
.context("starting audio pipeline")?;
Ok(Self {
pipeline,
src,
timeline: Mutex::new(AudioTimeline::default()),
})
}
pub fn push(&self, pkt: AudioPacket) {
let buf = live_audio_buffer(pkt, &self.timeline);
#[cfg(not(coverage))]
if let Err(e) = self.src.push_buffer(buf) {
warn!("📉 AppSrc push failed: {e:?}");
}
#[cfg(coverage)]
{
let _ = self.src.push_buffer(buf);
}
}
}
fn audio_output_pipeline_desc(sink: &str, gain: f64, tee_dump: bool) -> String {
let gain = format_audio_gain_for_gst(gain);
if tee_dump {
return format!(
"appsrc name=src is-live=true format=time do-timestamp=true block=false ! \
tee name=t ! \
queue max-size-time=500000000 max-size-bytes=0 max-size-buffers=0 ! \
aacparse ! avdec_aac ! audioconvert ! audioresample ! \
audio/x-raw,format=S16LE,channels=2,rate=48000 ! \
volume name=remote_audio_gain volume={gain} ! \
level name=remote_audio_level interval=1000000000 message=true ! \
queue max-size-time=400000000 max-size-bytes=0 max-size-buffers=0 ! {sink} \
t. ! queue ! filesink location=/tmp/lesavka-audio.aac"
);
}
format!(
"appsrc name=src is-live=true format=time do-timestamp=true \
block=false ! \
queue max-size-time=500000000 max-size-bytes=0 max-size-buffers=0 ! \
aacparse ! avdec_aac ! \
audioconvert ! audioresample ! \
audio/x-raw,format=S16LE,channels=2,rate=48000 ! \
volume name=remote_audio_gain volume={gain} ! \
level name=remote_audio_level interval=1000000000 message=true ! \
queue max-size-time=400000000 max-size-bytes=0 max-size-buffers=0 ! {sink}"
)
}
fn audio_gain_from_env() -> f64 {
std::env::var(AUDIO_GAIN_ENV)
.ok()
.and_then(|raw| parse_audio_gain(&raw))
.unwrap_or(DEFAULT_AUDIO_GAIN)
}
fn parse_audio_gain(raw: &str) -> Option<f64> {
let value = raw.split_ascii_whitespace().next()?.parse::<f64>().ok()?;
value.is_finite().then_some(clamp_audio_gain(value))
}
fn clamp_audio_gain(value: f64) -> f64 {
value.clamp(0.0, MAX_AUDIO_GAIN)
}
fn format_audio_gain_for_gst(gain: f64) -> String {
format!("{:.3}", clamp_audio_gain(gain))
}
#[cfg(not(coverage))]
fn maybe_spawn_audio_gain_control(volume: gst::Element) {
let Ok(path) = std::env::var(AUDIO_GAIN_CONTROL_ENV) else {
return;
};
let path = PathBuf::from(path);
thread::spawn(move || {
let mut last_gain = None;
loop {
apply_audio_gain_control_sample(&path, &volume, &mut last_gain);
thread::sleep(Duration::from_millis(250));
}
});
}
#[cfg(coverage)]
fn maybe_spawn_audio_gain_control(volume: gst::Element) {
let Ok(path) = std::env::var(AUDIO_GAIN_CONTROL_ENV) else {
return;
};
let path = PathBuf::from(path);
let mut last_gain = None;
apply_audio_gain_control_sample(&path, &volume, &mut last_gain);
}
fn apply_audio_gain_control_sample(
path: &StdPath,
volume: &gst::Element,
last_gain: &mut Option<f64>,
) -> Option<f64> {
let gain = read_audio_gain_control(path)?;
if *last_gain == Some(gain) {
return None;
}
volume.set_property("volume", gain);
*last_gain = Some(gain);
info!("🔊 remote audio gain set to {gain:.2}x");
Some(gain)
}
fn read_audio_gain_control(path: &StdPath) -> Option<f64> {
std_fs::read_to_string(path)
.ok()
.and_then(|raw| parse_audio_gain(&raw))
}
fn live_audio_buffer(pkt: AudioPacket, timeline: &Mutex<AudioTimeline>) -> gst::Buffer {
let buf = gst::Buffer::from_slice(pkt.data);
if let Ok(mut timeline) = timeline.lock() {
let remote_gap_us = timeline
.last_remote_pts_us
.map(|last| pkt.pts.saturating_sub(last));
timeline.last_remote_pts_us = Some(pkt.pts);
timeline.packets = timeline.packets.saturating_add(1);
if timeline.packets <= 8 || timeline.packets % 600 == 0 {
#[cfg(not(coverage))]
debug!(
packet = timeline.packets,
remote_pts_us = pkt.pts,
remote_gap_us,
bytes = buf.size(),
"🔊 audio packet queued for live appsrc timestamping"
);
}
}
buf
}
impl Drop for AudioOut {
fn drop(&mut self) {
let _ = self.pipeline.set_state(gst::State::Null);
}
}
#[cfg(not(coverage))]
fn pick_sink_element(sink_override: Option<&str>, allow_env_sink: bool) -> Result<String> {
if let Some(s) = sink_override.filter(|value| !value.trim().is_empty()) {
let sink = normalize_sink_override(s);
info!("💪 sink overridden via live media control={s} -> {sink}");
return Ok(sink);
}
if allow_env_sink && let Ok(s) = std::env::var("LESAVKA_AUDIO_SINK") {
let sink = normalize_sink_override(&s);
info!(
"💪 sink overridden via LESAVKA_AUDIO_SINK={} -> {}",
s, sink
);
return Ok(sink);
}
let sinks = list_pw_sinks();
if let Some((n, st)) = sinks.first() {
info!("🔈 using PipeWire sink '{}' ({st})", n);
return Ok(pulsesink_device_element(n));
}
warn!("🫣 no PipeWire sinks readable - falling back to autoaudiosink");
Ok("autoaudiosink".to_string())
}
#[cfg(coverage)]
fn pick_sink_element(sink_override: Option<&str>, allow_env_sink: bool) -> Result<String> {
if let Some(s) = sink_override.filter(|value| !value.trim().is_empty()) {
return Ok(normalize_sink_override(s));
}
if allow_env_sink && let Ok(s) = std::env::var("LESAVKA_AUDIO_SINK") {
return Ok(normalize_sink_override(&s));
}
if let Some((n, _)) = list_pw_sinks().first() {
return Ok(pulsesink_device_element(n));
}
Ok("autoaudiosink".to_string())
}
/// Interpret `LESAVKA_AUDIO_SINK` as either a full sink element or bare device.
fn normalize_sink_override(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.contains([' ', '=', '!']) || trimmed.ends_with("sink") {
return trimmed.to_string();
}
pulsesink_device_element(trimmed)
}
fn pulsesink_device_element(device: &str) -> String {
let escaped = device.replace('\\', "\\\\").replace('"', "\\\"");
let (buffer_time, latency_time) = if device.starts_with("bluez_output.") {
(750_000, 250_000)
} else {
(350_000, 100_000)
};
format!(
"pulsesink device=\"{escaped}\" buffer-time={buffer_time} latency-time={latency_time} sync=true"
)
}
fn list_pw_sinks() -> Vec<(String, String)> {
let default_sink = std::process::Command::new("pactl")
.args(["info"])
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| parse_pactl_default_sink(&String::from_utf8_lossy(&output.stdout)));
if let Ok(output) = std::process::Command::new("pactl")
.args(["list", "short", "sinks"])
.output()
&& output.status.success()
{
return parse_pactl_short_sinks(
&String::from_utf8_lossy(&output.stdout),
default_sink.as_deref(),
);
}
default_sink
.map(|sink| vec![(sink, "DEFAULT".to_string())])
.unwrap_or_default()
}
fn parse_pactl_default_sink(stdout: &str) -> Option<String> {
stdout
.lines()
.find_map(|line| line.strip_prefix("Default Sink:"))
.map(str::trim)
.filter(|sink| !sink.is_empty())
.map(str::to_string)
}
fn parse_pactl_short_sinks(stdout: &str, default_sink: Option<&str>) -> Vec<(String, String)> {
let mut sinks = Vec::new();
for line in stdout.lines() {
let columns: Vec<_> = line.split_whitespace().collect();
if columns.len() < 2 {
continue;
}
let name = columns[1].to_string();
let state = columns
.last()
.copied()
.unwrap_or("UNKNOWN")
.to_ascii_uppercase();
sinks.push((name, state));
}
sinks.sort_by_key(|(name, state)| {
(
sink_state_rank(state),
if Some(name.as_str()) == default_sink {
0
} else {
1
},
name.clone(),
)
});
sinks.dedup_by(|left, right| left.0 == right.0);
if let Some(default_sink) = default_sink
&& sinks.iter().all(|(name, _)| name != default_sink)
{
sinks.insert(0, (default_sink.to_string(), "DEFAULT".to_string()));
}
sinks
}
fn sink_state_rank(state: &str) -> u8 {
match state {
"RUNNING" => 0,
"IDLE" => 1,
"SUSPENDED" => 2,
_ => 3,
}
}