@banou/media-player 0.10.9 → 0.10.11
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 +37 -0
- package/dist/audio/volume-normalizer.d.ts +37 -0
- package/dist/engine/index.js +1 -1
- package/dist/{engine-C6PdWBah.js → engine-_RCEvBvl.js} +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +585 -441
- package/dist/react/hooks/use-volume-normalizer.d.ts +8 -0
- package/dist/react/player.d.ts +1 -0
- package/dist/react/source-feature.d.ts +11 -0
- package/dist/react/video-player.d.ts +24 -0
- package/dist/volume-normalizer-worklet.js +162 -0
- package/package.json +4 -2
- package/src/lib/audio/volume-normalizer-worklet.js +162 -0
- package/src/lib/audio/volume-normalizer.ts +147 -0
- package/src/lib/engine/playback.ts +6 -2
- package/src/lib/index.tsx +1 -1
- package/src/lib/react/components/settings.tsx +56 -0
- package/src/lib/react/hooks/use-volume-normalizer.ts +37 -0
- package/src/lib/react/source-feature.ts +12 -0
- package/src/lib/react/video-player.tsx +53 -2
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalizes the element's volume while `enabled`. Nothing is built until it first is, and turning it off
|
|
3
|
+
* afterwards bypasses rather than unroutes, since an element cannot be handed back. `refused` is true once the
|
|
4
|
+
* engine turned the element down, which leaves it playing exactly as before.
|
|
5
|
+
*/
|
|
6
|
+
export declare const useVolumeNormalizer: (video: HTMLVideoElement | null, workletUrl: string | undefined, enabled: boolean) => {
|
|
7
|
+
refused: boolean;
|
|
8
|
+
};
|
package/dist/react/player.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export declare const Player: import("@videojs/react").CreatePlayerResult<import(
|
|
|
22
22
|
passThroughPictureInPicture: import("./source-feature").PassThroughControl | null;
|
|
23
23
|
pictureInPictureMode: import("../engine").PictureInPictureMode | null;
|
|
24
24
|
burnedInSubtitles: boolean;
|
|
25
|
+
volumeNormalizer: import("./source-feature").VolumeNormalizerControl | null;
|
|
25
26
|
requestSeek?: (time: number) => void;
|
|
26
27
|
playbackError: unknown;
|
|
27
28
|
playbackErrors: import("./source-feature").PlaybackErrorEntry[];
|
|
@@ -90,6 +90,13 @@ export type PassThroughControl = {
|
|
|
90
90
|
/** Leaves picture in picture through the media. */
|
|
91
91
|
exit: () => void;
|
|
92
92
|
};
|
|
93
|
+
/** The settings menu's volume normalizer switch, for a player that offers one (see `normalizeVolume`). */
|
|
94
|
+
export type VolumeNormalizerControl = {
|
|
95
|
+
enabled: boolean;
|
|
96
|
+
/** False once the engine refused to route the element, which then plays exactly as it did. */
|
|
97
|
+
available: boolean;
|
|
98
|
+
setEnabled: (enabled: boolean) => void;
|
|
99
|
+
};
|
|
93
100
|
/**
|
|
94
101
|
* Everything about the source that the chrome reads, carried on the player store next to the built-in
|
|
95
102
|
* playback state so a component never has to know which of two channels owns a field.
|
|
@@ -194,6 +201,8 @@ export type SourceState = {
|
|
|
194
201
|
pictureInPictureMode: PictureInPictureMode | null;
|
|
195
202
|
/** Burn-in only. True while the composite is the picture on screen. */
|
|
196
203
|
burnedInSubtitles: boolean;
|
|
204
|
+
/** null when the player offers no volume normalizer: a media it does not own, or no `normalizerWorkletUrl`. */
|
|
205
|
+
volumeNormalizer: VolumeNormalizerControl | null;
|
|
197
206
|
/**
|
|
198
207
|
* Move the playhead, letting the pipeline get the data there first.
|
|
199
208
|
*
|
|
@@ -337,6 +346,8 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
|
|
|
337
346
|
pictureInPictureMode: PictureInPictureMode | null;
|
|
338
347
|
/** Burn-in only. True while the composite is the picture on screen. */
|
|
339
348
|
burnedInSubtitles: boolean;
|
|
349
|
+
/** null when the player offers no volume normalizer: a media it does not own, or no `normalizerWorkletUrl`. */
|
|
350
|
+
volumeNormalizer: VolumeNormalizerControl | null;
|
|
340
351
|
/**
|
|
341
352
|
* Move the playhead, letting the pipeline get the data there first.
|
|
342
353
|
*
|
|
@@ -136,6 +136,30 @@ export type MediaPlayerLocalOptions = CommonOptions & MediaPlayerSource & {
|
|
|
136
136
|
thumbnailRead?: (offset: number, size: number) => Promise<ArrayBuffer>;
|
|
137
137
|
/** Off entirely. A second wasm worker during pipeline boot is worth avoiding on a slow source. */
|
|
138
138
|
thumbnailsEnabled?: boolean;
|
|
139
|
+
/**
|
|
140
|
+
* Where this package's `volume-normalizer-worklet.js` is served, a plain script to copy or import as a url
|
|
141
|
+
* (`@banou/media-player/volume-normalizer-worklet.js?no-inline&url` with vite). Without it the settings menu
|
|
142
|
+
* offers no volume normalizer.
|
|
143
|
+
*
|
|
144
|
+
* A url and not a blob, because a page whose CSP says `script-src 'self'` refuses a worklet module from a
|
|
145
|
+
* `blob:` or `data:` url, and `build.lib` inlines an asset as `data:` unless told otherwise.
|
|
146
|
+
*/
|
|
147
|
+
normalizerWorkletUrl?: string;
|
|
148
|
+
/**
|
|
149
|
+
* The settings menu's "Normalize volume" switch: holds the loudness steady, so a music cue is turned down to
|
|
150
|
+
* the dialogue around it. Off by default. The player keeps the switch's state from here and from the viewer's
|
|
151
|
+
* clicks; nothing is saved, so an app that wants it to stick saves what `onNormalizeVolumeChange` reports and
|
|
152
|
+
* passes it back.
|
|
153
|
+
*
|
|
154
|
+
* The first time it is on, the element is routed through Web Audio for the rest of its life (off then
|
|
155
|
+
* bypasses). If autoplay rules hold the audio back, that waits for the page's next click, tap or key press, and
|
|
156
|
+
* the video plays as before meanwhile. Where the engine refuses the routing (WebKit), it does so when routing
|
|
157
|
+
* would happen, once the switch is on and the context runs: the switch reads Unavailable from then on, and
|
|
158
|
+
* the element is never routed.
|
|
159
|
+
*/
|
|
160
|
+
normalizeVolume?: boolean;
|
|
161
|
+
/** The viewer flipped the switch. */
|
|
162
|
+
onNormalizeVolumeChange?: (normalizeVolume: boolean) => void;
|
|
139
163
|
};
|
|
140
164
|
/**
|
|
141
165
|
* A media the caller owns and the player only drives.
|
|
@@ -0,0 +1,162 @@
|
|
|
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.
|
|
5
|
+
*
|
|
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
|
|
10
|
+
*
|
|
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.
|
|
15
|
+
*/
|
|
16
|
+
|
|
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
|
|
25
|
+
|
|
26
|
+
// ITU-R BS.1770 K-weighting at any sample rate, in the bilinear form libebur128 uses
|
|
27
|
+
const kWeighting = (rate) => {
|
|
28
|
+
let f0 = 1681.974450955533
|
|
29
|
+
const G = 3.999843853973347
|
|
30
|
+
let Q = 0.7071752369554196
|
|
31
|
+
let K = Math.tan(Math.PI * f0 / rate)
|
|
32
|
+
const Vh = Math.pow(10, G / 20)
|
|
33
|
+
const Vb = Math.pow(Vh, 0.4996667741545416)
|
|
34
|
+
let a0 = 1 + K / Q + K * K
|
|
35
|
+
const shelf = [
|
|
36
|
+
(Vh + Vb * K / Q + K * K) / a0, 2 * (K * K - Vh) / a0, (Vh - Vb * K / Q + K * K) / a0,
|
|
37
|
+
2 * (K * K - 1) / a0, (1 - K / Q + K * K) / a0,
|
|
38
|
+
]
|
|
39
|
+
f0 = 38.13547087602444
|
|
40
|
+
Q = 0.5003270373238773
|
|
41
|
+
K = Math.tan(Math.PI * f0 / rate)
|
|
42
|
+
a0 = 1 + K / Q + K * K
|
|
43
|
+
const highpass = [1, -2, 1, 2 * (K * K - 1) / a0, (1 - K / Q + K * K) / a0]
|
|
44
|
+
return { shelf, highpass }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// BS.1770 channel weights in WAVE order: the LFE counts for nothing, the surrounds for +1.5 dB
|
|
48
|
+
const channelWeight = (index, count) => count >= 6 ? [1, 1, 1, 0, 1.41, 1.41, 1.41, 1.41][index] ?? 1 : 1
|
|
49
|
+
|
|
50
|
+
class VolumeNormalizer extends AudioWorkletProcessor {
|
|
51
|
+
constructor({ processorOptions = {} } = {}) {
|
|
52
|
+
super()
|
|
53
|
+
const option = (key, fallback) => typeof processorOptions[key] === 'number' ? processorOptions[key] : fallback
|
|
54
|
+
this.target = option('targetLufs', -24)
|
|
55
|
+
this.enabled = processorOptions.enabled !== false
|
|
56
|
+
this.mix = this.enabled ? 1 : 0
|
|
57
|
+
this.inputPower = Math.pow(10, option('inputGainDb', 0) / 10)
|
|
58
|
+
|
|
59
|
+
this.coefficients = kWeighting(sampleRate)
|
|
60
|
+
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
|
|
67
|
+
this.desiredDb = 0
|
|
68
|
+
this.gainDb = 0
|
|
69
|
+
this.appliedGain = 1
|
|
70
|
+
this.energy = new Float32Array(128)
|
|
71
|
+
|
|
72
|
+
this.port.onmessage = ({ data }) => {
|
|
73
|
+
if (typeof data?.enabled === 'boolean') this.enabled = data.enabled
|
|
74
|
+
if (typeof data?.inputGainDb === 'number') this.inputPower = Math.pow(10, data.inputGainDb / 10)
|
|
75
|
+
if (data?.reset === 'seek' || data?.reset === 'source') {
|
|
76
|
+
this.blocks.fill(-1)
|
|
77
|
+
this.blockSum = 0
|
|
78
|
+
this.blockFill = 0
|
|
79
|
+
}
|
|
80
|
+
if (data?.reset === 'source') {
|
|
81
|
+
this.desiredDb = 0
|
|
82
|
+
this.gainDb = 0
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
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
|
|
93
|
+
|
|
94
|
+
let sum = 0
|
|
95
|
+
let active = 0
|
|
96
|
+
for (const block of this.blocks) {
|
|
97
|
+
if (block >= 0) { sum += block; active++ }
|
|
98
|
+
}
|
|
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
|
+
}
|
|
104
|
+
|
|
105
|
+
process(inputs, outputs) {
|
|
106
|
+
const input = inputs[0]
|
|
107
|
+
const output = outputs[0]
|
|
108
|
+
const frames = output[0].length
|
|
109
|
+
const channels = input.length
|
|
110
|
+
if (channels === 0) {
|
|
111
|
+
for (const channel of output) channel.fill(0)
|
|
112
|
+
return true
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (this.energy.length < frames) this.energy = new Float32Array(frames)
|
|
116
|
+
const energy = this.energy
|
|
117
|
+
energy.fill(0, 0, frames)
|
|
118
|
+
const [b0, b1, b2, a1, a2] = this.coefficients.shelf
|
|
119
|
+
const [c0, c1, c2, d1, d2] = this.coefficients.highpass
|
|
120
|
+
for (let c = 0; c < channels; c++) {
|
|
121
|
+
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]
|
|
126
|
+
const x = input[c]
|
|
127
|
+
for (let i = 0; i < frames; i++) {
|
|
128
|
+
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
|
|
136
|
+
}
|
|
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()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
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))
|
|
149
|
+
const from = this.appliedGain
|
|
150
|
+
const to = Math.pow(10, this.gainDb * this.mix / 20)
|
|
151
|
+
this.appliedGain = to
|
|
152
|
+
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))
|
|
157
|
+
}
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
registerProcessor('banou-volume-normalizer', VolumeNormalizer)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@banou/media-player",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.11",
|
|
4
4
|
"description": "A video player for containers and codecs the browser cannot play natively, remuxed on the fly",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"types": "./dist/remote/index.d.ts",
|
|
19
19
|
"import": "./dist/remote/index.js"
|
|
20
20
|
},
|
|
21
|
+
"./volume-normalizer-worklet.js": "./dist/volume-normalizer-worklet.js",
|
|
21
22
|
"./package.json": "./package.json"
|
|
22
23
|
},
|
|
23
24
|
"files": [
|
|
@@ -31,7 +32,7 @@
|
|
|
31
32
|
"dev": "npm run copy-assets && vp dev --port 4560",
|
|
32
33
|
"build": "shx rm -rf build/ && npm run copy-assets && vp build",
|
|
33
34
|
"preview": "vp preview",
|
|
34
|
-
"build-lib": "shx rm -rf dist/ && vp build -c vite.lib.config.ts && npm run types",
|
|
35
|
+
"build-lib": "shx rm -rf dist/ && vp build -c vite.lib.config.ts && shx cp src/lib/audio/volume-normalizer-worklet.js dist/ && npm run types",
|
|
35
36
|
"prepublishOnly": "npm run build-lib",
|
|
36
37
|
"types": "tsc -p tsconfig.lib.json",
|
|
37
38
|
"copy-assets": "shx mkdir -p public && npm run copy-libav",
|
|
@@ -41,6 +42,7 @@
|
|
|
41
42
|
},
|
|
42
43
|
"author": "Banou26",
|
|
43
44
|
"dependencies": {
|
|
45
|
+
"@banou/ponyfill": "^0.0.10",
|
|
44
46
|
"@videojs/core": "10.0.0-beta.26",
|
|
45
47
|
"@videojs/react": "10.0.0-beta.26",
|
|
46
48
|
"ass-compiler": "^0.1.16",
|
|
@@ -0,0 +1,162 @@
|
|
|
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.
|
|
5
|
+
*
|
|
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
|
|
10
|
+
*
|
|
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.
|
|
15
|
+
*/
|
|
16
|
+
|
|
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
|
|
25
|
+
|
|
26
|
+
// ITU-R BS.1770 K-weighting at any sample rate, in the bilinear form libebur128 uses
|
|
27
|
+
const kWeighting = (rate) => {
|
|
28
|
+
let f0 = 1681.974450955533
|
|
29
|
+
const G = 3.999843853973347
|
|
30
|
+
let Q = 0.7071752369554196
|
|
31
|
+
let K = Math.tan(Math.PI * f0 / rate)
|
|
32
|
+
const Vh = Math.pow(10, G / 20)
|
|
33
|
+
const Vb = Math.pow(Vh, 0.4996667741545416)
|
|
34
|
+
let a0 = 1 + K / Q + K * K
|
|
35
|
+
const shelf = [
|
|
36
|
+
(Vh + Vb * K / Q + K * K) / a0, 2 * (K * K - Vh) / a0, (Vh - Vb * K / Q + K * K) / a0,
|
|
37
|
+
2 * (K * K - 1) / a0, (1 - K / Q + K * K) / a0,
|
|
38
|
+
]
|
|
39
|
+
f0 = 38.13547087602444
|
|
40
|
+
Q = 0.5003270373238773
|
|
41
|
+
K = Math.tan(Math.PI * f0 / rate)
|
|
42
|
+
a0 = 1 + K / Q + K * K
|
|
43
|
+
const highpass = [1, -2, 1, 2 * (K * K - 1) / a0, (1 - K / Q + K * K) / a0]
|
|
44
|
+
return { shelf, highpass }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// BS.1770 channel weights in WAVE order: the LFE counts for nothing, the surrounds for +1.5 dB
|
|
48
|
+
const channelWeight = (index, count) => count >= 6 ? [1, 1, 1, 0, 1.41, 1.41, 1.41, 1.41][index] ?? 1 : 1
|
|
49
|
+
|
|
50
|
+
class VolumeNormalizer extends AudioWorkletProcessor {
|
|
51
|
+
constructor({ processorOptions = {} } = {}) {
|
|
52
|
+
super()
|
|
53
|
+
const option = (key, fallback) => typeof processorOptions[key] === 'number' ? processorOptions[key] : fallback
|
|
54
|
+
this.target = option('targetLufs', -24)
|
|
55
|
+
this.enabled = processorOptions.enabled !== false
|
|
56
|
+
this.mix = this.enabled ? 1 : 0
|
|
57
|
+
this.inputPower = Math.pow(10, option('inputGainDb', 0) / 10)
|
|
58
|
+
|
|
59
|
+
this.coefficients = kWeighting(sampleRate)
|
|
60
|
+
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
|
|
67
|
+
this.desiredDb = 0
|
|
68
|
+
this.gainDb = 0
|
|
69
|
+
this.appliedGain = 1
|
|
70
|
+
this.energy = new Float32Array(128)
|
|
71
|
+
|
|
72
|
+
this.port.onmessage = ({ data }) => {
|
|
73
|
+
if (typeof data?.enabled === 'boolean') this.enabled = data.enabled
|
|
74
|
+
if (typeof data?.inputGainDb === 'number') this.inputPower = Math.pow(10, data.inputGainDb / 10)
|
|
75
|
+
if (data?.reset === 'seek' || data?.reset === 'source') {
|
|
76
|
+
this.blocks.fill(-1)
|
|
77
|
+
this.blockSum = 0
|
|
78
|
+
this.blockFill = 0
|
|
79
|
+
}
|
|
80
|
+
if (data?.reset === 'source') {
|
|
81
|
+
this.desiredDb = 0
|
|
82
|
+
this.gainDb = 0
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
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
|
|
93
|
+
|
|
94
|
+
let sum = 0
|
|
95
|
+
let active = 0
|
|
96
|
+
for (const block of this.blocks) {
|
|
97
|
+
if (block >= 0) { sum += block; active++ }
|
|
98
|
+
}
|
|
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
|
+
}
|
|
104
|
+
|
|
105
|
+
process(inputs, outputs) {
|
|
106
|
+
const input = inputs[0]
|
|
107
|
+
const output = outputs[0]
|
|
108
|
+
const frames = output[0].length
|
|
109
|
+
const channels = input.length
|
|
110
|
+
if (channels === 0) {
|
|
111
|
+
for (const channel of output) channel.fill(0)
|
|
112
|
+
return true
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (this.energy.length < frames) this.energy = new Float32Array(frames)
|
|
116
|
+
const energy = this.energy
|
|
117
|
+
energy.fill(0, 0, frames)
|
|
118
|
+
const [b0, b1, b2, a1, a2] = this.coefficients.shelf
|
|
119
|
+
const [c0, c1, c2, d1, d2] = this.coefficients.highpass
|
|
120
|
+
for (let c = 0; c < channels; c++) {
|
|
121
|
+
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]
|
|
126
|
+
const x = input[c]
|
|
127
|
+
for (let i = 0; i < frames; i++) {
|
|
128
|
+
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
|
|
136
|
+
}
|
|
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()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
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))
|
|
149
|
+
const from = this.appliedGain
|
|
150
|
+
const to = Math.pow(10, this.gainDb * this.mix / 20)
|
|
151
|
+
this.appliedGain = to
|
|
152
|
+
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))
|
|
157
|
+
}
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
registerProcessor('banou-volume-normalizer', VolumeNormalizer)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createMediaElementSource } from '@banou/ponyfill/web-audio'
|
|
2
|
+
|
|
3
|
+
/** The worklet's processorOptions, read once when the node is made; its header has the defaults. */
|
|
4
|
+
export type VolumeNormalizerOptions = {
|
|
5
|
+
targetLufs?: number
|
|
6
|
+
enabled?: boolean
|
|
7
|
+
inputGainDb?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The worklet at `workletUrl`, as a node. A context takes the module once: make a node per context. */
|
|
11
|
+
export const createVolumeNormalizerNode = async (
|
|
12
|
+
context: BaseAudioContext,
|
|
13
|
+
workletUrl: string,
|
|
14
|
+
options: VolumeNormalizerOptions = {},
|
|
15
|
+
) => {
|
|
16
|
+
await context.audioWorklet.addModule(workletUrl)
|
|
17
|
+
return new AudioWorkletNode(context, 'banou-volume-normalizer', {
|
|
18
|
+
// with one input, one output and no outputChannelCount, the output follows the input's channels
|
|
19
|
+
channelCountMode: 'max',
|
|
20
|
+
processorOptions: options,
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type VolumeNormalizer = {
|
|
25
|
+
context: AudioContext
|
|
26
|
+
/** Settles once the element is routed, which waits for the context to run. Rejects if it never can be. */
|
|
27
|
+
routed: Promise<AudioWorkletNode>
|
|
28
|
+
/** Engage or bypass. A routed element stays routed: it cannot be handed back. */
|
|
29
|
+
setEnabled: (enabled: boolean) => void
|
|
30
|
+
/**
|
|
31
|
+
* For an element going away. One still in the document has its context suspended, and attaching it again resumes
|
|
32
|
+
* it. One out of the document is gone for good, so its context is closed.
|
|
33
|
+
*/
|
|
34
|
+
release: () => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const attached = new WeakMap<HTMLMediaElement, VolumeNormalizer & { wake: () => void }>()
|
|
38
|
+
|
|
39
|
+
const running = (context: AudioContext) => new Promise<void>((resolve, reject) => {
|
|
40
|
+
const check = () => {
|
|
41
|
+
if (context.state !== 'running' && context.state !== 'closed') return
|
|
42
|
+
context.removeEventListener('statechange', check)
|
|
43
|
+
if (context.state === 'running') resolve()
|
|
44
|
+
else reject(new DOMException('The AudioContext closed before it ran', 'InvalidStateError'))
|
|
45
|
+
}
|
|
46
|
+
context.addEventListener('statechange', check)
|
|
47
|
+
check()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Routes `element` through the normalizer, once for its whole life, and returns the same handle on every later
|
|
52
|
+
* call. Measured 2026-10-11 in Chrome 153, Firefox 151 and WebKit 26.5:
|
|
53
|
+
*
|
|
54
|
+
* - An element routed into a context that autoplay rules hold suspended goes silent (Firefox, WebKit) or stops
|
|
55
|
+
* advancing (Chrome). So nothing is routed until the context runs; until then the element plays as it always did,
|
|
56
|
+
* and the next pointerdown, pointerup or keydown resumes the context.
|
|
57
|
+
* - Chrome and WebKit refuse a second source for one element, and Chrome stops a playing element whose context is
|
|
58
|
+
* closed, so the context is closed only once the element has left the document, and off is a bypass in the same
|
|
59
|
+
* graph.
|
|
60
|
+
* - Chrome and Firefox apply the element's volume before the graph, so the worklet is told the volume and judges
|
|
61
|
+
* loudness without it: otherwise a viewer at 25% would read as a quiet episode and nothing would be cut.
|
|
62
|
+
* - WebKit loses the volume and mute once routed, so `@banou/ponyfill` refuses it and `routed` rejects with a
|
|
63
|
+
* `NotSupportedError` before the element is touched. That refusal comes once the context runs, the moment routing
|
|
64
|
+
* would happen: telling WebKit apart any earlier would take sniffing here.
|
|
65
|
+
*/
|
|
66
|
+
export const attachVolumeNormalizer = (
|
|
67
|
+
element: HTMLMediaElement,
|
|
68
|
+
workletUrl: string,
|
|
69
|
+
options: VolumeNormalizerOptions = {},
|
|
70
|
+
): VolumeNormalizer => {
|
|
71
|
+
const existing = attached.get(element)
|
|
72
|
+
if (existing) {
|
|
73
|
+
existing.wake()
|
|
74
|
+
return existing
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const context = new AudioContext()
|
|
78
|
+
let enabled = options.enabled !== false
|
|
79
|
+
let node: AudioWorkletNode | undefined
|
|
80
|
+
let released = false
|
|
81
|
+
const inputGainDb = () => 20 * Math.log10(Math.max(element.volume, 1e-6))
|
|
82
|
+
|
|
83
|
+
const document = element.ownerDocument
|
|
84
|
+
const resume = () => { if (context.state === 'suspended') context.resume().catch(() => {}) }
|
|
85
|
+
const listen = (on: boolean) => {
|
|
86
|
+
// a mouse activates on pointerdown, a touch or a pen only on pointerup
|
|
87
|
+
for (const type of ['pointerdown', 'pointerup', 'keydown']) {
|
|
88
|
+
if (on) document.addEventListener(type, resume, true)
|
|
89
|
+
else document.removeEventListener(type, resume, true)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const follow = () => {
|
|
93
|
+
const suspended = !released && context.state === 'suspended'
|
|
94
|
+
listen(suspended)
|
|
95
|
+
// Suspended by something else once routed: Chrome freezes the element and Firefox plays it silent until the
|
|
96
|
+
// context runs, and the page's activation already allows a resume.
|
|
97
|
+
if (suspended && node) resume()
|
|
98
|
+
}
|
|
99
|
+
context.addEventListener('statechange', follow)
|
|
100
|
+
follow()
|
|
101
|
+
|
|
102
|
+
const routed = (async () => {
|
|
103
|
+
const created = await createVolumeNormalizerNode(context, workletUrl, { ...options, enabled, inputGainDb: inputGainDb() })
|
|
104
|
+
await running(context)
|
|
105
|
+
const source = createMediaElementSource(context, element)
|
|
106
|
+
created.port.postMessage({ enabled, inputGainDb: inputGainDb() })
|
|
107
|
+
source.connect(created).connect(context.destination)
|
|
108
|
+
element.addEventListener('volumechange', () => created.port.postMessage({ inputGainDb: inputGainDb() }))
|
|
109
|
+
// the 3 s window describes where the playhead was, and a new file owes nothing to the last one's cut
|
|
110
|
+
element.addEventListener('seeking', () => created.port.postMessage({ reset: 'seek' }))
|
|
111
|
+
element.addEventListener('emptied', () => created.port.postMessage({ reset: 'source' }))
|
|
112
|
+
node = created
|
|
113
|
+
return created
|
|
114
|
+
})()
|
|
115
|
+
routed.catch(() => {
|
|
116
|
+
released = true
|
|
117
|
+
listen(false)
|
|
118
|
+
// never routed, so closing it cannot touch the element
|
|
119
|
+
context.close().catch(() => {})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const handle = {
|
|
123
|
+
context,
|
|
124
|
+
routed,
|
|
125
|
+
setEnabled: (value: boolean) => {
|
|
126
|
+
enabled = value
|
|
127
|
+
node?.port.postMessage({ enabled })
|
|
128
|
+
},
|
|
129
|
+
release: () => {
|
|
130
|
+
released = true
|
|
131
|
+
listen(false)
|
|
132
|
+
// Chrome keeps a context that is not closed alive, with its worklet node, for the life of the page
|
|
133
|
+
if (!element.isConnected) {
|
|
134
|
+
attached.delete(element)
|
|
135
|
+
context.close().catch(() => {})
|
|
136
|
+
} else if (context.state === 'running') context.suspend().catch(() => {})
|
|
137
|
+
},
|
|
138
|
+
wake: () => {
|
|
139
|
+
if (context.state === 'closed') return
|
|
140
|
+
released = false
|
|
141
|
+
resume()
|
|
142
|
+
follow()
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
attached.set(element, handle)
|
|
146
|
+
return handle
|
|
147
|
+
}
|
|
@@ -304,8 +304,12 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
304
304
|
const ct = videoElement.currentTime
|
|
305
305
|
const pre = tight ? PRE_EVICT_TIGHT : PRE_EVICT
|
|
306
306
|
const post = tight ? POST_EVICT_TIGHT : POST_EVICT
|
|
307
|
-
|
|
308
|
-
|
|
307
|
+
const ranges = getTimeRanges(sourceBuffer)
|
|
308
|
+
// From 0, never from the reported start: once a group is removed, Firefox 151 reports the start
|
|
309
|
+
// 1 ms after the keyframe it still holds, so a remove from there keeps that frame for good
|
|
310
|
+
// (measured 2026-10-11).
|
|
311
|
+
if (ranges.length && ranges[0].start < ct + pre) await unbufferRange(0, ct + pre)
|
|
312
|
+
for (const { end } of ranges) {
|
|
309
313
|
if (end > ct + post) await unbufferRange(ct + post, end)
|
|
310
314
|
}
|
|
311
315
|
}
|
package/src/lib/index.tsx
CHANGED
|
@@ -26,7 +26,7 @@ export type { PlayerStore } from './react/player'
|
|
|
26
26
|
// only hook the chrome needs.
|
|
27
27
|
export { sourceFeature } from './react/source-feature'
|
|
28
28
|
export type {
|
|
29
|
-
DownloadedRange, PassThroughControl, PlaybackErrorEntry, SourceState, ViewportBox,
|
|
29
|
+
DownloadedRange, PassThroughControl, PlaybackErrorEntry, SourceState, ViewportBox, VolumeNormalizerControl,
|
|
30
30
|
} from './react/source-feature'
|
|
31
31
|
|
|
32
32
|
export { useSeekThumbnails } from './react/hooks/use-thumbnails'
|