@banou/media-player 0.10.20 → 0.10.21

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,1027 @@ 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, decoded with WebCodecs.
633
+ *
634
+ * { type: 'port', port } the worklet's end of a MessageChannel, for the blocks
635
+ * { type: 'init', data, codec } the init segment and the codec string MediaSource was given
636
+ * { type: 'run', run, pts, start } a run of fragments starts, `pts` added to their times as the SourceBuffer's
637
+ * timestampOffset adds it for playback; `start` for the one from the start
638
+ * { type: 'fragment', run, data } answered { type: 'ack', run, spans, end, normal } once its audio is queued
639
+ * for decoding: the analysed seconds, the file's end once known, and the
640
+ * level learned so far in LUFS
641
+ * { type: 'end', run, finished } the run is over, answered { type: 'ended', run, spans, end, normal }
642
+ * { type: 'playhead', time }
643
+ *
644
+ * and posts { type: 'merged', run, spans, end, normal } when a run has met audio an earlier one analysed, and
645
+ * { type: 'unsupported' } when this browser's WebCodecs cannot decode the track.
646
+ */
647
+ const serve = (scope) => {
648
+ let port
649
+ let track
650
+ let codec
651
+ let decoder
652
+ let runId = -1
653
+ let pts = 0
654
+ const analysis = createAnalysis({
655
+ toWorklet: (message, transfer) => port?.postMessage(message, transfer),
656
+ toPage: (message) => scope.postMessage(message),
657
+ })
658
+ const unsupported = () => { track = undefined; scope.postMessage({ type: 'unsupported' }) }
659
+ const queue = () => new Promise((resolve) => {
660
+ const check = () => {
661
+ if (!decoder || decoder.state !== 'configured' || decoder.decodeQueueSize < 32) {
662
+ decoder?.removeEventListener('dequeue', check)
663
+ resolve()
664
+ }
665
+ }
666
+ decoder.addEventListener('dequeue', check)
667
+ check()
668
+ })
669
+ /*
670
+ * Each output is placed by its own chunk's timestamp, never the one AudioData carries: Chrome 155 stamps outputs by
671
+ * counting samples from the first chunk, and libav's first audio sample in a run lasts until the video's first frame
672
+ * is due (62 ms more than its own length in Matroska and mp4 with B-frames, 82 ms in FLAC), which MediaSource plays
673
+ * as a gap; counted, what follows came out that much early, too far for the first match to find (HOR-498 build,
674
+ * 2026-10-17).
675
+ */
676
+ let stamps = []
677
+ const open = (id) => {
678
+ if (decoder && decoder.state !== 'closed') decoder.close()
679
+ stamps = []
680
+ const mine = new AudioDecoder({
681
+ output: (data) => {
682
+ if (id !== runId) return data.close()
683
+ const planes = []
684
+ for (let c = 0; c < data.numberOfChannels; c++) {
685
+ const plane = new Float32Array(data.numberOfFrames)
686
+ data.copyTo(plane, { planeIndex: c, format: 'f32-planar' })
687
+ planes.push(plane)
688
+ }
689
+ analysis.push(planes, data.numberOfFrames, data.sampleRate, stamps.shift() ?? data.timestamp / 1e6)
690
+ data.close()
691
+ },
692
+ error: () => { if (id === runId) unsupported() },
693
+ })
694
+ mine.configure({ codec, sampleRate: track.sampleRate, numberOfChannels: track.channels, ...(track.description ? { description: track.description } : {}) })
695
+ decoder = mine
696
+ }
697
+ const handle = async (data) => {
698
+ if (data.type === 'init') {
699
+ codec = data.codec
700
+ const found = audioTrackOf(data.data)
701
+ const config = found && { codec, sampleRate: found.sampleRate, numberOfChannels: found.channels, ...(found.description ? { description: found.description } : {}) }
702
+ const support = config && typeof AudioDecoder === 'function' && await AudioDecoder.isConfigSupported(config).catch(() => undefined)
703
+ if (support?.supported) track = found
704
+ else unsupported()
705
+ } else if (!track) return
706
+ else if (data.type === 'run') {
707
+ runId = data.run
708
+ pts = data.pts
709
+ open(runId)
710
+ analysis.startRun(runId, pts, data.start)
711
+ } else if (data.type === 'fragment') {
712
+ if (data.run !== runId || decoder.state !== 'configured') return
713
+ for (const sample of audioSamplesOf(data.data, track)) {
714
+ const time = pts + sample.time / track.timescale
715
+ stamps.push(time)
716
+ decoder.decode(new EncodedAudioChunk({
717
+ type: 'key',
718
+ timestamp: Math.round(time * 1e6),
719
+ duration: Math.round(sample.duration / track.timescale * 1e6),
720
+ data: sample.data,
721
+ }))
722
+ }
723
+ await queue()
724
+ scope.postMessage({ type: 'ack', run: data.run, ...analysis.coverage() })
725
+ } else if (data.type === 'end') {
726
+ if (data.run !== runId) return
727
+ const ending = decoder
728
+ if (ending.state === 'configured') await ending.flush().catch(() => {})
729
+ if (data.run !== runId) return
730
+ if (ending.state === 'configured') ending.close()
731
+ analysis.endRun(data.finished)
732
+ scope.postMessage({ type: 'ended', run: data.run, ...analysis.coverage() })
733
+ }
734
+ }
735
+ // in order, each after the last is done, except what moves no audio
736
+ let chain = Promise.resolve()
737
+ scope.onmessage = ({ data }) => {
738
+ if (data.type === 'port') port = data.port
739
+ else if (data.type === 'playhead') analysis.setPlayhead(data.time)
740
+ else chain = chain.then(() => handle(data)).catch(() => {})
741
+ }
742
+ }
743
+
744
+ // --- the worklet's side ---
745
+
746
+ const LIVE_BINS = 2048
747
+ const EPSILON = 1e-10
748
+ // the live stage's settings (HOR-498 arm C)
749
+ const LIVE = {
750
+ headroom: 5, floor: -26, minBlocks: 50, maxCut: 20, attack: 0.005, release: 0.3, window: 0.1,
751
+ shortCeiling: 10, shortWindow: 0.01, lookahead: 0.01, shortRelease: 0.15,
752
+ }
753
+ const BLOCK_SECONDS = 0.1
754
+ const LEARN_GATE_LUFS = -50
755
+ const HIST_MIN = -70
756
+ const HIST_STEP = 0.5
757
+ const HIST_BINS = 140
758
+ const QUANTUM = 128
759
+ // how fast the ahead stage takes over from the live one, and the switch engages or bypasses
760
+ const HANDOVER_SECONDS = 0.05
761
+ const CROSSFADE_SECONDS = 0.05
762
+ /*
763
+ * How far either way the live audio is searched for in the analysed audio, in ms: first, and after a seek, against
764
+ * 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
765
+ * build, 2026-10-17), as long as libav's first audio sample stretches to the video's first frame, which B-frames set.
766
+ */
767
+ const LOCK_RANGE_MS = 250
768
+ const RELOCK_RANGE_MS = 30
769
+ // 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
770
+ // across the first remuxer seek, from the stream's start, where MediaSource plays libav's first sample short
771
+ const DOUBT_MS = 100
772
+ // how far from the best offset another must come close for the match to be in question, in ms
773
+ const CLEAR_MS = 60
774
+ // a search scores this many bins against offsets in a render quantum, about 0.2 ms of a 2.7 ms one
775
+ const SCORES_PER_QUANTUM = 40_000
776
+
777
+ /*
778
+ * Port messages: `{ enabled }` engages or bypasses with a 50 ms crossfade; `{ inputGainDb }` is the gain already
779
+ * applied upstream (the element's volume), judged without; `{ reset: 'seek' }` empties the live stage's window and
780
+ * holds the ahead stage's alignment in doubt until the audio is matched again; `{ reset: 'source' }` forgets
781
+ * everything heard; `{ media, ctx, rate, paused }` is the main thread's video.currentTime read together with
782
+ * context.currentTime; `{ analysis }` is the port the analysis sends its blocks on, and replaces their sender.
783
+ *
784
+ * Timing, measured in headless Chrome 155 (HOR-498 arm D, 2026-10-16): a pair of video.currentTime and
785
+ * context.currentTime read in one task names the media time of the audio reaching a worklet that same render frame,
786
+ * 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
787
+ * 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
788
+ * playback's audio sits against the analysed audio changes with each run of the remuxer behind it: -18 to +44 ms on
789
+ * the fixtures, the same file 44 ms apart before and after a seek (HOR-498 build, 2026-10-17). So the offset is found
790
+ * from the audio itself: the live fingerprint against the analysed one, every 250 ms at normal speed, to a fraction of
791
+ * a millisecond. The same match catches the rare stall (once, 60 ms of silence came in mid-file and the audio after it
792
+ * ran 11 ms ahead of the clock). Nothing is cut ahead until the first match, which also proves the blocks belong to
793
+ * this audio; after a seek, until the next one, each quantum gets the deepest cut within the doubt, never less than
794
+ * the cut asks, as it does away from normal speed (Chrome's time stretching moves the audio up to about 30 ms either
795
+ * way).
796
+ */
797
+ const defineProcessor = () => class VolumeNormalizer extends AudioWorkletProcessor {
51
798
  constructor({ processorOptions = {} } = {}) {
52
799
  super()
53
- const option = (key, fallback) => typeof processorOptions[key] === 'number' ? processorOptions[key] : fallback
54
- this.target = option('targetLufs', -24)
55
800
  this.enabled = processorOptions.enabled !== false
56
801
  this.mix = this.enabled ? 1 : 0
57
- this.inputPower = Math.pow(10, option('inputGainDb', 0) / 10)
802
+ this.inputPower = Math.pow(10, (typeof processorOptions.inputGainDb === 'number' ? processorOptions.inputGainDb : 0) / 10)
58
803
 
59
804
  this.coefficients = kWeighting(sampleRate)
60
805
  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
806
+ this.ring = new Float64Array(Math.max(1, Math.round(LIVE.window * sampleRate / QUANTUM)))
807
+ this.ringIndex = 0
808
+ this.ringSum = 0
809
+ this.ringFill = 0
810
+ this.blockQuanta = Math.round(BLOCK_SECONDS * sampleRate / QUANTUM)
811
+ this.quantaInBlock = 0
812
+ this.blockEnergy = 0
813
+ this.lastBlocks = new Float64Array(4)
814
+ this.lastBlockIndex = 0
815
+ this.hist = new Float64Array(HIST_BINS)
816
+ this.histTotal = 0
817
+ this.reference = 0
67
818
  this.desiredDb = 0
68
- this.gainDb = 0
819
+ this.liveGainDb = 0
69
820
  this.appliedGain = 1
70
- this.energy = new Float32Array(128)
821
+ this.sampleEnergy = new Float64Array(QUANTUM)
822
+ this.delay = Math.max(1, Math.round(LIVE.lookahead * sampleRate))
823
+ this.delaySeconds = this.delay / sampleRate
824
+ this.audioLines = []
825
+ this.gainLine = new Float32Array(this.delay).fill(1)
826
+ this.lineIndex = 0
827
+ this.shortRelease = 1 - Math.exp(-1 / (LIVE.shortRelease * sampleRate))
828
+ this.windowEnergy = new Float64Array(Math.max(1, Math.round(LIVE.shortWindow * sampleRate)))
829
+ this.windowSum = 0
830
+ this.windowIndex = 0
831
+ /*
832
+ * The cut each 10 ms window asks for goes through a sliding minimum over the ramp plus one sample, the release,
833
+ * then an average over that same span: every value in the span is that cut or deeper, so the cut is complete
834
+ * when the sample half a window before the window's end leaves the delay line. Complete only at the window's
835
+ * 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
836
+ * arm C review, 2026-10-16).
837
+ */
838
+ this.shortSpan = Math.max(1, this.delay - Math.floor(this.windowEnergy.length / 2)) + 1
839
+ this.minValues = new Float64Array(this.shortSpan + 1)
840
+ this.minTimes = new Float64Array(this.shortSpan + 1)
841
+ this.minHead = 0
842
+ this.minLength = 0
843
+ this.average = new Float64Array(this.shortSpan)
844
+ this.averageSum = 0
845
+ this.averageIndex = 0
846
+ this.shortEnvelope = 0
847
+ this.shortTime = 0
848
+
849
+ this.blocks = new Map()
850
+ this.analysis = null
851
+ this.fingerFilter = fingerprintFilter(sampleRate)
852
+ this.fingerState = []
853
+ this.binFrames = sampleRate / BINS_PER_SECOND
854
+ this.binFill = 0
855
+ this.binSum = 0
856
+ this.binEdge = null
857
+ this.liveValues = new Float64Array(LIVE_BINS)
858
+ this.liveTimes = new Float64Array(LIVE_BINS)
859
+ this.binsSinceLock = 0
860
+ this.fingerEnergy = new Float64Array(QUANTUM)
861
+ this.aheadDb = 0
862
+ this.handover = 0
863
+ this.forgetTimeline()
71
864
 
72
865
  this.port.onmessage = ({ data }) => {
73
866
  if (typeof data?.enabled === 'boolean') this.enabled = data.enabled
74
867
  if (typeof data?.inputGainDb === 'number') this.inputPower = Math.pow(10, data.inputGainDb / 10)
75
868
  if (data?.reset === 'seek' || data?.reset === 'source') {
76
- this.blocks.fill(-1)
77
- this.blockSum = 0
78
- this.blockFill = 0
869
+ this.ring.fill(0)
870
+ this.ringSum = 0
871
+ this.ringFill = 0
872
+ if (this.offset !== null) this.doubt = true
79
873
  }
80
874
  if (data?.reset === 'source') {
875
+ this.hist.fill(0)
876
+ this.histTotal = 0
877
+ this.reference = 0
81
878
  this.desiredDb = 0
82
- this.gainDb = 0
879
+ this.liveGainDb = 0
880
+ this.minLength = 0
881
+ this.windowEnergy.fill(0)
882
+ this.windowSum = 0
883
+ this.average.fill(0)
884
+ this.averageSum = 0
885
+ this.shortEnvelope = 0
886
+ this.forgetTimeline()
887
+ }
888
+ if (typeof data?.media === 'number') this.pair(data)
889
+ if (data?.analysis !== undefined) {
890
+ this.analysis?.close()
891
+ this.blocks.clear()
892
+ this.search = null
893
+ this.analysis = data.analysis
894
+ if (this.analysis) this.analysis.onmessage = ({ data: block }) => this.receive(block)
83
895
  }
84
896
  }
85
897
  }
86
898
 
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
899
+ forgetTimeline() {
900
+ // reference time = media time + offset, in seconds; null until the audio has been matched
901
+ this.offset = null
902
+ this.candidate = null
903
+ this.search = null
904
+ this.doubt = false
905
+ this.model = null
906
+ this.settling = []
907
+ this.liveCount = 0
908
+ }
93
909
 
94
- let sum = 0
95
- let active = 0
96
- for (const block of this.blocks) {
97
- if (block >= 0) { sum += block; active++ }
910
+ receive(message) {
911
+ if (typeof message?.drop === 'number') this.blocks.delete(message.drop)
912
+ else if (typeof message?.block === 'number') this.blocks.set(message.block, message)
913
+ this.lastBlock = undefined
914
+ }
915
+
916
+ pair({ media, ctx, rate, paused }) {
917
+ const frame = ctx * sampleRate
918
+ const model = this.model
919
+ if (paused) {
920
+ this.model = { paused: true, media: model && !model.paused ? this.mediaAt(frame) : media }
921
+ this.liveCount = 0
922
+ return
923
+ }
924
+ const predicted = model && !model.paused && model.rate === rate ? model.media + (frame - model.frame) * rate / sampleRate : NaN
925
+ if (!(Math.abs(media - predicted) <= 0.025)) {
926
+ this.model = { frame, media, rate }
927
+ this.settling = []
928
+ this.liveCount = 0
929
+ this.search = null
930
+ return
931
+ }
932
+ if (this.settling) {
933
+ this.settling.push(media - predicted)
934
+ if (this.settling.length === 5) {
935
+ const sorted = this.settling.sort((a, b) => a - b)
936
+ model.media += sorted[2]
937
+ this.settling = null
938
+ this.liveCount = 0
939
+ }
940
+ }
941
+ }
942
+
943
+ mediaAt(frame) {
944
+ const m = this.model
945
+ return m.paused ? m.media : m.media + (frame - m.frame) * m.rate / sampleRate
946
+ }
947
+
948
+ // the block holding bin i, cached, since a quantum reads the same one over and over
949
+ blockOf(i) {
950
+ const index = Math.floor(i / BLOCK_BINS)
951
+ if (this.lastBlock?.block !== index) this.lastBlock = this.blocks.get(index) ?? { block: index }
952
+ return this.lastBlock
953
+ }
954
+
955
+ gainBin(i) {
956
+ const block = this.blockOf(i)
957
+ return block.gain ? block.gain[i - block.block * BLOCK_BINS] : NaN
958
+ }
959
+
960
+ // the cut at reference time t (s), linear between bin centres; NaN where it is not known
961
+ cutAt(t) {
962
+ const x = t * BINS_PER_SECOND - 0.5, i = Math.floor(x)
963
+ const a = this.gainBin(i), b = this.gainBin(i + 1)
964
+ return a + (b - a) * (x - i)
965
+ }
966
+
967
+ // the deepest cut over [from, to] s; NaN unless it is known at both ends
968
+ deepest(from, to) {
969
+ const first = Math.floor(from * BINS_PER_SECOND), last = Math.ceil(to * BINS_PER_SECOND)
970
+ if (!(this.gainBin(first) <= 0) || !(this.gainBin(last) <= 0)) return NaN
971
+ let m = 0
972
+ for (let i = first; i <= last; i++) {
973
+ const v = this.gainBin(i)
974
+ if (v < m) m = v
975
+ }
976
+ return m
977
+ }
978
+
979
+ /*
980
+ * Starts a search for the offset that best matches the live fingerprint to the analysed one, +-`range` ms around
981
+ * `center` (s), on a copy of the live bins taken now; `step` scores it a few offsets at a time.
982
+ */
983
+ startSearch(center, range) {
984
+ // 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
985
+ const n = Math.min(this.liveCount, this.offset === null ? 1000 : 500)
986
+ const values = new Float64Array(n), times = new Float64Array(n)
987
+ for (let k = 0; k < n; k++) {
988
+ const at = (this.liveCount - n + k) % LIVE_BINS
989
+ values[k] = Math.log(this.liveValues[at] + EPSILON)
990
+ times[k] = this.liveTimes[at]
991
+ }
992
+ let mean = 0
993
+ for (const v of values) mean += v
994
+ mean /= n
995
+ let spread = 0
996
+ for (const v of values) spread += (v - mean) ** 2
997
+ // nearly steady audio (or silence) says nothing about where it is
998
+ if (n < 500 || spread / n < 1) return
999
+ // the analysed fingerprint over every bin the search can reach, copied out of the blocks once
1000
+ const first = Math.floor((times[0] + center) * BINS_PER_SECOND) - range - 2
1001
+ const ref = new Float64Array(Math.ceil((times[n - 1] - times[0]) * BINS_PER_SECOND) + 2 * range + 6)
1002
+ for (let k = 0; k < ref.length; k++) {
1003
+ const block = this.blockOf(first + k)
1004
+ ref[k] = block.finger ? block.finger[first + k - block.block * BLOCK_BINS] : NaN
1005
+ }
1006
+ this.search = { center, range, n, values, times, first, ref, scores: new Float64Array(2 * range + 1), next: 0 }
1007
+ }
1008
+
1009
+ /*
1010
+ * Matched on the change from one bin to the next, not the level: the level's slow swings correlate at any nearby
1011
+ * shift (0.94 at 5 ms off on synthetic dialogue, against 0.996 in place), its changes do not.
1012
+ */
1013
+ score(shiftMs) {
1014
+ const { center, n, values, times, first, ref } = this.search
1015
+ let sx = 0, sy = 0, sxx = 0, syy = 0, sxy = 0, m = 0, previous = NaN
1016
+ for (let k = 0; k < n; k++) {
1017
+ const x = (times[k] + center) * BINS_PER_SECOND + shiftMs - 0.5 - first, i = Math.floor(x)
1018
+ const r0 = ref[i], r1 = ref[i + 1]
1019
+ if (!(r0 === r0 && r1 === r1)) { previous = NaN; continue }
1020
+ const y = r0 + (r1 - r0) * (x - i)
1021
+ if (k > 0 && previous === previous) {
1022
+ const v = values[k] - values[k - 1], w = y - previous
1023
+ sx += v; sy += w; sxx += v * v; syy += w * w; sxy += v * w; m++
1024
+ }
1025
+ previous = y
1026
+ }
1027
+ if (m < n / 2) return -1
1028
+ const cov = sxy - sx * sy / m, vx = sxx - sx * sx / m, vy = syy - sy * sy / m
1029
+ return vx > 0 && vy > 0 ? cov / Math.sqrt(vx * vy) : -1
1030
+ }
1031
+
1032
+ step() {
1033
+ const search = this.search
1034
+ const { range, scores } = search
1035
+ for (let k = 0; k < SCORES_PER_QUANTUM / search.n && search.next < scores.length; k++, search.next++) {
1036
+ scores[search.next] = this.score(search.next - range)
1037
+ }
1038
+ if (search.next < scores.length) return
1039
+ this.search = null
1040
+ let best = 0
1041
+ for (let k = 1; k < scores.length; k++) if (scores[k] > scores[best]) best = k
1042
+ let runnerUp = -1
1043
+ for (let k = Math.max(0, best - CLEAR_MS); k <= Math.min(scores.length - 1, best + CLEAR_MS); k++) {
1044
+ if (Math.abs(k - best) > 3 && scores[k] > runnerUp) runnerUp = scores[k]
1045
+ }
1046
+ if (scores[best] < 0.4 || best === 0 || best === scores.length - 1) return
1047
+ const [a, b, c] = [scores[best - 1], scores[best], scores[best + 1]]
1048
+ const fraction = a - 2 * b + c < 0 ? 0.5 * (a - c) / (a - 2 * b + c) : 0
1049
+ this.found(search.center + (best - range + fraction) / BINS_PER_SECOND, scores[best] - runnerUp >= 0.15)
1050
+ }
1051
+
1052
+ /*
1053
+ * 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
1054
+ * finds it at all, is taken only once the next search, 250 ms later, peaks within 2 ms of it, clear or not: voiced
1055
+ * dialogue repeats with its pitch, so a peak a pitch period off can come out ahead, and a FLAC run once matched 45 ms
1056
+ * off with every check passed. Over the whole 250 ms either way an Opus file's matches were never that clear (HOR-498
1057
+ * build, 2026-10-17).
1058
+ */
1059
+ found(offset, clear) {
1060
+ const near = (other) => other !== null && Math.abs(offset - other) <= 0.002
1061
+ if (near(this.candidate)) {
1062
+ this.offset = offset
1063
+ this.doubt = false
1064
+ this.candidate = null
1065
+ } else if (clear) {
1066
+ if (!this.doubt && near(this.offset)) this.offset = offset
1067
+ else this.candidate = offset
1068
+ }
1069
+ }
1070
+
1071
+ // the live stage's learned level, from 400 ms readings
1072
+ learn(level) {
1073
+ if (!(level >= LEARN_GATE_LUFS)) return
1074
+ const bin = Math.max(0, Math.min(HIST_BINS - 1, Math.floor((level - HIST_MIN) / HIST_STEP)))
1075
+ this.hist[bin]++
1076
+ this.histTotal++
1077
+ if (this.histTotal < LIVE.minBlocks) return
1078
+ const wanted = 0.5 * this.histTotal
1079
+ let seen = 0
1080
+ for (let i = 0; i < HIST_BINS; i++) {
1081
+ seen += this.hist[i]
1082
+ if (seen >= wanted) {
1083
+ this.reference = Math.max(LIVE.floor, Math.min(0, HIST_MIN + (i + 0.5) * HIST_STEP))
1084
+ return
1085
+ }
98
1086
  }
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
1087
  }
104
1088
 
105
1089
  process(inputs, outputs) {
@@ -107,56 +1091,184 @@ class VolumeNormalizer extends AudioWorkletProcessor {
107
1091
  const output = outputs[0]
108
1092
  const frames = output[0].length
109
1093
  const channels = input.length
1094
+ // the context's frame, never a count of process() calls: Chrome left out 0 to 2560 frames of calls while the
1095
+ // page built its graph (29 loads, headless Chrome 155), and a count put the timeline that far behind the clock
1096
+ // pairs; past about 3000 frames the first match cannot find the offset at all (HOR-498 review, 2026-10-16)
1097
+ const start = currentFrame
1098
+ this.binEdge ??= start + this.binFrames
110
1099
  if (channels === 0) {
111
1100
  for (const channel of output) channel.fill(0)
112
1101
  return true
113
1102
  }
1103
+ if (this.sampleEnergy.length < frames) {
1104
+ this.sampleEnergy = new Float64Array(frames)
1105
+ this.fingerEnergy = new Float64Array(frames)
1106
+ }
114
1107
 
115
- if (this.energy.length < frames) this.energy = new Float32Array(frames)
116
- const energy = this.energy
117
- energy.fill(0, 0, frames)
118
1108
  const [b0, b1, b2, a1, a2] = this.coefficients.shelf
119
1109
  const [c0, c1, c2, d1, d2] = this.coefficients.highpass
1110
+ const [h0, h1, h2, g1, g2] = this.fingerFilter
1111
+ const sampleEnergy = this.sampleEnergy, fingerEnergy = this.fingerEnergy
1112
+ sampleEnergy.fill(0, 0, frames)
1113
+ fingerEnergy.fill(0, 0, frames)
1114
+ let quantumEnergy = 0
120
1115
  for (let c = 0; c < channels; c++) {
121
1116
  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]
1117
+ const state = this.filterState[c] ??= new Float64Array(4)
1118
+ const finger = this.fingerState[c] ??= new Float64Array(2)
1119
+ let s1 = state[0], s2 = state[1], t1 = state[2], t2 = state[3], u1 = finger[0], u2 = finger[1]
126
1120
  const x = input[c]
127
1121
  for (let i = 0; i < frames; i++) {
128
1122
  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
1123
+ if (weight) {
1124
+ const y = b0 * v + s1
1125
+ s1 = b1 * v - a1 * y + s2
1126
+ s2 = b2 * v - a2 * y
1127
+ const z = c0 * y + t1
1128
+ t1 = c1 * y - d1 * z + t2
1129
+ t2 = c2 * y - d2 * z
1130
+ quantumEnergy += weight * z * z
1131
+ sampleEnergy[i] += weight * z * z
1132
+ }
1133
+ const q = h0 * v + u1
1134
+ u1 = h1 * v - g1 * q + u2
1135
+ u2 = h2 * v - g2 * q
1136
+ fingerEnergy[i] += q * q
136
1137
  }
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()
1138
+ state[0] = s1; state[1] = s2; state[2] = t1; state[3] = t2; finger[0] = u1; finger[1] = u2
142
1139
  }
143
1140
 
1141
+ this.ringSum += quantumEnergy - this.ring[this.ringIndex]
1142
+ this.ring[this.ringIndex] = quantumEnergy
1143
+ this.ringIndex = (this.ringIndex + 1) % this.ring.length
1144
+ if (this.ringFill < this.ring.length) this.ringFill++
1145
+ // float drift from the running sum
1146
+ if (this.ringIndex === 0) this.ringSum = this.ring.reduce((a, b) => a + b, 0)
1147
+ const level = this.ringFill < this.ring.length ? -Infinity : -0.691 + 10 * Math.log10(this.ringSum / (this.ring.length * QUANTUM) / this.inputPower)
1148
+ this.blockEnergy += quantumEnergy
1149
+ if (++this.quantaInBlock === this.blockQuanta) {
1150
+ this.quantaInBlock = 0
1151
+ this.lastBlocks[this.lastBlockIndex] = this.blockEnergy / (this.blockQuanta * QUANTUM)
1152
+ this.lastBlockIndex = (this.lastBlockIndex + 1) % 4
1153
+ this.blockEnergy = 0
1154
+ const lb = this.lastBlocks
1155
+ this.learn(-0.691 + 10 * Math.log10((lb[0] + lb[1] + lb[2] + lb[3]) / 4 / this.inputPower))
1156
+ }
1157
+ // below the gate (a pause) the gain holds
1158
+ if (level >= LEARN_GATE_LUFS) {
1159
+ this.desiredDb = Math.max(-LIVE.maxCut, Math.min(0, this.reference + LIVE.headroom - level))
1160
+ }
144
1161
  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))
1162
+ const tau = this.desiredDb < this.liveGainDb ? LIVE.attack : LIVE.release
1163
+ this.liveGainDb += (this.desiredDb - this.liveGainDb) * (1 - Math.exp(-seconds / tau))
149
1164
  const from = this.appliedGain
150
- const to = Math.pow(10, this.gainDb * this.mix / 20)
1165
+ const to = Math.pow(10, this.liveGainDb / 20)
151
1166
  this.appliedGain = to
152
1167
  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))
1168
+
1169
+ // the live fingerprint, 1 ms bins of context time, each stamped with the media time at its centre
1170
+ const model = this.model
1171
+ const live = model && !model.paused && this.settling === null && model.rate === 1
1172
+ for (let i = 0; i < frames; i++) {
1173
+ this.binSum += fingerEnergy[i]
1174
+ this.binFill++
1175
+ if (start + i + 1 >= this.binEdge) {
1176
+ if (live) {
1177
+ const k = this.liveCount++ % LIVE_BINS
1178
+ this.liveValues[k] = this.binSum / this.binFill
1179
+ this.liveTimes[k] = this.mediaAt(start + i + 1 - this.binFill / 2)
1180
+ this.binsSinceLock++
1181
+ }
1182
+ this.binSum = 0
1183
+ this.binFill = 0
1184
+ this.binEdge += this.binFrames
1185
+ }
1186
+ }
1187
+ if (this.search) this.step()
1188
+ else if (live && this.blocks.size && this.binsSinceLock >= 250 && (this.offset !== null || this.liveCount >= 1000)) {
1189
+ this.binsSinceLock = 0
1190
+ this.startSearch(this.offset ?? 0, this.offset === null || this.doubt ? LOCK_RANGE_MS : RELOCK_RANGE_MS)
1191
+ }
1192
+
1193
+ // the ahead stage for this quantum's output, which left the input `delay` frames ago: exact at normal speed
1194
+ // once matched, otherwise the deepest cut within the doubt
1195
+ let exact = false, held = NaN
1196
+ if (this.offset !== null && model) {
1197
+ const m0 = this.mediaAt(start - this.delay) + this.offset, m1 = this.mediaAt(start + frames - this.delay) + this.offset
1198
+ if (!this.doubt && this.settling === null && (model.paused || model.rate === 1)) {
1199
+ exact = this.cutAt(m0) <= 0 && this.cutAt(m1) <= 0
1200
+ } else {
1201
+ const slack = this.doubt ? DOUBT_MS / BINS_PER_SECOND : model.rate === 1 ? 0.01 : 0.03
1202
+ held = this.deepest(Math.min(m0, m1) - slack, Math.max(m0, m1) + slack)
1203
+ }
1204
+ }
1205
+ const ahead = exact || held <= 0
1206
+
1207
+ const delay = this.delay
1208
+ const size = this.minValues.length
1209
+ const window = this.windowEnergy.length
1210
+ // the 10 ms window's energy sum at the ceiling
1211
+ const limit = window * this.inputPower * Math.pow(10, (this.reference + LIVE.shortCeiling + 0.691) / 10)
1212
+ for (let c = 0; c < output.length; c++) this.audioLines[c] ??= new Float32Array(delay)
1213
+ const handoverStep = 1 / (HANDOVER_SECONDS * sampleRate)
1214
+ const crossfadeStep = 1 / (CROSSFADE_SECONDS * sampleRate)
1215
+ const target = this.enabled ? 1 : 0
1216
+ for (let i = 0; i < frames; i++) {
1217
+ const gain = from + step * (i + 1)
1218
+ // the live stage's 10 ms detector on the 100 ms stage's output: the gain moves slowly enough to apply after
1219
+ // the filter
1220
+ const energy = sampleEnergy[i] * gain * gain
1221
+ this.windowSum += energy - this.windowEnergy[this.windowIndex]
1222
+ this.windowEnergy[this.windowIndex] = energy
1223
+ this.windowIndex = (this.windowIndex + 1) % window
1224
+ if (this.windowIndex === 0) this.windowSum = this.windowEnergy.reduce((a, b) => a + b, 0)
1225
+ const wanted = this.windowSum > limit ? 10 * Math.log10(limit / this.windowSum) : 0
1226
+ const time = this.shortTime++
1227
+ while (this.minLength > 0 && this.minValues[(this.minHead + this.minLength - 1) % size] >= wanted) this.minLength--
1228
+ const tail = (this.minHead + this.minLength) % size
1229
+ this.minValues[tail] = wanted
1230
+ this.minTimes[tail] = time
1231
+ this.minLength++
1232
+ while (this.minTimes[this.minHead] <= time - this.shortSpan) {
1233
+ this.minHead = (this.minHead + 1) % size
1234
+ this.minLength--
1235
+ }
1236
+ const lowest = this.minValues[this.minHead]
1237
+ this.shortEnvelope = lowest < this.shortEnvelope ? lowest : this.shortEnvelope + (lowest - this.shortEnvelope) * this.shortRelease
1238
+ this.averageSum += this.shortEnvelope - this.average[this.averageIndex]
1239
+ this.average[this.averageIndex] = this.shortEnvelope
1240
+ this.averageIndex = (this.averageIndex + 1) % this.shortSpan
1241
+ if (this.averageIndex === 0) this.averageSum = this.average.reduce((a, b) => a + b, 0)
1242
+
1243
+ const at = this.lineIndex
1244
+ const liveDb = 20 * Math.log10(this.gainLine[at]) + Math.max(-LIVE.maxCut, Math.min(0, this.averageSum / this.shortSpan))
1245
+ this.gainLine[at] = gain
1246
+
1247
+ if (ahead) {
1248
+ // at most 0.2 dB a sample, so a change of mode cannot click; the cut itself moves slower
1249
+ let wantedDb = exact ? this.cutAt(this.mediaAt(start + i - delay) + this.offset) : held
1250
+ if (!(wantedDb <= 0)) wantedDb = this.aheadDb
1251
+ if (wantedDb > this.aheadDb + 0.2) wantedDb = this.aheadDb + 0.2
1252
+ else if (wantedDb < this.aheadDb - 0.2) wantedDb = this.aheadDb - 0.2
1253
+ this.aheadDb = wantedDb
1254
+ this.handover = Math.min(1, this.handover + handoverStep)
1255
+ } else {
1256
+ this.handover = Math.max(0, this.handover - handoverStep)
1257
+ if (this.handover === 0) this.aheadDb = liveDb
1258
+ }
1259
+ this.mix += Math.max(-crossfadeStep, Math.min(crossfadeStep, target - this.mix))
1260
+ const db = (liveDb + (this.aheadDb - liveDb) * this.handover) * this.mix
1261
+ const scale = db < 0 ? Math.pow(10, db / 20) : 1
1262
+ for (let c = 0; c < output.length; c++) {
1263
+ const line = this.audioLines[c]
1264
+ output[c][i] = line[at] * scale
1265
+ line[at] = (input[c] ?? input[0])[i]
1266
+ }
1267
+ this.lineIndex = (at + 1) % delay
157
1268
  }
158
1269
  return true
159
1270
  }
160
1271
  }
161
1272
 
162
- registerProcessor('banou-volume-normalizer', VolumeNormalizer)
1273
+ if (typeof registerProcessor === 'function') registerProcessor('banou-volume-normalizer', defineProcessor())
1274
+ else if (typeof WorkerGlobalScope === 'function' && globalThis instanceof WorkerGlobalScope) serve(globalThis)