Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eb5edaff4 | ||
|
|
d0a3eca7b8 | ||
|
|
bf741f8693 |
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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). |
|
||||
|
||||
Reference in New Issue
Block a user