@banou/media-player 0.10.20 → 0.10.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +814 -605
- 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/hooks/use-volume-normalizer.ts +37 -2
- package/src/lib/react/video-player.tsx +39 -11
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createMediaElementSource } from '@banou/ponyfill/web-audio'
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* The worklet's processorOptions, read once when the node is made: `enabled` false starts it bypassed, and
|
|
5
|
+
* `inputGainDb` is the gain already applied upstream (the element's volume), which loudness is judged without.
|
|
6
|
+
*/
|
|
4
7
|
export type VolumeNormalizerOptions = {
|
|
5
|
-
targetLufs?: number
|
|
6
8
|
enabled?: boolean
|
|
7
9
|
inputGainDb?: number
|
|
8
10
|
}
|
|
@@ -106,7 +108,7 @@ export const attachVolumeNormalizer = (
|
|
|
106
108
|
created.port.postMessage({ enabled, inputGainDb: inputGainDb() })
|
|
107
109
|
source.connect(created).connect(context.destination)
|
|
108
110
|
element.addEventListener('volumechange', () => created.port.postMessage({ inputGainDb: inputGainDb() }))
|
|
109
|
-
// the
|
|
111
|
+
// the live detector describes where the playhead was, and a new file owes nothing to the last one's level
|
|
110
112
|
element.addEventListener('seeking', () => created.port.postMessage({ reset: 'seek' }))
|
|
111
113
|
element.addEventListener('emptied', () => created.port.postMessage({ reset: 'source' }))
|
|
112
114
|
node = created
|
|
@@ -1,24 +1,38 @@
|
|
|
1
1
|
import type { VolumeNormalizer } from '../../audio/volume-normalizer'
|
|
2
|
+
import type { LoudnessSource } from '../../audio/loudness-analysis'
|
|
2
3
|
|
|
3
4
|
import { useEffect, useRef, useState } from 'react'
|
|
4
5
|
|
|
5
6
|
import { attachVolumeNormalizer } from '../../audio/volume-normalizer'
|
|
7
|
+
import { startLoudnessAnalysis } from '../../audio/loudness-analysis'
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Normalizes the element's volume while `enabled`. Nothing is built until it first is, and turning it off
|
|
9
11
|
* afterwards bypasses rather than unroutes, since an element cannot be handed back. `refused` is true once the
|
|
10
12
|
* engine turned the element down, which leaves it playing exactly as before.
|
|
13
|
+
*
|
|
14
|
+
* With a `source`, the file is also analysed ahead of the playhead while it is on, reading only the byte `ranges`
|
|
15
|
+
* given (all of them when undefined).
|
|
11
16
|
*/
|
|
12
|
-
export const useVolumeNormalizer = (
|
|
17
|
+
export const useVolumeNormalizer = (
|
|
18
|
+
video: HTMLVideoElement | null,
|
|
19
|
+
workletUrl: string | undefined,
|
|
20
|
+
enabled: boolean,
|
|
21
|
+
source?: LoudnessSource,
|
|
22
|
+
ranges?: [number, number][],
|
|
23
|
+
) => {
|
|
13
24
|
const handle = useRef<VolumeNormalizer | null>(null)
|
|
14
25
|
const [refused, setRefused] = useState(false)
|
|
26
|
+
const [node, setNode] = useState<AudioWorkletNode | null>(null)
|
|
15
27
|
|
|
16
28
|
useEffect(() => {
|
|
17
29
|
if (!video || !workletUrl) return
|
|
18
30
|
if (enabled && !handle.current) {
|
|
19
31
|
const attached = attachVolumeNormalizer(video, workletUrl, { enabled })
|
|
20
32
|
handle.current = attached
|
|
21
|
-
attached.routed.
|
|
33
|
+
attached.routed.then((routed) => {
|
|
34
|
+
if (handle.current === attached) setNode(routed)
|
|
35
|
+
}, (error) => {
|
|
22
36
|
// released first, which closes a context that had not run yet: nothing was refused
|
|
23
37
|
if (handle.current !== attached) return
|
|
24
38
|
console.warn('[media-player] the volume normalizer is unavailable:', error)
|
|
@@ -31,7 +45,28 @@ export const useVolumeNormalizer = (video: HTMLVideoElement | null, workletUrl:
|
|
|
31
45
|
useEffect(() => () => {
|
|
32
46
|
handle.current?.release()
|
|
33
47
|
handle.current = null
|
|
48
|
+
setNode(null)
|
|
34
49
|
}, [video])
|
|
35
50
|
|
|
51
|
+
const rangesRef = useRef(ranges)
|
|
52
|
+
rangesRef.current = ranges
|
|
53
|
+
const analysis = useRef<ReturnType<typeof startLoudnessAnalysis> | null>(null)
|
|
54
|
+
const sourceRef = useRef(source)
|
|
55
|
+
sourceRef.current = source
|
|
56
|
+
const { length, publicPath, libavWorkerUrl, audioStreamIndex } = source ?? {}
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const current = sourceRef.current
|
|
59
|
+
if (!node || !video || !workletUrl || !enabled || !current) return
|
|
60
|
+
const started = startLoudnessAnalysis({ node, element: video, scriptUrl: workletUrl, source: current })
|
|
61
|
+
started.update(rangesRef.current)
|
|
62
|
+
analysis.current = started
|
|
63
|
+
return () => {
|
|
64
|
+
started.stop()
|
|
65
|
+
analysis.current = null
|
|
66
|
+
}
|
|
67
|
+
}, [node, video, workletUrl, enabled, length, publicPath, libavWorkerUrl, audioStreamIndex])
|
|
68
|
+
|
|
69
|
+
useEffect(() => { analysis.current?.update(ranges) }, [ranges])
|
|
70
|
+
|
|
36
71
|
return { refused }
|
|
37
72
|
}
|
|
@@ -156,11 +156,12 @@ export type MediaPlayerLocalOptions =
|
|
|
156
156
|
downloadedRanges?: DownloadedRange[]
|
|
157
157
|
|
|
158
158
|
/**
|
|
159
|
-
* Reader for the thumbnail engine, when it should differ from playback's.
|
|
159
|
+
* Reader for the thumbnail engine and the volume normalizer's analysis, when it should differ from playback's.
|
|
160
160
|
*
|
|
161
161
|
* They are the same by default so generation shares playback's fetch order. A consumer whose
|
|
162
162
|
* reads are not free wants them apart: a torrent hands this a non-prioritising, fail-fast reader
|
|
163
|
-
* so generating previews cannot steal download order from the bytes playback is blocked on.
|
|
163
|
+
* so generating previews cannot steal download order from the bytes playback is blocked on. The
|
|
164
|
+
* analysis asks it only for bytes inside `downloadedRanges`, and with none given, for the whole file.
|
|
164
165
|
*/
|
|
165
166
|
thumbnailRead?: (offset: number, size: number) => Promise<ArrayBuffer>
|
|
166
167
|
/** Off entirely. A second wasm worker during pipeline boot is worth avoiding on a slow source. */
|
|
@@ -176,10 +177,13 @@ export type MediaPlayerLocalOptions =
|
|
|
176
177
|
*/
|
|
177
178
|
normalizerWorkletUrl?: string
|
|
178
179
|
/**
|
|
179
|
-
* The settings menu's "Normalize volume" switch:
|
|
180
|
-
*
|
|
181
|
-
* clicks; nothing is saved, so an app that wants it to stick saves what
|
|
182
|
-
* passes it back.
|
|
180
|
+
* The settings menu's "Normalize volume" switch: keeps the file's normal level and turns down what goes well
|
|
181
|
+
* over it, a loud burst before it starts. On by default; pass false for off. The player keeps the switch's
|
|
182
|
+
* state from here and from the viewer's clicks; nothing is saved, so an app that wants it to stick saves what
|
|
183
|
+
* `onNormalizeVolumeChange` reports and passes it back.
|
|
184
|
+
*
|
|
185
|
+
* While it is on the file is read a second time, in a worker, as far as `downloadedRanges` allows, to find the
|
|
186
|
+
* loud parts ahead of the playhead. The sound plays 10 ms behind its input, on or off, once routed.
|
|
183
187
|
*
|
|
184
188
|
* The first time it is on, the element is routed through Web Audio for the rest of its life (off then
|
|
185
189
|
* bypasses). If autoplay rules hold the audio back, that waits for the page's next click, tap or key press, and
|
|
@@ -188,7 +192,7 @@ export type MediaPlayerLocalOptions =
|
|
|
188
192
|
* the element is never routed.
|
|
189
193
|
*/
|
|
190
194
|
normalizeVolume?: boolean
|
|
191
|
-
/** The viewer flipped the switch
|
|
195
|
+
/** The viewer flipped the switch, to `normalizeVolume`: what an app saves to pass back as `normalizeVolume`. */
|
|
192
196
|
onNormalizeVolumeChange?: (normalizeVolume: boolean) => void
|
|
193
197
|
}
|
|
194
198
|
|
|
@@ -298,13 +302,37 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
|
|
|
298
302
|
// so there is nothing for them to attach to and nothing to guard at the call site.
|
|
299
303
|
usePlayback(video, subtitleLayer, local)
|
|
300
304
|
|
|
301
|
-
const normalizeVolumeProp = local?.normalizeVolume
|
|
302
|
-
const [normalizeVolume, setNormalizeVolume] = useState(
|
|
303
|
-
useEffect(() => { setNormalizeVolume(
|
|
305
|
+
const normalizeVolumeProp = local?.normalizeVolume ?? true
|
|
306
|
+
const [normalizeVolume, setNormalizeVolume] = useState(normalizeVolumeProp)
|
|
307
|
+
useEffect(() => { setNormalizeVolume(normalizeVolumeProp) }, [normalizeVolumeProp])
|
|
304
308
|
const onNormalizeVolumeChange = useRef(local?.onNormalizeVolumeChange)
|
|
305
309
|
onNormalizeVolumeChange.current = local?.onNormalizeVolumeChange
|
|
306
310
|
const normalizerWorkletUrl = local?.normalizerWorkletUrl
|
|
307
|
-
|
|
311
|
+
// the background reader, through a ref: a streaming consumer passes a fresh closure several times a second
|
|
312
|
+
const analysisRead = useRef(thumbnailRead ?? read)
|
|
313
|
+
analysisRead.current = thumbnailRead ?? read
|
|
314
|
+
const playingAudio = usePlayer((state) => state.selectedAudioTrack)
|
|
315
|
+
const hasRead = !!read
|
|
316
|
+
const normalizerSource = useMemo(
|
|
317
|
+
() => hasRead && size && publicPath && libavWorkerUrl && typeof playingAudio === 'number' && playingAudio >= 0
|
|
318
|
+
? {
|
|
319
|
+
read: (offset: number, length: number) => analysisRead.current!(offset, length),
|
|
320
|
+
length: size,
|
|
321
|
+
publicPath,
|
|
322
|
+
libavWorkerUrl,
|
|
323
|
+
audioStreamIndex: playingAudio,
|
|
324
|
+
}
|
|
325
|
+
: undefined,
|
|
326
|
+
[hasRead, size, publicPath, libavWorkerUrl, playingAudio],
|
|
327
|
+
)
|
|
328
|
+
const normalizerRanges = useMemo(
|
|
329
|
+
() => downloadedRanges?.map(({ startByteOffset, endByteOffset }) => [startByteOffset, endByteOffset] as [number, number]),
|
|
330
|
+
// a streaming consumer changes the array identity every tick, so compare contents
|
|
331
|
+
[downloadedRanges?.map(({ startByteOffset, endByteOffset }) => `${startByteOffset}-${endByteOffset}`).join(',')],
|
|
332
|
+
)
|
|
333
|
+
const { refused: normalizerRefused } = useVolumeNormalizer(
|
|
334
|
+
video, normalizerWorkletUrl, normalizeVolume, normalizerSource, normalizerRanges,
|
|
335
|
+
)
|
|
308
336
|
const volumeNormalizer = useMemo<VolumeNormalizerControl | null>(
|
|
309
337
|
() => normalizerWorkletUrl && typeof AudioWorkletNode === 'function'
|
|
310
338
|
? {
|