feat: add replaygain peak; general performance improvements - #1
Merged
Conversation
The gating loop allocated a full K-weighted copy of every channel (`Float32Array.from(ch)`), so peak memory scaled with the input: 92 MB of copies for 4 minutes of 48 kHz stereo, on top of the caller's own PCM. It then re-summed every sample four times, once per overlapping 400 ms block. K-weight one hop at a time through a single reused scratch buffer instead. Filter state already persists on the `params` object across calls, so chunked filtering is bit-identical to filtering the whole channel. Each hop's power is summed once and shared by the four blocks covering it. Allocation is now independent of input length and channel count — one hop-sized scratch plus two hop-count arrays, ~58 KB at 48 kHz versus 92 MB before. Measured 1.27x faster on 4 minutes of stereo (210ms -> 165ms); the filter pass dominates, so dropping the 4x re-summing is the smaller half of the win. Results are unchanged to within float noise (worst delta 9e-14 LU across mono/stereo/5.1 at 44.1 and 48 kHz). One intentional difference: the window is now derived as exactly four hops rather than rounded independently, so the overlap stays exactly 75% at rates where round(0.4*fs) != 4*round(0.1*fs) — e.g. 44056 Hz, where the two disagree by 6.7e-6 LU. Adds a test pinning that the caller's channels are not modified, which the scratch-buffer approach now depends on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same two problems loudness-lufs had, and the re-summing one is far worse here: the short-term window is 3 s advancing by 100 ms, so every sample was summed thirty times, once per overlapping window. Sum each hop's power once and share it across the thirty windows covering it, and K-weight through a single reused hop-sized scratch buffer rather than a full copy of each channel. 3.79x faster on 4 minutes of 48 kHz stereo (612ms -> 162ms), with allocation no longer scaling with input length or channel count. Results unchanged to within float noise (worst delta 9e-14 LU across stereo and 5.1 at 44.1 and 48 kHz). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…le 1 Both kernels defaulted `weights` to 1.0 for every channel, leaving it to the caller to pass Table 1 values. For mono and stereo that is already correct, but for surround input it silently produces a non-conformant reading: Ls/Rs are under-weighted, and — worse — LFE is folded into a measurement the standard excludes outright. A −6 dBFS LFE track shifts a 5.1 reading by 14 LU. Default by channel count instead, assuming the SMPTE/WAV/Web Audio ordering these layouts overwhelmingly arrive in: 4ch [1,1,1.41,1.41] (L R Ls Rs), 5ch [1,1,1,1.41,1.41] (L R C Ls Rs), 6ch [1,1,1,0,1.41,1.41] (L R C LFE Ls Rs). Counts without a standard interpretation keep the 1.0 fallback, and an explicit `weights` argument still wins, so callers with a different channel order are unaffected. Zero-weight channels now skip filtering entirely rather than being measured and multiplied by zero, so excluding LFE also makes 5.1 cheaper. This changes results for 4-, 5- and 6-channel input that did not pass `weights`. Mono and stereo are unaffected, as is any caller already passing Table 1 values explicitly. LAYOUTS is exported so callers can inspect or extend the defaults. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ReplayGain 2.0 defines a gain/peak pair — REPLAYGAIN_TRACK_GAIN and
REPLAYGAIN_TRACK_PEAK — and the peak is what makes the gain safe to apply:
a player boosting a quiet track needs it to know how far it can go before
clipping. The kernel returned only { gain, lufs }, so the two audiences the
README names, players and tag writers, both had to scan the samples again
themselves.
Return `peak` too: the maximum absolute sample across all channels. It costs
one pass with no new dependency, and callers were going to make that pass
regardless.
Peak spans every channel including any the loudness measurement excludes —
an LFE track carries no Table 1 weight but clips exactly like the others,
and peak describes playback headroom rather than weighted loudness.
Sample peak, not true peak: it is what RG2 tags carry, and 4x-oversampled
dBTP already has its own atom in @audio/loudness-truepeak. Mixing the two
would give values that disagree with every existing tag in the wild.
Purely additive — existing { gain, lufs } destructuring is unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ne pass Chunked measurement removed the per-channel copy but still walked each hop four times: memcpy into the scratch buffer, one pass per biquad section (`cascade` calls `process` per section), then a pass to square and sum. Take the sections from `kWeighting.coefs(fs)` and run both through `@audio/biquad`'s `step` per sample, reading straight off the source channel and accumulating power in the same loop. One traversal, no scratch buffer, no copy. 2.2x faster on top of the chunking change — lufs 210ms -> 74ms and lra 591ms -> 78ms against main, on 4 minutes of 48 kHz stereo. Also slightly more accurate: the scratch buffer was a Float32Array, so every intermediate was rounded back to float32 between the shelf and the RLB highpass. Staying in float64 shifts results by ~5e-9 LU — five orders of magnitude below the 0.1 LU conformance tolerance, and in the direction of the reference. Adds @audio/biquad as a direct dependency. It was already in the tree underneath @audio/weighting-k, and `state`/`step` are its published per-sample primitives, so the biquad math stays owned by that package rather than being reimplemented here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… definitions LAYOUTS was exported from both kernels but never added to index.d.ts, so TypeScript consumers importing it got "has no exported member". Runtime and declared surface now match; both files typecheck under --strict. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Exporting the table handed callers a live reference to the module's own default weights. Mutating an entry silently changed every later measurement in the process — a stereo tone read -23.0 before `LAYOUTS[2][1] = 0` and -26.0 after. Deep-freeze the table and its rows, and type it readonly. Deriving from it still works; only in-place mutation is blocked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The non-mutation test was named after the scratch buffer introduced by the chunking change, which the fused loop then removed — it now reads straight from the input. The invariant under test is unchanged; only the reason for it moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
Author
|
@dy Thoughts? |
Member
|
Oh I somehow missed out the PRs, thanks for the ping. Will review quickly |
dy
added a commit
that referenced
this pull request
Aug 18, 2026
… defaults, perf, rg peak), patch bumps rest (types via exports); umbrella index.d.ts + shipped-atoms README; reattach lufs/lra jsdoc split by LAYOUTS insert
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hi, I added a
peakvalue for ReplayGain, and in the process made a couple performance improvements. Model used: Anthropic Opus 5