2025-06-29 03:46:34 -05:00
|
|
|
|
// client/src/output/audio.rs
|
|
|
|
|
|
|
2025-06-30 11:38:57 -05:00
|
|
|
|
use anyhow::{Context, Result};
|
2025-12-01 11:38:51 -03:00
|
|
|
|
use gst::MessageView::*;
|
|
|
|
|
|
use gst::prelude::*;
|
2025-06-29 03:46:34 -05:00
|
|
|
|
use gstreamer as gst;
|
|
|
|
|
|
use gstreamer_app as gst_app;
|
2026-04-21 01:52:39 -03:00
|
|
|
|
use std::sync::Mutex;
|
2025-12-01 11:38:51 -03:00
|
|
|
|
use tracing::{debug, error, info, warn};
|
2025-06-29 03:46:34 -05:00
|
|
|
|
|
2025-06-30 11:38:57 -05:00
|
|
|
|
use lesavka_common::lesavka::AudioPacket;
|
|
|
|
|
|
|
2025-06-29 03:46:34 -05:00
|
|
|
|
pub struct AudioOut {
|
2025-06-30 11:38:57 -05:00
|
|
|
|
pipeline: gst::Pipeline,
|
2025-06-29 03:46:34 -05:00
|
|
|
|
src: gst_app::AppSrc,
|
2026-04-21 01:52:39 -03:00
|
|
|
|
timeline: Mutex<AudioTimeline>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Default)]
|
|
|
|
|
|
struct AudioTimeline {
|
|
|
|
|
|
first_remote_pts_us: Option<u64>,
|
|
|
|
|
|
last_local_pts_us: u64,
|
|
|
|
|
|
packets: u64,
|
2025-06-29 03:46:34 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl AudioOut {
|
|
|
|
|
|
pub fn new() -> anyhow::Result<Self> {
|
2025-06-30 11:38:57 -05:00
|
|
|
|
gst::init().context("initialising GStreamer")?;
|
|
|
|
|
|
let sink = pick_sink_element()?;
|
|
|
|
|
|
let tee_dump = std::env::var("LESAVKA_TAP_AUDIO")
|
|
|
|
|
|
.ok()
|
|
|
|
|
|
.as_deref()
|
|
|
|
|
|
.map(|v| v == "1")
|
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
|
let mut pipe = format!(
|
|
|
|
|
|
"appsrc name=src is-live=true format=time do-timestamp=true \
|
|
|
|
|
|
block=false ! \
|
2026-04-20 11:11:51 -03:00
|
|
|
|
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 ! \
|
|
|
|
|
|
queue max-size-time=400000000 max-size-bytes=0 max-size-buffers=0 ! {}",
|
2025-06-30 11:38:57 -05:00
|
|
|
|
sink,
|
|
|
|
|
|
);
|
|
|
|
|
|
if tee_dump {
|
|
|
|
|
|
pipe = format!(
|
|
|
|
|
|
"appsrc name=src is-live=true format=time do-timestamp=true block=false ! \
|
|
|
|
|
|
tee name=t ! \
|
2026-04-20 11:11:51 -03:00
|
|
|
|
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 ! \
|
|
|
|
|
|
queue max-size-time=400000000 max-size-bytes=0 max-size-buffers=0 ! {} \
|
2025-06-30 11:38:57 -05:00
|
|
|
|
t. ! queue ! filesink location=/tmp/lesavka-audio.aac",
|
|
|
|
|
|
sink,
|
|
|
|
|
|
);
|
|
|
|
|
|
warn!("💾 tee to /tmp/lesavka-audio.aac enabled (LESAVKA_TAP_AUDIO=1)");
|
|
|
|
|
|
}
|
|
|
|
|
|
let pipeline: gst::Pipeline = gst::parse::launch(&pipe)?
|
2025-06-29 03:46:34 -05:00
|
|
|
|
.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");
|
2025-12-01 11:38:51 -03:00
|
|
|
|
|
|
|
|
|
|
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(),
|
2025-06-30 11:38:57 -05:00
|
|
|
|
));
|
2025-06-29 03:46:34 -05:00
|
|
|
|
src.set_format(gst::Format::Time);
|
2025-06-30 19:35:38 -05:00
|
|
|
|
|
2026-04-13 02:52:32 -03:00
|
|
|
|
#[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(|s| s.path_string()),
|
|
|
|
|
|
e.error(),
|
|
|
|
|
|
e.debug().unwrap_or_default()
|
|
|
|
|
|
),
|
|
|
|
|
|
Warning(w) => warn!(
|
|
|
|
|
|
"⚠️ gst warning from {:?}: {} ({})",
|
|
|
|
|
|
msg.src().map(|s| s.path_string()),
|
|
|
|
|
|
w.error(),
|
|
|
|
|
|
w.debug().unwrap_or_default()
|
|
|
|
|
|
),
|
|
|
|
|
|
Element(e) => debug!(
|
|
|
|
|
|
"🔎 gst element message: {}",
|
|
|
|
|
|
e.structure().map(|s| s.to_string()).unwrap_or_default()
|
|
|
|
|
|
),
|
|
|
|
|
|
StateChanged(s) if s.current() == gst::State::Playing => {
|
|
|
|
|
|
if msg.src().map(|s| s.is::<gst::Pipeline>()).unwrap_or(false) {
|
|
|
|
|
|
info!("🔊 audio pipeline ▶️ (sink='{}')", sink);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
debug!(
|
|
|
|
|
|
"🔊 element {} now ▶️",
|
|
|
|
|
|
msg.src().map(|s| s.name()).unwrap_or_default()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-06-30 15:45:37 -05:00
|
|
|
|
}
|
2026-04-13 02:52:32 -03:00
|
|
|
|
_ => {}
|
2025-12-01 11:38:51 -03:00
|
|
|
|
}
|
2025-06-30 02:42:20 -05:00
|
|
|
|
}
|
2026-04-13 02:52:32 -03:00
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-04-14 23:03:18 -03:00
|
|
|
|
pipeline
|
|
|
|
|
|
.set_state(gst::State::Playing)
|
|
|
|
|
|
.context("starting audio pipeline")?;
|
2026-04-21 01:52:39 -03:00
|
|
|
|
Ok(Self {
|
|
|
|
|
|
pipeline,
|
|
|
|
|
|
src,
|
|
|
|
|
|
timeline: Mutex::new(AudioTimeline::default()),
|
|
|
|
|
|
})
|
2025-06-29 03:46:34 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn push(&self, pkt: AudioPacket) {
|
|
|
|
|
|
let mut buf = gst::Buffer::from_slice(pkt.data);
|
2026-04-21 01:52:39 -03:00
|
|
|
|
if let Ok(mut timeline) = self.timeline.lock() {
|
|
|
|
|
|
let base = timeline.first_remote_pts_us.get_or_insert(pkt.pts);
|
|
|
|
|
|
let mut local_pts_us = pkt.pts.saturating_sub(*base);
|
|
|
|
|
|
if local_pts_us < timeline.last_local_pts_us {
|
|
|
|
|
|
local_pts_us = timeline.last_local_pts_us.saturating_add(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
timeline.last_local_pts_us = local_pts_us;
|
|
|
|
|
|
timeline.packets = timeline.packets.saturating_add(1);
|
|
|
|
|
|
if timeline.packets <= 8 || timeline.packets % 600 == 0 {
|
|
|
|
|
|
debug!(
|
|
|
|
|
|
packet = timeline.packets,
|
|
|
|
|
|
remote_pts_us = pkt.pts,
|
|
|
|
|
|
local_pts_us,
|
|
|
|
|
|
bytes = buf.size(),
|
|
|
|
|
|
"🔊 audio packet queued"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
buf.get_mut()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.set_pts(Some(gst::ClockTime::from_useconds(local_pts_us)));
|
|
|
|
|
|
}
|
2026-04-13 02:52:32 -03:00
|
|
|
|
#[cfg(not(coverage))]
|
2025-06-30 11:38:57 -05:00
|
|
|
|
if let Err(e) = self.src.push_buffer(buf) {
|
|
|
|
|
|
warn!("📉 AppSrc push failed: {e:?}");
|
|
|
|
|
|
}
|
2026-04-13 02:52:32 -03:00
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
|
{
|
|
|
|
|
|
let _ = self.src.push_buffer(buf);
|
|
|
|
|
|
}
|
2025-06-30 11:38:57 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Drop for AudioOut {
|
|
|
|
|
|
fn drop(&mut self) {
|
|
|
|
|
|
let _ = self.pipeline.set_state(gst::State::Null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 02:52:32 -03:00
|
|
|
|
#[cfg(not(coverage))]
|
2025-06-30 11:38:57 -05:00
|
|
|
|
fn pick_sink_element() -> Result<String> {
|
|
|
|
|
|
if let Ok(s) = std::env::var("LESAVKA_AUDIO_SINK") {
|
2026-04-14 07:49:38 -03:00
|
|
|
|
let sink = normalize_sink_override(&s);
|
2026-04-14 23:03:18 -03:00
|
|
|
|
info!(
|
|
|
|
|
|
"💪 sink overridden via LESAVKA_AUDIO_SINK={} -> {}",
|
|
|
|
|
|
s, sink
|
|
|
|
|
|
);
|
2026-04-14 07:49:38 -03:00
|
|
|
|
return Ok(sink);
|
2025-06-30 11:38:57 -05:00
|
|
|
|
}
|
2026-04-14 07:49:38 -03:00
|
|
|
|
let sinks = list_pw_sinks();
|
2026-04-20 18:41:48 -03:00
|
|
|
|
if let Some((n, st)) = sinks.first() {
|
|
|
|
|
|
info!("🔈 using PipeWire sink '{}' ({st})", n);
|
2026-04-14 07:49:38 -03:00
|
|
|
|
return Ok(pulsesink_device_element(n));
|
2025-06-30 11:38:57 -05:00
|
|
|
|
}
|
2025-06-30 15:45:37 -05:00
|
|
|
|
warn!("🫣 no PipeWire sinks readable - falling back to autoaudiosink");
|
2025-06-30 11:38:57 -05:00
|
|
|
|
Ok("autoaudiosink".to_string())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 02:52:32 -03:00
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
|
fn pick_sink_element() -> Result<String> {
|
|
|
|
|
|
if let Ok(s) = std::env::var("LESAVKA_AUDIO_SINK") {
|
2026-04-14 07:49:38 -03:00
|
|
|
|
return Ok(normalize_sink_override(&s));
|
2026-04-13 02:52:32 -03:00
|
|
|
|
}
|
|
|
|
|
|
if let Some((n, _)) = list_pw_sinks().first() {
|
2026-04-14 07:49:38 -03:00
|
|
|
|
return Ok(pulsesink_device_element(n));
|
2026-04-13 02:52:32 -03:00
|
|
|
|
}
|
|
|
|
|
|
Ok("autoaudiosink".to_string())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 07:49:38 -03:00
|
|
|
|
/// 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('"', "\\\"");
|
2026-04-20 11:11:51 -03:00
|
|
|
|
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"
|
|
|
|
|
|
)
|
2026-04-14 07:49:38 -03:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-06-30 11:38:57 -05:00
|
|
|
|
fn list_pw_sinks() -> Vec<(String, String)> {
|
2026-04-20 18:41:48 -03:00
|
|
|
|
let default_sink = std::process::Command::new("pactl")
|
2025-11-30 23:41:29 -03:00
|
|
|
|
.args(["info"])
|
|
|
|
|
|
.output()
|
2026-04-20 18:41:48 -03:00
|
|
|
|
.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()
|
2025-11-30 23:41:29 -03:00
|
|
|
|
{
|
2026-04-20 18:41:48 -03:00
|
|
|
|
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;
|
2025-06-30 11:38:57 -05:00
|
|
|
|
}
|
2026-04-20 18:41:48 -03:00
|
|
|
|
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,
|
2025-06-29 03:46:34 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|