Closes a gap a field report walked into: PUNKTFUNK_AUDIO_GAIN had two defects that compounded.
The two defects
It existed only on the GameStream plane. On native punktfunk/1 it silently did nothing. That mattered more than it looks, because 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.
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 anyone who pushed past roughly 1.5× heard gross distortion long before reaching the level they were chasing. A user report of "+18 dB and everything warbles" is the expected output of that line, not a fault downstream of it.
What this does
punktfunk_core::audio::apply_gain replaces the clamp with a tanh soft knee above 0.7 (≈ −3.1 dBFS), chosen for three properties:
C¹-continuous at the knee — the shaped branch's slope where it meets the linear one is exactly 1, so there is no corner in the transfer curve and the onset of limiting is not itself an audible event.
Bounded by construction — asymptotic to 1.0, and ±inf maps to ±1.0, so no sample can leave out of range.
Odd-symmetric — benign odd-harmonic distortion, no DC offset.
It is a memoryless waveshaper, not a lookahead limiter, so it costs zero added latency in the realtime encode path. That is the trade that makes it acceptable here.
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 logged).
Measured transfer — material below ≈−15 dBFS gets the full requested boost linearly; only near-full-scale peaks bend; nothing exceeds 0 dBFS:
input
+6 dB
+12 dB
+18 dB
−20 dBFS
−14.0 (6.0)
−8.0 (12.0)
−2.0 (18.0)
−12 dBFS
−6.0 (6.0)
−0.6 (11.4)
−0.0 (12.0)
−4.4 dBFS
−0.2 (4.3)
−0.0 (4.4)
−0.0 (4.4)
Scope — deliberately not a compressor
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. The requester asked for makeup gain to match TV-app loudness — that half is held pending an actual LUFS measurement, since compressing game audio flattens the explosion-vs-footstep range and is a much larger surface.
Notes for review
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. A test locks this.
SOFT_LIMIT_KNEE is excluded from cbindgen. It is host-side capture processing no C embedder can act on, and exporting it would add a bare #define against the config's own R21 prefix rule. Verified by regenerating include/punktfunk_core.h — byte-identical, ABI 19 untouched.
No CHANGELOG entry: there is no Unreleased section and v0.28.1 is already tagged, so opening one is a convention call left to the maintainer. It does want a line when the next release is cut, since an env var changed semantics and gained a second protocol.
Verification
Both call sites are cfg(linux/windows), so a macOS check compiles stubs and proves nothing; macOS host clippy is also already red on main for unrelated pre-existing pf-encode reasons. Gated on Linux amd64 in punktfunk-rust-ci:latest, confirmed non-vacuous (Compiling punktfunk-host present):
cargo test -p punktfunk-core — 215/215 local, 51 audio:: on Linux
include/punktfunk_core.h — 0 lines of drift
New tests cover: unity bit-exactness, transparency below the knee, boundedness under absurd gain (incl. ±inf), monotonicity, odd symmetry, and slope continuity across the knee.
Closes a gap a field report walked into: `PUNKTFUNK_AUDIO_GAIN` had two defects that compounded.
## The two defects
**It existed only on the GameStream plane.** On native `punktfunk/1` it silently did nothing. That mattered more than it looks, because **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.
**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 anyone who pushed past roughly 1.5× heard gross distortion long before reaching the level they were chasing. A user report of "+18 dB and everything warbles" is the expected output of that line, not a fault downstream of it.
## What this does
`punktfunk_core::audio::apply_gain` replaces the clamp with a `tanh` soft knee above `0.7` (≈ −3.1 dBFS), chosen for three properties:
1. **C¹-continuous at the knee** — the shaped branch's slope where it meets the linear one is exactly 1, so there is no corner in the transfer curve and the onset of limiting is not itself an audible event.
2. **Bounded by construction** — asymptotic to 1.0, and ±inf maps to ±1.0, so no sample can leave out of range.
3. **Odd-symmetric** — benign odd-harmonic distortion, no DC offset.
It is a *memoryless waveshaper*, not a lookahead limiter, so it costs **zero added latency** in the realtime encode path. That is the trade that makes it acceptable here.
`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 logged).
Measured transfer — material below ≈−15 dBFS gets the **full** requested boost linearly; only near-full-scale peaks bend; nothing exceeds 0 dBFS:
| input | +6 dB | +12 dB | +18 dB |
|---|---|---|---|
| −20 dBFS | −14.0 (6.0) | −8.0 (12.0) | −2.0 (18.0) |
| −12 dBFS | −6.0 (6.0) | −0.6 (11.4) | −0.0 (12.0) |
| −4.4 dBFS | −0.2 (4.3) | −0.0 (4.4) | −0.0 (4.4) |
## Scope — deliberately not a compressor
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. The requester asked for makeup gain to match TV-app loudness — that half is held pending an actual LUFS measurement, since compressing game audio flattens the explosion-vs-footstep range and is a much larger surface.
## Notes for review
- **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. A test locks this.
- **`SOFT_LIMIT_KNEE` is excluded from cbindgen.** It is host-side capture processing no C embedder can act on, and exporting it would add a bare `#define` against the config's own R21 prefix rule. Verified by regenerating `include/punktfunk_core.h` — **byte-identical, ABI 19 untouched**.
- No `CHANGELOG` entry: there is no `Unreleased` section and v0.28.1 is already tagged, so opening one is a convention call left to the maintainer. It does want a line when the next release is cut, since an env var changed semantics *and* gained a second protocol.
## Verification
Both call sites are `cfg(linux/windows)`, so a macOS check compiles stubs and proves nothing; macOS host clippy is also already red on main for unrelated pre-existing `pf-encode` reasons. Gated on Linux amd64 in `punktfunk-rust-ci:latest`, confirmed non-vacuous (`Compiling punktfunk-host` present):
- `cargo fmt --check` (core + host) — clean
- `cargo clippy --all-targets -p punktfunk-core -- -D warnings` — clean
- `cargo clippy --all-targets -p punktfunk-host -- -D warnings` — clean
- `cargo test -p punktfunk-core` — 215/215 local, 51 `audio::` on Linux
- `include/punktfunk_core.h` — 0 lines of drift
New tests cover: unity bit-exactness, transparency below the knee, boundedness under absurd gain (incl. ±inf), monotonicity, odd symmetry, and slope continuity across the knee.
`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.
enricobuehler
scheduled this pull request to auto merge when all checks succeed 2026-08-14 17:20:49 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes a gap a field report walked into:
PUNKTFUNK_AUDIO_GAINhad two defects that compounded.The two defects
It existed only on the GameStream plane. On native
punktfunk/1it silently did nothing. That mattered more than it looks, because 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.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 anyone who pushed past roughly 1.5× heard gross distortion long before reaching the level they were chasing. A user report of "+18 dB and everything warbles" is the expected output of that line, not a fault downstream of it.What this does
punktfunk_core::audio::apply_gainreplaces the clamp with atanhsoft knee above0.7(≈ −3.1 dBFS), chosen for three properties:It is a memoryless waveshaper, not a lookahead limiter, so it costs zero added latency in the realtime encode path. That is the trade that makes it acceptable here.
capture_gain()is now shared by both planes, and rejects the two values that are always typos: non-positive (would invert or mute) and above8.0/+18 dB (capped, and logged).Measured transfer — material below ≈−15 dBFS gets the full requested boost linearly; only near-full-scale peaks bend; nothing exceeds 0 dBFS:
Scope — deliberately not a compressor
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. The requester asked for makeup gain to match TV-app loudness — that half is held pending an actual LUFS measurement, since compressing game audio flattens the explosion-vs-footstep range and is a much larger surface.
Notes for review
apply_gainitself, 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. A test locks this.SOFT_LIMIT_KNEEis excluded from cbindgen. It is host-side capture processing no C embedder can act on, and exporting it would add a bare#defineagainst the config's own R21 prefix rule. Verified by regeneratinginclude/punktfunk_core.h— byte-identical, ABI 19 untouched.CHANGELOGentry: there is noUnreleasedsection and v0.28.1 is already tagged, so opening one is a convention call left to the maintainer. It does want a line when the next release is cut, since an env var changed semantics and gained a second protocol.Verification
Both call sites are
cfg(linux/windows), so a macOS check compiles stubs and proves nothing; macOS host clippy is also already red on main for unrelated pre-existingpf-encodereasons. Gated on Linux amd64 inpunktfunk-rust-ci:latest, confirmed non-vacuous (Compiling punktfunk-hostpresent):cargo fmt --check(core + host) — cleancargo clippy --all-targets -p punktfunk-core -- -D warnings— cleancargo clippy --all-targets -p punktfunk-host -- -D warnings— cleancargo test -p punktfunk-core— 215/215 local, 51audio::on Linuxinclude/punktfunk_core.h— 0 lines of driftNew tests cover: unity bit-exactness, transparency below the knee, boundedness under absurd gain (incl. ±inf), monotonicity, odd symmetry, and slope continuity across the knee.