From 40c2f682314510e74c436afb5bafdd06fca1e1d1 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 19 Jul 2026 19:32:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(client):=20device-pick=20plumbing=20?= =?UTF-8?q?=E2=80=94=20GPU=20enumeration=20+=20audio=20endpoint=20targetin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Settings GPU pick existed (adapter → PUNKTFUNK_VK_ADAPTER) but no Linux shell could enumerate anything to pick: the GTK shell deliberately links no Vulkan. pf_presenter::vk::list_adapters() reads the physical devices' marketing names (no surface, discrete first, deduped — the name is the whole match key in pick_device), surfaced as `punktfunk-session --list-adapters`. Audio gets the same treatment for the new speaker_device/mic_device settings (PipeWire node.name; empty = default): session main maps them onto PUNKTFUNK_AUDIO_SINK/SOURCE — a hand-set env still wins, like the adapter — and the playback/mic streams pass them as `target.object` (raw key: the keys::TARGET_OBJECT constant is feature-gated on a newer libpipewire than we require). pf_client_core::audio::devices() is the registry roundtrip the pickers read, exposed for debugging as `punktfunk-session --list-audio`. The WASAPI leg (Windows endpoint IDs) is still to come; the fields are ignored there. Co-Authored-By: Claude Fable 5 --- clients/session/src/main.rs | 61 ++++++++++-- crates/pf-client-core/src/audio.rs | 140 ++++++++++++++++++++++------ crates/pf-client-core/src/trust.rs | 12 +++ crates/pf-presenter/src/vk/mod.rs | 2 + crates/pf-presenter/src/vk/setup.rs | 46 +++++++++ 5 files changed, 228 insertions(+), 33 deletions(-) diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index 971d7b95..a4a1a2bf 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -268,19 +268,66 @@ mod session_main { ) .init(); + // `--list-adapters`: print the Vulkan physical devices' marketing names (one per + // line, discrete first) for the desktop shells' GPU picker, then exit. + if arg_flag("--list-adapters") { + return match pf_presenter::vk::list_adapters() { + Ok(names) => { + for n in names { + println!("{n}"); + } + 0 + } + Err(e) => { + eprintln!("list-adapters: {e:#}"); + EXIT_PRESENTER_FAILED + } + }; + } + + // `--list-audio`: the PipeWire endpoints the settings pickers offer, as + // `sink|sourcenode.namedescription` lines — a debug window into the + // same enumeration the GTK shell probes. + #[cfg(target_os = "linux")] + if arg_flag("--list-audio") { + return match pf_client_core::audio::devices() { + Ok((sinks, sources)) => { + for d in sinks { + println!("sink\t{}\t{}", d.name, d.description); + } + for d in sources { + println!("source\t{}\t{}", d.name, d.description); + } + 0 + } + Err(e) => { + eprintln!("list-audio: {e:#}"); + EXIT_PRESENTER_FAILED + } + }; + } + // Before any Vulkan call: make RADV expose its video-decode queue + extensions so the // decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV). // Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally. #[cfg(target_os = "linux")] enable_radv_video_decode(); - // The Settings GPU pick (the WinUI shell's picker stores the adapter's marketing - // name) → the presenter's device selection, unless the user already forced one. - // Before any Vulkan call, like the RADV knob (covers --connect and --browse). - if std::env::var_os("PUNKTFUNK_VK_ADAPTER").is_none() { - let adapter = trust::Settings::load().adapter; - if !adapter.is_empty() { - std::env::set_var("PUNKTFUNK_VK_ADAPTER", adapter); + // The Settings device picks → env, unless the user already forced one by hand: + // the GPU (the shells' pickers store the adapter's marketing name) for the + // presenter's device selection, and the audio endpoints (PipeWire node names) + // for the playback/mic streams' `target.object`. Before any Vulkan call, like + // the RADV knob (covers --connect and --browse). + { + let s = trust::Settings::load(); + for (var, value) in [ + ("PUNKTFUNK_VK_ADAPTER", &s.adapter), + ("PUNKTFUNK_AUDIO_SINK", &s.speaker_device), + ("PUNKTFUNK_AUDIO_SOURCE", &s.mic_device), + ] { + if std::env::var_os(var).is_none() && !value.is_empty() { + std::env::set_var(var, value); + } } } diff --git a/crates/pf-client-core/src/audio.rs b/crates/pf-client-core/src/audio.rs index 5bd0a1c9..6e62912b 100644 --- a/crates/pf-client-core/src/audio.rs +++ b/crates/pf-client-core/src/audio.rs @@ -20,6 +20,83 @@ const MIC_FRAME: usize = 960; struct Terminate; +/// A selectable PipeWire endpoint for the settings pickers. +#[derive(Clone, Debug)] +pub struct AudioDevice { + /// `node.name` — the stable key the streams target via `target.object`. + pub name: String, + /// `node.description` — the human label the picker shows. + pub description: String, +} + +/// Enumerate audio endpoints: `(sinks, sources)`. One registry roundtrip on a private +/// mainloop (a few ms against a live PipeWire); no daemon errors out and the caller +/// simply shows no pickers. +pub fn devices() -> Result<(Vec, Vec)> { + use pipewire as pw; + use std::cell::RefCell; + use std::rc::Rc; + + static PW_INIT: std::sync::Once = std::sync::Once::new(); + PW_INIT.call_once(pw::init); + + let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?; + let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?; + let core = context + .connect_rc(None) + .context("pw connect (is PipeWire running in this session?)")?; + let registry = core.get_registry_rc().context("pw registry")?; + + let found: Rc, Vec)>> = Rc::default(); + let _reg_listener = registry + .add_listener_local() + .global({ + let found = found.clone(); + move |g| { + let Some(props) = g.props else { return }; + let sink = match props.get("media.class") { + Some("Audio/Sink") => true, + Some("Audio/Source") => false, + _ => return, + }; + let Some(name) = props.get("node.name") else { + return; + }; + let description = props + .get("node.description") + .or_else(|| props.get("node.nick")) + .unwrap_or(name) + .to_string(); + let dev = AudioDevice { + name: name.to_string(), + description, + }; + let mut f = found.borrow_mut(); + if sink { &mut f.0 } else { &mut f.1 }.push(dev); + } + }) + .register(); + + // The registry replays existing globals asynchronously; one core sync marks the + // point they've all been delivered — quit the loop there. + let pending = core.sync(0).context("pw sync")?; + let _core_listener = core + .add_listener_local() + .done({ + let mainloop = mainloop.clone(); + move |_, seq| { + if seq == pending { + mainloop.quit(); + } + } + }) + .register(); + mainloop.run(); + + let result = found.borrow().clone(); + Ok(result) +} + pub struct AudioPlayer { pcm_tx: SyncSender>, /// Drained chunk Vecs coming back from the PipeWire consumer for reuse (the pool half @@ -118,20 +195,26 @@ fn pw_thread( move |_| mainloop.quit() }); - let stream = pw::stream::StreamBox::new( - &core, - "punktfunk-client", - properties! { - *pw::keys::MEDIA_TYPE => "Audio", - *pw::keys::MEDIA_CATEGORY => "Playback", - *pw::keys::MEDIA_ROLE => "Game", - *pw::keys::NODE_NAME => "punktfunk-client", - *pw::keys::NODE_DESCRIPTION => "Punktfunk Stream", - // ~5 ms quantum (one Opus frame) keeps the ring — and so the latency — small. - *pw::keys::NODE_LATENCY => "240/48000", - }, - ) - .context("pw Stream")?; + let mut props = properties! { + *pw::keys::MEDIA_TYPE => "Audio", + *pw::keys::MEDIA_CATEGORY => "Playback", + *pw::keys::MEDIA_ROLE => "Game", + *pw::keys::NODE_NAME => "punktfunk-client", + *pw::keys::NODE_DESCRIPTION => "Punktfunk Stream", + // ~5 ms quantum (one Opus frame) keeps the ring — and so the latency — small. + *pw::keys::NODE_LATENCY => "240/48000", + }; + // The Settings speaker pick (session main maps `Settings::speaker_device` here); + // unset/empty = PipeWire's default routing. + if let Ok(target) = std::env::var("PUNKTFUNK_AUDIO_SINK") { + if !target.is_empty() { + // Raw key: the `keys::TARGET_OBJECT` constant is feature-gated on a newer + // libpipewire than we require; the wire name is stable. + props.insert("target.object", target); + } + } + let stream = + pw::stream::StreamBox::new(&core, "punktfunk-client", props).context("pw Stream")?; let ud = PlayerData { rx: pcm_rx, @@ -316,18 +399,23 @@ fn mic_thread( move |_| mainloop.quit() }); - let stream = pw::stream::StreamBox::new( - &core, - "punktfunk-mic-capture", - properties! { - *pw::keys::MEDIA_TYPE => "Audio", - *pw::keys::MEDIA_CATEGORY => "Capture", - *pw::keys::MEDIA_ROLE => "Communication", - *pw::keys::NODE_NAME => "punktfunk-mic-capture", - *pw::keys::NODE_DESCRIPTION => "Punktfunk Microphone", - }, - ) - .context("pw mic Stream")?; + let mut props = properties! { + *pw::keys::MEDIA_TYPE => "Audio", + *pw::keys::MEDIA_CATEGORY => "Capture", + *pw::keys::MEDIA_ROLE => "Communication", + *pw::keys::NODE_NAME => "punktfunk-mic-capture", + *pw::keys::NODE_DESCRIPTION => "Punktfunk Microphone", + }; + // The Settings microphone pick (`Settings::mic_device` via session main). + if let Ok(target) = std::env::var("PUNKTFUNK_AUDIO_SOURCE") { + if !target.is_empty() { + // Raw key: the `keys::TARGET_OBJECT` constant is feature-gated on a newer + // libpipewire than we require; the wire name is stable. + props.insert("target.object", target); + } + } + let stream = pw::stream::StreamBox::new(&core, "punktfunk-mic-capture", props) + .context("pw mic Stream")?; let ud = MicData { connector: connector.clone(), diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index f4ee6f03..7adf116f 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -544,6 +544,16 @@ pub struct Settings { /// "Invert scroll direction"). Default off = the host scrolls the way this machine does. #[serde(default)] pub invert_scroll: bool, + /// Playback endpoint for stream audio — on Linux the PipeWire `node.name` the + /// playback stream targets (`target.object`); empty = the session default (the + /// Apple client's Speaker picker). The session maps it onto `PUNKTFUNK_AUDIO_SINK`. + /// Ignored on Windows until the WASAPI endpoint leg exists. + #[serde(default)] + pub speaker_device: String, + /// Capture endpoint for the mic uplink (same semantics as `speaker_device`; + /// `PUNKTFUNK_AUDIO_SOURCE`). + #[serde(default)] + pub mic_device: String, /// Match-window resolution policy (design/midstream-resolution-resize.md D1): the /// stream mode follows the session window — the connect asks for the window's pixel /// size and a mid-session resize renegotiates the host's virtual display + encoder @@ -634,6 +644,8 @@ impl Default for Settings { library_enabled: false, auto_wake: true, invert_scroll: false, + speaker_device: String::new(), + mic_device: String::new(), match_window: false, last_window_w: 0, last_window_h: 0, diff --git a/crates/pf-presenter/src/vk/mod.rs b/crates/pf-presenter/src/vk/mod.rs index 4c3c9807..ffa46042 100644 --- a/crates/pf-presenter/src/vk/mod.rs +++ b/crates/pf-presenter/src/vk/mod.rs @@ -33,6 +33,8 @@ mod reconfig; mod resources; mod setup; +pub use setup::list_adapters; + /// One presenter iteration's video input. pub enum FrameInput<'a> { /// No new frame — re-composite the retained video image (expose/resize). diff --git a/crates/pf-presenter/src/vk/setup.rs b/crates/pf-presenter/src/vk/setup.rs index 8b2081a0..487b62f3 100644 --- a/crates/pf-presenter/src/vk/setup.rs +++ b/crates/pf-presenter/src/vk/setup.rs @@ -497,6 +497,52 @@ impl Presenter { } } +/// The physical devices' marketing names — the shells' GPU-picker source +/// (`punktfunk-session --list-adapters`). No surface and no logical device; discrete +/// GPUs first (mirroring `pick_device`'s tie-break), duplicates collapsed (the name is +/// the whole `PUNKTFUNK_VK_ADAPTER` match key, so a second identical card adds nothing). +/// Same 1.3 instance the presenter creates, so the list matches what streaming sees. +pub fn list_adapters() -> Result> { + let entry = unsafe { ash::Entry::load() }.context("libvulkan not loadable")?; + let app_name = CString::new("punktfunk-session").unwrap(); + let app_info = vk::ApplicationInfo::default() + .application_name(&app_name) + .api_version(vk::API_VERSION_1_3); + let instance = unsafe { + entry.create_instance( + &vk::InstanceCreateInfo::default().application_info(&app_info), + None, + ) + } + .context("vkCreateInstance")?; + let mut ranked: Vec<(u8, String)> = unsafe { instance.enumerate_physical_devices() }? + .into_iter() + .map(|d| { + let props = unsafe { instance.get_physical_device_properties(d) }; + let rank = match props.device_type { + vk::PhysicalDeviceType::DISCRETE_GPU => 0u8, + vk::PhysicalDeviceType::INTEGRATED_GPU => 1, + _ => 2, + }; + let name = props + .device_name_as_c_str() + .map(|c| c.to_string_lossy().into_owned()) + .unwrap_or_default(); + (rank, name) + }) + .filter(|(_, n)| !n.is_empty()) + .collect(); + unsafe { instance.destroy_instance(None) }; + ranked.sort_by_key(|(r, _)| *r); // stable: enumeration order within each tier + let mut names: Vec = Vec::new(); + for (_, n) in ranked { + if !names.contains(&n) { + names.push(n); + } + } + Ok(names) +} + /// First physical device with a queue family that does graphics + present here; /// `PUNKTFUNK_VK_DEVICE=` overrides on multi-GPU boxes. fn pick_device(