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:
2026-07-19 19:32:38 +02:00
parent 8fe90a8a4b
commit 40c2f68231
5 changed files with 228 additions and 33 deletions
+114 -26
View File
@@ -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<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 {
pcm_tx: SyncSender<Vec<f32>>,
/// 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(),