@banou/media-player 0.10.9 → 0.10.10

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.
@@ -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
+ };
@@ -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.9",
3
+ "version": "0.10.10",
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
+ }
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'
@@ -28,6 +28,41 @@ position: static;
28
28
 
29
29
  ${popoverStyle}
30
30
 
31
+ .switch {
32
+ position: relative;
33
+ flex-shrink: 0;
34
+ width: 28px;
35
+ height: 16px;
36
+ margin-right: 6px;
37
+ border-radius: 8px;
38
+ background-color: rgba(255,255,255,.3);
39
+ transition: background-color 0.15s ease-in-out;
40
+
41
+ ::after {
42
+ content: '';
43
+ position: absolute;
44
+ top: 2px;
45
+ left: 2px;
46
+ width: 12px;
47
+ height: 12px;
48
+ border-radius: 50%;
49
+ background-color: #fff;
50
+ transition: transform 0.15s ease-in-out;
51
+ }
52
+
53
+ &.on {
54
+ background-color: #fff;
55
+ ::after {
56
+ background-color: #1c1c1c;
57
+ transform: translateX(12px);
58
+ }
59
+ }
60
+ }
61
+
62
+ .unavailable {
63
+ opacity: 0.5;
64
+ }
65
+
31
66
  .playback-rate {
32
67
  .slider {
33
68
  width: 100%;
@@ -81,6 +116,7 @@ export const SettingsAction = () => {
81
116
  const audioTracks = usePlayer((state) => state.audioTracks)
82
117
  const selectedAudioTrack = usePlayer((state) => state.selectedAudioTrack)
83
118
  const selectAudioTrack = usePlayer((state) => state.selectAudioTrack)
119
+ const volumeNormalizer = usePlayer((state) => state.volumeNormalizer)
84
120
 
85
121
  const [popoverContent, setPopoverContent] = useState(PopoverContent.Default)
86
122
  const { open, toggle, containerRef, pending, failed, runSelect } = useTrackMenu({
@@ -156,6 +192,26 @@ export const SettingsAction = () => {
156
192
  <ChevronRight />
157
193
  </div>
158
194
  </div>
195
+ {volumeNormalizer
196
+ ? (
197
+ <div
198
+ role='switch'
199
+ aria-checked={volumeNormalizer.available && volumeNormalizer.enabled}
200
+ aria-disabled={!volumeNormalizer.available}
201
+ className={volumeNormalizer.available ? undefined : 'no-hover unavailable'}
202
+ onClick={() => {
203
+ if (volumeNormalizer.available) volumeNormalizer.setEnabled(!volumeNormalizer.enabled)
204
+ }}
205
+ >
206
+ <div>Normalize volume</div>
207
+ <div>
208
+ {volumeNormalizer.available
209
+ ? <span className={volumeNormalizer.enabled ? 'switch on' : 'switch'} />
210
+ : <span className='secondary'>Unavailable</span>}
211
+ </div>
212
+ </div>
213
+ )
214
+ : null}
159
215
  </div>
160
216
  )
161
217
  }