From e7afe8d091e7ab5ffac94e7bb5341ad0d4fdc675 Mon Sep 17 00:00:00 2001 From: Max Gasner Date: Mon, 28 Sep 2026 20:19:39 +0000 Subject: [PATCH] Honor YAS_AUDIO=0 and YAS_AUDIO_BITRATE when PipeWire runs for media - YAS_AUDIO=0 only kept PipeWire from starting when media input and portals were also off. With either on, the runtime started and desktop audio was still offered and streamed. The output device is now unavailable and OPEN_OUTPUT is refused under YAS_AUDIO=0 regardless of why PipeWire runs. - A viewer at "Desktop default" (0 kbps) reset the encoder to a built-in 64 kbps, overriding YAS_AUDIO_BITRATE. The configured bitrate is now what a server-default viewer, the last unsubscribe, and a pipeline restart use. - When audio cannot start and was not disabled, print one line naming the reason at normal verbosity; -v keeps the detailed lines. --- README.md | 2 +- crates/server/src/audio.rs | 54 +++++++++++++++++++ crates/server/src/lib.rs | 49 ++++++++++++----- crates/server/src/yas.rs | 39 +++++++------- .../tests/aggregate_receive_qualification.rs | 2 +- 5 files changed, 111 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 9ffdb2de..d55d7e4a 100644 --- a/README.md +++ b/README.md @@ -393,7 +393,7 @@ Without any of the above, the compositor falls back to CPU rendering and softwar | `wireplumber` | Session manager (optional, started if available) | | `xwayland-satellite` | X11 applications (optional, started if available) | -Audio is disabled automatically when PipeWire is not installed or `libpipewire-0.3.so.0` is not resolvable via `ld.so` (set `LD_LIBRARY_PATH` if you have it in a non-default location), or explicitly with `YAS_AUDIO=0`. +Audio is disabled automatically when PipeWire is not installed or `libpipewire-0.3.so.0` is not resolvable via `ld.so` (set `LD_LIBRARY_PATH` if you have it in a non-default location); the server then prints one `[audio] desktop audio unavailable` line naming the reason (`-v` for details). `YAS_AUDIO=0` stops desktop audio from being offered or streamed to viewers, even when PipeWire still runs for camera, microphone, or portals. `YAS_AUDIO_BITRATE` (bits/sec, default 64000) is the Opus bitrate viewers get when they leave the bitrate at the server's default. X11 applications run through `xwayland-satellite`, which yas starts once per session when the binary is on `PATH` and exports the resulting `DISPLAY` to every terminal. Wayland stays the first choice for anything that speaks it; X11 is the fallback behind it. Without the binary, sessions are Wayland-only and no `DISPLAY` is exported. Set `YAS_XWAYLAND=0` to opt out. diff --git a/crates/server/src/audio.rs b/crates/server/src/audio.rs index 8b8556d4..ee11bfca 100644 --- a/crates/server/src/audio.rs +++ b/crates/server/src/audio.rs @@ -41,6 +41,27 @@ const MAX_OPUS_PACKET: usize = 4000; /// Default Opus bitrate in bits/sec. pub const DEFAULT_BITRATE: i32 = 64_000; +/// The Opus bitrate in bits/sec that `YAS_AUDIO_BITRATE` configures, or +/// [`DEFAULT_BITRATE`] when it is unset or not a positive integer. +pub fn configured_bitrate() -> i32 { + std::env::var("YAS_AUDIO_BITRATE") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|&bitrate| bitrate > 0) + .unwrap_or(DEFAULT_BITRATE) +} + +/// Encoder bitrate for the current viewers: the highest bitrate any viewer +/// asked for, or the server's configured bitrate when every viewer left the +/// choice to the server (0 kbps). +pub fn output_bitrate(max_native_kbps: u16, configured: i32) -> i32 { + if max_native_kbps == 0 { + configured + } else { + i32::from(max_native_kbps) * 1_000 + } +} + /// Server-side ring buffer depth: 200 ms = 10 Opus frames at 20 ms. /// Also sizes the encoder -> fan-out channel (at `RING_CAPACITY * 2`), /// which needs slack for a briefly-descheduled fan-out task — so keep @@ -478,6 +499,19 @@ pub fn pipewire_available() -> bool { /// Returns the list of required PipeWire / D-Bus binaries that are not /// found on `$PATH`. Empty list means audio can run (provided /// libpipewire is also loadable at runtime; see `pipewire_available`). +/// One-line reason desktop audio could not start, for normal verbosity. +pub fn unavailable_reason(desktop_bus: bool, missing: &[&str], load_error: &str) -> String { + if !missing.is_empty() { + format!("missing on $PATH: {}", missing.join(", ")) + } else if !load_error.is_empty() { + load_error.to_owned() + } else if !desktop_bus { + "no private desktop D-Bus (is dbus-daemon installed?)".to_owned() + } else { + "PipeWire is not available".to_owned() + } +} + pub fn missing_pipewire_binaries() -> Vec<&'static str> { [ "pipewire", @@ -1423,6 +1457,26 @@ fn consume_pcm_prefix(bytes: &mut Vec, pts_ns: &mut Option, consumed: u mod tests { use super::*; + #[test] + fn unavailable_reason_names_the_first_missing_dependency() { + assert_eq!( + unavailable_reason(true, &["pipewire", "pipewire-pulse"], "dlopen failed"), + "missing on $PATH: pipewire, pipewire-pulse" + ); + assert_eq!( + unavailable_reason(true, &[], "libpipewire-0.3.so.0: not found"), + "libpipewire-0.3.so.0: not found" + ); + assert!(unavailable_reason(false, &[], "").contains("D-Bus")); + } + + #[test] + fn viewers_at_the_server_default_get_the_configured_bitrate() { + assert_eq!(output_bitrate(0, 128_000), 128_000); + assert_eq!(output_bitrate(0, DEFAULT_BITRATE), DEFAULT_BITRATE); + assert_eq!(output_bitrate(96, 128_000), 96_000); + } + fn frame(ts: u32) -> OpusFrame { OpusFrame { timestamp: ts, diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 8c817423..091d2fa8 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -1360,7 +1360,7 @@ struct MprisBackendState { #[cfg(target_os = "linux")] #[derive(Clone, Debug, Default, PartialEq, Eq)] struct MediaBackendState { - pipewire_available: bool, + audio_output_available: bool, microphone_available: bool, camera_available: bool, screencasts: Vec, @@ -1497,6 +1497,13 @@ struct SharedCompositor { /// `None` when PipeWire is not available or `YAS_AUDIO=0`. #[cfg(target_os = "linux")] audio_pipeline: Option, + /// False under `YAS_AUDIO=0`: the PipeWire runtime may still run for + /// media input or portals, but desktop audio is not offered to viewers. + #[cfg(target_os = "linux")] + audio_output_enabled: bool, + /// Opus bitrate in bits/sec for viewers that leave it to the server. + #[cfg(target_os = "linux")] + audio_bitrate: i32, /// Private session bus whose activation environment points at this /// compositor. Desktop portals spawned through it map inside yas rather /// than escaping to the host compositor. @@ -1900,7 +1907,7 @@ impl SharedCompositor { .collect::>(); screencasts.sort_unstable_by_key(|session| session.session_id); MediaBackendState { - pipewire_available: self.audio_pipeline.is_some(), + audio_output_available: self.audio_pipeline.is_some() && self.audio_output_enabled, microphone_available: microphone, camera_available: camera, screencasts, @@ -5959,11 +5966,13 @@ impl Session { #[cfg(target_os = "linux")] let audio_broadcast = audio::AudioBroadcast::new(); #[cfg(target_os = "linux")] + let audio_output_enabled = std::env::var("YAS_AUDIO").map_or(true, |v| v != "0"); + #[cfg(target_os = "linux")] + let audio_bitrate = audio::configured_bitrate(); + #[cfg(target_os = "linux")] let audio_pipeline = { let desktop_bus_address = desktop_bus.as_ref().map(|bus| bus.address().to_owned()); - let audio_disabled = std::env::var("YAS_AUDIO") - .map(|v| v == "0") - .unwrap_or(false); + let audio_disabled = !audio_output_enabled; let media_input_enabled = std::env::var("YAS_MEDIA_INPUT") .map_or(true, |value| value != "0") && (std::env::var("YAS_MEDIA_MICROPHONE").map_or(true, |value| value != "0") @@ -5977,10 +5986,7 @@ impl Session { let runtime_dir = std::path::Path::new(&handle.socket_name) .parent() .unwrap_or(std::path::Path::new("/tmp")); - let bitrate = std::env::var("YAS_AUDIO_BITRATE") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(0); + let bitrate = audio_bitrate; // Wrap in block_in_place so the thread::sleep calls // inside spawn() don't stall the tokio runtime. let broadcast = audio_broadcast.clone(); @@ -6009,6 +6015,16 @@ impl Session { } }) } else { + if !verbose && !audio_disabled { + eprintln!( + "[audio] desktop audio unavailable: {}; -v for details, YAS_AUDIO=0 to silence", + audio::unavailable_reason( + desktop_bus_address.is_some(), + &audio::missing_pipewire_binaries(), + &audio_pw::load_error(), + ) + ); + } if verbose && (!audio_disabled || media_input_enabled || screencast_enabled) { let missing = audio::missing_pipewire_binaries(); let load_err = audio_pw::load_error(); @@ -6082,6 +6098,10 @@ impl Session { #[cfg(target_os = "linux")] audio_pipeline, #[cfg(target_os = "linux")] + audio_output_enabled, + #[cfg(target_os = "linux")] + audio_bitrate, + #[cfg(target_os = "linux")] desktop_bus, #[cfg(target_os = "linux")] xwayland, @@ -12715,11 +12735,12 @@ async fn tick(state: &AppState) -> TickOutcome { // for seconds and must not hold the session mutex or park the tick loop. // #[cfg(target_os = "linux")] - let audio_restart_bitrate: i32 = i32::from( - sess.compositor - .as_ref() - .map_or(0, |cs| cs.audio_broadcast.max_native_bitrate_kbps()), - ) * 1_000; + let audio_restart_bitrate: i32 = sess.compositor.as_ref().map_or(0, |cs| { + audio::output_bitrate( + cs.audio_broadcast.max_native_bitrate_kbps(), + cs.audio_bitrate, + ) + }); #[cfg(target_os = "linux")] { let camera_results = sess diff --git a/crates/server/src/yas.rs b/crates/server/src/yas.rs index 148bb063..0bbc9285 100644 --- a/crates/server/src/yas.rs +++ b/crates/server/src/yas.rs @@ -12748,7 +12748,11 @@ impl Session { #[cfg(target_os = "linux")] let subscribed = if let Some(state) = self.services.app_state.as_ref() { let mut shared = state.session.lock().await; - if let Some(compositor) = shared.compositor.as_mut() { + if let Some(compositor) = shared + .compositor + .as_mut() + .filter(|compositor| compositor.audio_output_enabled) + { compositor.audio_broadcast.subscribe_native( backend_owner, request.target_bitrate_kbps, @@ -12756,11 +12760,10 @@ impl Session { ); let max_kbps = compositor.audio_broadcast.max_native_bitrate_kbps(); if let Some(pipeline) = compositor.audio_pipeline.as_ref() { - pipeline.set_bitrate(if max_kbps == 0 { - super::audio::DEFAULT_BITRATE - } else { - i32::from(max_kbps) * 1_000 - }); + pipeline.set_bitrate(super::audio::output_bitrate( + max_kbps, + compositor.audio_bitrate, + )); } true } else { @@ -26315,11 +26318,10 @@ impl Session { let max_kbps = compositor.audio_broadcast.max_native_bitrate_kbps(); let max_delay = compositor.audio_broadcast.max_native_playout_delay_ns(); if let Some(pipeline) = compositor.audio_pipeline.as_ref() { - pipeline.set_bitrate(if max_kbps == 0 { - super::audio::DEFAULT_BITRATE - } else { - i32::from(max_kbps) * 1_000 - }); + pipeline.set_bitrate(super::audio::output_bitrate( + max_kbps, + compositor.audio_bitrate, + )); // Publish even when the remaining current maximum is // zero: the removed report may have expired before the // maintenance tick cleared its advertised latency. @@ -28404,11 +28406,10 @@ impl Session { let max_delay = compositor.audio_broadcast.max_native_playout_delay_ns(); let max_kbps = compositor.audio_broadcast.max_native_bitrate_kbps(); if let Some(pipeline) = compositor.audio_pipeline.as_ref() { - pipeline.set_bitrate(if max_kbps == 0 { - super::audio::DEFAULT_BITRATE - } else { - i32::from(max_kbps) * 1_000 - }); + pipeline.set_bitrate(super::audio::output_bitrate( + max_kbps, + compositor.audio_bitrate, + )); // Publish even when the remaining current maximum is // zero: the removed report may have expired before the // maintenance tick cleared its advertised latency. @@ -33634,7 +33635,7 @@ fn rebuild_media_devices(runtime: &mut MediaRuntime) -> bool { old.get(&MEDIA_OUTPUT_DEVICE_HANDLE), MEDIA_OUTPUT_DEVICE_HANDLE, yas_wire::schema::media::KIND_AUDIO_OUTPUT as u8, - backend.is_some_and(|state| state.pipewire_available), + backend.is_some_and(|state| state.audio_output_available), "Default audio output", vec![media_audio_format( yas_wire::schema::media::CODEC_OPUS as u16, @@ -41543,7 +41544,7 @@ mod tests { .as_mut() .unwrap() .native_media_state_override = Some(super::super::MediaBackendState { - pipewire_available: true, + audio_output_available: true, microphone_available: false, camera_available: false, screencasts: Vec::new(), @@ -47901,7 +47902,7 @@ mod tests { let compositor = shared.compositor.as_mut().unwrap(); compositor.audio_broadcast = super::super::audio::AudioBroadcast::new(); compositor.native_media_state_override = Some(super::super::MediaBackendState { - pipewire_available: true, + audio_output_available: true, microphone_available: false, camera_available: false, screencasts: Vec::new(), diff --git a/crates/server/src/yas/tests/aggregate_receive_qualification.rs b/crates/server/src/yas/tests/aggregate_receive_qualification.rs index 010683bd..2e153865 100644 --- a/crates/server/src/yas/tests/aggregate_receive_qualification.rs +++ b/crates/server/src/yas/tests/aggregate_receive_qualification.rs @@ -923,7 +923,7 @@ async fn every_retaining_family_shares_one_live_session_budget() { .as_mut() .unwrap() .native_media_state_override = Some(super::super::super::MediaBackendState { - pipewire_available: true, + audio_output_available: true, microphone_available: true, camera_available: false, screencasts: Vec::new(),