From 455e73cdbc182fd8fed67ac6c9cff2238b39dc6f Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Tue, 14 Apr 2026 07:49:38 -0300 Subject: [PATCH] client: normalize launcher audio sink override --- client/src/output/audio.rs | 58 ++++++++----------- .../client_output_audio_include_contract.rs | 18 +++++- 2 files changed, 41 insertions(+), 35 deletions(-) diff --git a/client/src/output/audio.rs b/client/src/output/audio.rs index 47b8682..d07cf9c 100644 --- a/client/src/output/audio.rs +++ b/client/src/output/audio.rs @@ -17,18 +17,12 @@ pub struct AudioOut { impl AudioOut { pub fn new() -> anyhow::Result { 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::() .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 { - // 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 { #[cfg(coverage)] fn pick_sink_element() -> Result { 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() diff --git a/testing/tests/client_output_audio_include_contract.rs b/testing/tests/client_output_audio_include_contract.rs index a7a7d80..ec00d20 100644 --- a/testing/tests/client_output_audio_include_contract.rs +++ b/testing/tests/client_output_audio_include_contract.rs @@ -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\"" ); }); });