@banou/media-player 0.10.19 → 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.
- package/README.md +29 -22
- package/dist/audio/loudness-analysis.d.ts +30 -0
- package/dist/audio/volume-normalizer.d.ts +4 -2
- package/dist/index.js +841 -623
- package/dist/react/hooks/use-volume-normalizer.d.ts +5 -1
- package/dist/react/video-player.d.ts +11 -7
- package/dist/volume-normalizer-worklet.js +1190 -78
- package/package.json +1 -1
- package/src/lib/audio/loudness-analysis.ts +274 -0
- package/src/lib/audio/volume-normalizer-worklet.d.ts +49 -0
- package/src/lib/audio/volume-normalizer-worklet.js +1190 -78
- package/src/lib/audio/volume-normalizer.ts +5 -3
- package/src/lib/react/components/chrome.tsx +31 -6
- package/src/lib/react/components/control-bar.tsx +1 -0
- package/src/lib/react/hooks/use-volume-normalizer.ts +37 -2
- package/src/lib/react/video-player.tsx +39 -11
|
@@ -1,27 +1,46 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* The volume normalizer
|
|
3
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
62
|
-
this.
|
|
63
|
-
this.
|
|
64
|
-
|
|
65
|
-
this.
|
|
66
|
-
this.
|
|
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.
|
|
819
|
+
this.liveGainDb = 0
|
|
69
820
|
this.appliedGain = 1
|
|
70
|
-
this.
|
|
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.
|
|
77
|
-
this.
|
|
78
|
-
this.
|
|
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.
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
this.
|
|
90
|
-
this.
|
|
91
|
-
this.
|
|
92
|
-
this.
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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.
|
|
146
|
-
this.
|
|
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.
|
|
1165
|
+
const to = Math.pow(10, this.liveGainDb / 20)
|
|
151
1166
|
this.appliedGain = to
|
|
152
1167
|
const step = (to - from) / frames
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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',
|
|
1273
|
+
if (typeof registerProcessor === 'function') registerProcessor('banou-volume-normalizer', defineProcessor())
|
|
1274
|
+
else if (typeof WorkerGlobalScope === 'function' && globalThis instanceof WorkerGlobalScope) serve(globalThis)
|