diff --git a/crates/punktfunk-core/cbindgen.toml b/crates/punktfunk-core/cbindgen.toml index 11de6b6d..29b986e2 100644 --- a/crates/punktfunk-core/cbindgen.toml +++ b/crates/punktfunk-core/cbindgen.toml @@ -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 diff --git a/crates/punktfunk-core/src/audio.rs b/crates/punktfunk-core/src/audio.rs index b0bf4d12..b8e8ba2d 100644 --- a/crates/punktfunk-core/src/audio.rs +++ b/crates/punktfunk-core/src/audio.rs @@ -955,6 +955,68 @@ pub fn crossfade_drop(ring: &mut std::collections::VecDeque, 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 = (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 = (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}" + ); + } } diff --git a/crates/punktfunk-host/src/audio.rs b/crates/punktfunk-host/src/audio.rs index 033d28db..2de21c9a 100644 --- a/crates/punktfunk-host/src/audio.rs +++ b/crates/punktfunk-host/src/audio.rs @@ -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). diff --git a/crates/punktfunk-host/src/gamestream/audio.rs b/crates/punktfunk-host/src/gamestream/audio.rs index cb15c579..58069c25 100644 --- a/crates/punktfunk-host/src/gamestream/audio.rs +++ b/crates/punktfunk-host/src/gamestream/audio.rs @@ -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 = 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 = diff --git a/crates/punktfunk-host/src/native/audio.rs b/crates/punktfunk-host/src/native/audio.rs index 27ecb33c..75f03d17 100644 --- a/crates/punktfunk-host/src/native/audio.rs +++ b/crates/punktfunk-host/src/native/audio.rs @@ -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 = 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 = acc.drain(..frame_len).collect(); + let mut frame: Vec = 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) { diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index c050bf72..1ff01520 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -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). |