Compare commits

..
Author SHA1 Message Date
enricobuehler 6eb5edaff4 feat(audio): capture gain on punktfunk/1, and a soft knee instead of the clamp that made boosting a trap
android / android (pull_request) Failing after 1m5s
apple / swift (pull_request) Successful in 1m57s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m37s
ci / web (pull_request) Successful in 1m58s
ci / bun-nix (pull_request) Successful in 41s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m41s
ci / rust (pull_request) Successful in 21m14s
ci / rust-arm64 (pull_request) Failing after 3m56s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m50s
`PUNKTFUNK_AUDIO_GAIN` had two defects that compounded.

It existed only on the GameStream plane, so on native `punktfunk/1` it silently did
nothing — and since WASAPI loopback is tapped UPSTREAM of the endpoint's master volume,
turning the host's speaker slider up does not change the level a client receives either.
Between the two there was no host-side way at all to lift a quiet desktop mix on the
protocol that matters.

And where it did apply it was `(s * gain).clamp(-1.0, 1.0)` — a hard clip. Flat-topping a
waveform is a first-derivative discontinuity, which radiates harsh high-order harmonics, so
any operator who pushed past roughly 1.5x heard gross distortion long before reaching the
level they were chasing. A field report of "+18 dB and everything warbles" is the expected
output of that line, not a fault anywhere downstream of it.

`punktfunk_core::audio::apply_gain` replaces the clamp with a tanh soft knee above 0.7
(~-3.1 dBFS), chosen for three properties: C1-continuous where the branches meet (slope 1
on both sides, so the onset of limiting is not itself an audible event), bounded by
construction (asymptotic to 1.0, and +-inf maps to +-1.0, so nothing leaves out of range),
and odd-symmetric (benign harmonics, no DC). It is a memoryless waveshaper, so it costs
zero latency in the realtime encode path.

Unity is a no-op inside `apply_gain` itself, not merely at the call sites, so the default
wire stays byte-for-byte identical and a future caller that forgets to gate cannot quietly
bend every peak. `capture_gain` is now shared by both planes and rejects the two values
that are always typos: non-positive (would invert or mute) and above 8.0/+18 dB (capped,
and said out loud).

This buys headroom, NOT loudness. It cannot close a peak-to-loudness gap against
already-limited broadcast content; that needs a compressor with a real time constant, which
this deliberately is not, and the docs say so.

`SOFT_LIMIT_KNEE` is excluded from cbindgen: it is host-side capture processing that no C
embedder can act on, and exporting it would add a bare `#define` against the config's own
R21 rule. Verified by regenerating `include/punktfunk_core.h` — byte-identical, ABI 19
untouched.
2026-08-14 18:49:29 +02:00
enricobuehler d0a3eca7b8 Merge pull request 'A per-user Playnite install is invisible to a SYSTEM host, and one tile killed the whole library' (#225) from fix/playnite-launcher-resolve into main
arch / build-publish (push) Successful in 9m22s
deb / build-publish-host (push) Successful in 5m46s
ci / docs-site (push) Successful in 3m54s
deb / build-publish-gamescope (push) Successful in 33s
ci / rust-arm64 (push) Successful in 4m33s
ci / rust (push) Successful in 5m19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / deploy-docs (push) Successful in 52s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
windows-host / package (push) Successful in 16m20s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m34s
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 4m0s
windows-host / canary-manifest (push) Successful in 38s
deb / smoke-install (push) Successful in 6m25s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m3s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m5s
android / android (push) Successful in 7m28s
deb / build-publish-client-arm64 (push) Successful in 3m31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m2s
ci / web (push) Successful in 1m4s
ci / bun-nix (push) Successful in 1m30s
docker / builders-arm64cross (push) Successful in 50s
2026-08-14 16:07:37 +00:00
enricobuehler bf741f8693 fix(library): a per-user Playnite install is invisible to a SYSTEM host, and one tile killed the library
ci / web (pull_request) Successful in 1m13s
ci / bun-nix (pull_request) Successful in 1m25s
ci / rust (pull_request) Successful in 4m18s
ci / docs-site (pull_request) Successful in 4m27s
ci / rust-arm64 (pull_request) Successful in 4m39s
android / android (pull_request) Successful in 7m42s
Syncing the Playnite plugin failed outright:

  PUT /library/provider/playnite failed: entries[9]: launch.value for kind
  launcher_ui names a launcher this host cannot open (playnite)

Two defects, and the second is why it cost every game rather than one tile.

1. The host looked for Playnite in the wrong registry hive and the wrong
   profile. `playnite_fullscreen_exe()` read HKEY_CURRENT_USER, then fell back
   to %LOCALAPPDATA% — but the Windows host is a LocalSystem service, so its
   HKCU is the SYSTEM hive (S-1-5-18) and its %LOCALAPPDATA% is
   C:\Windows\System32\config\systemprofile\AppData\Local. Playnite installs
   per-user by default, so both lookups miss on a default install. The doc
   comment reasoned correctly that Playnite is per-user and then read the one
   HKCU that cannot see it.

   It also hardcoded `…\Uninstall\Playnite`. Playnite ships an Inno Setup
   installer, and Inno registers `<AppId>_is1` — measured on a Windows box
   where Git and Inno itself appear as `Git_is1` and `Inno Setup 6_is1` — so
   that key matched nothing anywhere.

   Now: every loaded hive under HKEY_USERS plus both HKLM views, matched on
   DisplayName rather than key name, then `C:\Users\*\AppData\Local\Playnite`
   for the conventional install (and for a user whose hive is not loaded).

2. One unopenable tile 400'd the whole reconcile. The Playnite plugin appends
   a single launcher tile beside its games, so refusing the payload cost the
   operator the entire library — the same shape as the unservable-cover bug
   that sanitize_art_paths was introduced to fix, on the launch side this time.

   `valid_launcher_ui` conflated two different failures. Split into
   `known_launcher_ui` (vocabulary — a plugin bug, still a hard 400, because
   the author has no other way to find out) and `resolvable_launcher_ui`
   (environment — the launcher just is not installed here, which is a fact
   about the box). `sanitize_launcher_entries` drops only the latter, with one
   warn, and the games sync.
2026-08-14 15:28:41 +02:00
14 changed files with 481 additions and 860 deletions
-87
View File
@@ -1,87 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="dpms">
<copyright><![CDATA[
SPDX-FileCopyrightText: 2015 Martin Gräßlin
SPDX-License-Identifier: LGPL-2.1-or-later
]]></copyright>
<interface name="org_kde_kwin_dpms_manager" version="1">
<description summary="Output dpms manager">
The Dpms manager allows to get a org_kde_kwin_dpms for a given wl_output.
The org_kde_kwin_dpms provides the currently used VESA Display Power Management
Signaling state (see https://en.wikipedia.org/wiki/VESA_Display_Power_Management_Signaling ).
In addition it allows to request a state change. A compositor is not obliged to honor it
and will normally automatically switch back to on state.
Warning! The protocol described in this file is a desktop environment
implementation detail. Regular clients must not use this protocol.
Backward incompatible changes may be added without bumping the major
version of the extension.
</description>
<request name="get">
<description summary="Get org_kde_kwin_dpms for wl_output">
Factory request to get the org_kde_kwin_dpms for a given wl_output.
</description>
<arg name="id" type="new_id" interface="org_kde_kwin_dpms"/>
<arg name="output" type="object" interface="wl_output"/>
</request>
</interface>
<interface name="org_kde_kwin_dpms" version="1">
<description summary="Dpms for a wl_output">
This interface provides information about the VESA DPMS state for a wl_output.
It gets created through the request get on the org_kde_kwin_dpms_manager interface.
On creating the resource the server will push whether DPSM is supported for the output,
the currently used DPMS state and notifies the client through the done event once all
states are pushed. Whenever a state changes the set of changes is committed with the
done event.
</description>
<event name="supported">
<description summary="Event indicating whether DPMS is supported on the wl_output">
This event gets pushed on binding the resource and indicates whether the wl_output
supports DPMS. There are operation modes of a Wayland server where DPMS might not
make sense (e.g. nested compositors).
</description>
<arg name="supported" type="uint" summary="Boolean value whether DPMS is supported (1) for the wl_output or not (0)"/>
</event>
<enum name="mode">
<entry name="On" value="0"/>
<entry name="Standby" value="1"/>
<entry name="Suspend" value="2"/>
<entry name="Off" value="3"/>
</enum>
<event name="mode">
<description summary="Event indicating used DPMS mode">
This mode gets pushed on binding the resource and provides the currently used
DPMS mode. It also gets pushed if DPMS is not supported for the wl_output, in that
case the value will be On.
The event is also pushed whenever the state changes.
</description>
<arg name="mode" type="uint" summary="The new currently used mode"/>
</event>
<event name="done">
<description summary="All changes are pushed">
This event gets pushed on binding the resource once all other states are pushed.
In addition it gets pushed whenever a state changes to tell the client that all
state changes have been pushed.
</description>
</event>
<request name="set">
<description summary="Request DPMS state change for the wl_output">
Requests that the compositor puts the wl_output into the passed mode. The compositor
is not obliged to change the state. In addition the compositor might leave the mode
whenever it seems suitable. E.g. the compositor might return to On state on user input.
The client should not assume that the mode changed after requesting a new mode.
Instead the client should listen for the mode event.
</description>
<arg name="mode" type="uint" summary="Requested mode"/>
</request>
<request name="release" type="destructor">
<description summary="release the dpms object"/>
</request>
</interface>
</protocol>
-9
View File
@@ -848,15 +848,6 @@ mod kwin;
#[path = "vdisplay/linux/kwin_output_mgmt.rs"]
mod kwin_output_mgmt;
// DPMS control of the box's live KDE desktop (org_kde_kwin_dpms) — how a bare-spawn gamescope
// session honors `Topology::Exclusive`: the spawn is its own headless compositor, so the desktop's
// physical outputs can't be *disabled* (KWin refuses zero enabled outputs and no output there is
// ours) — they are put to DPMS-off for the stream instead, refcounted across concurrent spawns.
// Consumed by `gamescope` (best-effort, with kscreen fallback).
#[cfg(target_os = "linux")]
#[path = "vdisplay/linux/kwin_dpms.rs"]
mod kwin_dpms;
#[cfg(target_os = "windows")]
#[path = "vdisplay/windows/manager.rs"]
pub mod manager;
@@ -69,11 +69,6 @@ pub struct GamescopeDisplay {
/// the decision and this session's `create`. `None` = nothing resolved it (a caller that never
/// ran `apply_input_env`); `create` then falls through to the bare spawn, the safe default.
route: Option<crate::GamescopeRoute>,
/// The topology-restore action the bare-spawn `create` prepared under `Topology::Exclusive` —
/// the release of this display's [`crate::kwin_dpms`] darken hold — pending pickup by the
/// registry via [`VirtualDisplay::take_topology_restore`], so it runs at the display's
/// teardown (§6.1) and never before.
pending_restore: Option<Box<dyn FnOnce() + Send>>,
}
/// A running host-managed session (its transient systemd --user unit) + the mode it was launched at.
@@ -446,14 +441,6 @@ impl VirtualDisplay for GamescopeDisplay {
self.route = route;
}
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
// The DPMS darken-hold release the bare-spawn `create` registered (Exclusive topology
// only). The registry stores it on this display's entry and runs it at teardown — which,
// for gamescope, is the display's OWN teardown: every spawn is its own group, and the
// cross-session ordering lives in `kwin_dpms`'s refcount, not in the group float.
self.pending_restore.take()
}
fn poolable_now(&self) -> bool {
// Only a bare SPAWN is registry-poolable (its `create` reports `Owned`); Managed and
// Attach report `SessionManaged`/`External`, so the registry must not reuse a kept spawn
@@ -589,23 +576,6 @@ impl VirtualDisplay for GamescopeDisplay {
hz = mode.refresh_hz,
"gamescope virtual output ready"
);
// `Topology::Exclusive`, bare-spawn edition: this spawn is its OWN headless compositor —
// nothing above touched the box's live desktop (KWin), which would otherwise keep driving
// the physical panel with the idle desktop for the whole stream. The KWin route disables
// the physicals outright, but that door is closed here (KWin refuses zero enabled outputs,
// and no output on that desktop is ours to leave enabled) — so the desktop's panels go to
// DPMS-off instead, best-effort and self-gating (a box with no KDE desktop declines
// quietly inside `kwin_dpms`). Placed AFTER the spawn succeeded, so a failed create never
// blanks the user's screen. The hold is refcounted in `kwin_dpms` rather than floated
// through the registry's group restore, because every gamescope spawn is its own group
// (`registry::group_key`) — the float alone would re-light the panel when the FIRST of two
// concurrent spawns ends, under the second's still-live stream. Skipped for Managed (its
// takeover already stopped the desktop) and Attach (it mirrors a gamescope that may itself
// be driving the physical panel) — both returned earlier in this function.
if crate::effective_topology() == crate::policy::Topology::Exclusive {
crate::kwin_dpms::acquire_stream_darken();
self.pending_restore = Some(Box::new(crate::kwin_dpms::release_stream_darken));
}
// Bare SPAWN: we own the nested gamescope process → registry-poolable (keep-alive-able).
Ok(VirtualOutput::owned(
node_id,
@@ -704,7 +704,7 @@ fn kscreen_ok(args: &[String]) -> bool {
/// before exiting, so a slow-but-working KWin gives us a kill on a request that already landed;
/// any caller that treats `None` as "it failed" is asserting something it does not know, and for
/// the restore path that assertion costs a monitor its refresh rate.
pub(crate) fn kscreen_verdict(args: &[String]) -> Option<bool> {
fn kscreen_verdict(args: &[String]) -> Option<bool> {
match crate::proc::status_within(
std::process::Command::new("kscreen-doctor").args(args),
KSCREEN_BUDGET,
@@ -1,675 +0,0 @@
//! DPMS control of the box's live KDE desktop (`org_kde_kwin_dpms`) — how a bare-spawn gamescope
//! session honors [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
//!
//! A bare spawn is its OWN headless compositor: nothing on that route touches the desktop the box
//! is showing, so on a KDE machine the physical panel keeps displaying the (idle) desktop for the
//! whole stream — while the same `exclusive` policy on the KWin route turns the physicals off
//! outright. The KWin route's mechanism is closed to us here: KWin refuses an output configuration
//! with ZERO enabled outputs, and a gamescope session has no KWin output of its own to leave
//! enabled. DPMS is the honest translation of `exclusive` for this route — the desktop stays
//! exactly where it is (no topology churn, no window re-homing), the panels go dark, and any
//! LOCAL input wakes them, which is the right answer for a desktop someone can walk up to.
//! Stream input never wakes them: it is injected into the nested gamescope's own EIS socket and
//! does not pass through KWin.
//!
//! Driven in-process over the compositor's own Wayland (`Connection::connect_to_env`, the same
//! stack as [`crate::kwin_output_mgmt`] and for the same reason: `kscreen-doctor` rides a separate
//! libkscreen/KDED layer that can be wedged while KWin itself answers fine), with a
//! `kscreen-doctor --dpms` shell-out fallback. Best-effort everywhere — a box with no Wayland
//! session, or a non-KDE desktop, declines quietly and the stream proceeds with the panel lit,
//! exactly as before this module existed.
//!
//! **The hold is refcounted here, NOT floated through the registry's per-group restore.** Every
//! gamescope spawn is its own display group (`registry::group_key` — deliberately, they are
//! independent nested sessions), so the §6.1 group machinery alone would run the FIRST session's
//! restore at that session's teardown and re-light the panel under a second, still-streaming
//! session. Instead each exclusive spawn takes one [`acquire_stream_darken`] hold (the 0→1 edge
//! darkens) and registers [`release_stream_darken`] as its per-display topology restore (the 1→0
//! edge re-lights) — the same shape as `sleep_inhibit`'s refcount, riding the registry only for
//! the *timing* of each release.
//!
//! Crash safety comes free: DPMS is non-persistent, so a host that dies holding the panel dark
//! leaves nothing to journal — the screen re-lights on the next local input or compositor
//! restart. (Contrast the Windows `pnp_disable_monitors` path, which needs a recovery journal
//! precisely because its disable survives everything.)
use std::collections::HashMap;
use std::os::fd::{AsFd, AsRawFd};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use wayland_client::protocol::wl_callback::{self, WlCallback};
use wayland_client::protocol::wl_output::{self, WlOutput};
use wayland_client::protocol::wl_registry::{self, WlRegistry};
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
// Client bindings for the vendored KDE dpms protocol (`protocols/dpms.xml`), generated inline like
// the two in `kwin_output_mgmt`. Self-contained: its only foreign object type is the core
// `wl_output`, which `wayland_client::protocol` already provides.
#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
pub mod protocol {
use wayland_client;
use wayland_client::protocol::*;
pub mod __interfaces {
use wayland_client::protocol::__interfaces::*;
wayland_scanner::generate_interfaces!("protocols/dpms.xml");
}
use self::__interfaces::*;
wayland_scanner::generate_client_code!("protocols/dpms.xml");
}
use protocol::org_kde_kwin_dpms::{Event as DpmsEvent, OrgKdeKwinDpms as Dpms};
use protocol::org_kde_kwin_dpms_manager::OrgKdeKwinDpmsManager as DpmsManager;
// The wire enum `org_kde_kwin_dpms.mode`. The XML types the `mode` request/event args as plain
// `uint` (no `enum=` attribute), so the generated signatures take/deliver `u32` — these constants
// are the protocol's values, kept in sync with the vendored `dpms.xml`.
const DPMS_MODE_ON: u32 = 0;
const DPMS_MODE_OFF: u32 = 3;
/// `org_kde_kwin_dpms_manager` is a frozen v1 protocol (its own header warns it may change
/// without a version bump, but no v2 has appeared since 2015); bind `min(advertised, 1)`.
const MANAGER_MAX: u32 = 1;
/// `wl_output.name` — the connector name used for logging — arrived in v4. Everything else we do
/// works at v1, so a lower advert just costs the log its names.
const WL_OUTPUT_MAX: u32 = 4;
/// Overall budget for one darken/re-light operation (mirrors `kwin_output_mgmt::OP_BUDGET`):
/// generous next to a healthy roundtrip, and only there so a wedged compositor can't pin the
/// session-create (or group-teardown) thread.
const OP_BUDGET: Duration = Duration::from_secs(3);
/// Poll slice while waiting on the Wayland fd (matches `kwin_output_mgmt`).
const POLL_MS: i32 = 100;
/// One output's accumulated state on this connection, keyed by its `wl_output` global name.
#[derive(Default)]
struct OutputState {
proxy: Option<WlOutput>,
/// Connector name (`DP-1`) from `wl_output.name` (v4) — logging only; the global number is
/// the address everything operates on.
connector: Option<String>,
dpms: Option<Dpms>,
/// `org_kde_kwin_dpms.supported` — `None` until the bind burst arrives.
supported: Option<bool>,
/// The last `org_kde_kwin_dpms.mode` seen — kept current, so the post-`set` wait can watch it
/// flip.
mode: Option<u32>,
}
/// Everything one connection's queue accumulates.
#[derive(Default)]
struct State {
manager: Option<DpmsManager>,
/// Keyed by the `wl_output` GLOBAL NAME — a stable address for the compositor's lifetime, and
/// the identity the darken records so the re-light (a separate, later connection) can find the
/// same outputs again.
outputs: HashMap<u32, OutputState>,
/// Highest `wl_callback` serial whose `done` has arrived — the barrier the pump waits on.
sync_done: u32,
}
impl Dispatch<WlRegistry, ()> for State {
fn event(
state: &mut Self,
registry: &WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
match event {
wl_registry::Event::Global {
name,
interface,
version,
} => {
if interface == DpmsManager::interface().name {
let v = version.min(MANAGER_MAX);
state.manager = Some(registry.bind::<DpmsManager, _, _>(name, v, qh, ()));
} else if interface == WlOutput::interface().name {
let v = version.min(WL_OUTPUT_MAX);
// The global name rides in the UserData so the output's own events (and the
// dpms object's, which gets the same stamp) can find this entry.
let out = registry.bind::<WlOutput, _, _>(name, v, qh, name);
state.outputs.entry(name).or_default().proxy = Some(out);
}
}
// An output unplugged mid-operation: drop the entry so we never `set` on its corpse.
wl_registry::Event::GlobalRemove { name } => {
state.outputs.remove(&name);
}
_ => {}
}
}
}
impl Dispatch<WlOutput, u32> for State {
fn event(
state: &mut Self,
_: &WlOutput,
event: wl_output::Event,
global: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_output::Event::Name { name } = event {
if let Some(o) = state.outputs.get_mut(global) {
o.connector = Some(name);
}
}
}
}
impl Dispatch<Dpms, u32> for State {
fn event(
state: &mut Self,
_: &Dpms,
event: DpmsEvent,
global: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
let Some(o) = state.outputs.get_mut(global) else {
return;
};
match event {
DpmsEvent::Supported { supported } => o.supported = Some(supported != 0),
DpmsEvent::Mode { mode } => o.mode = Some(mode),
DpmsEvent::Done => {}
}
}
}
// The manager has no events; the impl exists because `WlRegistry::bind` demands one.
impl Dispatch<DpmsManager, ()> for State {
fn event(
_: &mut Self,
_: &DpmsManager,
_: protocol::org_kde_kwin_dpms_manager::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<WlCallback, u32> for State {
fn event(
state: &mut Self,
_: &WlCallback,
event: wl_callback::Event,
serial: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_callback::Event::Done { .. } = event {
state.sync_done = state.sync_done.max(*serial);
}
}
}
/// Why [`Session::open`] declined — the same honest-decline discipline as
/// `kwin_output_mgmt::OpenFailure`: which rung said no decides both the log level and whether the
/// `kscreen-doctor` fallback is worth attempting.
enum OpenFailure {
/// No Wayland connection at all (`WAYLAND_DISPLAY` unset/stale). The common case for the bare
/// spawn's natural habitat — a headless plain-distro box with no desktop to darken.
Connect(String),
/// The compositor accepted the connection but did not answer the registry barrier in budget:
/// a live but wedged session — the case the shell-out fallback exists for.
RegistryBarrier,
/// Connected and answering, but `org_kde_kwin_dpms_manager` is not advertised — not KWin. A
/// definitive answer: no fallback can succeed here either (`kscreen-doctor` drives the same
/// KDE-only machinery), so this rung declines without one.
NoDpmsGlobal,
/// The manager is there but the per-output DPMS state bursts never completed in budget.
StateBarrier,
}
impl std::fmt::Display for OpenFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OpenFailure::Connect(e) => write!(f, "no Wayland connection ({e})"),
OpenFailure::RegistryBarrier => {
write!(
f,
"the compositor did not answer the registry roundtrip in budget"
)
}
OpenFailure::NoDpmsGlobal => {
write!(f, "org_kde_kwin_dpms_manager is not advertised (not KWin)")
}
OpenFailure::StateBarrier => {
write!(
f,
"the outputs' DPMS state never finished announcing in budget"
)
}
}
}
}
/// A connected session with the manager bound and every output's DPMS state read.
struct Session {
conn: Connection,
queue: wayland_client::EventQueue<State>,
state: State,
next_sync: u32,
}
impl Session {
/// [`Session::connect`] for the operation named by `op`, logging the decline at a level that
/// matches what it means: `Connect`/`NoDpmsGlobal` are the everyday non-KDE answers (most
/// bare-spawn boxes have no desktop at all) and log at debug; the two barrier failures mean a
/// LIVE session stopped answering — on a KDE box that is a panel left lit, so they warn.
fn open(op: &'static str) -> Result<Session, OpenFailure> {
let opened = Session::connect();
if let Err(reason) = &opened {
match reason {
OpenFailure::Connect(_) | OpenFailure::NoDpmsGlobal => {
tracing::debug!(op, %reason, "KWin DPMS unavailable");
}
OpenFailure::RegistryBarrier | OpenFailure::StateBarrier => {
tracing::warn!(
op,
%reason,
"KWin DPMS: in-process path unavailable — falling back to kscreen-doctor"
);
}
}
}
opened
}
/// Connect to the desktop's Wayland socket, bind the dpms manager + every `wl_output`, create
/// a dpms status object per output and drain their state bursts — all bounded by [`OP_BUDGET`].
fn connect() -> Result<Session, OpenFailure> {
let conn = Connection::connect_to_env().map_err(|e| OpenFailure::Connect(e.to_string()))?;
let queue = conn.new_event_queue();
let qh = queue.handle();
let _registry = conn.display().get_registry(&qh, ());
let mut s = Session {
conn,
queue,
state: State::default(),
next_sync: 0,
};
let deadline = Instant::now() + OP_BUDGET;
// Phase 1: process the registry globals (binds the manager + every wl_output).
if !s.sync_barrier(deadline) {
return Err(OpenFailure::RegistryBarrier);
}
let Some(mgr) = s.state.manager.clone() else {
return Err(OpenFailure::NoDpmsGlobal);
};
// Phase 2: one dpms status object per output (stamped with the output's global name so its
// events land on the right entry), then a barrier that drains both the outputs' `name`
// events and the dpms objects' supported/mode/done bursts.
let qh = s.queue.handle();
let bound: Vec<(u32, WlOutput)> = s
.state
.outputs
.iter()
.filter_map(|(g, o)| o.proxy.clone().map(|p| (*g, p)))
.collect();
for (global, out) in bound {
let d = mgr.get(&out, &qh, global);
if let Some(o) = s.state.outputs.get_mut(&global) {
o.dpms = Some(d);
}
}
if !s.sync_barrier(deadline) {
return Err(OpenFailure::StateBarrier);
}
Ok(s)
}
/// Send a `wl_display.sync` and pump the queue until its `done` arrives or `deadline` passes.
fn sync_barrier(&mut self, deadline: Instant) -> bool {
self.next_sync += 1;
let serial = self.next_sync;
let qh = self.queue.handle();
let _cb = self.conn.display().sync(&qh, serial);
self.pump_until(deadline, |st| st.sync_done >= serial)
}
/// Bounded manual event loop — flush, dispatch, poll the fd. Mirrors
/// `kwin_output_mgmt::Session::pump_until` (same rationale: `blocking_dispatch` can't be
/// interrupted, so the fd is polled in [`POLL_MS`] slices against `deadline`).
fn pump_until(&mut self, deadline: Instant, done: impl Fn(&State) -> bool) -> bool {
loop {
if done(&self.state) {
return true;
}
if self.queue.dispatch_pending(&mut self.state).is_err() {
return false;
}
if done(&self.state) {
return true;
}
if Instant::now() >= deadline {
return false;
}
if self.conn.flush().is_err() {
return false;
}
let Some(guard) = self.conn.prepare_read() else {
continue; // events already queued — loop dispatches them
};
let mut pfd = libc::pollfd {
fd: self.conn.as_fd().as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
let remaining = deadline.saturating_duration_since(Instant::now());
let timeout = (remaining.as_millis() as i32).clamp(0, POLL_MS);
// SAFETY: `&mut pfd` points at one live, fully-initialized `libc::pollfd` on the stack
// and the count `1` matches that single element, so `poll` reads `fd`/`events` and
// writes `revents` strictly within `pfd`. `pfd.fd` is the Wayland connection's fd,
// valid because `self.conn` (and the `prepare_read` guard) outlive the call. `poll`
// blocks up to `timeout` ms and writes only `revents`; `pfd` is a fresh local that
// aliases nothing.
let r = unsafe { libc::poll(&mut pfd, 1, timeout) };
if r > 0 && (pfd.revents & libc::POLLIN) != 0 {
let _ = guard.read();
} // else: timeout/signal — drop the guard, re-check the deadline
}
}
/// Request `target` on every DPMS-supporting output not already there — restricted to the
/// globals in `only` when given (the re-light path, which must touch ONLY what the darken
/// touched: a panel the USER had put to sleep before the stream is theirs to keep dark).
/// Returns the outputs actually asked to change, `(global, connector)`, then waits (within
/// budget) for each one's `mode` event to confirm — the protocol is explicit that `set` is a
/// request the compositor may decline, so the confirmation is watched and its absence logged
/// rather than assumed.
fn set_mode(&mut self, target: u32, only: Option<&[u32]>) -> Vec<(u32, Option<String>)> {
let deadline = Instant::now() + OP_BUDGET;
let mut touched: Vec<(u32, Option<String>)> = Vec::new();
for (global, o) in &self.state.outputs {
if only.is_some_and(|list| !list.contains(global)) {
continue;
}
if o.supported != Some(true) || o.mode == Some(target) {
continue;
}
if let Some(dpms) = &o.dpms {
dpms.set(target);
touched.push((*global, o.connector.clone()));
}
}
if touched.is_empty() {
return touched;
}
let want: Vec<u32> = touched.iter().map(|(g, _)| *g).collect();
// An output that vanished mid-wait (GlobalRemove pruned it) counts as settled — there is
// nothing left to flip.
let confirmed = self.pump_until(deadline, |st| {
want.iter()
.all(|g| st.outputs.get(g).is_none_or(|o| o.mode == Some(target)))
});
if !confirmed {
tracing::warn!(
outputs = ?touched,
target,
"KWin DPMS: the compositor did not confirm the mode change in budget (the \
requests are flushed; it may still land, or KWin may have declined)"
);
}
touched
}
}
/// What the 0→1 darken actually achieved — the record the 1→0 re-light undoes. Which arm did the
/// work matters: the two are undone through different doors.
enum Darkened {
/// The in-process path turned these outputs off — `(wl_output global, connector)`. Global
/// names are stable for the compositor's lifetime, so a later connection re-lights exactly
/// these. If KWin restarted in between the names match nothing — and that is the CORRECT
/// no-op, because a fresh KWin brings its outputs up lit anyway.
Wayland(Vec<(u32, Option<String>)>),
/// The `kscreen-doctor --dpms off` fallback ran (it takes no per-output address, so the
/// re-light is the symmetric `--dpms on`).
Kscreen,
}
/// The host-wide darken hold — refcounted like `sleep_inhibit`: the 0→1 edge darkens, the 1→0
/// edge re-lights, and everything between is bookkeeping. See the module docs for why the
/// registry's per-group restore float can't provide this (every gamescope spawn is its own group).
struct Holds {
count: u32,
/// What the 0→1 darken achieved, held until the 1→0 release undoes it. `None` while count > 0
/// means the darken found nothing to do (no KDE, panels already dark) — the release then has
/// nothing to undo, which is exactly right.
darkened: Option<Darkened>,
}
impl Holds {
/// Take a hold; `true` on the 0→1 edge — the caller darkens and [`record`](Self::record)s.
fn acquire_edge(&mut self) -> bool {
self.count += 1;
self.count == 1
}
/// Store the 0→1 darken's outcome.
fn record(&mut self, d: Option<Darkened>) {
self.darkened = d;
}
/// Drop a hold; `Some` on the 1→0 edge hands the caller the record to undo. A release with no
/// hold outstanding is a caller bug (an unbalanced restore) — logged, never underflowed.
fn release_edge(&mut self) -> Option<Darkened> {
if self.count == 0 {
tracing::warn!("KWin DPMS: release without a matching acquire (unbalanced restore)");
return None;
}
self.count -= 1;
if self.count == 0 {
self.darkened.take()
} else {
None
}
}
}
static HOLDS: Mutex<Holds> = Mutex::new(Holds {
count: 0,
darkened: None,
});
/// Take one darken hold for an exclusive-topology stream. The first hold turns the live KDE
/// desktop's panels off (best-effort, bounded); later holds just count. Callers MUST balance each
/// call with [`release_stream_darken`] — the gamescope backend does it by registering the release
/// as the display's topology restore, so the registry runs it exactly once per display at
/// teardown (§6.1).
///
/// The lock is deliberately held across the darken itself: a racing second acquire must queue
/// behind it (and then see the recorded outcome), not observe a count of 2 with nothing darkened.
/// Same discipline on the release side, which keeps a teardown-overlapping-connect sequence
/// strictly ordered: re-light completes, then the new stream's darken runs.
pub fn acquire_stream_darken() {
let mut h = HOLDS.lock().unwrap_or_else(|e| e.into_inner());
if h.acquire_edge() {
let d = darken();
h.record(d);
}
}
/// Drop one darken hold; the last one out re-lights whatever the first hold's darken achieved.
pub fn release_stream_darken() {
let mut h = HOLDS.lock().unwrap_or_else(|e| e.into_inner());
if let Some(d) = h.release_edge() {
relight(d);
}
}
/// The 0→1 darken: in-process over `org_kde_kwin_dpms` first, `kscreen-doctor --dpms off` as the
/// wedged-compositor fallback. `None` = nothing was darkened (no desktop, not KDE, panels already
/// off, or every arm declined) — and therefore nothing to restore.
fn darken() -> Option<Darkened> {
match Session::open("darken") {
Ok(mut s) => {
let touched = s.set_mode(DPMS_MODE_OFF, None);
if touched.is_empty() {
tracing::debug!(
"KWin DPMS: no output to darken (none supported, or all already off)"
);
None
} else {
tracing::info!(
outputs = ?touched,
"KWin DPMS: desktop outputs off for the exclusive gamescope stream"
);
Some(Darkened::Wayland(touched))
}
}
// Definitive "not KDE" / "no desktop": no fallback can do better (kscreen-doctor drives
// the same KDE-only machinery), so decline quietly — already logged by `open`.
Err(OpenFailure::NoDpmsGlobal) | Err(OpenFailure::Connect(_)) => None,
// A live session that stopped answering: the standalone tool rides a different stack
// (libkscreen/KDED) and may still get through — the same rationale as `kwin.rs`'s
// kscreen fallbacks, honest-verdict discipline included.
Err(_) => match kscreen_dpms("off") {
Some(true) => {
tracing::info!(
"KWin DPMS: desktop outputs off for the exclusive gamescope stream \
(kscreen-doctor fallback)"
);
Some(Darkened::Kscreen)
}
// Killed at its budget — NOT a refusal: kscreen-doctor applies first and then waits
// on the compositor, so a loaded KWin routinely lands the change and still gets
// killed. Record the darken so the teardown re-light runs either way; a `--dpms on`
// against a lit panel is a no-op.
None => Some(Darkened::Kscreen),
Some(false) => {
tracing::warn!(
"KWin DPMS: could not darken the desktop outputs for the exclusive topology \
(in-process path and kscreen-doctor both declined) the panel stays lit"
);
None
}
},
}
}
/// The 1→0 re-light. **This is the last line of defence for a dark monitor**, so every arm that
/// gives up says so loudly (the same discipline as `kwin.rs::reenable_outputs_kscreen`) — a dark
/// panel with no line in the log is the failure mode this chain exists to prevent. The worst case
/// stays self-healing regardless: DPMS is non-persistent, and any local input wakes the panel.
fn relight(d: Darkened) {
match d {
Darkened::Wayland(outputs) => {
let globals: Vec<u32> = outputs.iter().map(|(g, _)| *g).collect();
match Session::open("re-light") {
Ok(mut s) => {
s.set_mode(DPMS_MODE_ON, Some(&globals));
tracing::info!(outputs = ?outputs, "KWin DPMS: desktop outputs back on");
}
Err(_) => match kscreen_dpms("on") {
Some(true) | None => {
tracing::info!(
"KWin DPMS: desktop outputs back on (kscreen-doctor fallback)"
);
}
Some(false) => {
tracing::error!(
outputs = ?outputs,
"KWin DPMS: could NOT re-light the desktop outputs (in-process \
restore and kscreen-doctor both declined) the panel stays dark \
until local input wakes it"
);
}
},
}
}
Darkened::Kscreen => {
if kscreen_dpms("on") == Some(false) {
tracing::error!(
"KWin DPMS: could NOT re-light the desktop outputs (kscreen-doctor refused \
the --dpms on it earlier accepted the off for) the panel stays dark until \
local input wakes it"
);
}
}
}
}
/// `kscreen-doctor --dpms <on|off>` for its verdict, on `kwin.rs`'s shared budget and three-state
/// convention (`Some(true)` ran and succeeded, `Some(false)` refused or unrunnable, `None` killed
/// at the budget — which, for a tool that applies first and waits after, usually means it landed).
fn kscreen_dpms(mode: &'static str) -> Option<bool> {
crate::kwin::kscreen_verdict(&["--dpms".to_string(), mode.to_string()])
}
#[cfg(test)]
mod tests {
use super::{Darkened, Holds};
fn fresh() -> Holds {
Holds {
count: 0,
darkened: None,
}
}
#[test]
fn first_acquire_darkens_later_ones_count() {
let mut h = fresh();
assert!(h.acquire_edge(), "0→1 must darken");
h.record(Some(Darkened::Kscreen));
assert!(
!h.acquire_edge(),
"a second concurrent stream must not re-darken"
);
assert!(!h.acquire_edge());
}
#[test]
fn only_the_last_release_relights() {
let mut h = fresh();
assert!(h.acquire_edge());
h.record(Some(Darkened::Wayland(vec![(7, Some("DP-1".into()))])));
assert!(!h.acquire_edge());
// First release: a sibling still streams — the panel must stay dark.
assert!(h.release_edge().is_none());
// Last release hands back the record to undo.
let d = h.release_edge();
assert!(matches!(d, Some(Darkened::Wayland(v)) if v == vec![(7, Some("DP-1".into()))]));
}
#[test]
fn a_darken_that_did_nothing_restores_nothing() {
let mut h = fresh();
assert!(h.acquire_edge());
h.record(None); // no KDE / already dark: nothing was changed
assert!(h.release_edge().is_none(), "nothing to undo");
assert_eq!(h.count, 0);
}
#[test]
fn unbalanced_release_never_underflows() {
let mut h = fresh();
assert!(h.release_edge().is_none());
assert_eq!(h.count, 0, "count must not wrap");
// And the state machine still works afterwards.
assert!(h.acquire_edge());
h.record(Some(Darkened::Kscreen));
assert!(matches!(h.release_edge(), Some(Darkened::Kscreen)));
}
#[test]
fn a_full_cycle_rearms_the_darken() {
let mut h = fresh();
assert!(h.acquire_edge());
h.record(Some(Darkened::Kscreen));
assert!(h.release_edge().is_some());
// A later stream on the same host lifetime darkens again.
assert!(
h.acquire_edge(),
"the 0→1 edge must re-arm after a full cycle"
);
}
}
+13 -1
View File
@@ -17,7 +17,19 @@ parse_deps = false
# imports and their #[repr(C)] structs into the header, where socklen_t/ssize_t/iovec/msghdr are
# undefined and the C harness fails to compile: the Apple batched recv (transport/udp.rs
# `recvmsg_x` + `MsghdrX`) and the Android bionic mmsg bindings (`android_mmsg` module).
exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
#
# `SOFT_LIMIT_KNEE` is host-side CAPTURE processing (the operator gain's soft knee, applied before
# the encoder). No C embedder can act on it — they receive already-gained audio — so exporting it
# would add a bare `#define` to the ABI surface, against R21 below, for a constant with no meaning
# on that side of the boundary. Excluded rather than renamed: the header stays byte-identical.
exclude = [
"MsghdrX",
"recvmsg_x",
"mmsghdr",
"sendmmsg",
"recvmmsg",
"SOFT_LIMIT_KNEE",
]
# Reached by no exported SIGNATURE, so cbindgen's sweep misses it — but a C embedder needs the
# vocabulary: `punktfunk_connection_end_reason` writes one of these as a bare byte (deliberately,
# so the JNI/Swift sides can marshal a `u8` rather than an enum), which without this would leave
+135
View File
@@ -955,6 +955,68 @@ pub fn crossfade_drop(ring: &mut std::collections::VecDeque<f32>, drop: usize, f
ring.drain(..drop);
}
/// Where [`apply_gain`]'s soft knee begins, in linear amplitude (≈ 3.1 dBFS). Below this the
/// gained signal is passed through EXACTLY — a boost whose peaks never reach the knee is plain
/// multiplication, sample for sample, so the limiter costs nothing on material that does not need
/// it.
pub const SOFT_LIMIT_KNEE: f32 = 0.7;
/// Multiply `samples` by `gain`, bending anything that would overshoot full scale into a soft knee
/// instead of slicing it flat.
///
/// **Why this is not a `clamp`.** The GameStream plane's gain was `(s * gain).clamp(-1.0, 1.0)`,
/// which is a hard clip: the waveform's peaks are replaced by literal flat tops, and a flat top is
/// a discontinuity in the first derivative. That radiates high-order harmonics — the harsher and
/// more aliasing-prone the higher they go — which is why a field report of "+18 dB and everything
/// warbles" is the expected outcome of that code and not a bug in anything downstream. Any operator
/// who set `PUNKTFUNK_AUDIO_GAIN` much above ~1.5 was hearing this.
///
/// The curve here is `tanh`-based and chosen for three properties, in this order:
///
/// 1. **C¹-continuous at the knee.** The shaped branch's slope at `m == KNEE` is
/// `(1-K) · sech²(0) · 1/(1-K) == 1`, exactly the slope of the linear branch it meets. There is
/// no corner in the transfer curve, so the onset of limiting is not itself an audible event —
/// the failure mode of a naïve piecewise limiter, which trades one discontinuity for another.
/// 2. **Bounded by construction.** `tanh` is asymptotic to 1, so the output approaches but never
/// exceeds full scale for any finite input, and `±inf` maps to `±1.0`. No sample can leave here
/// out of range, which is what the encoder downstream assumes.
/// 3. **Odd-symmetric.** `f(-x) == -f(x)`, so the distortion it does introduce is odd-harmonic and
/// adds no DC offset — the benign, "saturating" flavour rather than the rectifying one.
///
/// Callers gate on `gain != 1.0`, so the default path is untouched and the wire stays byte-for-byte
/// identical to a build without this. Note this is a WAVESHAPER, not a lookahead limiter: it is
/// memoryless and therefore costs zero latency, which is the trade that makes it acceptable in the
/// realtime encode path. It raises headroom; it does not raise *loudness* the way a compressor
/// with a real time constant would, and it should not be sold as one.
pub fn apply_gain(samples: &mut [f32], gain: f32) {
// Unity is a no-op, not "multiply by one and shape": the shaper is only correct to apply to a
// signal somebody asked to boost. Without this, calling at unity would bend every peak above
// the knee — a silent quality change for anyone who forgot to gate the call, and the reason
// the callers' `gain != 1.0` guards are a convenience rather than a load-bearing contract.
if gain == 1.0 {
return;
}
for s in samples {
*s = soft_limit(*s * gain);
}
}
/// The waveshaper behind [`apply_gain`]: identity below [`SOFT_LIMIT_KNEE`], asymptotic to ±1.0
/// above it. Exposed so the clients can mirror the curve if they ever grow a gain of their own.
pub fn soft_limit(x: f32) -> f32 {
let m = x.abs();
if m <= SOFT_LIMIT_KNEE {
return x;
}
let head = 1.0 - SOFT_LIMIT_KNEE;
let shaped = SOFT_LIMIT_KNEE + head * ((m - SOFT_LIMIT_KNEE) / head).tanh();
if x < 0.0 {
-shaped
} else {
shaped
}
}
// ---- per-platform channel-layout helpers (pure data; no platform deps) --------------------
/// Windows `WAVEFORMATEXTENSIBLE.dwChannelMask` for the wire layout.
@@ -2432,4 +2494,77 @@ mod tests {
assert!(s.audible_tail <= 4, "{s:?}");
assert!(s.audible <= 12, "{s:?}");
}
/// Unity must be bit-exact. The callers gate on `gain != 1.0` anyway, but if this ever stopped
/// holding, every default session's wire would shift and the "byte-for-byte identical" claim
/// the tier machinery rests on would quietly become false.
#[test]
fn unity_gain_is_bit_exact() {
let src: Vec<f32> = (0..512).map(|i| (i as f32 / 512.0) * 2.0 - 1.0).collect();
let mut got = src.clone();
apply_gain(&mut got, 1.0);
assert_eq!(got, src, "unity gain must not touch a single sample");
}
/// Below the knee the limiter is not in circuit at all: a boost whose peaks stay under
/// `SOFT_LIMIT_KNEE` must be plain multiplication, or quiet material pays for a limiter it
/// never needed.
#[test]
fn below_the_knee_is_plain_multiplication() {
let mut got = vec![0.0, 0.1, -0.2, 0.34, -0.05];
apply_gain(&mut got, 2.0);
for (i, (g, s)) in got.iter().zip([0.0f32, 0.1, -0.2, 0.34, -0.05]).enumerate() {
assert_eq!(*g, s * 2.0, "sample {i} must be untouched below the knee");
}
}
/// The property the hard `clamp` violated and this exists to restore: no input, however
/// absurdly gained, may leave the shaper out of range — and non-finite input must not escape
/// as something the encoder would choke on.
#[test]
fn nothing_escapes_full_scale() {
for gain in [1.5f32, 4.0, 8.0, 64.0, 1000.0] {
let mut got: Vec<f32> = (0..401).map(|i| (i as f32 - 200.0) / 200.0).collect();
apply_gain(&mut got, gain);
for s in &got {
assert!(s.abs() <= 1.0, "gain {gain} produced {s}");
}
}
assert_eq!(soft_limit(f32::INFINITY), 1.0);
assert_eq!(soft_limit(f32::NEG_INFINITY), -1.0);
}
/// Monotonic and odd-symmetric. Monotonicity is what keeps the shaper a limiter rather than a
/// fold-back distortion; odd symmetry is what keeps its harmonics benign and its DC at zero.
#[test]
fn the_curve_is_monotonic_and_odd() {
let mut prev = f32::NEG_INFINITY;
for i in 0..=4000 {
let x = (i as f32 - 2000.0) / 500.0; // -4.0 ..= 4.0
let y = soft_limit(x);
assert!(y >= prev, "not monotonic at {x}: {y} < {prev}");
prev = y;
assert!(
(soft_limit(-x) + y).abs() < 1e-6,
"not odd-symmetric at {x}"
);
}
}
/// The knee must not itself be an audible event. Both branches meet at the same value AND the
/// same slope, so the transfer curve has no corner — a piecewise limiter that gets this wrong
/// just swaps the clip's discontinuity for a softer one.
#[test]
fn the_knee_has_no_corner() {
let k = SOFT_LIMIT_KNEE;
assert!((soft_limit(k) - k).abs() < 1e-6, "value jumps at the knee");
let h = 1e-4;
let below = (soft_limit(k) - soft_limit(k - h)) / h;
let above = (soft_limit(k + h) - soft_limit(k)) / h;
assert!((below - 1.0).abs() < 1e-2, "linear side slope {below}");
assert!(
(above - below).abs() < 1e-2,
"slope jumps at the knee: {below} -> {above}"
);
}
}
+48
View File
@@ -13,6 +13,54 @@ pub const SAMPLE_RATE: u32 = 48_000;
/// Stereo channel count — the default and the punktfunk/1 audio plane's fixed layout.
pub const CHANNELS: usize = 2;
/// Highest boost `PUNKTFUNK_AUDIO_GAIN` will honour (+18 dB). Past this the soft knee is doing
/// essentially all the work and the result is a squashed signal, not a louder one — so a runaway
/// value (a stray `180` for `1.8`) is capped and said out loud rather than silently shipped.
const MAX_CAPTURE_GAIN: f32 = 8.0;
/// The operator's capture gain, shared by BOTH audio planes (`PUNKTFUNK_AUDIO_GAIN`, default
/// `1.0` = untouched).
///
/// **Why the host needs one at all.** WASAPI loopback is tapped UPSTREAM of the endpoint's master
/// volume, so turning the host's speaker slider up does nothing whatsoever to the level a client
/// receives. Before this, the native `punktfunk/1` plane had no gain of any kind, which left no
/// host-side way to raise a quiet desktop mix — the GameStream plane's knob was the only one, and
/// it applied to the wrong protocol.
///
/// Applied through [`punktfunk_core::audio::apply_gain`], whose soft knee replaces the hard
/// `clamp(-1.0, 1.0)` this used to be. That clamp is why boosting was a trap: it flat-tops peaks,
/// and flat tops are audible as harsh distortion long before the operator reaches the level they
/// were chasing.
///
/// ⚠ This is headroom, not loudness. It cannot close a peak-to-loudness gap against
/// already-limited broadcast content — that needs a real compressor with a time constant, which is
/// deliberately NOT what this is.
pub fn capture_gain() -> f32 {
let raw: f32 = std::env::var("PUNKTFUNK_AUDIO_GAIN")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1.0);
// A negative or non-finite gain is a typo, never an intent: it would invert or poison every
// sample. Fall back to unity rather than shipping it.
if !raw.is_finite() || raw <= 0.0 {
if std::env::var("PUNKTFUNK_AUDIO_GAIN").is_ok() {
tracing::warn!(
"PUNKTFUNK_AUDIO_GAIN must be a positive number (1.0 = unchanged) — ignoring"
);
}
return 1.0;
}
if raw > MAX_CAPTURE_GAIN {
tracing::warn!(
requested = raw,
capped = MAX_CAPTURE_GAIN,
"PUNKTFUNK_AUDIO_GAIN is above the +18 dB ceiling — capping"
);
return MAX_CAPTURE_GAIN;
}
raw
}
/// Produces interleaved `f32` PCM at [`SAMPLE_RATE`] in the channel count it was opened
/// with. Lives on its own thread; never blocks the capture loop (drops if the consumer
/// falls behind).
@@ -397,11 +397,9 @@ fn audio_body(
// stays small.
let start = Instant::now();
let mut frame_no: u64 = 0;
// Optional linear gain for quiet capture sources (PUNKTFUNK_AUDIO_GAIN, default 1.0).
let gain: f32 = std::env::var("PUNKTFUNK_AUDIO_GAIN")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1.0);
// Optional gain for quiet capture sources (PUNKTFUNK_AUDIO_GAIN, default 1.0). Soft-limited
// rather than clamped — see `crate::audio::capture_gain`.
let gain = crate::audio::capture_gain();
tracing::info!(
channels = layout.channels,
streams = layout.streams,
@@ -418,9 +416,7 @@ fn audio_body(
while acc.len() >= frame_len {
let mut frame: Vec<f32> = acc.drain(..frame_len).collect();
if gain != 1.0 {
for s in &mut frame {
*s = (*s * gain).clamp(-1.0, 1.0);
}
punktfunk_core::audio::apply_gain(&mut frame, gain);
}
let n = enc.encode_float(&frame, &mut out)?;
// AES-128-CBC the Opus payload (RTP header stays plaintext). Per-packet IV =
+79 -5
View File
@@ -442,6 +442,35 @@ pub fn validate_store_claim(store: &str) -> Result<(), String> {
}
}
/// Drop every `launcher_ui` entry naming a launcher this host cannot actually open, returning the
/// `(title, value)` pairs removed.
///
/// The launch-side counterpart to [`sanitize_art_paths`], and it exists for the same reason: a
/// plugin reconciles its **whole** entry set at once, so anything that fails the payload costs the
/// operator every game in it. The Playnite plugin appends one launcher tile beside the games, so a
/// host that could not resolve `Playnite.FullscreenApp.exe` refused the lot — the operator saw an
/// empty grid and a `HostRequestError` naming `entries[9]`, with nothing to say the other entries
/// were fine.
///
/// Only the *unresolvable* case is dropped. A value outside the platform's vocabulary is still a
/// hard 400 in [`validate_provider_payload`]: that one is a bug in the plugin, and silently
/// swallowing it would leave the author with a tile that never appears and no reason why.
///
/// Dropping the whole entry rather than clearing its `launch` is deliberate — a launcher tile with
/// no launch is a dead tile, which is strictly worse than no tile.
pub fn sanitize_launcher_entries(inputs: &mut Vec<ProviderEntryInput>) -> Vec<(String, String)> {
let mut dropped = Vec::new();
inputs.retain(|e| {
let Some(launch) = &e.launch else { return true };
if launch.kind != "launcher_ui" || resolvable_launcher_ui(&launch.value) {
return true;
}
dropped.push((e.title.clone(), launch.value.clone()));
false
});
dropped
}
/// Validate a reconcile payload: non-empty titles and unique, non-empty external ids (the
/// diff key — a duplicate would make ownership of the surviving entry ambiguous).
pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), String> {
@@ -467,12 +496,13 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
"entries[{i}]: `launch.value` for kind `steam_ui` must be `bigpicture` or `desktop`"
));
}
// Refused rather than silently accepted, because the failure is otherwise invisible
// until a user clicks the tile: an unresolvable value yields no command at launch time.
if launch.kind == "launcher_ui" && !valid_launcher_ui(&launch.value) {
// Only the VOCABULARY is refused here. Whether the launcher is actually installed on
// this box is not the payload's fault, and 400ing over it threw away every game in the
// reconcile — see `sanitize_launcher_entries`, which drops just the tile instead.
if launch.kind == "launcher_ui" && !known_launcher_ui(&launch.value) {
return Err(format!(
"entries[{i}]: `launch.value` for kind `launcher_ui` names a launcher this host \
cannot open (`{}`)",
"entries[{i}]: `launch.value` for kind `launcher_ui` is not a launcher this \
host's platform supports (`{}`)",
launch.value
));
}
@@ -1065,6 +1095,14 @@ mod tests {
// Other kinds are unconstrained here (the host validates them per-kind at launch).
assert!(validate_provider_payload(&[with_launch("command", "anything")]).is_ok());
// `launcher_ui` is checked for VOCABULARY only. A launcher that is merely not installed
// must pass here and be dropped later — see `an_unopenable_launcher_tile_costs_only_itself`.
assert!(validate_provider_payload(&[with_launch("launcher_ui", "nonesuch")]).is_err());
#[cfg(windows)]
assert!(validate_provider_payload(&[with_launch("launcher_ui", "playnite")]).is_ok());
#[cfg(target_os = "linux")]
assert!(validate_provider_payload(&[with_launch("launcher_ui", "lutris")]).is_ok());
let with_env = |key: &str, value: Option<&str>| {
let mut i = input("a", "A");
i.detect.env_marker = Some(EnvMarker {
@@ -1129,4 +1167,40 @@ mod tests {
"duplicate external_id"
);
}
/// The regression `sanitize_launcher_entries` exists for: a launcher tile this host cannot open
/// must cost that tile, not the games reconciled beside it.
///
/// Field shape — the Playnite plugin appends exactly one `launcher_ui` tile after its games, so
/// `entries[N]` failing validation used to refuse the entire payload and leave the operator with
/// an empty grid and a `HostRequestError` that named only the index.
#[test]
fn an_unopenable_launcher_tile_costs_only_itself() {
let mut tile = input("launcher", "Playnite");
tile.role = GameRole::Launcher;
tile.launch = Some(LaunchSpec {
kind: "launcher_ui".into(),
value: "playnite".into(),
});
let mut inputs = vec![input("a", "A"), tile, input("b", "B")];
let dropped = sanitize_launcher_entries(&mut inputs);
if resolvable_launcher_ui("playnite") {
// A Windows box with Playnite actually installed keeps all three.
assert!(dropped.is_empty());
assert_eq!(inputs.len(), 3);
} else {
// Everywhere else the tile goes and both games survive — the whole point of the split.
assert_eq!(dropped.len(), 1);
assert_eq!(dropped[0].1, "playnite");
assert_eq!(inputs.len(), 2);
assert!(inputs.iter().all(|e| e.external_id != "launcher"));
}
// A payload of nothing but games is untouched on every OS.
let mut only_games = vec![input("a", "A"), input("b", "B")];
assert!(sanitize_launcher_entries(&mut only_games).is_empty());
assert_eq!(only_games.len(), 2);
}
}
+170 -42
View File
@@ -478,13 +478,31 @@ fn launcher_ui_stores() -> &'static [&'static str] {
}
}
/// Is this a `launcher_ui` value this host can resolve?
/// Is `value` a launcher this host's platform knows about at all?
///
/// On Windows, Playnite is validated by *resolution* rather than by being on the list: a host
/// without Playnite installed refuses the entry (a 400 the plugin author can act on) instead of
/// publishing a tile that does nothing when a user clicks it.
pub(crate) fn valid_launcher_ui(value: &str) -> bool {
if !launcher_ui_stores().contains(&value) {
/// The *vocabulary* half of the old `valid_launcher_ui`. A value outside this set is a plugin
/// author's mistake — a typo, or a launcher this OS has no support for — and no amount of
/// installing things on the box will make it resolve, so the reconcile refuses the payload.
pub(crate) fn known_launcher_ui(value: &str) -> bool {
launcher_ui_stores().contains(&value)
}
/// Can this host open `value`'s launcher **right now**?
///
/// The *environment* half. Deliberately separate from [`known_launcher_ui`], because the two
/// failures are not the same kind of thing and must not get the same answer:
///
/// - an unknown value is a bug in the plugin, and a 400 is the only way its author finds out;
/// - a known value that will not resolve means the launcher simply is not installed here, which is
/// an ordinary fact about the box, not a defect in the payload.
///
/// Conflating them cost a real library: the Playnite plugin publishes one launcher tile alongside
/// every game, so a host that could not resolve Playnite 400'd the whole reconcile and the operator
/// got **no games at all** — the same shape as the unservable-cover bug that
/// [`super::sanitize_art_paths`] was introduced to fix. The tile is dropped now (see
/// [`super::sanitize_launcher_entries`]) and the games sync.
pub(crate) fn resolvable_launcher_ui(value: &str) -> bool {
if !known_launcher_ui(value) {
return false;
}
#[cfg(windows)]
@@ -502,36 +520,141 @@ pub(crate) fn valid_launcher_ui(value: &str) -> bool {
/// directly, which is also why nothing here is interpolated from the entry: the whole value is the
/// literal `"playnite"`.
///
/// Playnite installs per-user by default, so the install directory comes from its own uninstall
/// entry (HKCU first, then HKLM for a machine-wide install), falling back to the default
/// `%LOCALAPPDATA%\Playnite`. `None` when nothing resolves, which is what refuses the tile.
/// `None` when nothing resolves, which is what drops the tile.
#[cfg(windows)]
fn playnite_fullscreen_exe() -> Option<std::path::PathBuf> {
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
use winreg::RegKey;
const KEY: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Playnite";
const EXE: &str = "Playnite.FullscreenApp.exe";
let from_registry = [HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE]
playnite_install_dirs()
.into_iter()
.find_map(|root| {
RegKey::predef(root)
.open_subkey(KEY)
.ok()?
.get_value::<String, _>("InstallLocation")
.ok()
})
.map(std::path::PathBuf::from);
from_registry
.into_iter()
.chain(
std::env::var_os("LOCALAPPDATA").map(|l| std::path::PathBuf::from(l).join("Playnite")),
)
.map(|dir| dir.join(EXE))
.find(|p| p.is_file())
}
/// Windows: every directory that might hold a Playnite install, best candidates first.
///
/// **Playnite installs per-user by default, and this host is a LocalSystem service** — which
/// invalidates all three of the obvious lookups, and is why this is not a two-liner:
///
/// - `HKEY_CURRENT_USER` is *SYSTEM's own* hive (`S-1-5-18`), never the person's, so a per-user
/// install is invisible there. Every **loaded** hive under `HKEY_USERS` is read instead: only
/// logged-on users' hives are loaded, which is exactly the set that can be streaming, and it
/// avoids a `WTSQueryUserToken` dance for what is a best-effort probe. Same trade-off
/// [`crate::procscan::steam_running_hint`] makes, for the same reason.
/// - The uninstall subkey is matched by its **`DisplayName`**, not by key name. Playnite ships an
/// Inno Setup installer and Inno registers `<AppId>_is1` — measured on a Windows box where Git
/// and Inno itself appear as `Git_is1` and `Inno Setup 6_is1`. The hardcoded
/// `…\Uninstall\Playnite` this replaced matched nothing on any box.
/// - `%LOCALAPPDATA%` for a SYSTEM service is `C:\Windows\System32\config\systemprofile\AppData\
/// Local`, so the default-install fallback cannot trust the variable — it enumerates the profiles
/// under the users base instead, the same breadth [`super::art::art_roots`] already allows.
///
/// Order matters only as a preference: a registry `InstallLocation` is what the installer actually
/// did, so it is consulted before the conventional path. Every candidate is probed for the exe, so
/// a stale entry costs one `is_file` and nothing else.
#[cfg(windows)]
fn playnite_install_dirs() -> Vec<std::path::PathBuf> {
use winreg::enums::{HKEY_LOCAL_MACHINE, HKEY_USERS, KEY_READ};
use winreg::RegKey;
// 64-bit and 32-bit views. HKCU/HKU `Software` is not redirected (only `Software\Classes` is),
// so the WOW view is a machine-hive concern only.
const UNINSTALL: &str = r"Software\Microsoft\Windows\CurrentVersion\Uninstall";
const UNINSTALL_WOW: &str = r"Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall";
let mut dirs: Vec<std::path::PathBuf> = Vec::new();
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
playnite_dirs_from_uninstall(&hklm, UNINSTALL, &mut dirs);
playnite_dirs_from_uninstall(&hklm, UNINSTALL_WOW, &mut dirs);
let users = RegKey::predef(HKEY_USERS);
for sid in users.enum_keys().flatten() {
// The `…_Classes` companion hives carry file associations, never uninstall entries.
if sid.ends_with("_Classes") {
continue;
}
if let Ok(hive) = users.open_subkey_with_flags(&sid, KEY_READ) {
playnite_dirs_from_uninstall(&hive, UNINSTALL, &mut dirs);
}
}
// The conventional per-user location, for every profile on the box — this is where Playnite's
// own default install lands, and it covers a user whose hive is not currently loaded.
for profile in windows_user_profiles() {
push_unique(&mut dirs, profile.join(r"AppData\Local\Playnite"));
}
dirs
}
/// Collect `InstallLocation` from every Playnite-looking uninstall entry under `root\path`.
///
/// Matched on `DisplayName` because the key name is the installer's `AppId` (see
/// [`playnite_install_dirs`]). `starts_with` rather than equality so a versioned or suffixed display
/// name still counts; the value is only ever used as a directory to probe for the exe, so a false
/// positive costs one failed `is_file`.
#[cfg(windows)]
fn playnite_dirs_from_uninstall(
root: &winreg::RegKey,
path: &str,
out: &mut Vec<std::path::PathBuf>,
) {
use winreg::enums::KEY_READ;
let Ok(uninstall) = root.open_subkey_with_flags(path, KEY_READ) else {
return;
};
for name in uninstall.enum_keys().flatten() {
let Ok(entry) = uninstall.open_subkey_with_flags(&name, KEY_READ) else {
continue;
};
let display: String = entry.get_value("DisplayName").unwrap_or_default();
if !display.starts_with("Playnite") {
continue;
}
if let Ok(location) = entry.get_value::<String, _>("InstallLocation") {
let location = location.trim();
if !location.is_empty() {
push_unique(out, std::path::PathBuf::from(location));
}
}
}
}
/// Every user profile directory on the box (`C:\Users\*`), minus the shared `Public` pseudo-profile.
///
/// `%PUBLIC%`'s parent is the users base on every supported Windows — the same derivation
/// [`super::art::art_roots`] uses — with `%SystemDrive%\Users` as the fallback when the variable is
/// missing from a service's environment.
#[cfg(windows)]
fn windows_user_profiles() -> Vec<std::path::PathBuf> {
let base = std::env::var_os("PUBLIC")
.map(std::path::PathBuf::from)
.and_then(|p| p.parent().map(std::path::Path::to_path_buf))
.or_else(|| {
std::env::var_os("SystemDrive").map(|d| std::path::PathBuf::from(d).join("Users"))
});
let Some(base) = base else {
return Vec::new();
};
let Ok(entries) = std::fs::read_dir(&base) else {
return Vec::new();
};
entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir() && !p.ends_with("Public"))
.collect()
}
/// Push `path` unless an equal one is already there — the candidate lists are a handful of entries,
/// so a linear check beats carrying a set around.
#[cfg(windows)]
fn push_unique(out: &mut Vec<std::path::PathBuf>, path: std::path::PathBuf) {
if !out.contains(&path) {
out.push(path);
}
}
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
@@ -800,33 +923,38 @@ mod tests {
fn launcher_ui_accepts_only_launchers_this_host_can_open() {
#[cfg(target_os = "linux")]
{
assert!(valid_launcher_ui("heroic"));
assert!(valid_launcher_ui("lutris"));
// Not wired on this OS — refused inbound rather than becoming a tile that does nothing.
assert!(!valid_launcher_ui("gog"));
assert!(known_launcher_ui("heroic"));
assert!(known_launcher_ui("lutris"));
// Not wired on this OS — outside the vocabulary, so it is refused inbound rather than
// becoming a tile that does nothing.
assert!(!known_launcher_ui("gog"));
}
#[cfg(windows)]
{
// Playnite is accepted only when this host can actually FIND its Fullscreen app:
// validation is resolution, so a box without Playnite refuses the entry rather than
// publishing a tile that does nothing when clicked.
// Playnite is in the vocabulary unconditionally — whether this particular box has it
// installed is a separate question, answered by `resolvable_launcher_ui` below. Keeping
// them separate is the fix for the reconcile that 400'd a whole library over one tile.
assert!(known_launcher_ui("playnite"));
assert_eq!(
valid_launcher_ui("playnite"),
resolvable_launcher_ui("playnite"),
playnite_fullscreen_exe().is_some()
);
// The Linux launchers, and the Windows ones whose activation is still unverified
// (Epic, GOG Galaxy, the Xbox app), stay refused.
assert!(!valid_launcher_ui("heroic"));
assert!(!valid_launcher_ui("gog"));
assert!(!known_launcher_ui("heroic"));
assert!(!known_launcher_ui("gog"));
}
#[cfg(not(any(target_os = "linux", windows)))]
{
// No launcher UIs are wired on this OS, so every value is refused.
assert!(!valid_launcher_ui("heroic"));
assert!(!valid_launcher_ui("gog"));
assert!(!known_launcher_ui("heroic"));
assert!(!known_launcher_ui("gog"));
}
assert!(!valid_launcher_ui(""));
assert!(!valid_launcher_ui("lutris; rm -rf ~"));
// Junk is outside the vocabulary on every OS, so it never reaches a resolver.
assert!(!known_launcher_ui(""));
assert!(!known_launcher_ui("lutris; rm -rf ~"));
assert!(!resolvable_launcher_ui(""));
assert!(!resolvable_launcher_ui("lutris; rm -rf ~"));
}
/// The `xbox` kind is what a library PLUGIN can publish: the runner's principal cannot read
+12
View File
@@ -524,6 +524,18 @@ pub(crate) async fn reconcile_provider_entries(
return denied;
}
}
// A launcher this box cannot open is a fact about the box, not a defect in the payload, so it
// costs its own tile and nothing else. Before this, the Playnite plugin's single launcher entry
// 400'd every game it shipped alongside.
for (title, value) in crate::library::sanitize_launcher_entries(&mut inputs) {
tracing::warn!(
provider,
launcher = %value,
title = %title,
"library reconcile: dropped a launcher tile this host cannot open — the rest of the \
payload still syncs. Install the launcher, or turn the tile off in the plugin's config"
);
}
// One aggregated line, not one per entry: a root mismatch misses EVERY cover in the payload, and
// a per-entry warn would bury the rest of the log under a thousand copies of one fact.
let mut dropped_art = 0usize;
+18 -1
View File
@@ -142,6 +142,20 @@ pub(super) fn audio_thread(
};
let frame_len = SAMPLES_PER_FRAME * want as usize;
// Operator capture gain, soft-limited (`PUNKTFUNK_AUDIO_GAIN`, default 1.0 = untouched). This
// plane had NO gain at all until now, so `PUNKTFUNK_AUDIO_GAIN` silently did nothing on
// punktfunk/1 while working on GameStream — and since WASAPI loopback taps upstream of the
// endpoint's master volume, there was no other host-side way to lift a quiet desktop mix.
// Read once per session rather than per frame: this is an operator setting, not a live control.
let gain = crate::audio::capture_gain();
if gain != 1.0 {
tracing::info!(
gain,
"audio: applying operator capture gain (soft-limited above \
{}; headroom, not loudness)",
punktfunk_core::audio::SOFT_LIMIT_KNEE
);
}
let mut acc: Vec<f32> = Vec::with_capacity(frame_len * 4);
// Sized for the largest surround frame (7.1 HQ ≈ 1.3 KB at 5 ms); ample for normal quality.
let mut opus_buf = vec![0u8; 4096];
@@ -253,7 +267,10 @@ pub(super) fn audio_thread(
}
pace_due = Some(pace_due.unwrap_or_else(std::time::Instant::now) + FRAME_INTERVAL);
let frame: Vec<f32> = acc.drain(..frame_len).collect();
let mut frame: Vec<f32> = acc.drain(..frame_len).collect();
if gain != 1.0 {
punktfunk_core::audio::apply_gain(&mut frame, gain);
}
let pts_ns = next_pts_ns;
next_pts_ns += FRAME_MS as u64 * 1_000_000;
match enc.encode_float(&frame, &mut opus_buf) {
+1 -1
View File
@@ -156,7 +156,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|---|---|---|
| `PUNKTFUNK_AUDIO_QUALITY` | `low` · `standard` · `high` *(default `high`)* | Desktop-audio encode quality. `high` (stereo 256 kbps Opus, effectively transparent) costs about 1 % of a normal video bitrate, so there's rarely a reason to go lower. `standard` is exactly the pre-0.25 encoder (stereo 128 kbps) — handy for an A/B comparison; `low` is for genuinely constrained links (noticeably lossy on music, still fine for game audio and voice). A typo warns in the log and keeps `high` rather than silently downgrading. Host-side only — clients play whatever arrives, no client setting involved. |
| `PUNKTFUNK_AUDIO_REDUNDANCY` | `1` · `0` *(default: automatic)* | Send audio packets redundantly so a lossy link doesn't crackle. Leave it unset: the host turns redundancy on by itself, only toward clients that support it and only while the link is actually losing packets. `1` forces it on for the whole session, `0` never sends it. |
| `PUNKTFUNK_AUDIO_GAIN` | float (default `1.0`) | **(Moonlight/GameStream sessions only)** Linear gain applied to captured desktop audio — bump it for a quiet source. The native `punktfunk/1` path ignores it; adjust the source's own volume there instead. |
| `PUNKTFUNK_AUDIO_GAIN` | float (default `1.0`) | Gain applied to captured desktop audio — bump it for a quiet source. Applies to **both** the native `punktfunk/1` and Moonlight/GameStream paths. Peaks are rounded off by a soft limiter rather than clipped, so a boost distorts gracefully instead of abruptly; values above `8.0` (+18 dB) are capped, and a non-positive value is ignored. Note this buys **headroom, not loudness** — it cannot make a desktop mix as loud as already-limited streaming-app audio, and pushing it hard to try will audibly squash the signal. On Windows this is the only host-side control that works at all: loopback capture is tapped upstream of the endpoint's master volume, so the speaker slider does not affect what a client receives. |
| `PUNKTFUNK_MIC_DEVICE` | name substring | **(Windows)** Target mic-uplink device by friendly-name substring (first match wins). |
| `PUNKTFUNK_MIC_LEGACY_BUFFER` | `1` | Restore the fixed pre-adaptive mic buffering (a ~48 ms prime and ~120 ms cap on Windows; a buffer scaled to the recording app's audio quantum on Linux) instead of the adaptive per-client jitter target. One-release escape hatch: if the microphone coming out of the host only sounds right *with* this set, that's a bug — please report it. |
| `PUNKTFUNK_NO_MIC_INSTALL` | set | **(Windows)** Skip installing the virtual-mic driver (e.g. when the host runs as SYSTEM). |