feat(client): device-pick plumbing — GPU enumeration + audio endpoint targeting
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 <noreply@anthropic.com>
This commit is contained in:
@@ -268,19 +268,66 @@ mod session_main {
|
|||||||
)
|
)
|
||||||
.init();
|
.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|source<TAB>node.name<TAB>description` 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
|
// 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).
|
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
|
||||||
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
|
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
enable_radv_video_decode();
|
enable_radv_video_decode();
|
||||||
|
|
||||||
// The Settings GPU pick (the WinUI shell's picker stores the adapter's marketing
|
// The Settings device picks → env, unless the user already forced one by hand:
|
||||||
// name) → the presenter's device selection, unless the user already forced one.
|
// the GPU (the shells' pickers store the adapter's marketing name) for the
|
||||||
// Before any Vulkan call, like the RADV knob (covers --connect and --browse).
|
// presenter's device selection, and the audio endpoints (PipeWire node names)
|
||||||
if std::env::var_os("PUNKTFUNK_VK_ADAPTER").is_none() {
|
// for the playback/mic streams' `target.object`. Before any Vulkan call, like
|
||||||
let adapter = trust::Settings::load().adapter;
|
// the RADV knob (covers --connect and --browse).
|
||||||
if !adapter.is_empty() {
|
{
|
||||||
std::env::set_var("PUNKTFUNK_VK_ADAPTER", adapter);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,83 @@ const MIC_FRAME: usize = 960;
|
|||||||
|
|
||||||
struct Terminate;
|
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<AudioDevice>, Vec<AudioDevice>)> {
|
||||||
|
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<RefCell<(Vec<AudioDevice>, Vec<AudioDevice>)>> = 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 {
|
pub struct AudioPlayer {
|
||||||
pcm_tx: SyncSender<Vec<f32>>,
|
pcm_tx: SyncSender<Vec<f32>>,
|
||||||
/// Drained chunk Vecs coming back from the PipeWire consumer for reuse (the pool half
|
/// Drained chunk Vecs coming back from the PipeWire consumer for reuse (the pool half
|
||||||
@@ -118,20 +195,26 @@ fn pw_thread(
|
|||||||
move |_| mainloop.quit()
|
move |_| mainloop.quit()
|
||||||
});
|
});
|
||||||
|
|
||||||
let stream = pw::stream::StreamBox::new(
|
let mut props = properties! {
|
||||||
&core,
|
*pw::keys::MEDIA_TYPE => "Audio",
|
||||||
"punktfunk-client",
|
*pw::keys::MEDIA_CATEGORY => "Playback",
|
||||||
properties! {
|
*pw::keys::MEDIA_ROLE => "Game",
|
||||||
*pw::keys::MEDIA_TYPE => "Audio",
|
*pw::keys::NODE_NAME => "punktfunk-client",
|
||||||
*pw::keys::MEDIA_CATEGORY => "Playback",
|
*pw::keys::NODE_DESCRIPTION => "Punktfunk Stream",
|
||||||
*pw::keys::MEDIA_ROLE => "Game",
|
// ~5 ms quantum (one Opus frame) keeps the ring — and so the latency — small.
|
||||||
*pw::keys::NODE_NAME => "punktfunk-client",
|
*pw::keys::NODE_LATENCY => "240/48000",
|
||||||
*pw::keys::NODE_DESCRIPTION => "Punktfunk Stream",
|
};
|
||||||
// ~5 ms quantum (one Opus frame) keeps the ring — and so the latency — small.
|
// The Settings speaker pick (session main maps `Settings::speaker_device` here);
|
||||||
*pw::keys::NODE_LATENCY => "240/48000",
|
// unset/empty = PipeWire's default routing.
|
||||||
},
|
if let Ok(target) = std::env::var("PUNKTFUNK_AUDIO_SINK") {
|
||||||
)
|
if !target.is_empty() {
|
||||||
.context("pw Stream")?;
|
// 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 {
|
let ud = PlayerData {
|
||||||
rx: pcm_rx,
|
rx: pcm_rx,
|
||||||
@@ -316,18 +399,23 @@ fn mic_thread(
|
|||||||
move |_| mainloop.quit()
|
move |_| mainloop.quit()
|
||||||
});
|
});
|
||||||
|
|
||||||
let stream = pw::stream::StreamBox::new(
|
let mut props = properties! {
|
||||||
&core,
|
*pw::keys::MEDIA_TYPE => "Audio",
|
||||||
"punktfunk-mic-capture",
|
*pw::keys::MEDIA_CATEGORY => "Capture",
|
||||||
properties! {
|
*pw::keys::MEDIA_ROLE => "Communication",
|
||||||
*pw::keys::MEDIA_TYPE => "Audio",
|
*pw::keys::NODE_NAME => "punktfunk-mic-capture",
|
||||||
*pw::keys::MEDIA_CATEGORY => "Capture",
|
*pw::keys::NODE_DESCRIPTION => "Punktfunk Microphone",
|
||||||
*pw::keys::MEDIA_ROLE => "Communication",
|
};
|
||||||
*pw::keys::NODE_NAME => "punktfunk-mic-capture",
|
// The Settings microphone pick (`Settings::mic_device` via session main).
|
||||||
*pw::keys::NODE_DESCRIPTION => "Punktfunk Microphone",
|
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
|
||||||
.context("pw mic Stream")?;
|
// 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 {
|
let ud = MicData {
|
||||||
connector: connector.clone(),
|
connector: connector.clone(),
|
||||||
|
|||||||
@@ -544,6 +544,16 @@ pub struct Settings {
|
|||||||
/// "Invert scroll direction"). Default off = the host scrolls the way this machine does.
|
/// "Invert scroll direction"). Default off = the host scrolls the way this machine does.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub invert_scroll: bool,
|
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
|
/// 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
|
/// 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
|
/// size and a mid-session resize renegotiates the host's virtual display + encoder
|
||||||
@@ -634,6 +644,8 @@ impl Default for Settings {
|
|||||||
library_enabled: false,
|
library_enabled: false,
|
||||||
auto_wake: true,
|
auto_wake: true,
|
||||||
invert_scroll: false,
|
invert_scroll: false,
|
||||||
|
speaker_device: String::new(),
|
||||||
|
mic_device: String::new(),
|
||||||
match_window: false,
|
match_window: false,
|
||||||
last_window_w: 0,
|
last_window_w: 0,
|
||||||
last_window_h: 0,
|
last_window_h: 0,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ mod reconfig;
|
|||||||
mod resources;
|
mod resources;
|
||||||
mod setup;
|
mod setup;
|
||||||
|
|
||||||
|
pub use setup::list_adapters;
|
||||||
|
|
||||||
/// One presenter iteration's video input.
|
/// One presenter iteration's video input.
|
||||||
pub enum FrameInput<'a> {
|
pub enum FrameInput<'a> {
|
||||||
/// No new frame — re-composite the retained video image (expose/resize).
|
/// No new frame — re-composite the retained video image (expose/resize).
|
||||||
|
|||||||
@@ -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<Vec<String>> {
|
||||||
|
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<String> = 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;
|
/// First physical device with a queue family that does graphics + present here;
|
||||||
/// `PUNKTFUNK_VK_DEVICE=<index>` overrides on multi-GPU boxes.
|
/// `PUNKTFUNK_VK_DEVICE=<index>` overrides on multi-GPU boxes.
|
||||||
fn pick_device(
|
fn pick_device(
|
||||||
|
|||||||
Reference in New Issue
Block a user