@banou/media-player 0.10.20 → 0.10.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,27 +1,46 @@
1
1
  /*
2
- * The volume normalizer's AudioWorkletProcessor, served to the browser as it is written: plain script, no imports,
3
- * nothing a bundler has to touch. Holds EBU short-term loudness (K-weighted, ITU-R BS.1770, gated) at a target by
4
- * turning the gain down, never up, so it needs no limiter and adds no delay.
2
+ * The volume normalizer, served to the browser as it is written: one ES module with no imports, nothing a bundler
3
+ * has to touch. The same file is loaded twice:
5
4
  *
6
- * processorOptions, every one optional:
7
- * targetLufs -24 short-term loudness held, typical anime dialogue
8
- * enabled true false starts it bypassed
9
- * inputGainDb 0 gain already applied upstream (the element's volume), judged without
5
+ * as an AudioWorklet module registers 'banou-volume-normalizer', which shapes the sound
6
+ * as a module Worker analyses the file's own audio ahead of the playhead and sends the worklet its cut
10
7
  *
11
- * Port messages: `{ enabled }` bypasses or engages with a 50 ms crossfade, `{ inputGainDb }` follows the volume,
12
- * `{ reset: 'seek' }` empties the loudness window, so the new position is judged on its own while the gain holds
13
- * until three blocks have refilled it, and `{ reset: 'source' }` also returns the gain to 0 dB, because nothing
14
- * heard so far says anything about a new file.
8
+ * The cut, in dB and never above 0, comes from one of two stages (HOR-498):
9
+ *
10
+ * ahead the worker decodes the audio as far as the file has been read, learns the file's normal level (the
11
+ * median 400 ms loudness, gated at -40 LUFS) and computes a smooth cut with unlimited lookahead, so a cut
12
+ * starts before a spike; the worklet plays it in step with the media time
13
+ * live wherever that cut has not reached the playhead: a learned level (the median 400 ms loudness since the
14
+ * file started, never under -26 LUFS), a 100 ms detector that cuts what goes 5 LU over it, then the 10 ms
15
+ * loudness held to 10 LU over it, seen 10 ms ahead
16
+ *
17
+ * Every output is 10 ms behind its input, the live stage's lookahead, whichever stage is cutting.
15
18
  */
16
19
 
17
- const BLOCK_SECONDS = 0.1
18
- const WINDOW_SECONDS = 3
19
- const MAX_CUT_DB = 20
20
- // time constants of a cut and of a recovery
21
- const ATTACK_SECONDS = 0.25
22
- const RELEASE_SECONDS = 1
23
- // 100 ms blocks quieter than this are left out, so a pause holds the gain
24
- const GATE_LUFS = -50
20
+ const BINS_PER_SECOND = 1000
21
+ // the analysis's unit of work and of what the worklet holds: 10 s of 1 ms bins
22
+ const BLOCK_BINS = 10_000
23
+ /*
24
+ * How far a cut reaches from the audio that asks for it, in bins. The slow stage reads 1.85 s ahead and 1.65 s
25
+ * behind, and its exponential attack (0.3 s, backward) and release (0.5 s, forward) fall under 0.01 dB of a 20 dB cut
26
+ * within 2.3 s and 3.8 s, so 8 s of context on either side gives the cut the whole file would.
27
+ */
28
+ const MARGIN_BINS = 8_000
29
+ const GATE_LUFS = -40
30
+ // a reading's resolution in the level histogram, LU
31
+ const HISTOGRAM_STEP = 0.01
32
+ const HISTOGRAM_MAX = 10
33
+ // readings above the gate before the level means anything
34
+ const MIN_READINGS = 50
35
+ // while only part of the file is known, the learned level is held at least here, as the live stage's is
36
+ const PARTIAL_FLOOR_LUFS = -26
37
+ // bins of a run that starts mid-file which the decoder's fresh start leaves untrustworthy
38
+ const RUN_SETTLE_BINS = 50
39
+ // a run reading audio already analysed for this long has met the work of an earlier one
40
+ const MERGED_BINS = 1000
41
+
42
+ const LUFS = (energy) => -0.691 + 10 * Math.log10(energy)
43
+ const ENERGY = (lufs) => Math.pow(10, (lufs + 0.691) / 10)
25
44
 
26
45
  // ITU-R BS.1770 K-weighting at any sample rate, in the bilinear form libebur128 uses
27
46
  const kWeighting = (rate) => {
@@ -44,62 +63,1037 @@ const kWeighting = (rate) => {
44
63
  return { shelf, highpass }
45
64
  }
46
65
 
66
+ // the sync fingerprint's filter, a second order Butterworth high-pass at 2 kHz (RBJ), [b0, b1, b2, a1, a2]
67
+ const fingerprintFilter = (rate, f = 2000) => {
68
+ const w = 2 * Math.PI * f / rate, alpha = Math.sin(w) / (2 * Math.SQRT1_2), c = Math.cos(w), a0 = 1 + alpha
69
+ return [(1 + c) / 2 / a0, -(1 + c) / a0, (1 + c) / 2 / a0, -2 * c / a0, (1 - alpha) / a0]
70
+ }
71
+
47
72
  // BS.1770 channel weights in WAVE order: the LFE counts for nothing, the surrounds for +1.5 dB
48
73
  const channelWeight = (index, count) => count >= 6 ? [1, 1, 1, 0, 1.41, 1.41, 1.41, 1.41][index] ?? 1 : 1
49
74
 
50
- class VolumeNormalizer extends AudioWorkletProcessor {
75
+ /*
76
+ * The cut computed ahead, from 1 ms bins of K-weighted energy. Each stage holds a reading to a headroom over the
77
+ * normal level, on what the stages before it leave:
78
+ * 3 s, then 400 ms the median reading around each moment, cut to its limit as one smooth gain, for loud scenes
79
+ * and loud sounds that last
80
+ * 50 ms, then 10 ms the loudness of every window ending on each bin; a window over its limit asks for its cut on
81
+ * each of its bins, so no window of the output is over it
82
+ * Each stage's cut is held, released forward and eased in backward (both exponential), and put through a minimum
83
+ * filter and a moving average of the same width, which rounds the corners and never makes it shallower. The
84
+ * headrooms are where the corpus's subtitled dialogue sits: over 12 files they cut 3.5% of dialogue time by more than
85
+ * 1 dB (the median file) against 21% for the live stage alone (HOR-498 arm D, 2026-10-16).
86
+ */
87
+ export const CURVE = {
88
+ short: [{ seconds: 0.05, headroom: 8 }, { seconds: 0.01, headroom: 10 }],
89
+ headroom400: 9,
90
+ headroom3s: 8,
91
+ maxCutDb: 20,
92
+ // seconds
93
+ fast: { hold: 0.02, release: 0.06, attack: 0.002, smooth: 0.002 },
94
+ medium: { hold: 0.05, release: 0.12, attack: 0.03, smooth: 0.02 },
95
+ slow: { hold: 0.2, release: 0.5, attack: 0.3, smooth: 0.1 },
96
+ }
97
+
98
+ /*
99
+ * The median of the 400 ms readings, every 100 ms, leaving out those under -40 LUFS. Against the dialogue level the
100
+ * corpus's own subtitles mark, a -50 LUFS gate read 0.6 LU under it on average (sd 0.87, as much as 2.9 under on a
101
+ * quiet file), -40 read 0.0 (sd 0.66, worst 1.8), the gated integrated loudness 3.0 over it (sd 1.91) (HOR-498 arm
102
+ * D, 12 corpus files, 2026-10-16). The analysis keeps the same readings as it goes, in a histogram.
103
+ */
104
+ export const normalLevel = (energy) => {
105
+ const prefix = new Float64Array(energy.length + 1)
106
+ for (let i = 0; i < energy.length; i++) prefix[i + 1] = prefix[i] + energy[i]
107
+ const readings = []
108
+ for (let t = 400; t <= energy.length; t += 100) {
109
+ const level = LUFS((prefix[t] - prefix[t - 400]) / 400)
110
+ if (level >= GATE_LUFS) readings.push(level)
111
+ }
112
+ if (!readings.length) return -Infinity
113
+ readings.sort((a, b) => a - b)
114
+ return readings[Math.floor(readings.length / 2)]
115
+ }
116
+
117
+ // sliding minimum of x over [i - before, i + after]
118
+ const slidingMin = (x, before, after) => {
119
+ const n = x.length, out = new Float32Array(n), queue = new Int32Array(n)
120
+ let head = 0, tail = 0, next = 0
121
+ for (let i = 0; i < n; i++) {
122
+ const end = Math.min(n - 1, i + after)
123
+ for (; next <= end; next++) {
124
+ while (tail > head && x[queue[tail - 1]] >= x[next]) tail--
125
+ queue[tail++] = next
126
+ }
127
+ while (queue[head] < i - before) head++
128
+ out[i] = x[queue[head]]
129
+ }
130
+ return out
131
+ }
132
+
133
+ // past either end the missing part counts as no cut
134
+ const movingAverage = (x, half) => {
135
+ const n = x.length, out = new Float32Array(n), prefix = new Float64Array(n + 1)
136
+ for (let i = 0; i < n; i++) prefix[i + 1] = prefix[i] + x[i]
137
+ for (let i = 0; i < n; i++) {
138
+ const a = Math.max(0, i - half), b = Math.min(n, i + half + 1)
139
+ out[i] = (prefix[b] - prefix[a]) / (2 * half + 1)
140
+ }
141
+ return out
142
+ }
143
+
144
+ // the cut asked for (dB, at most 0) held, released, eased in and rounded; step is the bin length in seconds
145
+ const smooth = (asked, step, { hold, release, attack, smooth: width }) => {
146
+ const held = slidingMin(asked, Math.round(hold / step), 0)
147
+ const r = Math.exp(-step / release), a = Math.exp(-step / attack)
148
+ for (let i = 1; i < held.length; i++) held[i] = Math.min(held[i], held[i - 1] * r)
149
+ for (let i = held.length - 2; i >= 0; i--) held[i] = Math.min(held[i], held[i + 1] * a)
150
+ const half = Math.round(width / step)
151
+ return half > 0 ? movingAverage(slidingMin(held, half, half), half) : held
152
+ }
153
+
154
+ const median = (values) => {
155
+ const sorted = Float64Array.from(values).sort()
156
+ return sorted[sorted.length >> 1]
157
+ }
158
+
159
+ /*
160
+ * The slow stages' detectors, in LUFS: per 100 ms step, the median of the 400 ms readings over the 3 s around it; per
161
+ * 10 ms block, the median of the 10 ms readings over the 400 ms around it. A median ignores whatever lasts under half
162
+ * its window, so a short spike is left to the fast stages and does not pull the dialogue around it down with it.
163
+ */
164
+ const slowDetector = (e10) => {
165
+ const steps = Math.ceil(e10.length / 10), readings = new Float64Array(steps)
166
+ for (let s = 0; s < steps; s++) {
167
+ let sum = 0, count = 0
168
+ for (let j = 10 * s - 15; j < 10 * s + 25; j++) if (j >= 0 && j < e10.length) { sum += e10[j]; count++ }
169
+ readings[s] = count ? LUFS(sum / count) : -Infinity
170
+ }
171
+ return readings.map((_, s) => median(readings.subarray(Math.max(0, s - 15), Math.min(steps, s + 16))))
172
+ }
173
+
174
+ const mediumDetector = (e10) => {
175
+ const blocks = e10.map(LUFS)
176
+ return blocks.map((_, j) => median(blocks.subarray(Math.max(0, j - 20), Math.min(blocks.length, j + 20))))
177
+ }
178
+
179
+ /** The cut in dB for each 1 ms bin of `energy`, at most 0, against the file's normal level in LUFS. */
180
+ export const gainCurve = (energy, normal, o = CURVE) => {
181
+ const n = energy.length
182
+ const gainDb = new Float32Array(n)
183
+ if (!Number.isFinite(normal) || !n) return gainDb
184
+ const blocks = Math.ceil(n / 10), e10 = new Float64Array(blocks)
185
+ for (let j = 0; j < blocks; j++) {
186
+ let s = 0, c = 0
187
+ for (let i = 10 * j; i < Math.min(n, 10 * j + 10); i++) { s += energy[i]; c++ }
188
+ e10[j] = s / c
189
+ }
190
+ const asked = (level, headroom) => Math.max(-o.maxCutDb, Math.min(0, normal + headroom - level))
191
+ const slowSteps = slowDetector(e10)
192
+ const slow = smooth(Float32Array.from({ length: blocks }, (_, j) => asked(slowSteps[Math.floor(j / 10)], o.headroom3s)), 0.01, o.slow)
193
+ const afterSlow = e10.map((e, j) => e * Math.pow(10, slow[j] / 10))
194
+ const medium = smooth(mediumDetector(afterSlow).map((l) => asked(l, o.headroom400)), 0.01, o.medium)
195
+ // both slow stages on the 1 ms bins, linear between block centres
196
+ const coarse = new Float32Array(n)
197
+ for (let i = 0; i < n; i++) {
198
+ const t = (i + 0.5) / 10 - 0.5, j = Math.max(0, Math.min(blocks - 1, Math.floor(t))), k = Math.min(blocks - 1, j + 1), f = Math.max(0, Math.min(1, t - j))
199
+ coarse[i] = (slow[j] + medium[j]) * (1 - f) + (slow[k] + medium[k]) * f
200
+ }
201
+ const after = new Float64Array(n)
202
+ for (let i = 0; i < n; i++) after[i] = energy[i] * Math.pow(10, coarse[i] / 10)
203
+ const fast = new Float32Array(n)
204
+ for (const { seconds, headroom } of o.short) {
205
+ const width = Math.max(1, Math.round(seconds * BINS_PER_SECOND)), limit = ENERGY(normal + headroom)
206
+ const need = new Float32Array(n)
207
+ let sum = 0
208
+ for (let k = 0; k < n; k++) {
209
+ sum += after[k]
210
+ if (k >= width) sum -= after[k - width]
211
+ const mean = Math.max(0, sum) / Math.min(width, k + 1)
212
+ need[k] = mean > limit ? Math.max(-o.maxCutDb, 10 * Math.log10(limit / mean)) : 0
213
+ }
214
+ const cut = smooth(slidingMin(need, 0, width - 1), 0.001, o.fast)
215
+ for (let i = 0; i < n; i++) { fast[i] += cut[i]; after[i] *= Math.pow(10, cut[i] / 10) }
216
+ }
217
+ for (let i = 0; i < n; i++) gainDb[i] = Math.max(-o.maxCutDb, Math.min(0, coarse[i] + fast[i]))
218
+ return gainDb
219
+ }
220
+
221
+ /**
222
+ * Takes decoded audio in order and calls `emit(bin, energy, finger)` for each 1 ms bin of the timeline it covers,
223
+ * bin `b` being [b, b + 1) ms: the K-weighted energy (BS.1770 channel weights) and the energy above 2 kHz, every
224
+ * channel summed, which the worklet matches against the live audio. `push` takes planes, a frame count and the
225
+ * first frame's time in seconds. Audio follows on from the last push while its time is within 10 ms of where that
226
+ * ended, since Matroska's millisecond timestamps are off the samples by up to half a millisecond every frame; a gap
227
+ * of more than that and under a second is silence, as MediaSource plays it, and any other jump starts a new stretch,
228
+ * keeping the filters' state.
229
+ */
230
+ export const makeBinner = (rate, channelCount, emit) => {
231
+ const { shelf: [b0, b1, b2, a1, a2], highpass: [c0, c1, c2, d1, d2] } = kWeighting(rate)
232
+ const [h0, h1, h2, g1, g2] = fingerprintFilter(rate)
233
+ const kState = Array.from({ length: channelCount }, () => new Float64Array(4))
234
+ const hState = Array.from({ length: channelCount }, () => new Float64Array(2))
235
+ let ek = new Float64Array(0), fk = new Float64Array(0)
236
+ let originBin = 0, fraction = 0, position = 0, bin = 0, edge = 0, eSum = 0, fSum = 0, count = 0, expected = NaN
237
+ const close = () => {
238
+ if (count) emit(originBin + bin, eSum / count, fSum / count)
239
+ eSum = fSum = count = 0
240
+ }
241
+ const restart = (time) => {
242
+ close()
243
+ const at = time * BINS_PER_SECOND
244
+ const next = originBin + bin + 1
245
+ if (time > expected && time - expected < 1) for (let b = next; b < Math.floor(at); b++) emit(b, 0, 0)
246
+ originBin = Math.floor(at)
247
+ fraction = at - originBin
248
+ position = 0
249
+ bin = 0
250
+ edge = Math.ceil((1 - fraction) * rate / BINS_PER_SECOND)
251
+ }
252
+ return {
253
+ push(planes, frames, time) {
254
+ if (!(Math.abs(time - expected) < 0.01)) restart(time)
255
+ else time = expected
256
+ expected = time + frames / rate
257
+ if (ek.length < frames) { ek = new Float64Array(frames); fk = new Float64Array(frames) }
258
+ ek.fill(0, 0, frames)
259
+ fk.fill(0, 0, frames)
260
+ for (let c = 0; c < channelCount; c++) {
261
+ const x = planes[c], w = channelWeight(c, channelCount), s = kState[c], h = hState[c]
262
+ let s1 = s[0], s2 = s[1], t1 = s[2], t2 = s[3], u1 = h[0], u2 = h[1]
263
+ for (let i = 0; i < frames; i++) {
264
+ const v = x[i]
265
+ const y = b0 * v + s1
266
+ s1 = b1 * v - a1 * y + s2
267
+ s2 = b2 * v - a2 * y
268
+ const z = c0 * y + t1
269
+ t1 = c1 * y - d1 * z + t2
270
+ t2 = c2 * y - d2 * z
271
+ ek[i] += w * z * z
272
+ const q = h0 * v + u1
273
+ u1 = h1 * v - g1 * q + u2
274
+ u2 = h2 * v - g2 * q
275
+ fk[i] += q * q
276
+ }
277
+ s[0] = s1; s[1] = s2; s[2] = t1; s[3] = t2; h[0] = u1; h[1] = u2
278
+ }
279
+ for (let i = 0; i < frames; i++) {
280
+ if (position >= edge) {
281
+ close()
282
+ bin++
283
+ edge = Math.ceil((bin + 1 - fraction) * rate / BINS_PER_SECOND)
284
+ }
285
+ eSum += ek[i]
286
+ fSum += fk[i]
287
+ count++
288
+ position++
289
+ }
290
+ },
291
+ flush: close,
292
+ }
293
+ }
294
+
295
+ // --- fragmented mp4, as libav-wasm writes it ---
296
+
297
+ const fourcc = (view, at) => String.fromCharCode(view.getUint8(at), view.getUint8(at + 1), view.getUint8(at + 2), view.getUint8(at + 3))
298
+
299
+ const boxes = (view, start, end) => {
300
+ const out = []
301
+ for (let at = start; at + 8 <= end;) {
302
+ let size = view.getUint32(at), body = at + 8
303
+ if (size === 1) { size = Number(view.getBigUint64(at + 8)); body = at + 16 }
304
+ else if (size === 0) size = end - at
305
+ if (size < 8 || at + size > end) break
306
+ out.push({ type: fourcc(view, at + 4), start: at, body, end: at + size })
307
+ at += size
308
+ }
309
+ return out
310
+ }
311
+ const child = (view, box, type) => box && boxes(view, box.body, box.end).find((b) => b.type === type)
312
+
313
+ // an MPEG-4 descriptor's tag, and where its body starts and ends
314
+ const descriptor = (bytes, at) => {
315
+ let size = 0, p = at + 1
316
+ for (let k = 0; k < 4; k++) {
317
+ const b = bytes[p++]
318
+ size = (size << 7) | (b & 0x7f)
319
+ if (!(b & 0x80)) break
320
+ }
321
+ return { tag: bytes[at], body: p, end: p + size }
322
+ }
323
+
324
+ // the AudioSpecificConfig inside an esds box: ES_Descriptor, DecoderConfigDescriptor, DecoderSpecificInfo
325
+ const audioSpecificConfig = (bytes, esds) => {
326
+ const es = descriptor(bytes, esds.body + 4)
327
+ if (es.tag !== 3) return undefined
328
+ let p = es.body + 2
329
+ const flags = bytes[p++]
330
+ if (flags & 0x80) p += 2
331
+ if (flags & 0x40) p += 1 + bytes[p]
332
+ if (flags & 0x20) p += 2
333
+ const config = descriptor(bytes, p)
334
+ if (config.tag !== 4) return undefined
335
+ const info = descriptor(bytes, config.body + 13)
336
+ return info.tag === 5 ? bytes.slice(info.body, info.end) : undefined
337
+ }
338
+
339
+ // WebCodecs wants Opus's identification header, little-endian, where mp4 carries dOps, big-endian
340
+ const opusHead = (view, bytes, dOps) => {
341
+ const channels = bytes[dOps.body + 1], family = bytes[dOps.body + 10]
342
+ const head = new Uint8Array(19 + (family ? 2 + channels : 0))
343
+ head.set([79, 112, 117, 115, 72, 101, 97, 100, 1, channels])
344
+ const out = new DataView(head.buffer)
345
+ out.setUint16(10, view.getUint16(dOps.body + 2), true)
346
+ out.setUint32(12, view.getUint32(dOps.body + 4), true)
347
+ out.setInt16(16, view.getInt16(dOps.body + 8), true)
348
+ head[18] = family
349
+ if (family) head.set(bytes.subarray(dOps.body + 11, dOps.body + 13 + channels), 19)
350
+ return head
351
+ }
352
+
353
+ /**
354
+ * The audio track of an mp4 init segment: its track id, timescale, the sample entry's channel count and rate, the
355
+ * decoder description WebCodecs takes (AudioSpecificConfig, OpusHead, or "fLaC" and the metadata blocks), and the
356
+ * trex defaults. Undefined when it has none.
357
+ */
358
+ export const audioTrackOf = (init) => {
359
+ const view = new DataView(init), bytes = new Uint8Array(init)
360
+ const moov = boxes(view, 0, view.byteLength).find((b) => b.type === 'moov')
361
+ if (!moov) return undefined
362
+ for (const trak of boxes(view, moov.body, moov.end).filter((b) => b.type === 'trak')) {
363
+ const mdia = child(view, trak, 'mdia'), hdlr = child(view, mdia, 'hdlr')
364
+ if (!hdlr || fourcc(view, hdlr.body + 8) !== 'soun') continue
365
+ const tkhd = child(view, trak, 'tkhd'), mdhd = child(view, mdia, 'mdhd')
366
+ const id = view.getUint32(tkhd.body + (view.getUint8(tkhd.body) ? 20 : 12))
367
+ const timescale = view.getUint32(mdhd.body + (view.getUint8(mdhd.body) ? 20 : 12))
368
+ const stsd = child(view, child(view, child(view, mdia, 'minf'), 'stbl'), 'stsd')
369
+ const entry = stsd && boxes(view, stsd.body + 8, stsd.end)[0]
370
+ if (!entry) return undefined
371
+ const inner = boxes(view, entry.body + 28, entry.end)
372
+ const esds = inner.find((b) => b.type === 'esds'), dOps = inner.find((b) => b.type === 'dOps'), dfLa = inner.find((b) => b.type === 'dfLa')
373
+ let description
374
+ if (esds) description = audioSpecificConfig(bytes, esds)
375
+ else if (dOps) description = opusHead(view, bytes, dOps)
376
+ else if (dfLa) {
377
+ description = new Uint8Array(dfLa.end - dfLa.body)
378
+ description.set([102, 76, 97, 67])
379
+ description.set(bytes.subarray(dfLa.body + 4, dfLa.end), 4)
380
+ }
381
+ let defaultDuration = 0, defaultSize = 0
382
+ const mvex = child(view, moov, 'mvex')
383
+ for (const trex of mvex ? boxes(view, mvex.body, mvex.end).filter((b) => b.type === 'trex') : []) {
384
+ if (view.getUint32(trex.body + 4) !== id) continue
385
+ defaultDuration = view.getUint32(trex.body + 12)
386
+ defaultSize = view.getUint32(trex.body + 16)
387
+ }
388
+ return {
389
+ id, timescale, description, defaultDuration, defaultSize,
390
+ channels: view.getUint16(entry.body + 16),
391
+ sampleRate: view.getUint32(entry.body + 24) >>> 16,
392
+ }
393
+ }
394
+ return undefined
395
+ }
396
+
397
+ /** The track's samples in a fragment (moof and mdat): each one's time and duration in the track's timescale, and its bytes. */
398
+ export const audioSamplesOf = (fragment, track) => {
399
+ const view = new DataView(fragment), bytes = new Uint8Array(fragment), samples = []
400
+ for (const moof of boxes(view, 0, view.byteLength).filter((b) => b.type === 'moof')) {
401
+ for (const traf of boxes(view, moof.body, moof.end).filter((b) => b.type === 'traf')) {
402
+ const tfhd = child(view, traf, 'tfhd'), flags = view.getUint32(tfhd.body) & 0xffffff
403
+ if (view.getUint32(tfhd.body + 4) !== track.id) continue
404
+ let at = tfhd.body + 8, base = moof.start, duration = track.defaultDuration, size = track.defaultSize
405
+ if (flags & 0x1) { base = Number(view.getBigUint64(at)); at += 8 }
406
+ if (flags & 0x2) at += 4
407
+ if (flags & 0x8) { duration = view.getUint32(at); at += 4 }
408
+ if (flags & 0x10) { size = view.getUint32(at); at += 4 }
409
+ const tfdt = child(view, traf, 'tfdt')
410
+ let time = !tfdt ? 0 : view.getUint8(tfdt.body) ? Number(view.getBigUint64(tfdt.body + 4)) : view.getUint32(tfdt.body + 4)
411
+ let offset = base
412
+ for (const trun of boxes(view, traf.body, traf.end).filter((b) => b.type === 'trun')) {
413
+ const runFlags = view.getUint32(trun.body) & 0xffffff, count = view.getUint32(trun.body + 4)
414
+ let p = trun.body + 8
415
+ if (runFlags & 0x1) { offset = base + view.getInt32(p); p += 4 }
416
+ if (runFlags & 0x4) p += 4
417
+ for (let i = 0; i < count; i++) {
418
+ let d = duration, s = size
419
+ if (runFlags & 0x100) { d = view.getUint32(p); p += 4 }
420
+ if (runFlags & 0x200) { s = view.getUint32(p); p += 4 }
421
+ if (runFlags & 0x400) p += 4
422
+ if (runFlags & 0x800) p += 4
423
+ if (offset + s <= bytes.length) samples.push({ time, duration: d, data: bytes.subarray(offset, offset + s) })
424
+ time += d
425
+ offset += s
426
+ }
427
+ }
428
+ }
429
+ }
430
+ return samples
431
+ }
432
+
433
+ /**
434
+ * The analysis: 1 ms bins of whatever audio it is given, the normal level learned from them, and the cut for the
435
+ * blocks around the playhead. `toWorklet` takes `{ block, gain, finger }` (10 s of bins from `block * 10` s, NaN where
436
+ * the cut or the audio is not known) and `{ drop }`; `toPage` takes the coverage reports the page schedules reads on.
437
+ * Separate from the worker's messages so a test can drive it with decoded audio.
438
+ */
439
+ export const createAnalysis = ({ toWorklet, toPage = () => {} }) => {
440
+ const chunks = new Map()
441
+ // analysed bins, as sorted and disjoint [start, end) pairs
442
+ const spans = []
443
+ const histogram = new Uint32Array(Math.round((HISTOGRAM_MAX - GATE_LUFS) / HISTOGRAM_STEP))
444
+ let readings = 0
445
+ let endBin = Infinity
446
+ // the first bin of the run that read from the start of the file
447
+ let firstBin = -1
448
+ let playhead = 0
449
+ const sent = new Map()
450
+ let run = null
451
+ let timer
452
+
453
+ const spanAt = (bin) => {
454
+ let lo = 0, hi = spans.length - 1
455
+ while (lo <= hi) {
456
+ const mid = (lo + hi) >> 1, span = spans[mid]
457
+ if (bin < span[0]) hi = mid - 1
458
+ else if (bin >= span[1]) lo = mid + 1
459
+ else return span
460
+ }
461
+ return undefined
462
+ }
463
+ // only ever called for a bin no span holds
464
+ const cover = (bin) => {
465
+ let i = 0
466
+ while (i < spans.length && spans[i][1] < bin) i++
467
+ const span = spans[i]
468
+ if (span && span[1] === bin) {
469
+ span[1]++
470
+ const next = spans[i + 1]
471
+ if (next && next[0] === span[1]) { span[1] = next[1]; spans.splice(i + 1, 1) }
472
+ } else if (span && span[0] === bin + 1) span[0] = bin
473
+ else spans.splice(i, 0, [bin, bin + 1])
474
+ }
475
+ const chunkOf = (bin) => {
476
+ const index = Math.floor(bin / BLOCK_BINS)
477
+ let chunk = chunks.get(index)
478
+ if (!chunk) {
479
+ chunk = { energy: new Float32Array(BLOCK_BINS), finger: new Float32Array(BLOCK_BINS) }
480
+ chunks.set(index, chunk)
481
+ }
482
+ return chunk
483
+ }
484
+ const energyAt = (bin) => chunks.get(Math.floor(bin / BLOCK_BINS)).energy[bin % BLOCK_BINS]
485
+
486
+ const count = (end) => {
487
+ let sum = 0
488
+ for (let b = end - 400; b < end; b++) sum += energyAt(b)
489
+ const level = LUFS(sum / 400)
490
+ if (!(level >= GATE_LUFS)) return
491
+ histogram[Math.min(histogram.length - 1, Math.floor((level - GATE_LUFS) / HISTOGRAM_STEP))]++
492
+ readings++
493
+ }
494
+
495
+ const write = (run, bin, energy, finger) => {
496
+ if (bin < run.from || bin >= endBin) return
497
+ run.last = bin
498
+ if (spanAt(bin)) {
499
+ if (run.wrote && ++run.skipped === MERGED_BINS) toPage({ type: 'merged', run: run.id, ...coverage() })
500
+ return
501
+ }
502
+ run.skipped = 0
503
+ run.wrote = true
504
+ if (run.start && firstBin < 0) firstBin = bin
505
+ const chunk = chunkOf(bin)
506
+ chunk.energy[bin % BLOCK_BINS] = energy
507
+ chunk.finger[bin % BLOCK_BINS] = finger
508
+ cover(bin)
509
+ // each 400 ms reading every 100 ms, counted when its last bin lands with the rest already there
510
+ if ((bin + 1) % 100 === 0 && bin + 1 >= 400 && spanAt(bin)[0] <= bin + 1 - 400) count(bin + 1)
511
+ }
512
+
513
+ const normal = () => {
514
+ if (readings < MIN_READINGS) return -Infinity
515
+ const rank = Math.floor(readings / 2)
516
+ let seen = 0
517
+ for (let i = 0; i < histogram.length; i++) {
518
+ seen += histogram[i]
519
+ if (seen > rank) {
520
+ const level = GATE_LUFS + (i + 0.5) * HISTOGRAM_STEP
521
+ const whole = firstBin >= 0 && spans.length === 1 && spans[0][0] <= firstBin && spans[0][1] >= endBin
522
+ return whole ? level : Math.max(PARTIAL_FLOOR_LUFS, level)
523
+ }
524
+ }
525
+ return -Infinity
526
+ }
527
+
528
+ // what is known in and around a block, so a block is computed again only once that or the level moved
529
+ const signature = (start, end) => {
530
+ let covered = 0
531
+ for (const [a, b] of spans) covered += Math.max(0, Math.min(b, end) - Math.max(a, start))
532
+ return `${covered}/${endBin}`
533
+ }
534
+
535
+ const computeBlock = (block, level) => {
536
+ const start = block * BLOCK_BINS, end = start + BLOCK_BINS
537
+ const gain = new Float32Array(BLOCK_BINS).fill(NaN), finger = new Float32Array(BLOCK_BINS).fill(NaN)
538
+ for (const [a, b] of spans) {
539
+ if (b <= start || a >= end) continue
540
+ const from = Math.max(a, start - MARGIN_BINS), to = Math.min(b, end + MARGIN_BINS)
541
+ const energy = new Float32Array(to - from)
542
+ for (let bin = from; bin < to; bin++) energy[bin - from] = energyAt(bin)
543
+ const curve = gainCurve(energy, level)
544
+ // a cut needs what follows it: short of the margin it is known only at the end of the file
545
+ const known = b >= endBin ? b : b - MARGIN_BINS
546
+ for (let bin = Math.max(a, start); bin < Math.min(b, end); bin++) {
547
+ const chunk = chunks.get(Math.floor(bin / BLOCK_BINS))
548
+ finger[bin - start] = Math.log(chunk.finger[bin % BLOCK_BINS] + 1e-10)
549
+ if (bin < known) gain[bin - start] = curve[bin - from]
550
+ }
551
+ // the last cut holds past the end of the file, where playback's audio can run on by its offset
552
+ if (b >= endBin && b > start && b < end) gain.fill(gain[b - 1 - start], b - start)
553
+ }
554
+ return { gain, finger }
555
+ }
556
+
557
+ const update = () => {
558
+ timer = undefined
559
+ const level = normal()
560
+ const here = Math.floor(playhead * BINS_PER_SECOND / BLOCK_BINS)
561
+ for (const block of [...sent.keys()]) {
562
+ if (block >= here - 2 && block <= here + 4) continue
563
+ sent.delete(block)
564
+ toWorklet({ drop: block })
565
+ }
566
+ if (!Number.isFinite(level)) return
567
+ for (let block = Math.max(0, here - 1); block <= here + 3; block++) {
568
+ if (block * BLOCK_BINS >= endBin) break
569
+ const start = block * BLOCK_BINS
570
+ const known = signature(start - MARGIN_BINS, start + BLOCK_BINS + MARGIN_BINS)
571
+ const last = sent.get(block)
572
+ if (last && last.known === known && Math.abs(last.level - level) < 0.05) continue
573
+ if (!spans.some(([a, b]) => a < start + BLOCK_BINS && b > start)) continue
574
+ const { gain, finger } = computeBlock(block, level)
575
+ sent.set(block, { known, level })
576
+ toWorklet({ block, gain, finger }, [gain.buffer, finger.buffer])
577
+ }
578
+ }
579
+ const schedule = () => { timer ??= setTimeout(update, 250) }
580
+
581
+ const coverage = () => ({
582
+ spans: spans.map(([a, b]) => [a / BINS_PER_SECOND, b / BINS_PER_SECOND]),
583
+ end: Number.isFinite(endBin) ? endBin / BINS_PER_SECOND : null,
584
+ normal: normal(),
585
+ })
586
+
587
+ return {
588
+ /**
589
+ * A run of decoded audio starts at `time` s; `start` when it reads from the start of the file. A run that starts
590
+ * anywhere else leaves out its first 50 ms, which a decoder starting fresh gets wrong.
591
+ */
592
+ startRun: (id, time, start = false) => {
593
+ run?.binner?.flush()
594
+ const from = start ? 0 : Math.floor(time * BINS_PER_SECOND) + RUN_SETTLE_BINS
595
+ run = { id, start, from, skipped: 0, wrote: false, last: -1, binner: null }
596
+ },
597
+ /** Decoded audio of the current run, as planes of `frames` frames at `rate`, from `time` s. */
598
+ push: (planes, frames, rate, time) => {
599
+ if (!run) return
600
+ const current = run
601
+ current.binner ??= makeBinner(rate, planes.length, (bin, energy, finger) => write(current, bin, energy, finger))
602
+ current.binner.push(planes, frames, time)
603
+ schedule()
604
+ },
605
+ /** The current run is over; `finished` when it reached the end of the file. */
606
+ endRun: (finished) => {
607
+ if (!run) return
608
+ run.binner?.flush()
609
+ if (finished && run.last >= 0) endBin = run.last + 1
610
+ run = null
611
+ schedule()
612
+ },
613
+ coverage,
614
+ // a playhead in another block has the blocks around it sent at once, before a seek's audio arrives
615
+ setPlayhead: (time) => {
616
+ const moved = Math.floor(time * BINS_PER_SECOND / BLOCK_BINS) !== Math.floor(playhead * BINS_PER_SECOND / BLOCK_BINS)
617
+ playhead = time
618
+ if (!moved) return
619
+ clearTimeout(timer)
620
+ update()
621
+ },
622
+ flushBlocks: () => {
623
+ clearTimeout(timer)
624
+ update()
625
+ },
626
+ normal,
627
+ spans,
628
+ }
629
+ }
630
+
631
+ /*
632
+ * The worker's side: fragments from a second libav remuxer reading the same file, or from playback's own, decoded with
633
+ * WebCodecs.
634
+ *
635
+ * { type: 'port', port } the worklet's end of a MessageChannel, for the blocks
636
+ * { type: 'init', data, codec } the init segment and the codec string MediaSource was given
637
+ * { type: 'run', run, pts, start } a run of fragments starts, `pts` added to their times as the SourceBuffer's
638
+ * timestampOffset adds it for playback; `start` for the one from the start,
639
+ * and `join: true` for one whose first fragments went by unseen, which starts
640
+ * at its first sample
641
+ * { type: 'fragment', run, data } answered { type: 'ack', run, spans, end, normal } once its audio is queued
642
+ * for decoding: the analysed seconds, the file's end once known, and the
643
+ * level learned so far in LUFS
644
+ * { type: 'end', run, finished } the run is over, answered { type: 'ended', run, spans, end, normal }
645
+ * { type: 'playhead', time }
646
+ *
647
+ * and posts { type: 'merged', run, spans, end, normal } when a run has met audio an earlier one analysed, and
648
+ * { type: 'unsupported' } when this browser's WebCodecs cannot decode the track.
649
+ */
650
+ const serve = (scope) => {
651
+ let port
652
+ let track
653
+ let codec
654
+ let decoder
655
+ let runId = -1
656
+ let pts = 0
657
+ let joining = false
658
+ const analysis = createAnalysis({
659
+ toWorklet: (message, transfer) => port?.postMessage(message, transfer),
660
+ toPage: (message) => scope.postMessage(message),
661
+ })
662
+ const unsupported = () => { track = undefined; scope.postMessage({ type: 'unsupported' }) }
663
+ const queue = () => new Promise((resolve) => {
664
+ const check = () => {
665
+ if (!decoder || decoder.state !== 'configured' || decoder.decodeQueueSize < 32) {
666
+ decoder?.removeEventListener('dequeue', check)
667
+ resolve()
668
+ }
669
+ }
670
+ decoder.addEventListener('dequeue', check)
671
+ check()
672
+ })
673
+ /*
674
+ * Each output is placed by its own chunk's timestamp, never the one AudioData carries: Chrome 155 stamps outputs by
675
+ * counting samples from the first chunk, and libav's first audio sample in a run lasts until the video's first frame
676
+ * is due (62 ms more than its own length in Matroska and mp4 with B-frames, 82 ms in FLAC), which MediaSource plays
677
+ * as a gap; counted, what follows came out that much early, too far for the first match to find (HOR-498 build,
678
+ * 2026-10-17).
679
+ */
680
+ let stamps = []
681
+ const open = (id) => {
682
+ if (decoder && decoder.state !== 'closed') decoder.close()
683
+ stamps = []
684
+ const mine = new AudioDecoder({
685
+ output: (data) => {
686
+ if (id !== runId) return data.close()
687
+ const planes = []
688
+ for (let c = 0; c < data.numberOfChannels; c++) {
689
+ const plane = new Float32Array(data.numberOfFrames)
690
+ data.copyTo(plane, { planeIndex: c, format: 'f32-planar' })
691
+ planes.push(plane)
692
+ }
693
+ analysis.push(planes, data.numberOfFrames, data.sampleRate, stamps.shift() ?? data.timestamp / 1e6)
694
+ data.close()
695
+ },
696
+ error: () => { if (id === runId) unsupported() },
697
+ })
698
+ mine.configure({ codec, sampleRate: track.sampleRate, numberOfChannels: track.channels, ...(track.description ? { description: track.description } : {}) })
699
+ decoder = mine
700
+ }
701
+ const handle = async (data) => {
702
+ if (data.type === 'init') {
703
+ codec = data.codec
704
+ const found = audioTrackOf(data.data)
705
+ const config = found && { codec, sampleRate: found.sampleRate, numberOfChannels: found.channels, ...(found.description ? { description: found.description } : {}) }
706
+ const support = config && typeof AudioDecoder === 'function' && await AudioDecoder.isConfigSupported(config).catch(() => undefined)
707
+ if (support?.supported) track = found
708
+ else unsupported()
709
+ } else if (!track) return
710
+ else if (data.type === 'run') {
711
+ runId = data.run
712
+ pts = data.pts
713
+ open(runId)
714
+ joining = !!data.join
715
+ if (!joining) analysis.startRun(runId, pts, data.start)
716
+ } else if (data.type === 'fragment') {
717
+ if (data.run !== runId || decoder.state !== 'configured') return
718
+ const samples = audioSamplesOf(data.data, track)
719
+ if (joining && samples.length) {
720
+ joining = false
721
+ analysis.startRun(runId, pts + samples[0].time / track.timescale)
722
+ }
723
+ for (const sample of samples) {
724
+ const time = pts + sample.time / track.timescale
725
+ stamps.push(time)
726
+ decoder.decode(new EncodedAudioChunk({
727
+ type: 'key',
728
+ timestamp: Math.round(time * 1e6),
729
+ duration: Math.round(sample.duration / track.timescale * 1e6),
730
+ data: sample.data,
731
+ }))
732
+ }
733
+ await queue()
734
+ scope.postMessage({ type: 'ack', run: data.run, ...analysis.coverage() })
735
+ } else if (data.type === 'end') {
736
+ if (data.run !== runId) return
737
+ const ending = decoder
738
+ if (ending.state === 'configured') await ending.flush().catch(() => {})
739
+ if (data.run !== runId) return
740
+ if (ending.state === 'configured') ending.close()
741
+ analysis.endRun(data.finished)
742
+ scope.postMessage({ type: 'ended', run: data.run, ...analysis.coverage() })
743
+ }
744
+ }
745
+ // in order, each after the last is done, except what moves no audio
746
+ let chain = Promise.resolve()
747
+ scope.onmessage = ({ data }) => {
748
+ if (data.type === 'port') port = data.port
749
+ else if (data.type === 'playhead') analysis.setPlayhead(data.time)
750
+ else chain = chain.then(() => handle(data)).catch(() => {})
751
+ }
752
+ }
753
+
754
+ // --- the worklet's side ---
755
+
756
+ const LIVE_BINS = 2048
757
+ const EPSILON = 1e-10
758
+ // the live stage's settings (HOR-498 arm C)
759
+ const LIVE = {
760
+ headroom: 5, floor: -26, minBlocks: 50, maxCut: 20, attack: 0.005, release: 0.3, window: 0.1,
761
+ shortCeiling: 10, shortWindow: 0.01, lookahead: 0.01, shortRelease: 0.15,
762
+ }
763
+ const BLOCK_SECONDS = 0.1
764
+ const LEARN_GATE_LUFS = -50
765
+ const HIST_MIN = -70
766
+ const HIST_STEP = 0.5
767
+ const HIST_BINS = 140
768
+ const QUANTUM = 128
769
+ // how fast the ahead stage takes over from the live one, and the switch engages or bypasses
770
+ const HANDOVER_SECONDS = 0.05
771
+ const CROSSFADE_SECONDS = 0.05
772
+ /*
773
+ * How far either way the live audio is searched for in the analysed audio, in ms: first, and after a seek, against
774
+ * where the last match put it. Played from the start, the fixtures matched 21 to 60 ms off, and FLAC past 60 ms (HOR-498
775
+ * build, 2026-10-17), as long as libav's first audio sample stretches to the video's first frame, which B-frames set.
776
+ */
777
+ const LOCK_RANGE_MS = 250
778
+ const RELOCK_RANGE_MS = 30
779
+ // how far from the last match a seek may move the audio, which the deepest cut in doubt covers, in ms: FLAC moved 82 ms
780
+ // across the first remuxer seek, from the stream's start, where MediaSource plays libav's first sample short
781
+ const DOUBT_MS = 100
782
+ // how far from the best offset another must come close for the match to be in question, in ms
783
+ const CLEAR_MS = 60
784
+ // a search scores this many bins against offsets in a render quantum, about 0.2 ms of a 2.7 ms one
785
+ const SCORES_PER_QUANTUM = 40_000
786
+
787
+ /*
788
+ * Port messages: `{ enabled }` engages or bypasses with a 50 ms crossfade; `{ inputGainDb }` is the gain already
789
+ * applied upstream (the element's volume), judged without; `{ reset: 'seek' }` empties the live stage's window and
790
+ * holds the ahead stage's alignment in doubt until the audio is matched again; `{ reset: 'source' }` forgets
791
+ * everything heard; `{ media, ctx, rate, paused }` is the main thread's video.currentTime read together with
792
+ * context.currentTime; `{ analysis }` is the port the analysis sends its blocks on, and replaces their sender.
793
+ *
794
+ * Timing, measured in headless Chrome 155 (HOR-498 arm D, 2026-10-16): a pair of video.currentTime and
795
+ * context.currentTime read in one task names the media time of the audio reaching a worklet that same render frame,
796
+ * to the sample for a 48 kHz file and within about 2 ms for a 44.1 kHz one, except for the odd pair off by up to
797
+ * 13 ms; so a jump of more than 25 ms re-anchors the timeline, and the median of the next five pairs settles it. Where
798
+ * playback's audio sits against the analysed audio changes with each run of the remuxer behind it: -18 to +44 ms on
799
+ * the fixtures, the same file 44 ms apart before and after a seek (HOR-498 build, 2026-10-17). So the offset is found
800
+ * from the audio itself: the live fingerprint against the analysed one, every 250 ms at normal speed, to a fraction of
801
+ * a millisecond. The same match catches the rare stall (once, 60 ms of silence came in mid-file and the audio after it
802
+ * ran 11 ms ahead of the clock). Nothing is cut ahead until the first match, which also proves the blocks belong to
803
+ * this audio; after a seek, until the next one, each quantum gets the deepest cut within the doubt, never less than
804
+ * the cut asks, as it does away from normal speed (Chrome's time stretching moves the audio up to about 30 ms either
805
+ * way).
806
+ */
807
+ const defineProcessor = () => class VolumeNormalizer extends AudioWorkletProcessor {
51
808
  constructor({ processorOptions = {} } = {}) {
52
809
  super()
53
- const option = (key, fallback) => typeof processorOptions[key] === 'number' ? processorOptions[key] : fallback
54
- this.target = option('targetLufs', -24)
55
810
  this.enabled = processorOptions.enabled !== false
56
811
  this.mix = this.enabled ? 1 : 0
57
- this.inputPower = Math.pow(10, option('inputGainDb', 0) / 10)
812
+ this.inputPower = Math.pow(10, (typeof processorOptions.inputGainDb === 'number' ? processorOptions.inputGainDb : 0) / 10)
58
813
 
59
814
  this.coefficients = kWeighting(sampleRate)
60
815
  this.filterState = []
61
- this.blockLength = Math.round(sampleRate * BLOCK_SECONDS)
62
- this.blockFill = 0
63
- this.blockSum = 0
64
- // mean square of each block in the window, or -1 for a block the gate left out
65
- this.blocks = new Float64Array(Math.round(WINDOW_SECONDS / BLOCK_SECONDS)).fill(-1)
66
- this.blockIndex = 0
816
+ this.ring = new Float64Array(Math.max(1, Math.round(LIVE.window * sampleRate / QUANTUM)))
817
+ this.ringIndex = 0
818
+ this.ringSum = 0
819
+ this.ringFill = 0
820
+ this.blockQuanta = Math.round(BLOCK_SECONDS * sampleRate / QUANTUM)
821
+ this.quantaInBlock = 0
822
+ this.blockEnergy = 0
823
+ this.lastBlocks = new Float64Array(4)
824
+ this.lastBlockIndex = 0
825
+ this.hist = new Float64Array(HIST_BINS)
826
+ this.histTotal = 0
827
+ this.reference = 0
67
828
  this.desiredDb = 0
68
- this.gainDb = 0
829
+ this.liveGainDb = 0
69
830
  this.appliedGain = 1
70
- this.energy = new Float32Array(128)
831
+ this.sampleEnergy = new Float64Array(QUANTUM)
832
+ this.delay = Math.max(1, Math.round(LIVE.lookahead * sampleRate))
833
+ this.delaySeconds = this.delay / sampleRate
834
+ this.audioLines = []
835
+ this.gainLine = new Float32Array(this.delay).fill(1)
836
+ this.lineIndex = 0
837
+ this.shortRelease = 1 - Math.exp(-1 / (LIVE.shortRelease * sampleRate))
838
+ this.windowEnergy = new Float64Array(Math.max(1, Math.round(LIVE.shortWindow * sampleRate)))
839
+ this.windowSum = 0
840
+ this.windowIndex = 0
841
+ /*
842
+ * The cut each 10 ms window asks for goes through a sliding minimum over the ramp plus one sample, the release,
843
+ * then an average over that same span: every value in the span is that cut or deeper, so the cut is complete
844
+ * when the sample half a window before the window's end leaves the delay line. Complete only at the window's
845
+ * newest sample instead (a 5 ms delay), sharp onsets went 1.6 to 2.3 LU over the ceiling for about 5 ms (HOR-498
846
+ * arm C review, 2026-10-16).
847
+ */
848
+ this.shortSpan = Math.max(1, this.delay - Math.floor(this.windowEnergy.length / 2)) + 1
849
+ this.minValues = new Float64Array(this.shortSpan + 1)
850
+ this.minTimes = new Float64Array(this.shortSpan + 1)
851
+ this.minHead = 0
852
+ this.minLength = 0
853
+ this.average = new Float64Array(this.shortSpan)
854
+ this.averageSum = 0
855
+ this.averageIndex = 0
856
+ this.shortEnvelope = 0
857
+ this.shortTime = 0
858
+
859
+ this.blocks = new Map()
860
+ this.analysis = null
861
+ this.fingerFilter = fingerprintFilter(sampleRate)
862
+ this.fingerState = []
863
+ this.binFrames = sampleRate / BINS_PER_SECOND
864
+ this.binFill = 0
865
+ this.binSum = 0
866
+ this.binEdge = null
867
+ this.liveValues = new Float64Array(LIVE_BINS)
868
+ this.liveTimes = new Float64Array(LIVE_BINS)
869
+ this.binsSinceLock = 0
870
+ this.fingerEnergy = new Float64Array(QUANTUM)
871
+ this.aheadDb = 0
872
+ this.handover = 0
873
+ this.forgetTimeline()
71
874
 
72
875
  this.port.onmessage = ({ data }) => {
73
876
  if (typeof data?.enabled === 'boolean') this.enabled = data.enabled
74
877
  if (typeof data?.inputGainDb === 'number') this.inputPower = Math.pow(10, data.inputGainDb / 10)
75
878
  if (data?.reset === 'seek' || data?.reset === 'source') {
76
- this.blocks.fill(-1)
77
- this.blockSum = 0
78
- this.blockFill = 0
879
+ this.ring.fill(0)
880
+ this.ringSum = 0
881
+ this.ringFill = 0
882
+ if (this.offset !== null) this.doubt = true
79
883
  }
80
884
  if (data?.reset === 'source') {
885
+ this.hist.fill(0)
886
+ this.histTotal = 0
887
+ this.reference = 0
81
888
  this.desiredDb = 0
82
- this.gainDb = 0
889
+ this.liveGainDb = 0
890
+ this.minLength = 0
891
+ this.windowEnergy.fill(0)
892
+ this.windowSum = 0
893
+ this.average.fill(0)
894
+ this.averageSum = 0
895
+ this.shortEnvelope = 0
896
+ this.forgetTimeline()
897
+ }
898
+ if (typeof data?.media === 'number') this.pair(data)
899
+ if (data?.analysis !== undefined) {
900
+ this.analysis?.close()
901
+ this.blocks.clear()
902
+ this.search = null
903
+ this.analysis = data.analysis
904
+ if (this.analysis) this.analysis.onmessage = ({ data: block }) => this.receive(block)
83
905
  }
84
906
  }
85
907
  }
86
908
 
87
- closeBlock() {
88
- const meanSquare = this.blockSum / this.blockLength / this.inputPower
89
- this.blocks[this.blockIndex] = -0.691 + 10 * Math.log10(meanSquare) >= GATE_LUFS ? meanSquare : -1
90
- this.blockIndex = (this.blockIndex + 1) % this.blocks.length
91
- this.blockSum = 0
92
- this.blockFill = 0
909
+ forgetTimeline() {
910
+ // reference time = media time + offset, in seconds; null until the audio has been matched
911
+ this.offset = null
912
+ this.candidate = null
913
+ this.search = null
914
+ this.doubt = false
915
+ this.model = null
916
+ this.settling = []
917
+ this.liveCount = 0
918
+ }
93
919
 
94
- let sum = 0
95
- let active = 0
96
- for (const block of this.blocks) {
97
- if (block >= 0) { sum += block; active++ }
920
+ receive(message) {
921
+ if (typeof message?.drop === 'number') this.blocks.delete(message.drop)
922
+ else if (typeof message?.block === 'number') this.blocks.set(message.block, message)
923
+ this.lastBlock = undefined
924
+ }
925
+
926
+ pair({ media, ctx, rate, paused }) {
927
+ const frame = ctx * sampleRate
928
+ const model = this.model
929
+ if (paused) {
930
+ this.model = { paused: true, media: model && !model.paused ? this.mediaAt(frame) : media }
931
+ this.liveCount = 0
932
+ return
933
+ }
934
+ const predicted = model && !model.paused && model.rate === rate ? model.media + (frame - model.frame) * rate / sampleRate : NaN
935
+ if (!(Math.abs(media - predicted) <= 0.025)) {
936
+ this.model = { frame, media, rate }
937
+ this.settling = []
938
+ this.liveCount = 0
939
+ this.search = null
940
+ return
941
+ }
942
+ if (this.settling) {
943
+ this.settling.push(media - predicted)
944
+ if (this.settling.length === 5) {
945
+ const sorted = this.settling.sort((a, b) => a - b)
946
+ model.media += sorted[2]
947
+ this.settling = null
948
+ this.liveCount = 0
949
+ }
950
+ }
951
+ }
952
+
953
+ mediaAt(frame) {
954
+ const m = this.model
955
+ return m.paused ? m.media : m.media + (frame - m.frame) * m.rate / sampleRate
956
+ }
957
+
958
+ // the block holding bin i, cached, since a quantum reads the same one over and over
959
+ blockOf(i) {
960
+ const index = Math.floor(i / BLOCK_BINS)
961
+ if (this.lastBlock?.block !== index) this.lastBlock = this.blocks.get(index) ?? { block: index }
962
+ return this.lastBlock
963
+ }
964
+
965
+ gainBin(i) {
966
+ const block = this.blockOf(i)
967
+ return block.gain ? block.gain[i - block.block * BLOCK_BINS] : NaN
968
+ }
969
+
970
+ // the cut at reference time t (s), linear between bin centres; NaN where it is not known
971
+ cutAt(t) {
972
+ const x = t * BINS_PER_SECOND - 0.5, i = Math.floor(x)
973
+ const a = this.gainBin(i), b = this.gainBin(i + 1)
974
+ return a + (b - a) * (x - i)
975
+ }
976
+
977
+ // the deepest cut over [from, to] s; NaN unless it is known at both ends
978
+ deepest(from, to) {
979
+ const first = Math.floor(from * BINS_PER_SECOND), last = Math.ceil(to * BINS_PER_SECOND)
980
+ if (!(this.gainBin(first) <= 0) || !(this.gainBin(last) <= 0)) return NaN
981
+ let m = 0
982
+ for (let i = first; i <= last; i++) {
983
+ const v = this.gainBin(i)
984
+ if (v < m) m = v
985
+ }
986
+ return m
987
+ }
988
+
989
+ /*
990
+ * Starts a search for the offset that best matches the live fingerprint to the analysed one, +-`range` ms around
991
+ * `center` (s), on a copy of the live bins taken now; `step` scores it a few offsets at a time.
992
+ */
993
+ startSearch(center, range) {
994
+ // the first match looks at 1 s, the ones after at the last 0.5 s, so a stall is put right within about 0.5 s
995
+ const n = Math.min(this.liveCount, this.offset === null ? 1000 : 500)
996
+ const values = new Float64Array(n), times = new Float64Array(n)
997
+ for (let k = 0; k < n; k++) {
998
+ const at = (this.liveCount - n + k) % LIVE_BINS
999
+ values[k] = Math.log(this.liveValues[at] + EPSILON)
1000
+ times[k] = this.liveTimes[at]
1001
+ }
1002
+ let mean = 0
1003
+ for (const v of values) mean += v
1004
+ mean /= n
1005
+ let spread = 0
1006
+ for (const v of values) spread += (v - mean) ** 2
1007
+ // nearly steady audio (or silence) says nothing about where it is
1008
+ if (n < 500 || spread / n < 1) return
1009
+ // the analysed fingerprint over every bin the search can reach, copied out of the blocks once
1010
+ const first = Math.floor((times[0] + center) * BINS_PER_SECOND) - range - 2
1011
+ const ref = new Float64Array(Math.ceil((times[n - 1] - times[0]) * BINS_PER_SECOND) + 2 * range + 6)
1012
+ for (let k = 0; k < ref.length; k++) {
1013
+ const block = this.blockOf(first + k)
1014
+ ref[k] = block.finger ? block.finger[first + k - block.block * BLOCK_BINS] : NaN
1015
+ }
1016
+ this.search = { center, range, n, values, times, first, ref, scores: new Float64Array(2 * range + 1), next: 0 }
1017
+ }
1018
+
1019
+ /*
1020
+ * Matched on the change from one bin to the next, not the level: the level's slow swings correlate at any nearby
1021
+ * shift (0.94 at 5 ms off on synthetic dialogue, against 0.996 in place), its changes do not.
1022
+ */
1023
+ score(shiftMs) {
1024
+ const { center, n, values, times, first, ref } = this.search
1025
+ let sx = 0, sy = 0, sxx = 0, syy = 0, sxy = 0, m = 0, previous = NaN
1026
+ for (let k = 0; k < n; k++) {
1027
+ const x = (times[k] + center) * BINS_PER_SECOND + shiftMs - 0.5 - first, i = Math.floor(x)
1028
+ const r0 = ref[i], r1 = ref[i + 1]
1029
+ if (!(r0 === r0 && r1 === r1)) { previous = NaN; continue }
1030
+ const y = r0 + (r1 - r0) * (x - i)
1031
+ if (k > 0 && previous === previous) {
1032
+ const v = values[k] - values[k - 1], w = y - previous
1033
+ sx += v; sy += w; sxx += v * v; syy += w * w; sxy += v * w; m++
1034
+ }
1035
+ previous = y
1036
+ }
1037
+ if (m < n / 2) return -1
1038
+ const cov = sxy - sx * sy / m, vx = sxx - sx * sx / m, vy = syy - sy * sy / m
1039
+ return vx > 0 && vy > 0 ? cov / Math.sqrt(vx * vy) : -1
1040
+ }
1041
+
1042
+ step() {
1043
+ const search = this.search
1044
+ const { range, scores } = search
1045
+ for (let k = 0; k < SCORES_PER_QUANTUM / search.n && search.next < scores.length; k++, search.next++) {
1046
+ scores[search.next] = this.score(search.next - range)
1047
+ }
1048
+ if (search.next < scores.length) return
1049
+ this.search = null
1050
+ let best = 0
1051
+ for (let k = 1; k < scores.length; k++) if (scores[k] > scores[best]) best = k
1052
+ let runnerUp = -1
1053
+ for (let k = Math.max(0, best - CLEAR_MS); k <= Math.min(scores.length - 1, best + CLEAR_MS); k++) {
1054
+ if (Math.abs(k - best) > 3 && scores[k] > runnerUp) runnerUp = scores[k]
1055
+ }
1056
+ if (scores[best] < 0.4 || best === 0 || best === scores.length - 1) return
1057
+ const [a, b, c] = [scores[best - 1], scores[best], scores[best + 1]]
1058
+ const fraction = a - 2 * b + c < 0 ? 0.5 * (a - c) / (a - 2 * b + c) : 0
1059
+ this.found(search.center + (best - range + fraction) / BINS_PER_SECOND, scores[best] - runnerUp >= 0.15)
1060
+ }
1061
+
1062
+ /*
1063
+ * A clear match, the best by 0.15 over every offset 3 to 60 ms from it, that moves the offset by more than 2 ms, or
1064
+ * finds it at all, is taken only once the next search, 250 ms later, peaks within 2 ms of it, clear or not: voiced
1065
+ * dialogue repeats with its pitch, so a peak a pitch period off can come out ahead, and a FLAC run once matched 45 ms
1066
+ * off with every check passed. Over the whole 250 ms either way an Opus file's matches were never that clear (HOR-498
1067
+ * build, 2026-10-17).
1068
+ */
1069
+ found(offset, clear) {
1070
+ const near = (other) => other !== null && Math.abs(offset - other) <= 0.002
1071
+ if (near(this.candidate)) {
1072
+ this.offset = offset
1073
+ this.doubt = false
1074
+ this.candidate = null
1075
+ } else if (clear) {
1076
+ if (!this.doubt && near(this.offset)) this.offset = offset
1077
+ else this.candidate = offset
1078
+ }
1079
+ }
1080
+
1081
+ // the live stage's learned level, from 400 ms readings
1082
+ learn(level) {
1083
+ if (!(level >= LEARN_GATE_LUFS)) return
1084
+ const bin = Math.max(0, Math.min(HIST_BINS - 1, Math.floor((level - HIST_MIN) / HIST_STEP)))
1085
+ this.hist[bin]++
1086
+ this.histTotal++
1087
+ if (this.histTotal < LIVE.minBlocks) return
1088
+ const wanted = 0.5 * this.histTotal
1089
+ let seen = 0
1090
+ for (let i = 0; i < HIST_BINS; i++) {
1091
+ seen += this.hist[i]
1092
+ if (seen >= wanted) {
1093
+ this.reference = Math.max(LIVE.floor, Math.min(0, HIST_MIN + (i + 0.5) * HIST_STEP))
1094
+ return
1095
+ }
98
1096
  }
99
- // fewer than three blocks with anything in them is a pause, and the gain holds where it is
100
- if (active < 3) return
101
- const level = -0.691 + 10 * Math.log10(sum / active)
102
- this.desiredDb = Math.max(-MAX_CUT_DB, Math.min(0, this.target - level))
103
1097
  }
104
1098
 
105
1099
  process(inputs, outputs) {
@@ -107,56 +1101,184 @@ class VolumeNormalizer extends AudioWorkletProcessor {
107
1101
  const output = outputs[0]
108
1102
  const frames = output[0].length
109
1103
  const channels = input.length
1104
+ // the context's frame, never a count of process() calls: Chrome left out 0 to 2560 frames of calls while the
1105
+ // page built its graph (29 loads, headless Chrome 155), and a count put the timeline that far behind the clock
1106
+ // pairs; past about 3000 frames the first match cannot find the offset at all (HOR-498 review, 2026-10-16)
1107
+ const start = currentFrame
1108
+ this.binEdge ??= start + this.binFrames
110
1109
  if (channels === 0) {
111
1110
  for (const channel of output) channel.fill(0)
112
1111
  return true
113
1112
  }
1113
+ if (this.sampleEnergy.length < frames) {
1114
+ this.sampleEnergy = new Float64Array(frames)
1115
+ this.fingerEnergy = new Float64Array(frames)
1116
+ }
114
1117
 
115
- if (this.energy.length < frames) this.energy = new Float32Array(frames)
116
- const energy = this.energy
117
- energy.fill(0, 0, frames)
118
1118
  const [b0, b1, b2, a1, a2] = this.coefficients.shelf
119
1119
  const [c0, c1, c2, d1, d2] = this.coefficients.highpass
1120
+ const [h0, h1, h2, g1, g2] = this.fingerFilter
1121
+ const sampleEnergy = this.sampleEnergy, fingerEnergy = this.fingerEnergy
1122
+ sampleEnergy.fill(0, 0, frames)
1123
+ fingerEnergy.fill(0, 0, frames)
1124
+ let quantumEnergy = 0
120
1125
  for (let c = 0; c < channels; c++) {
121
1126
  const weight = channelWeight(c, channels)
122
- if (!this.filterState[c]) this.filterState[c] = new Float64Array(4)
123
- if (weight === 0) continue
124
- const state = this.filterState[c]
125
- let s1 = state[0], s2 = state[1], t1 = state[2], t2 = state[3]
1127
+ const state = this.filterState[c] ??= new Float64Array(4)
1128
+ const finger = this.fingerState[c] ??= new Float64Array(2)
1129
+ let s1 = state[0], s2 = state[1], t1 = state[2], t2 = state[3], u1 = finger[0], u2 = finger[1]
126
1130
  const x = input[c]
127
1131
  for (let i = 0; i < frames; i++) {
128
1132
  const v = x[i]
129
- const y = b0 * v + s1
130
- s1 = b1 * v - a1 * y + s2
131
- s2 = b2 * v - a2 * y
132
- const z = c0 * y + t1
133
- t1 = c1 * y - d1 * z + t2
134
- t2 = c2 * y - d2 * z
135
- energy[i] += weight * z * z
1133
+ if (weight) {
1134
+ const y = b0 * v + s1
1135
+ s1 = b1 * v - a1 * y + s2
1136
+ s2 = b2 * v - a2 * y
1137
+ const z = c0 * y + t1
1138
+ t1 = c1 * y - d1 * z + t2
1139
+ t2 = c2 * y - d2 * z
1140
+ quantumEnergy += weight * z * z
1141
+ sampleEnergy[i] += weight * z * z
1142
+ }
1143
+ const q = h0 * v + u1
1144
+ u1 = h1 * v - g1 * q + u2
1145
+ u2 = h2 * v - g2 * q
1146
+ fingerEnergy[i] += q * q
136
1147
  }
137
- state[0] = s1; state[1] = s2; state[2] = t1; state[3] = t2
138
- }
139
- for (let i = 0; i < frames; i++) {
140
- this.blockSum += energy[i]
141
- if (++this.blockFill === this.blockLength) this.closeBlock()
1148
+ state[0] = s1; state[1] = s2; state[2] = t1; state[3] = t2; finger[0] = u1; finger[1] = u2
142
1149
  }
143
1150
 
1151
+ this.ringSum += quantumEnergy - this.ring[this.ringIndex]
1152
+ this.ring[this.ringIndex] = quantumEnergy
1153
+ this.ringIndex = (this.ringIndex + 1) % this.ring.length
1154
+ if (this.ringFill < this.ring.length) this.ringFill++
1155
+ // float drift from the running sum
1156
+ if (this.ringIndex === 0) this.ringSum = this.ring.reduce((a, b) => a + b, 0)
1157
+ const level = this.ringFill < this.ring.length ? -Infinity : -0.691 + 10 * Math.log10(this.ringSum / (this.ring.length * QUANTUM) / this.inputPower)
1158
+ this.blockEnergy += quantumEnergy
1159
+ if (++this.quantaInBlock === this.blockQuanta) {
1160
+ this.quantaInBlock = 0
1161
+ this.lastBlocks[this.lastBlockIndex] = this.blockEnergy / (this.blockQuanta * QUANTUM)
1162
+ this.lastBlockIndex = (this.lastBlockIndex + 1) % 4
1163
+ this.blockEnergy = 0
1164
+ const lb = this.lastBlocks
1165
+ this.learn(-0.691 + 10 * Math.log10((lb[0] + lb[1] + lb[2] + lb[3]) / 4 / this.inputPower))
1166
+ }
1167
+ // below the gate (a pause) the gain holds
1168
+ if (level >= LEARN_GATE_LUFS) {
1169
+ this.desiredDb = Math.max(-LIVE.maxCut, Math.min(0, this.reference + LIVE.headroom - level))
1170
+ }
144
1171
  const seconds = frames / sampleRate
145
- const tau = this.desiredDb < this.gainDb ? ATTACK_SECONDS : RELEASE_SECONDS
146
- this.gainDb += (this.desiredDb - this.gainDb) * (1 - Math.exp(-seconds / tau))
147
- const crossfade = seconds / 0.05
148
- this.mix += Math.max(-crossfade, Math.min(crossfade, (this.enabled ? 1 : 0) - this.mix))
1172
+ const tau = this.desiredDb < this.liveGainDb ? LIVE.attack : LIVE.release
1173
+ this.liveGainDb += (this.desiredDb - this.liveGainDb) * (1 - Math.exp(-seconds / tau))
149
1174
  const from = this.appliedGain
150
- const to = Math.pow(10, this.gainDb * this.mix / 20)
1175
+ const to = Math.pow(10, this.liveGainDb / 20)
151
1176
  this.appliedGain = to
152
1177
  const step = (to - from) / frames
153
- for (let c = 0; c < output.length; c++) {
154
- const x = input[c] ?? input[0]
155
- const y = output[c]
156
- for (let i = 0; i < frames; i++) y[i] = x[i] * (from + step * (i + 1))
1178
+
1179
+ // the live fingerprint, 1 ms bins of context time, each stamped with the media time at its centre
1180
+ const model = this.model
1181
+ const live = model && !model.paused && this.settling === null && model.rate === 1
1182
+ for (let i = 0; i < frames; i++) {
1183
+ this.binSum += fingerEnergy[i]
1184
+ this.binFill++
1185
+ if (start + i + 1 >= this.binEdge) {
1186
+ if (live) {
1187
+ const k = this.liveCount++ % LIVE_BINS
1188
+ this.liveValues[k] = this.binSum / this.binFill
1189
+ this.liveTimes[k] = this.mediaAt(start + i + 1 - this.binFill / 2)
1190
+ this.binsSinceLock++
1191
+ }
1192
+ this.binSum = 0
1193
+ this.binFill = 0
1194
+ this.binEdge += this.binFrames
1195
+ }
1196
+ }
1197
+ if (this.search) this.step()
1198
+ else if (live && this.blocks.size && this.binsSinceLock >= 250 && (this.offset !== null || this.liveCount >= 1000)) {
1199
+ this.binsSinceLock = 0
1200
+ this.startSearch(this.offset ?? 0, this.offset === null || this.doubt ? LOCK_RANGE_MS : RELOCK_RANGE_MS)
1201
+ }
1202
+
1203
+ // the ahead stage for this quantum's output, which left the input `delay` frames ago: exact at normal speed
1204
+ // once matched, otherwise the deepest cut within the doubt
1205
+ let exact = false, held = NaN
1206
+ if (this.offset !== null && model) {
1207
+ const m0 = this.mediaAt(start - this.delay) + this.offset, m1 = this.mediaAt(start + frames - this.delay) + this.offset
1208
+ if (!this.doubt && this.settling === null && (model.paused || model.rate === 1)) {
1209
+ exact = this.cutAt(m0) <= 0 && this.cutAt(m1) <= 0
1210
+ } else {
1211
+ const slack = this.doubt ? DOUBT_MS / BINS_PER_SECOND : model.rate === 1 ? 0.01 : 0.03
1212
+ held = this.deepest(Math.min(m0, m1) - slack, Math.max(m0, m1) + slack)
1213
+ }
1214
+ }
1215
+ const ahead = exact || held <= 0
1216
+
1217
+ const delay = this.delay
1218
+ const size = this.minValues.length
1219
+ const window = this.windowEnergy.length
1220
+ // the 10 ms window's energy sum at the ceiling
1221
+ const limit = window * this.inputPower * Math.pow(10, (this.reference + LIVE.shortCeiling + 0.691) / 10)
1222
+ for (let c = 0; c < output.length; c++) this.audioLines[c] ??= new Float32Array(delay)
1223
+ const handoverStep = 1 / (HANDOVER_SECONDS * sampleRate)
1224
+ const crossfadeStep = 1 / (CROSSFADE_SECONDS * sampleRate)
1225
+ const target = this.enabled ? 1 : 0
1226
+ for (let i = 0; i < frames; i++) {
1227
+ const gain = from + step * (i + 1)
1228
+ // the live stage's 10 ms detector on the 100 ms stage's output: the gain moves slowly enough to apply after
1229
+ // the filter
1230
+ const energy = sampleEnergy[i] * gain * gain
1231
+ this.windowSum += energy - this.windowEnergy[this.windowIndex]
1232
+ this.windowEnergy[this.windowIndex] = energy
1233
+ this.windowIndex = (this.windowIndex + 1) % window
1234
+ if (this.windowIndex === 0) this.windowSum = this.windowEnergy.reduce((a, b) => a + b, 0)
1235
+ const wanted = this.windowSum > limit ? 10 * Math.log10(limit / this.windowSum) : 0
1236
+ const time = this.shortTime++
1237
+ while (this.minLength > 0 && this.minValues[(this.minHead + this.minLength - 1) % size] >= wanted) this.minLength--
1238
+ const tail = (this.minHead + this.minLength) % size
1239
+ this.minValues[tail] = wanted
1240
+ this.minTimes[tail] = time
1241
+ this.minLength++
1242
+ while (this.minTimes[this.minHead] <= time - this.shortSpan) {
1243
+ this.minHead = (this.minHead + 1) % size
1244
+ this.minLength--
1245
+ }
1246
+ const lowest = this.minValues[this.minHead]
1247
+ this.shortEnvelope = lowest < this.shortEnvelope ? lowest : this.shortEnvelope + (lowest - this.shortEnvelope) * this.shortRelease
1248
+ this.averageSum += this.shortEnvelope - this.average[this.averageIndex]
1249
+ this.average[this.averageIndex] = this.shortEnvelope
1250
+ this.averageIndex = (this.averageIndex + 1) % this.shortSpan
1251
+ if (this.averageIndex === 0) this.averageSum = this.average.reduce((a, b) => a + b, 0)
1252
+
1253
+ const at = this.lineIndex
1254
+ const liveDb = 20 * Math.log10(this.gainLine[at]) + Math.max(-LIVE.maxCut, Math.min(0, this.averageSum / this.shortSpan))
1255
+ this.gainLine[at] = gain
1256
+
1257
+ if (ahead) {
1258
+ // at most 0.2 dB a sample, so a change of mode cannot click; the cut itself moves slower
1259
+ let wantedDb = exact ? this.cutAt(this.mediaAt(start + i - delay) + this.offset) : held
1260
+ if (!(wantedDb <= 0)) wantedDb = this.aheadDb
1261
+ if (wantedDb > this.aheadDb + 0.2) wantedDb = this.aheadDb + 0.2
1262
+ else if (wantedDb < this.aheadDb - 0.2) wantedDb = this.aheadDb - 0.2
1263
+ this.aheadDb = wantedDb
1264
+ this.handover = Math.min(1, this.handover + handoverStep)
1265
+ } else {
1266
+ this.handover = Math.max(0, this.handover - handoverStep)
1267
+ if (this.handover === 0) this.aheadDb = liveDb
1268
+ }
1269
+ this.mix += Math.max(-crossfadeStep, Math.min(crossfadeStep, target - this.mix))
1270
+ const db = (liveDb + (this.aheadDb - liveDb) * this.handover) * this.mix
1271
+ const scale = db < 0 ? Math.pow(10, db / 20) : 1
1272
+ for (let c = 0; c < output.length; c++) {
1273
+ const line = this.audioLines[c]
1274
+ output[c][i] = line[at] * scale
1275
+ line[at] = (input[c] ?? input[0])[i]
1276
+ }
1277
+ this.lineIndex = (at + 1) % delay
157
1278
  }
158
1279
  return true
159
1280
  }
160
1281
  }
161
1282
 
162
- registerProcessor('banou-volume-normalizer', VolumeNormalizer)
1283
+ if (typeof registerProcessor === 'function') registerProcessor('banou-volume-normalizer', defineProcessor())
1284
+ else if (typeof WorkerGlobalScope === 'function' && globalThis instanceof WorkerGlobalScope) serve(globalThis)