Skip to content

feat: add replaygain peak; general performance improvements - #1

Merged
dy merged 8 commits into
audiojs:mainfrom
bhj:lufs-perf-weights-peak
Aug 18, 2026
Merged

feat: add replaygain peak; general performance improvements#1
dy merged 8 commits into
audiojs:mainfrom
bhj:lufs-perf-weights-peak

Conversation

@bhj

@bhj bhj commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Hi, I added a peak value for ReplayGain, and in the process made a couple performance improvements. Model used: Anthropic Opus 5

Summary — Three independent improvements to the BS.1770 kernels, found while evaluating @audio/loudness-replaygain: performance (lufs 2.8×, lra 7.6×, allocation no longer scaling with input), conformance (Table 1 default weights), completeness (replaygain returns track peak).

  1. Performance — Both kernels copied every channel and walked each window once per overlap step (lra re-summed every sample 30×); cascade also runs process per biquad section, so each hop was traversed four times. Sum each hop once and share it across covering windows, then fuse both sections and the accumulation into one per-sample pass via kWeighting.coefs() + @audio/biquad's state/step. 210→74 ms and 591→78 ms on 4 min of 48 kHz stereo; 92 MB of copies → ~40 KB. Adds @audio/biquad as a direct dep (already transitively present) — offered as droppable if unwanted. Also removes a float32 rounding step between filter sections (~5e-9 LU, toward the reference).

  2. Table 1 weights — Defaulting all channels to 1.0 under-weights Ls/Rs and folds in LFE, which the standard excludes; a −6 dBFS LFE shifts a 5.1 reading by 14 LU. Now derived from channel count on SMPTE/WAV/Web Audio ordering, with the 1.0 fallback kept for counts that have no standard interpretation and explicit weights still winning.

  3. Track peak — RG2 defines a gain/peak pair and the peak is what makes the gain safe to apply; both audiences the README names had to rescan the samples themselves. Sample peak (what RG2 tags carry), across all channels including excluded ones, since peak is about playback headroom not weighted loudness. Purely additive.

bhj and others added 8 commits July 26, 2026 17:50
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>
@bhj

bhj commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

@dy Thoughts?

@dy

dy commented Aug 18, 2026

Copy link
Copy Markdown
Member

Oh I somehow missed out the PRs, thanks for the ping. Will review quickly

@dy
dy merged commit 7ecd193 into audiojs:main Aug 18, 2026
3 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants