client: normalize launcher audio sink override

This commit is contained in:
Brad Stein 2026-04-14 07:49:38 -03:00
parent 0908fccdb4
commit 455e73cdbc
2 changed files with 41 additions and 35 deletions

View File

@ -17,18 +17,12 @@ pub struct AudioOut {
impl AudioOut {
pub fn new() -> anyhow::Result<Self> {
gst::init().context("initialising GStreamer")?;
// ── 1. Decide which sink element to instantiate ────────────────────
let sink = pick_sink_element()?;
// Operator can request a tee to /tmp via LESAVKA_TAP_AUDIO=1
let tee_dump = std::env::var("LESAVKA_TAP_AUDIO")
.ok()
.as_deref()
.map(|v| v == "1")
.unwrap_or(false);
// ── 2. Assemble pipeline description string ────────────────────────
let mut pipe = format!(
"appsrc name=src is-live=true format=time do-timestamp=true \
block=false ! \
@ -46,8 +40,6 @@ impl AudioOut {
);
warn!("💾 tee to /tmp/lesavka-audio.aac enabled (LESAVKA_TAP_AUDIO=1)");
}
// ── 3. Create the pipeline & fetch the AppSrc ───────────────────────
let pipeline: gst::Pipeline = gst::parse::launch(&pipe)?
.downcast::<gst::Pipeline>()
.expect("not a pipeline");
@ -70,7 +62,6 @@ impl AudioOut {
#[cfg(not(coverage))]
{
// ── 4. Log *all* warnings/errors from the bus ──────────────────────
let bus = pipeline.bus().unwrap();
std::thread::spawn(move || {
for msg in bus.iter_timed(gst::ClockTime::NONE) {
@ -106,11 +97,7 @@ impl AudioOut {
}
});
}
pipeline
.set_state(gst::State::Playing)
.context("starting audio pipeline")?;
pipeline.set_state(gst::State::Playing).context("starting audio pipeline")?;
Ok(Self { pipeline, src })
}
@ -133,42 +120,32 @@ impl AudioOut {
impl Drop for AudioOut {
fn drop(&mut self) {
// put the whole pipeline back to NULL so GStreamer can dispose cleanly
let _ = self.pipeline.set_state(gst::State::Null);
}
}
/*──────────────── helper: sink selection ─────────────────────────────*/
#[cfg(not(coverage))]
fn pick_sink_element() -> Result<String> {
// 1. Operator override
if let Ok(s) = std::env::var("LESAVKA_AUDIO_SINK") {
info!("💪 sink overridden via LESAVKA_AUDIO_SINK={}", s);
return Ok(s);
let sink = normalize_sink_override(&s);
info!("💪 sink overridden via LESAVKA_AUDIO_SINK={} -> {}", s, sink);
return Ok(sink);
}
// 2. Query PipeWire for default & running sinks
let sinks = list_pw_sinks(); // Vec<(name,state)>
let sinks = list_pw_sinks();
for (n, st) in &sinks {
if *st == "RUNNING" {
info!("🔈 using default RUNNING sink '{}'", n);
return Ok(format!("pulsesink device={}", n));
return Ok(pulsesink_device_element(n));
}
}
// 3. First RUNNING sink
if let Some((n, _)) = sinks.iter().find(|(_, st)| *st == "RUNNING") {
warn!("🏃 picking first RUNNING sink '{}'", n);
return Ok(format!("pulsesink device={}", n));
return Ok(pulsesink_device_element(n));
}
// 4. Anything
if let Some((n, _)) = sinks.first() {
warn!("🎲 picking first sink '{}'", n);
return Ok(format!("pulsesink device={}", n));
return Ok(pulsesink_device_element(n));
}
// Fallback - let autoaudiosink try its luck
warn!("🫣 no PipeWire sinks readable - falling back to autoaudiosink");
Ok("autoaudiosink".to_string())
}
@ -176,16 +153,29 @@ fn pick_sink_element() -> Result<String> {
#[cfg(coverage)]
fn pick_sink_element() -> Result<String> {
if let Ok(s) = std::env::var("LESAVKA_AUDIO_SINK") {
return Ok(s);
return Ok(normalize_sink_override(&s));
}
if let Some((n, _)) = list_pw_sinks().first() {
return Ok(format!("pulsesink device={}", n));
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('"', "\\\"");
format!("pulsesink device=\"{escaped}\"")
}
fn list_pw_sinks() -> Vec<(String, String)> {
// ── PulseAudio / pactl fallback ────────────────────────────────
if let Ok(info) = std::process::Command::new("pactl")
.args(["info"])
.output()

View File

@ -46,6 +46,22 @@ mod audio_include_contract {
});
}
#[test]
#[serial]
fn pick_sink_element_wraps_bare_device_override_for_pulsesink() {
with_var(
"LESAVKA_AUDIO_SINK",
Some("alsa_output.pci-0000_00_1f.3.analog-stereo"),
|| {
let sink = pick_sink_element().expect("device sink");
assert_eq!(
sink,
"pulsesink device=\"alsa_output.pci-0000_00_1f.3.analog-stereo\""
);
},
);
}
#[test]
#[serial]
fn pick_sink_element_uses_default_sink_from_pactl_info() {
@ -70,7 +86,7 @@ exit 0
let sink = pick_sink_element().expect("pick sink");
assert_eq!(
sink,
"pulsesink device=alsa_output.usb-DAC_1234-00.analog-stereo"
"pulsesink device=\"alsa_output.usb-DAC_1234-00.analog-stereo\""
);
});
});