Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 161 additions & 1 deletion src-tauri/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,14 @@ pub(crate) struct AudioOutputDevice {
* has ever started.
*/
pub(crate) fn list_output_devices() -> Result<Vec<AudioOutputDevice>, String> {
// When a sound server owns the hardware, its own sink list is the truth about what's
// actually plugged in (it already does jack-sensing); raw ALSA per-card enumeration below
// can't tell a live port from an empty one and duplicates what the server already presents.
#[cfg(target_os = "linux")]
if let Some(sinks) = pipewire_sinks() {
return Ok(sinks);
}

use rodio::cpal::traits::HostTrait;

let host = rodio::cpal::default_host();
Expand All @@ -390,13 +398,157 @@ pub(crate) fn list_output_devices() -> Result<Vec<AudioOutputDevice>, String> {
Ok(devices
.filter_map(|device| {
let id = device.id().ok()?;
let id_string = id.to_string();
#[cfg(target_os = "linux")]
if is_alsa_plugin_noise(&id_string) {
return None;
}
let name = device.description().ok()?.name().to_string();
let is_default = default_id.as_ref() == Some(&id);
Some(AudioOutputDevice { id: id.to_string(), name, is_default })
Some(AudioOutputDevice { id: id_string, name, is_default })
})
.collect())
}

/// ALSA registers dozens of generic "type" PCMs — rate converters, plugin wrappers, and
/// per-card surround/iec958 variants most cards don't actually support — globally in
/// alsa.conf regardless of what hardware is plugged in, and cpal's hint enumeration surfaces
/// every one of them. On top of that, cpal's physical-device probe re-adds every card as raw
/// `hw:`/`plughw:` entries, duplicating the friendly `sysdefault:`/`hdmi:` hints for the same
/// hardware. This keeps only the handful of genuinely distinct, selectable outputs (default,
/// pulse/pipewire, and each card's sysdefault:/typed hint) and drops the rest.
///
/// ponytail: excluding `hw`/`plughw` outright assumes every card also gets a friendly hint
/// (true for the stock alsa.conf `sysdefault` auto-config). A card with no such hint would
/// become unselectable — if that surfaces, add it back only when its CARD= has no other entry.
#[cfg(target_os = "linux")]
fn is_alsa_plugin_noise(id: &str) -> bool {
const NOISY_TYPES: &[&str] = &[
"null", "jack", "oss", "speex", "speexrate", "lavrate", "samplerate", "upmix", "vdownmix",
"surround21", "surround40", "surround41", "surround50", "surround51", "surround71",
"iec958", "spdif", "usbstream", "dmix", "dsnoop", "front", "rear", "center_lfe", "side",
"modem", "phoneline", "shm", "tee", "file", "empty", "rate", "route", "mulaw", "alaw",
"adpcm", "plug", "multi", "ladspa", "asym", "hw", "plughw",
];
let type_name = id.split(':').next().unwrap_or(id);
NOISY_TYPES.contains(&type_name)
}

#[cfg(all(test, target_os = "linux"))]
mod alsa_device_filter_tests {
use super::is_alsa_plugin_noise;

#[test]
fn drops_alsa_plugin_and_virtual_types() {
assert!(is_alsa_plugin_noise("null"));
assert!(is_alsa_plugin_noise("surround51:CARD=PCH,DEV=0"));
assert!(is_alsa_plugin_noise("iec958:CARD=PCH,DEV=0"));
assert!(is_alsa_plugin_noise("usbstream:CARD=Device"));
assert!(is_alsa_plugin_noise("front:CARD=PCH,DEV=0"));
// Raw hw:/plughw: duplicate the friendly sysdefault:/hdmi: hints for the same card.
assert!(is_alsa_plugin_noise("hw:CARD=PCH,DEV=0"));
assert!(is_alsa_plugin_noise("plughw:CARD=PCH,DEV=0"));
}

#[test]
fn keeps_genuinely_selectable_outputs() {
assert!(!is_alsa_plugin_noise("default"));
assert!(!is_alsa_plugin_noise("pulse"));
assert!(!is_alsa_plugin_noise("pipewire"));
assert!(!is_alsa_plugin_noise("sysdefault:CARD=PCH"));
assert!(!is_alsa_plugin_noise("hdmi:CARD=HDMI,DEV=0"));
}
}

/// Prefix marking an id as a PipeWire/PulseAudio sink name rather than a cpal `DeviceId` — the
/// two live in different namespaces and `open_device_sink` needs to tell them apart.
#[cfg(target_os = "linux")]
const PIPEWIRE_SINK_ID_PREFIX: &str = "pipewire-sink:";

/// Sink list from `pactl`, when a sound server is running. `None` means no server (or `pactl`
/// missing) — falls back to raw ALSA enumeration.
#[cfg(target_os = "linux")]
fn pipewire_sinks() -> Option<Vec<AudioOutputDevice>> {
let output = std::process::Command::new("pactl")
.args(["-f", "json", "list", "sinks"])
.output()
.ok()?;
let sinks = serde_json::from_slice::<serde_json::Value>(&output.stdout).ok()?;
let sinks = sinks.as_array().filter(|sinks| !sinks.is_empty())?;

let default_name = std::process::Command::new("pactl")
.arg("get-default-sink")
.output()
.ok()
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string());

Some(
sinks
.iter()
.filter_map(|sink| {
let name = sink.get("name")?.as_str()?;
let description = sink.get("description").and_then(|d| d.as_str()).unwrap_or(name);
Some(AudioOutputDevice {
id: format!("{PIPEWIRE_SINK_ID_PREFIX}{name}"),
name: description.to_string(),
is_default: default_name.as_deref() == Some(name),
})
})
.collect(),
)
}

/// Steers our own stream to `sink_name` after it's already open on whatever the server picked.
/// The ALSA "pulse"/"pipewire" virtual device cpal opens can't target a specific sink by name —
/// that's a sound-server-level move, done here via `pactl` rather than at open time.
///
/// A failure here is never fatal: the stream keeps playing on the server's chosen sink, same as
/// before the user picked a different one, which is a smaller surprise than losing audio.
#[cfg(target_os = "linux")]
fn move_our_stream_to_pipewire_sink(sink_name: &str) {
// ponytail: bounded retry rather than a proper "client registered" signal — PipeWire needs
// a moment after the stream opens before our sink-input shows up in its graph.
for attempt in 0..10 {
if attempt > 0 {
std::thread::sleep(Duration::from_millis(50));
}
let Some(sink_input_id) = our_sink_input_id() else { continue };
let moved = std::process::Command::new("pactl")
.args(["move-sink-input", &sink_input_id.to_string(), sink_name])
.status()
.is_ok_and(|status| status.success());
if moved {
return;
}
}
eprintln!(
"[internal][tauri][warn] failed to move audio stream to pipewire sink sink={sink_name}"
);
}

/// The freshest sink-input whose client name is ours — cpal opens a brand new PipeWire client
/// on every device switch, so the highest index among ours is the one that was just opened.
#[cfg(target_os = "linux")]
fn our_sink_input_id() -> Option<u64> {
let output = std::process::Command::new("pactl")
.args(["-f", "json", "list", "sink-inputs"])
.output()
.ok()?;
let inputs = serde_json::from_slice::<serde_json::Value>(&output.stdout).ok()?;
inputs
.as_array()?
.iter()
.filter(|input| {
input
.get("properties")
.and_then(|props| props.get("application.name"))
.and_then(|name| name.as_str())
.is_some_and(|name| name.contains(env!("CARGO_PKG_NAME")))
})
.filter_map(|input| input.get("index")?.as_u64())
.max()
}

/**
* Opens the sink for `id`, or the OS default when `id` is `None`.
*
Expand All @@ -406,6 +558,14 @@ pub(crate) fn list_output_devices() -> Result<Vec<AudioOutputDevice>, String> {
* settings, which is a worse failure than picking a device for them.
*/
fn open_device_sink(id: Option<&str>) -> Result<MixerDeviceSink, String> {
#[cfg(target_os = "linux")]
if let Some(sink_name) = id.and_then(|id| id.strip_prefix(PIPEWIRE_SINK_ID_PREFIX)) {
let stream = DeviceSinkBuilder::open_default_sink()
.map_err(|error| format!("no audio output device: {error}"))?;
move_our_stream_to_pipewire_sink(sink_name);
return Ok(stream);
}

let device = id.and_then(|id| {
let device = find_output_device(id);
if device.is_none() {
Expand Down
Loading