416 lines
14 KiB
Rust
416 lines
14 KiB
Rust
// 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) // 48 kHz
|
||
.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,
|
||
}
|
||
}
|