diff --git a/crates/punktfunk-host/src/audio.rs b/crates/punktfunk-host/src/audio.rs index a3a949ac..c02ef5e0 100644 --- a/crates/punktfunk-host/src/audio.rs +++ b/crates/punktfunk-host/src/audio.rs @@ -194,6 +194,11 @@ pub(crate) mod pad_endpoint; #[cfg(target_os = "windows")] #[path = "audio/windows/audio_probe.rs"] pub(crate) mod audio_probe; +// The minted "Punktfunk Speakers/Microphone" provider — punktfunk-owned instances of Valve's +// streaming-audio drivers, the wiring plan's tier-0 (the audio-substrate program). +#[cfg(target_os = "windows")] +#[path = "audio/windows/minted.rs"] +pub(crate) mod minted; #[cfg(target_os = "windows")] #[path = "audio/windows/wasapi_cap.rs"] mod wasapi_cap; diff --git a/crates/punktfunk-host/src/audio/windows/audio_control.rs b/crates/punktfunk-host/src/audio/windows/audio_control.rs index 0471377f..f298296c 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_control.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_control.rs @@ -198,6 +198,14 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan { // cannot carry stereo cannot carry 5.1 either. 2, &pad_ids, + // The minted "Punktfunk Speakers/Microphone" ids — tier-0 identity, empty until the + // provider latches. The ensure hook makes a box where Steam arrives later mint on a + // wiring pass instead of at the next reboot (cheap once latched; cooled-down retries + // while not). + &{ + super::minted::ensure_provisioned(); + super::minted::minted_ids() + }, ); let done = |wiring: Wiring| WiredPlan { wiring, @@ -220,6 +228,7 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan { loopback_render = wiring.loopback_render.as_ref().map(|(n, _)| n.as_str()), loopback_last_resort = wiring.loopback_last_resort, mic_withheld = wiring.mic_withheld, + readiness = ?wiring_plan::readiness(&wiring), renders = ?renders.iter().map(|(n, _)| n.as_str()).collect::>(), "audio wiring plan" ); @@ -265,7 +274,16 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan { if let Some((mic_name, mic_id)) = &wiring.mic_render { if default_render_id().as_deref() == Some(mic_id.as_str()) { // Audible preference = the host_audio plan's loopback pick (real hardware first). - match plan(&renders, &captures, want.as_deref(), true, &pad_ids).loopback_render { + match plan( + &renders, + &captures, + want.as_deref(), + true, + &pad_ids, + &super::minted::minted_ids(), + ) + .loopback_render + { Some((name, id)) => match set_default_endpoint(&id) { Ok(()) => tracing::info!(mic = %mic_name, device = %name, "default playback was the virtual-mic target — moved it so desktop \ diff --git a/crates/punktfunk-host/src/audio/windows/audio_probe.rs b/crates/punktfunk-host/src/audio/windows/audio_probe.rs index f806521d..d5bcf01a 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_probe.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_probe.rs @@ -34,7 +34,7 @@ use std::time::{Duration, Instant}; use wasapi::{Direction, SampleType, StreamMode, WaveFormat}; use windows::core::PCWSTR; use windows::Win32::Devices::DeviceAndDriverInstallation::{ - SetupDiEnumDeviceInfo, SetupDiOpenDevRegKey, DICS_FLAG_GLOBAL, DIREG_DEV, SPDRP_HARDWAREID, + SetupDiEnumDeviceInfo, SetupDiOpenDevRegKey, DICS_FLAG_GLOBAL, DIREG_DEV, }; use windows::Win32::System::Registry::{ RegCloseKey, RegQueryValueExW, RegSetValueExW, KEY_QUERY_VALUE, KEY_SET_VALUE, REG_DWORD, @@ -70,7 +70,36 @@ pub(crate) fn run(args: &[String]) -> Result<()> { probe_sss_primary(secs) } Some("cleanup") => cleanup(), - _ => bail!("usage: punktfunk-host audio-probe [--keep]"), + // The provider's synchronous pass: mint (or re-find) "Punktfunk Speakers/Microphone" + // and publish them for THIS process — `plan` then shows the tier-0 pick. + Some("mint") => super::minted::devtest_mint(), + // One real wiring pass (no default parking) + the verdict, readiness included — the + // field-triage "what would the host do right now" command. + Some("plan") => { + let plan = super::audio_control::wire_now_full(false); + let w = &plan.wiring; + let show = |ep: &Option| match ep { + Some((name, id)) => format!("{name:?} ({id})"), + None => "-".into(), + }; + println!("audio-plan: mic_render = {}", show(&w.mic_render)); + println!("audio-plan: mic_capture = {}", show(&w.mic_capture)); + println!("audio-plan: loopback = {}", show(&w.loopback_render)); + println!("audio-plan: last_resort = {}", w.loopback_last_resort); + println!("audio-plan: mic_withheld = {}", w.mic_withheld); + println!( + "audio-plan: narrowing = {}", + w.loopback_narrowing.as_deref().unwrap_or("-") + ); + println!( + "audio-plan: readiness = {:?}", + super::wiring_plan::readiness(w) + ); + Ok(()) + } + _ => bail!( + "usage: punktfunk-host audio-probe [--keep]" + ), } } @@ -243,57 +272,9 @@ fn probe_sss_primary(secs: u32) -> Result<()> { Ok(()) } -// --- driver discovery ---------------------------------------------------------------------- +// --- driver discovery — shared with the minted provider ------------------------------------- -/// Find the (exact hardware id, INF path) for a Steam streaming driver: prefer any installed -/// devnode whose hardware-id list contains `needle` (its `oemNN.inf` is the driver Windows -/// already trusts), else fall back to Steam's driver directory. -fn discover_driver(needle: &str, inf_name: &str) -> Result<(String, String)> { - let set = pe::media_class_devs()?; - for i in 0.. { - let mut did = pe::devinfo_data(); - // SAFETY: live set; `did` is a live out-param with cbSize set. - if unsafe { SetupDiEnumDeviceInfo(set.0, i, &mut did) }.is_err() { - break; - } - let Some(hwid) = pe::devnode_multi_sz_prop(&set, &did, SPDRP_HARDWAREID) - .into_iter() - .find(|h| h.to_lowercase().contains(needle)) - else { - continue; - }; - if let Some(inf) = pe::devnode_inf_path(&set, &did) { - let windir = std::env::var("WINDIR").unwrap_or_else(|_| r"C:\Windows".into()); - let full = format!(r"{windir}\INF\{inf}"); - if std::path::Path::new(&full).exists() { - return Ok((hwid, full)); - } - } - // Devnode exists but its INF is gone — keep its exact hwid, try Steam's directory. - if let Some(w) = super::wasapi_mic::steam_driver_inf_path(inf_name) { - let s = String::from_utf16_lossy(&w) - .trim_end_matches('\0') - .to_string(); - if std::path::Path::new(&s).exists() { - return Ok((hwid, s)); - } - } - } - // No installed devnode at all: canonical hwid + Steam's directory. - if let Some(w) = super::wasapi_mic::steam_driver_inf_path(inf_name) { - let s = String::from_utf16_lossy(&w) - .trim_end_matches('\0') - .to_string(); - if std::path::Path::new(&s).exists() { - let hwid = format!("ROOT\\{}", inf_name.trim_end_matches(".inf")); - return Ok((hwid, s)); - } - } - bail!( - "no installed devnode matches {needle:?} and Steam's driver directory has no \ - {inf_name} — install Steam (it never needs to run)" - ) -} +use super::minted::discover_driver; // --- probe devnode marker + cleanup -------------------------------------------------------- diff --git a/crates/punktfunk-host/src/audio/windows/minted.rs b/crates/punktfunk-host/src/audio/windows/minted.rs new file mode 100644 index 00000000..bfbdfdd4 --- /dev/null +++ b/crates/punktfunk-host/src/audio/windows/minted.rs @@ -0,0 +1,378 @@ +//! Minted punktfunk-owned audio endpoints — the Windows audio substrate. +//! +//! The audio-substrate decision (`windows-audio-endpoints-and-vbcable.md`, 2026-08-07, spikes +//! S2+S3 measured green): instead of borrowing Steam's primary endpoints and bundling VB-Cable +//! for the mic, the host mints its OWN instances of Valve's streaming-audio drivers — +//! +//! * **"Punktfunk Speakers"** (`SteamStreamingSpeakers.inf`): the client-only loopback sink. +//! Desktop audio routes here (the wiring plan parks the default playback on it during a +//! stream), its WASAPI loopback feeds the encoder, and the host stays silent. Measured +//! clean: 48 kHz stereo f32, loopback peak == rendered peak (S2). +//! * **"Punktfunk Microphone"** (`SteamStreamingMicrophone.inf`): the virtual mic. The host +//! writes the client's decoded voice into its render side; its capture side surfaces as the +//! microphone host apps record. Measured bit-faithful render→capture (S3). +//! +//! Provisioning mirrors the pad-audio provider: a background worker at host start, idempotent +//! devnode-per-role with a durable `PunktfunkAudioRole` marker in `Device Parameters` (names +//! are NOT identity — a minted instance is name-identical to Steam's primaries), results +//! published once for the wiring plan to consume BY ID ([`minted_ids`] → +//! [`wiring_plan::MintedIds`]). Everything is best-effort: no Steam driver, a denied install, +//! or `PUNKTFUNK_NO_AUDIO_MINT` leaves the ids empty and the wiring plan falls back to the +//! name-based ladder (Steam primaries → cable → real hardware) unchanged. +//! +//! Endpoints are PERSISTENT by design, like pad endpoints — they survive host restarts and +//! re-resolve by marker on the next start. `punktfunk-host audio-probe` carries the manual +//! `mint` / `plan` inspection paths. + +use super::pad_endpoint as pe; +use super::{audio_control, wiring_plan}; +use anyhow::{bail, Context, Result}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::thread; +use std::time::{Duration, Instant}; + +/// Durable role marker in a minted devnode's `Device Parameters` key. +const ROLE_MARKER: &str = "PunktfunkAudioRole"; +/// How long to wait for audiosrv to register a freshly minted endpoint. +const ENDPOINT_WAIT: Duration = Duration::from_secs(15); +/// Minimum spacing between provisioning retries once the startup attempt failed +/// ([`ensure_provisioned`] is called from wiring passes, which recur freely). +const RETRY_COOLDOWN: Duration = Duration::from_secs(60); + +/// The two minted roles. `value` is the persisted marker; the needles drive +/// [`discover_driver`]. +#[derive(Clone, Copy, PartialEq)] +enum Role { + Speakers, + Mic, +} + +impl Role { + fn value(self) -> u32 { + match self { + Role::Speakers => 1, + Role::Mic => 2, + } + } + fn desc(self) -> &'static str { + match self { + Role::Speakers => "Punktfunk Speakers", + Role::Mic => "Punktfunk Microphone", + } + } + fn needle(self) -> &'static str { + match self { + Role::Speakers => "steamstreamingspeakers", + Role::Mic => "steamstreamingmicrophone", + } + } + fn inf_name(self) -> &'static str { + match self { + Role::Speakers => "SteamStreamingSpeakers.inf", + Role::Mic => "SteamStreamingMicrophone.inf", + } + } + fn label(self) -> &'static str { + match self { + Role::Speakers => "speakers", + Role::Mic => "mic", + } + } +} + +/// The provider's published result. Partial is possible and usable (one driver leg failing +/// must not cost the other role); consumers read the per-role `Option`s. +#[derive(Debug, Default, Clone)] +pub(crate) struct MintedAudio { + pub speakers_devnode: Option, + pub speakers_render: Option, + pub mic_devnode: Option, + pub mic_render: Option, + pub mic_capture: Option, +} + +impl MintedAudio { + fn any(&self) -> bool { + self.speakers_render.is_some() || self.mic_render.is_some() + } +} + +/// Set once by the worker, and only when at least one role provisioned (the pad provider's R5 +/// lesson: latching an empty result turns one transient failure into a process-lifetime +/// disability). +static PROVISIONED: OnceLock> = OnceLock::new(); +/// A provisioning attempt is in flight — keeps concurrent askers to one worker. +static PROVISIONING: AtomicBool = AtomicBool::new(false); +/// When the last attempt STARTED — the [`RETRY_COOLDOWN`] anchor. +static LAST_ATTEMPT: Mutex> = Mutex::new(None); + +/// The wiring plan's tier-0 input: the minted ids, or all-empty while nothing is provisioned. +pub(crate) fn minted_ids() -> wiring_plan::MintedIds { + match PROVISIONED.get() { + Some(m) => wiring_plan::MintedIds { + speakers_render: m.speakers_render.clone(), + mic_render: m.mic_render.clone(), + mic_capture: m.mic_capture.clone(), + }, + None => wiring_plan::MintedIds::default(), + } +} + +/// Spawn the provisioning worker (idempotent; returns immediately). Called at host start next +/// to the pad provider, and again from [`ensure_provisioned`] on the retry path. +pub(crate) fn provision_at_startup() { + if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some() { + return; + } + if PROVISIONED.get().is_some() || PROVISIONING.swap(true, Ordering::SeqCst) { + return; + } + *LAST_ATTEMPT.lock().unwrap() = Some(Instant::now()); + let spawned = thread::Builder::new() + .name("punktfunk-audio-mint".into()) + .spawn(|| { + match ensure_all() { + Ok(m) if m.any() => { + tracing::info!( + speakers = m.speakers_render.as_deref().unwrap_or("-"), + mic_render = m.mic_render.as_deref().unwrap_or("-"), + mic_capture = m.mic_capture.as_deref().unwrap_or("-"), + "minted audio endpoints ready (the wiring plan's tier-0)" + ); + let _ = PROVISIONED.set(Arc::new(m)); + } + Ok(_) => tracing::info!( + "no minted audio endpoints (Steam's streaming drivers absent?) — the \ + wiring plan keeps the name-based ladder" + ), + Err(e) => tracing::warn!(error = %format!("{e:#}"), + "minted-audio provisioning failed — the wiring plan keeps the name-based \ + ladder and a later wiring pass retries"), + } + PROVISIONING.store(false, Ordering::SeqCst); + }); + if let Err(e) = spawned { + PROVISIONING.store(false, Ordering::SeqCst); + tracing::warn!(error = %e, "could not spawn the minted-audio provisioning thread"); + } +} + +/// Retry hook for wiring passes: cheap once latched; while unlatched it re-asks at most every +/// [`RETRY_COOLDOWN`] — a box where Steam arrives later mints on a later pass instead of at +/// the next reboot. +pub(crate) fn ensure_provisioned() { + if PROVISIONED.get().is_some() { + return; + } + { + let last = LAST_ATTEMPT.lock().unwrap(); + if last.is_some_and(|t| t.elapsed() < RETRY_COOLDOWN) { + return; + } + } + provision_at_startup(); +} + +/// One synchronous provisioning pass over both roles (worker thread + the `audio-probe mint` +/// devtest). Per-role failures degrade to that role being absent. +fn ensure_all() -> Result { + wasapi::initialize_mta() + .ok() + .context("CoInitializeEx (MTA, minted-audio)")?; + let mut out = MintedAudio::default(); + for role in [Role::Speakers, Role::Mic] { + match ensure_role(role) { + Ok((devnode, render, capture)) => match role { + Role::Speakers => { + out.speakers_devnode = Some(devnode); + out.speakers_render = Some(render); + } + Role::Mic => { + out.mic_devnode = Some(devnode); + out.mic_render = Some(render); + out.mic_capture = capture; + } + }, + Err(e) => tracing::info!(role = role.label(), error = %format!("{e:#}"), + "minted-audio role unavailable"), + } + } + Ok(out) +} + +/// Ensure one role's devnode + endpoint(s): reuse the marker-matched devnode from an earlier +/// run, else mint one; (re)bind the driver idempotently; wait for audiosrv's endpoints; put +/// back any default device the fresh endpoint grabbed (measured on the pad program: a newly +/// registered endpoint can take either default). +fn ensure_role(role: Role) -> Result<(String, String, Option)> { + let prev_render = audio_control::default_render_id(); + let prev_capture = audio_control::default_capture_id(); + + let (hwid, inf) = discover_driver(role.needle(), role.inf_name())?; + let devnode = match find_role_devnode(role)? { + Some(inst) => inst, + None => { + let inst = pe::create_media_devnode(role.desc(), &hwid, |set, did| { + pe::write_devparam_dword(set, did, ROLE_MARKER, role.value()) + })?; + tracing::info!(role = role.label(), devnode = %inst, "minted an audio devnode"); + inst + } + }; + pe::bind_driver(&hwid, &inf)?; + + let render = wait_for(&devnode, false)?; + let capture = match role { + Role::Mic => Some(wait_for(&devnode, true).with_context(|| { + format!("the minted mic devnode {devnode} produced no capture endpoint") + })?), + Role::Speakers => None, + }; + + // Freshly registered endpoints can grab a default; the wiring plan owns default policy, + // not the mint. + if let Some(prev) = prev_render { + if audio_control::default_render_id().as_deref() != Some(prev.as_str()) + && audio_control::set_default_endpoint(&prev).is_ok() + { + tracing::info!( + role = role.label(), + "default playback restored after minting" + ); + } + } + if let Some(prev) = prev_capture { + if audio_control::default_capture_id().as_deref() != Some(prev.as_str()) + && audio_control::set_default_endpoint(&prev).is_ok() + { + tracing::info!( + role = role.label(), + "default recording restored after minting" + ); + } + } + Ok((devnode, render, capture)) +} + +/// Poll audiosrv for the endpoint a minted devnode registers in one direction. +fn wait_for(devnode: &str, capture: bool) -> Result { + let deadline = Instant::now() + ENDPOINT_WAIT; + loop { + let found = if capture { + pe::find_capture_endpoint_for_devnode(devnode)? + } else { + pe::find_endpoint_for_devnode(devnode)? + }; + if let Some(ep) = found { + return Ok(ep); + } + if Instant::now() >= deadline { + bail!( + "no {} endpoint appeared for {devnode} within {}s — is Audiosrv running?", + if capture { "capture" } else { "render" }, + ENDPOINT_WAIT.as_secs() + ); + } + thread::sleep(Duration::from_millis(250)); + } +} + +/// The devnode a previous run minted for `role` (marker-matched — names are not identity). +fn find_role_devnode(role: Role) -> Result> { + let set = pe::media_class_devs()?; + for i in 0.. { + let mut did = pe::devinfo_data(); + // SAFETY: live set; `did` is a live out-param with cbSize set. + if unsafe { + windows::Win32::Devices::DeviceAndDriverInstallation::SetupDiEnumDeviceInfo( + set.0, i, &mut did, + ) + } + .is_err() + { + break; + } + if pe::read_devparam_dword(&set, &did, ROLE_MARKER) == Some(role.value()) { + if let Some(inst) = pe::instance_id(&set, &did) { + return Ok(Some(inst)); + } + } + } + Ok(None) +} + +/// Find the (exact hardware id, INF path) for one of Steam's streaming drivers: prefer any +/// installed devnode whose hardware-id list contains `needle` (its `oemNN.inf` is the driver +/// Windows already trusts), else fall back to Steam's driver directory. Shared with the +/// `audio-probe` devtest. +pub(crate) fn discover_driver(needle: &str, inf_name: &str) -> Result<(String, String)> { + use windows::Win32::Devices::DeviceAndDriverInstallation::{ + SetupDiEnumDeviceInfo, SPDRP_HARDWAREID, + }; + let steam_dir_inf = || -> Option { + let w = super::wasapi_mic::steam_driver_inf_path(inf_name)?; + let s = String::from_utf16_lossy(&w) + .trim_end_matches('\0') + .to_string(); + std::path::Path::new(&s).exists().then_some(s) + }; + let set = pe::media_class_devs()?; + for i in 0.. { + let mut did = pe::devinfo_data(); + // SAFETY: live set; `did` is a live out-param with cbSize set. + if unsafe { SetupDiEnumDeviceInfo(set.0, i, &mut did) }.is_err() { + break; + } + let Some(hwid) = pe::devnode_multi_sz_prop(&set, &did, SPDRP_HARDWAREID) + .into_iter() + .find(|h| h.to_lowercase().contains(needle)) + else { + continue; + }; + if let Some(inf) = pe::devnode_inf_path(&set, &did) { + let windir = std::env::var("WINDIR").unwrap_or_else(|_| r"C:\Windows".into()); + let full = format!(r"{windir}\INF\{inf}"); + if std::path::Path::new(&full).exists() { + return Ok((hwid, full)); + } + } + // Devnode exists but its INF is gone — keep its exact hwid, try Steam's directory. + if let Some(s) = steam_dir_inf() { + return Ok((hwid, s)); + } + } + // No installed devnode at all: canonical hwid + Steam's directory. + if let Some(s) = steam_dir_inf() { + return Ok((format!("ROOT\\{}", inf_name.trim_end_matches(".inf")), s)); + } + bail!( + "no installed devnode matches {needle:?} and Steam's driver directory has no \ + {inf_name} — install Steam (it never needs to run)" + ) +} + +/// `audio-probe mint` devtest body: one synchronous provisioning pass, results printed. +pub(crate) fn devtest_mint() -> Result<()> { + let m = ensure_all()?; + println!( + "audio-mint: speakers devnode={} render={}", + m.speakers_devnode.as_deref().unwrap_or("-"), + m.speakers_render.as_deref().unwrap_or("-") + ); + println!( + "audio-mint: mic devnode={} render={} capture={}", + m.mic_devnode.as_deref().unwrap_or("-"), + m.mic_render.as_deref().unwrap_or("-"), + m.mic_capture.as_deref().unwrap_or("-") + ); + if m.any() { + let _ = PROVISIONED.set(Arc::new(m)); + println!( + "audio-mint: published for this process — `audio-probe plan` shows the tier-0 pick" + ); + } else { + println!("audio-mint: nothing minted (Steam's streaming drivers absent?)"); + } + Ok(()) +} diff --git a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs index 0fed3aa4..0625ba66 100644 --- a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs +++ b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs @@ -571,9 +571,14 @@ pub(crate) fn devnode_inf_path(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Optio (len > 0).then(|| String::from_utf16_lossy(&units[..len])) } -/// The persisted pad slot of a devnode (the `PunktfunkPadIndex` value under its -/// `Device Parameters` key), or `None` for foreign devnodes. -fn devnode_pad_index(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Option { +/// Read a REG_DWORD from a devnode's `Device Parameters` key — the durable owner-marker +/// mechanism every punktfunk-minted devnode family uses (pad slot, minted-audio role, probe +/// marker). `None`: no key, no value, or wrong type — a foreign devnode. +pub(crate) fn read_devparam_dword( + set: &DevInfoSet, + did: &SP_DEVINFO_DATA, + value_name: &str, +) -> Option { // SAFETY: live set + element; DIREG_DEV opens the devnode's Device Parameters key. let hkey = unsafe { SetupDiOpenDevRegKey( @@ -586,7 +591,7 @@ fn devnode_pad_index(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Option { ) } .ok()?; - let name = wide(PAD_INDEX_VALUE); + let name = wide(value_name); let mut data = [0u8; 4]; let mut len = data.len() as u32; let mut ty = REG_VALUE_TYPE(0); @@ -609,6 +614,67 @@ fn devnode_pad_index(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Option { (rc.is_ok() && ty == REG_DWORD && len == 4).then(|| u32::from_le_bytes(data)) } +/// Write a REG_DWORD into a devnode's `Device Parameters` key, creating the key on a fresh +/// devnode — the write side of [`read_devparam_dword`]. +pub(crate) fn write_devparam_dword( + set: &DevInfoSet, + did: &mut SP_DEVINFO_DATA, + value_name: &str, + value: u32, +) -> Result<()> { + // SAFETY: live set + element; DIREG_DEV opens the devnode's Device Parameters key. + let opened = unsafe { + SetupDiOpenDevRegKey( + set.0, + did, + DICS_FLAG_GLOBAL.0, + 0, + DIREG_DEV, + KEY_SET_VALUE.0, + ) + }; + let hkey = match opened { + Ok(k) => k, + // SAFETY: same set + element; a fresh devnode has no Device Parameters key yet, so + // create it (no INF association). + Err(_) => unsafe { + SetupDiCreateDevRegKeyW( + set.0, + did, + DICS_FLAG_GLOBAL.0, + 0, + DIREG_DEV, + None, + PCWSTR::null(), + ) + } + .with_context(|| format!("create the Device Parameters key for {value_name}"))?, + }; + let name = wide(value_name); + // SAFETY: the value name is NUL-terminated and outlives the call; the DWORD bytes travel + // with the slice. + let rc = unsafe { + RegSetValueExW( + hkey, + PCWSTR(name.as_ptr()), + None, + REG_DWORD, + Some(&value.to_le_bytes()), + ) + }; + // SAFETY: closing the key opened/created above, exactly once. + unsafe { + let _ = RegCloseKey(hkey); + } + rc.ok().with_context(|| format!("write {value_name}")) +} + +/// The persisted pad slot of a devnode (the `PunktfunkPadIndex` value under its +/// `Device Parameters` key), or `None` for foreign devnodes. +fn devnode_pad_index(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Option { + read_devparam_dword(set, did, PAD_INDEX_VALUE) +} + /// Find the devnode previously created for `pad_index` (see the module doc: the persisted /// index value is the durable marker; DeviceDesc only survives until the INF installs). fn find_devnode(pad_index: u8) -> Result> { @@ -688,51 +754,7 @@ fn create_devnode(pad_index: u8) -> Result { /// Persist `pad_index` in the devnode's `Device Parameters` key (created on a fresh devnode). fn write_pad_index(set: &DevInfoSet, did: &mut SP_DEVINFO_DATA, pad_index: u8) -> Result<()> { - // SAFETY: live set + element; DIREG_DEV opens the devnode's Device Parameters key. - let opened = unsafe { - SetupDiOpenDevRegKey( - set.0, - did, - DICS_FLAG_GLOBAL.0, - 0, - DIREG_DEV, - KEY_SET_VALUE.0, - ) - }; - let hkey = match opened { - Ok(k) => k, - // SAFETY: same set + element; a fresh devnode has no Device Parameters key yet, so - // create it (no INF association). - Err(_) => unsafe { - SetupDiCreateDevRegKeyW( - set.0, - did, - DICS_FLAG_GLOBAL.0, - 0, - DIREG_DEV, - None, - PCWSTR::null(), - ) - } - .context("create the devnode's Device Parameters key")?, - }; - let name = wide(PAD_INDEX_VALUE); - // SAFETY: the value name is NUL-terminated and outlives the call; the DWORD bytes travel - // with the slice. - let rc = unsafe { - RegSetValueExW( - hkey, - PCWSTR(name.as_ptr()), - None, - REG_DWORD, - Some(&(pad_index as u32).to_le_bytes()), - ) - }; - // SAFETY: closing the key opened/created above, exactly once. - unsafe { - let _ = RegCloseKey(hkey); - } - rc.ok().context("write PunktfunkPadIndex") + write_devparam_dword(set, did, PAD_INDEX_VALUE, pad_index as u32) } /// The Steam Streaming Speakers INF to feed `UpdateDriverForPlugAndPlayDevices`: prefer the diff --git a/crates/punktfunk-host/src/audio/wiring_plan.rs b/crates/punktfunk-host/src/audio/wiring_plan.rs index 9614cfed..95e84162 100644 --- a/crates/punktfunk-host/src/audio/wiring_plan.rs +++ b/crates/punktfunk-host/src/audio/wiring_plan.rs @@ -107,6 +107,49 @@ pub(crate) fn no_formats(_: &Endpoint) -> Option { None } +/// The host's own MINTED endpoints — instances of Valve's streaming-audio driver the +/// [`minted`](super::minted) provider created at startup — by WASAPI endpoint id. +/// +/// Tier-0 is an IDENTITY tier, not a name tier: a minted instance is indistinguishable by +/// friendly name from Steam's own primaries (S1 measured exactly that confusion — the probe's +/// name match grabbed a stamped instance instead of the primary), so the provider records what +/// it minted and the plan matches by id. All fields empty when nothing is minted (Steam +/// absent, provisioning disabled or still running) — every rule then falls back to the +/// name-based ladder unchanged. +#[derive(Debug, Default, Clone, PartialEq)] +pub(crate) struct MintedIds { + /// "Punktfunk Speakers" — an SSS-driver instance reserved as the client-only loopback + /// sink. Never contended by Steam's own Remote Play, deterministic across re-plans. + pub speakers_render: Option, + /// "Punktfunk Microphone" render side — the virtual mic's write target. + pub mic_render: Option, + /// "Punktfunk Microphone" capture side — the microphone host apps record. + pub mic_capture: Option, +} + +/// The one-line runtime answer "does desktop audio work, does the mic work" — the §C4 +/// classification (logged with every plan change; the status API surfaces it later). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AudioReadiness { + /// Both roles have endpoints. + Full, + /// Desktop audio yes, mic passthrough no. + AudioOnly, + /// Mic yes, desktop audio no. + MicOnly, + /// Neither role has an endpoint. + Nothing, +} + +pub(crate) fn readiness(w: &Wiring) -> AudioReadiness { + match (w.loopback_render.is_some(), w.mic_render.is_some()) { + (true, true) => AudioReadiness::Full, + (true, false) => AudioReadiness::AudioOnly, + (false, true) => AudioReadiness::MicOnly, + (false, false) => AudioReadiness::Nothing, + } +} + /// The coherent endpoint assignment for one wiring pass. Computed fresh on every mic/capture /// (re)open — Windows endpoints churn (boot-time registration, hotplug, driver installs), so a /// once-per-process plan goes stale. @@ -224,6 +267,7 @@ pub(crate) fn plan( mic_want: Option<&str>, host_audio: bool, pad_renders: &[String], + minted: &MintedIds, ) -> Wiring { plan_with_formats( renders, @@ -233,6 +277,7 @@ pub(crate) fn plan( &no_formats, 2, pad_renders, + minted, ) } @@ -251,6 +296,7 @@ pub(crate) fn plan( /// exists — narrow audio beats no audio — but flagged in [`Wiring::loopback_narrowing`] so the /// capture side can say why. An unknown format (probe failed) counts as fine, so this can never /// make the plan worse than it was before formats existed. +#[allow(clippy::too_many_arguments)] // mirrors the enumeration inputs; a param struct would only rename the problem pub(crate) fn plan_with_formats( renders: &[Endpoint], captures: &[Endpoint], @@ -259,6 +305,7 @@ pub(crate) fn plan_with_formats( format_of: FormatProbe, want_channels: u8, pad_renders: &[String], + minted: &MintedIds, ) -> Wiring { // 0. Pad-audio endpoints are invisible to the plan: never the mic target (client voice // would play out of a pad "speaker"), never a loopback source (a game's controller @@ -279,10 +326,24 @@ pub(crate) fn plan_with_formats( .cloned() }; - // 1. Mic target first — it has the narrower requirements (must be a virtual cable). + // Tier-0 lookups: the minted ids resolved against THIS enumeration (an id the provider + // recorded but audiosrv no longer serves must not produce a phantom assignment). + let find_by_id = |id: &Option| -> Option { + id.as_deref() + .and_then(|id| renders.iter().find(|(_, rid)| rid == id).cloned()) + }; + let minted_mic = find_by_id(&minted.mic_render); + let minted_sink = find_by_id(&minted.speakers_render); + + // 1. Mic target first — it has the narrower requirements (must be a virtual cable). The + // minted "Punktfunk Microphone" outranks every name-based candidate: it exists for + // exactly this role, and taking it can never cost the loopback anything (the minted + // sink is its counterpart). An operator override still beats it. let mic_render = match mic_want { Some(w) => find_render(w), - None => MIC_CANDIDATES.iter().find_map(|c| find_render(c)), + None => minted_mic + .clone() + .or_else(|| MIC_CANDIDATES.iter().find_map(|c| find_render(c))), }; // Game audio outranks the mic: the Steam Streaming Microphone's render side is also the // only silent client-only loopback sink, so the mic may hold it only while the loopback @@ -315,8 +376,17 @@ pub(crate) fn plan_with_formats( other => other, }; - // 2. Its capture side (what host apps record). - let mic_capture = mic_render.as_ref().and_then(|(name, _)| { + // 2. Its capture side (what host apps record). A minted mic resolves by the provider's + // recorded CAPTURE id — a name search cannot tell the minted microphone from Steam's + // primary (same friendly name), and pairing the minted render with the primary's + // capture would record a mic nothing writes into. + let mic_capture = mic_render.as_ref().and_then(|(name, id)| { + if Some(id) == minted.mic_render.as_ref() { + return minted + .mic_capture + .as_deref() + .and_then(|cid| captures.iter().find(|(_, c)| c == cid).cloned()); + } capture_for(&name.to_lowercase()).iter().find_map(|c| { captures .iter() @@ -364,13 +434,38 @@ pub(crate) fn plan_with_formats( .iter() .find(|(n, id)| not_mic(id) && n.to_lowercase().contains("steam streaming speakers")) }; + // Tier-0 sink: the minted "Punktfunk Speakers". Same quality discipline as every silent + // sink — a narrowing minted instance demotes below real hardware rather than silently + // costing quality (S2 measured the driver clean at 48 kHz stereo, so this is a guard, not + // an expectation). + let minted_intact = || { + minted_sink + .as_ref() + .filter(|(_, id)| not_mic(id)) + .filter(|ep| narrowing_of(ep).is_none()) + }; + let minted_narrow = || { + minted_sink + .as_ref() + .filter(|(_, id)| not_mic(id)) + .filter(|ep| narrowing_of(ep).is_some()) + }; // A narrowing silent sink sits below real hardware in BOTH modes: preferring silence on the // host is a routing choice, but it must not silently cost audio quality when a clean endpoint - // is right there. + // is right there. The minted sink heads its tier in both modes — it is the one endpoint + // whose whole purpose is this role. let preferred = if host_audio { - real_hw().or_else(silent_intact).or_else(silent_narrow) + real_hw() + .or_else(minted_intact) + .or_else(silent_intact) + .or_else(minted_narrow) + .or_else(silent_narrow) } else { - silent_intact().or_else(real_hw).or_else(silent_narrow) + minted_intact() + .or_else(silent_intact) + .or_else(real_hw) + .or_else(minted_narrow) + .or_else(silent_narrow) }; let (loopback_render, loopback_last_resort) = match preferred { Some(ep) => (Some(ep.clone()), false), @@ -503,7 +598,7 @@ mod tests { ep("Microphone (Webcam)"), ep("CABLE Output (VB-Audio Virtual Cable)"), ]; - let w = plan(&renders, &captures, None, false, &[]); + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); assert_eq!( w.mic_render.unwrap().0, "CABLE Input (VB-Audio Virtual Cable)" @@ -532,7 +627,7 @@ mod tests { ep("CABLE Output (VB-Audio Virtual Cable)"), ep("Microphone (Steam Streaming Microphone)"), ]; - let w = plan(&renders, &captures, None, false, &[]); + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); assert_eq!( w.mic_render.unwrap().0, "CABLE Input (VB-Audio Virtual Cable)" @@ -552,7 +647,7 @@ mod tests { ep("CABLE Input (VB-Audio Virtual Cable)"), ep("Speakers (Steam Streaming Microphone)"), ]; - let w = plan(&renders, &[], None, true, &[]); + let w = plan(&renders, &[], None, true, &[], &MintedIds::default()); assert_eq!( w.loopback_render.unwrap().0, "Speakers (Apple Audio Device)" @@ -569,7 +664,7 @@ mod tests { ep("CABLE In 16ch (VB-Audio Virtual Cable)"), ]; for host_audio in [false, true] { - let w = plan(&renders, &[], None, host_audio, &[]); + let w = plan(&renders, &[], None, host_audio, &[], &MintedIds::default()); assert!(w.loopback_render.is_none(), "host_audio={host_audio}"); } } @@ -581,7 +676,7 @@ mod tests { fn headless_cable_only_mic_wins() { let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")]; let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")]; - let w = plan(&renders, &captures, None, false, &[]); + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); assert!(w.mic_render.is_some(), "mic must claim the only cable"); assert!(w.loopback_render.is_none(), "no echo-safe loopback exists"); } @@ -599,7 +694,7 @@ mod tests { ep("CABLE Output (VB-Audio Virtual Cable)"), ep("Microphone (Steam Streaming Microphone)"), ]; - let w = plan(&renders, &captures, None, false, &[]); + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); assert_eq!( w.mic_render.unwrap().0, "CABLE Input (VB-Audio Virtual Cable)" @@ -628,7 +723,7 @@ mod tests { ep("Speakers (Realtek HD Audio)"), ]; let captures = [ep("Microphone (Steam Streaming Microphone)")]; - let w = plan(&renders, &captures, None, false, &[]); + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); assert_eq!( w.mic_render.unwrap().0, "Speakers (Steam Streaming Microphone)" @@ -644,7 +739,7 @@ mod tests { fn steam_mic_only_audio_wins() { let renders = [ep("Speakers (Steam Streaming Microphone)")]; let captures = [ep("Microphone (Steam Streaming Microphone)")]; - let w = plan(&renders, &captures, None, false, &[]); + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); assert!(w.mic_render.is_none()); assert!(w.mic_withheld); assert_eq!( @@ -668,7 +763,7 @@ mod tests { ep("Microphone (Steam Streaming Microphone)"), ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)"), ]; - let w = plan(&renders, &captures, None, false, &[]); + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); assert_eq!( w.mic_render.as_ref().unwrap().0, "Voicemeeter Input (VB-Audio Voicemeeter VAIO)" @@ -696,7 +791,7 @@ mod tests { ep("Speakers (Steam Streaming Speakers)"), ]; for host_audio in [false, true] { - let w = plan(&renders, &[], None, host_audio, &[]); + let w = plan(&renders, &[], None, host_audio, &[], &MintedIds::default()); assert_eq!( w.loopback_render.as_ref().unwrap().0, "Speakers (Steam Streaming Speakers)", @@ -719,7 +814,14 @@ mod tests { ]; let captures = [ep("Microphone (Steam Streaming Microphone)")]; for host_audio in [false, true] { - let w = plan(&renders, &captures, None, host_audio, &[]); + let w = plan( + &renders, + &captures, + None, + host_audio, + &[], + &MintedIds::default(), + ); assert!(w.mic_render.is_none(), "host_audio={host_audio}"); assert!(w.mic_withheld, "host_audio={host_audio}"); assert_eq!( @@ -747,6 +849,7 @@ mod tests { Some("steam streaming microphone"), false, &[], + &MintedIds::default(), ); assert_eq!( w.mic_render.unwrap().0, @@ -771,7 +874,14 @@ mod tests { ]; let captures = [ep("Microphone (Steam Streaming Microphone)")]; for host_audio in [false, true] { - let w = plan(&renders, &captures, None, host_audio, &[]); + let w = plan( + &renders, + &captures, + None, + host_audio, + &[], + &MintedIds::default(), + ); assert_eq!( w.loopback_render.as_ref().unwrap().0, "Speakers (Realtek HD Audio)", @@ -793,7 +903,14 @@ mod tests { ]; let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")]; for host_audio in [false, true] { - let w = plan(&renders, &captures, None, host_audio, &[]); + let w = plan( + &renders, + &captures, + None, + host_audio, + &[], + &MintedIds::default(), + ); assert!(w.loopback_render.is_none(), "host_audio={host_audio}"); assert!(!w.loopback_last_resort, "host_audio={host_audio}"); assert!(w.loopback_unsatisfiable(), "host_audio={host_audio}"); @@ -842,7 +959,16 @@ mod tests { ("steam streaming microphone", fmt(24_000, 1)), ("odyssey", fmt(48_000, 2)), ]); - let w = plan_with_formats(&renders, &captures, None, false, &p, 2, &[]); + let w = plan_with_formats( + &renders, + &captures, + None, + false, + &p, + 2, + &[], + &MintedIds::default(), + ); assert_eq!( w.loopback_render.as_ref().unwrap().0, "1 - Odyssey G60SD (AMD High Definition Audio Device)", @@ -872,7 +998,16 @@ mod tests { ("steam streaming microphone", fmt(48_000, 2)), ("realtek", fmt(48_000, 2)), ]); - let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]); + let w = plan_with_formats( + &renders, + &[], + None, + false, + &p, + 2, + &[], + &MintedIds::default(), + ); assert_eq!( w.loopback_render.unwrap().0, "Speakers (Steam Streaming Microphone)" @@ -888,7 +1023,16 @@ mod tests { ep("Speakers (Steam Streaming Microphone)"), ]; let p = probe(vec![("steam streaming microphone", fmt(16_000, 1))]); - let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]); + let w = plan_with_formats( + &renders, + &[], + None, + false, + &p, + 2, + &[], + &MintedIds::default(), + ); assert_eq!( w.loopback_render.as_ref().unwrap().0, "Speakers (Steam Streaming Microphone)" @@ -904,7 +1048,16 @@ mod tests { fn narrowing_is_reported_for_real_hardware_too() { let renders = [ep("Headset (Hands-Free AG Audio)")]; let p = probe(vec![("headset", fmt(16_000, 1))]); - let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]); + let w = plan_with_formats( + &renders, + &[], + None, + false, + &p, + 2, + &[], + &MintedIds::default(), + ); assert_eq!( w.loopback_render.as_ref().unwrap().0, "Headset (Hands-Free AG Audio)" @@ -924,8 +1077,24 @@ mod tests { ]; let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")]; for host_audio in [false, true] { - let a = plan(&renders, &captures, None, host_audio, &[]); - let b = plan_with_formats(&renders, &captures, None, host_audio, &no_formats, 2, &[]); + let a = plan( + &renders, + &captures, + None, + host_audio, + &[], + &MintedIds::default(), + ); + let b = plan_with_formats( + &renders, + &captures, + None, + host_audio, + &no_formats, + 2, + &[], + &MintedIds::default(), + ); assert_eq!(a, b, "host_audio={host_audio}"); assert!(a.loopback_narrowing.is_none()); } @@ -943,7 +1112,7 @@ mod tests { ("steam streaming microphone", fmt(24_000, 1)), ("realtek", fmt(48_000, 2)), ]); - let w = plan_with_formats(&renders, &[], None, true, &p, 2, &[]); + let w = plan_with_formats(&renders, &[], None, true, &p, 2, &[], &MintedIds::default()); assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); } @@ -971,7 +1140,14 @@ mod tests { ep("Voicemeeter Input (VB-Audio Voicemeeter VAIO)"), ]; let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")]; - let w = plan(&renders, &captures, Some("voicemeeter input"), false, &[]); + let w = plan( + &renders, + &captures, + Some("voicemeeter input"), + false, + &[], + &MintedIds::default(), + ); assert_eq!( w.mic_render.unwrap().0, "Voicemeeter Input (VB-Audio Voicemeeter VAIO)" @@ -987,7 +1163,7 @@ mod tests { #[test] fn no_virtual_device() { let renders = [ep("Speakers (Realtek HD Audio)")]; - let w = plan(&renders, &[], None, false, &[]); + let w = plan(&renders, &[], None, false, &[], &MintedIds::default()); assert!(w.mic_render.is_none()); assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); } @@ -1005,7 +1181,14 @@ mod tests { ]; let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")]; for host_audio in [false, true] { - let w = plan(&renders, &captures, None, host_audio, &[]); + let w = plan( + &renders, + &captures, + None, + host_audio, + &[], + &MintedIds::default(), + ); assert_eq!( w.mic_render.as_ref().unwrap().0, "Voicemeeter Input (VB-Audio Voicemeeter VAIO)", @@ -1028,7 +1211,7 @@ mod tests { ep("Voicemeeter Aux Input (VB-Audio Voicemeeter AUX VAIO)"), ]; for host_audio in [false, true] { - let w = plan(&renders, &[], None, host_audio, &[]); + let w = plan(&renders, &[], None, host_audio, &[], &MintedIds::default()); assert!(w.mic_render.is_some(), "host_audio={host_audio}"); assert!(w.loopback_render.is_none(), "host_audio={host_audio}"); } @@ -1043,7 +1226,7 @@ mod tests { ep("CABLE Input (VB-Audio Virtual Cable)"), ep("Speakers (Some Virtual Audio Device)"), ]; - let w = plan(&renders, &[], None, false, &[]); + let w = plan(&renders, &[], None, false, &[], &MintedIds::default()); assert!(w.loopback_render.is_none()); } @@ -1077,6 +1260,7 @@ mod tests { Some("steam streaming microphone"), false, &[], + &MintedIds::default(), ); assert!(w.loopback_unsatisfiable()); let msg = describe_no_loopback(&renders, &w); @@ -1088,7 +1272,7 @@ mod tests { // anyway), while the Steam pair is the remedy that adds a capturable sink. let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")]; let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")]; - let w = plan(&renders, &captures, None, false, &[]); + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); assert!(w.loopback_unsatisfiable()); let msg = describe_no_loopback(&renders, &w); assert!(msg.contains("install Steam"), "{msg}"); @@ -1106,7 +1290,7 @@ mod tests { ep("Speakers (Realtek HD Audio)"), ]; let pads = [renders[0].1.clone()]; - let w = plan(&renders, &[], None, false, &pads); + let w = plan(&renders, &[], None, false, &pads, &MintedIds::default()); assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); // Even an operator mic override matching the pad's name must not claim it; with the // pad as the only render endpoint there is honestly no mic target and no loopback. @@ -1116,6 +1300,7 @@ mod tests { Some("wireless controller"), false, &pads, + &MintedIds::default(), ); assert!(w.mic_render.is_none()); assert!(w.loopback_render.is_none()); @@ -1143,6 +1328,7 @@ mod tests { Some("steam streaming microphone"), false, &pads, + &MintedIds::default(), ); assert_eq!( w.loopback_render.as_ref().unwrap().0, @@ -1153,7 +1339,14 @@ mod tests { // …and with the pad as the ONLY candidate left, the plan stays honestly unsatisfiable // rather than falling back onto the coils. - let w = plan(&renders[..1], &captures, None, false, &pads); + let w = plan( + &renders[..1], + &captures, + None, + false, + &pads, + &MintedIds::default(), + ); assert!( w.loopback_render.is_none(), "a pad was taken as the last resort" @@ -1161,4 +1354,164 @@ mod tests { assert!(!w.loopback_last_resort); assert!(w.loopback_unsatisfiable()); } + + // ---- minted tier-0 (the audio-substrate program) ------------------------------------- + + /// The minted zoo: both punktfunk instances present alongside the primaries, real + /// hardware, AND a cable — deliberately name-identical to the primaries, because that is + /// what the driver produces (S1 measured the confusion). + fn minted_zoo() -> ([Endpoint; 6], [Endpoint; 3], MintedIds) { + let renders = [ + ep("Speakers (Realtek HD Audio)"), + ep("CABLE Input (VB-Audio Virtual Cable)"), + ep("Lautsprecher (Steam Streaming Speakers)"), + ep("Lautsprecher (Steam Streaming Microphone)"), + ( + "Lautsprecher (Steam Streaming Speakers)".into(), + "id-minted-spk".into(), + ), + ( + "Lautsprecher (Steam Streaming Microphone)".into(), + "id-minted-mic-r".into(), + ), + ]; + let captures = [ + ep("CABLE Output (VB-Audio Virtual Cable)"), + ep("Mikrofon (Steam Streaming Microphone)"), + ( + "Mikrofon (Steam Streaming Microphone)".into(), + "id-minted-mic-c".into(), + ), + ]; + let minted = MintedIds { + speakers_render: Some("id-minted-spk".into()), + mic_render: Some("id-minted-mic-r".into()), + mic_capture: Some("id-minted-mic-c".into()), + }; + (renders, captures, minted) + } + + /// The end-state: with the minted pair present, the mic takes its own device and the + /// loopback takes the minted sink — by ID, ignoring the name-identical primaries, the + /// cable, and real hardware. Both features coexist without VB-Cable, client-only silent. + #[test] + fn minted_pair_is_tier_zero() { + let (renders, captures, minted) = minted_zoo(); + let w = plan(&renders, &captures, None, false, &[], &minted); + assert_eq!(w.mic_render.as_ref().unwrap().1, "id-minted-mic-r"); + assert_eq!( + w.mic_capture.as_ref().unwrap().1, + "id-minted-mic-c", + "the capture side must pair by the provider's id, never by name" + ); + assert_eq!(w.loopback_render.as_ref().unwrap().1, "id-minted-spk"); + assert!(!w.loopback_last_resort); + assert!(!w.mic_withheld); + assert_eq!(readiness(&w), AudioReadiness::Full); + } + + /// `host_audio` still prefers real hardware for the loopback; the mic keeps its minted + /// device either way. + #[test] + fn minted_host_audio_prefers_hardware() { + let (renders, captures, minted) = minted_zoo(); + let w = plan(&renders, &captures, None, true, &[], &minted); + assert_eq!(w.mic_render.as_ref().unwrap().1, "id-minted-mic-r"); + assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); + } + + /// The operator override still beats the minted mic — an explicit choice wins everything. + #[test] + fn env_override_beats_minted() { + let (renders, captures, minted) = minted_zoo(); + let w = plan( + &renders, + &captures, + Some("cable input"), + false, + &[], + &minted, + ); + assert_eq!( + w.mic_render.unwrap().0, + "CABLE Input (VB-Audio Virtual Cable)" + ); + // The minted sink still serves the loopback. + assert_eq!(w.loopback_render.unwrap().1, "id-minted-spk"); + } + + /// Partial mint (speakers only — the SSM leg failed): the mic falls back to the name + /// ladder, the loopback keeps the minted sink. Nothing regresses below today's behavior. + #[test] + fn minted_speakers_only_mic_uses_ladder() { + let (renders, captures, mut minted) = minted_zoo(); + minted.mic_render = None; + minted.mic_capture = None; + let w = plan(&renders, &captures, None, false, &[], &minted); + assert_eq!( + w.mic_render.unwrap().0, + "CABLE Input (VB-Audio Virtual Cable)" + ); + assert_eq!(w.loopback_render.unwrap().1, "id-minted-spk"); + } + + /// A minted id the enumeration no longer serves must not produce a phantom assignment — + /// the plan falls back to the ladder exactly as if nothing were minted. + #[test] + fn stale_minted_ids_fall_back() { + let renders = [ + ep("Speakers (Realtek HD Audio)"), + ep("CABLE Input (VB-Audio Virtual Cable)"), + ]; + let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")]; + let minted = MintedIds { + speakers_render: Some("id-gone".into()), + mic_render: Some("id-gone-too".into()), + mic_capture: Some("id-gone-three".into()), + }; + let a = plan(&renders, &captures, None, false, &[], &minted); + let b = plan(&renders, &captures, None, false, &[], &MintedIds::default()); + assert_eq!(a, b); + } + + /// A minted sink that NARROWS the mix demotes below real hardware like any silent sink — + /// tier-0 is an identity privilege, not a quality exemption. + #[test] + fn minted_sink_narrowing_demotes() { + let renders = [ + ep("Speakers (Realtek HD Audio)"), + ( + "Lautsprecher (Steam Streaming Speakers)".into(), + "id-minted-spk".into(), + ), + ]; + let minted = MintedIds { + speakers_render: Some("id-minted-spk".into()), + ..Default::default() + }; + let p = probe(vec![("steam streaming", fmt(16_000, 1))]); + let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[], &minted); + assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); + } + + /// The readiness classification the log line (and later the status API) carries. + #[test] + fn readiness_table() { + let (renders, captures, minted) = minted_zoo(); + let full = plan(&renders, &captures, None, false, &[], &minted); + assert_eq!(readiness(&full), AudioReadiness::Full); + // Steam-pair-only, no cable: audio yes (withheld mic), mic no. + let renders = [ep("Altavoces (Steam Streaming Microphone)")]; + let captures = [ep("Microphone (Steam Streaming Microphone)")]; + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); + assert_eq!(readiness(&w), AudioReadiness::AudioOnly); + // Cable-only headless: mic yes, audio no. + let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")]; + let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")]; + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); + assert_eq!(readiness(&w), AudioReadiness::MicOnly); + // Nothing at all. + let w = plan(&[], &[], None, false, &[], &MintedIds::default()); + assert_eq!(readiness(&w), AudioReadiness::Nothing); + } } diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index 81095888..2fff4e6c 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -495,11 +495,13 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { /// state without changing anything; `remove` deletes the devnode via pnputil — the escape /// hatch only, endpoints are persistent by design. Stamping needs SYSTEM (the MMDevices ACL); /// run `ensure` under the service account or PsExec when the property-store route is denied. -/// Windows: the audio-substrate spike measurements (S1–S3 in -/// `windows-audio-endpoints-and-vbcable.md`) — `audio-probe ssm|sink|sss-primary|cleanup -/// [--keep]`. `ssm` is the decision gate: mint a second Steam Streaming Microphone devnode and -/// prove render→capture end to end; `sink` parks the default on a minted Speakers instance and -/// loopback-measures it; `sss-primary` re-measures the primary Speakers' known-silent loopback. +/// Windows: the audio-substrate toolbox (`windows-audio-endpoints-and-vbcable.md`) — +/// `audio-probe ssm|sink|sss-primary|mint|plan|cleanup [--keep]`. The S1–S3 spikes (`ssm` = +/// the decision gate: mint a second Steam Streaming Microphone devnode and prove +/// render→capture end to end; `sink` parks the default on a minted Speakers instance and +/// loopback-measures it; `sss-primary` re-measures the primary Speakers' loopback), plus the +/// product paths: `mint` runs the minted-endpoint provider synchronously and `plan` prints +/// one real wiring pass with its readiness verdict. #[cfg(target_os = "windows")] pub fn audio_probe(args: &[String]) -> Result<()> { crate::audio::audio_probe::run(args) diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index db99a22b..b617f654 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -359,6 +359,11 @@ pub(crate) async fn serve( // Failures log once and leave the feature off: pads still work, just without pad audio. #[cfg(target_os = "windows")] crate::audio::pad_endpoint::provision_at_startup(); + // Windows: mint the punktfunk-owned audio endpoints ("Punktfunk Speakers/Microphone" — + // instances of Valve's streaming drivers, the wiring plan's tier-0). Best-effort on a + // worker thread; without Steam's drivers the wiring plan keeps its name-based ladder. + #[cfg(target_os = "windows")] + crate::audio::minted::provision_at_startup(); // Host-lifetime worker that fires debounced TV-session restores (the managed gamescope path // restores the box's autologin gaming session on idle, not per-disconnect — see // `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it.