Host capture gain works on punktfunk/1, and boosting no longer hard-clips #229

Merged
enricobuehler merged 1 commits from worktree-audio-soft-limit-gain into main 2026-08-14 17:20:53 +00:00
6 changed files with 219 additions and 11 deletions
+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 =
+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). |