Skip to content
Open
Show file tree
Hide file tree
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
54 changes: 54 additions & 0 deletions crates/server/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<i32>().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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1423,6 +1457,26 @@ fn consume_pcm_prefix(bytes: &mut Vec<u8>, pts_ns: &mut Option<i64>, 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,
Expand Down
49 changes: 35 additions & 14 deletions crates/server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MediaBackendScreencast>,
Expand Down Expand Up @@ -1497,6 +1497,13 @@ struct SharedCompositor {
/// `None` when PipeWire is not available or `YAS_AUDIO=0`.
#[cfg(target_os = "linux")]
audio_pipeline: Option<audio::AudioPipeline>,
/// 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.
Expand Down Expand Up @@ -1900,7 +1907,7 @@ impl SharedCompositor {
.collect::<Vec<_>>();
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,
Expand Down Expand Up @@ -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")
Expand All @@ -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::<i32>().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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
39 changes: 20 additions & 19 deletions crates/server/src/yas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12748,19 +12748,22 @@ 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,
self.media_audio.clone(),
);
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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading