@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
package/package.json
CHANGED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { makeRemuxer } from 'libav-wasm'
|
|
2
|
+
|
|
3
|
+
import { terminateRemuxer } from '../engine/playback'
|
|
4
|
+
|
|
5
|
+
/** The file the analysis reads, the same one playback plays. */
|
|
6
|
+
export type LoudnessSource = {
|
|
7
|
+
read: (offset: number, size: number) => Promise<ArrayBuffer>
|
|
8
|
+
length: number
|
|
9
|
+
publicPath: string
|
|
10
|
+
libavWorkerUrl: string
|
|
11
|
+
/** The input stream index of the audio track playing. */
|
|
12
|
+
audioStreamIndex: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type LoudnessAnalysis = {
|
|
16
|
+
/**
|
|
17
|
+
* Which bytes can be read without waiting on a download, as [start, end) pairs; undefined when all of them can.
|
|
18
|
+
* Nothing outside them is asked of the source.
|
|
19
|
+
*/
|
|
20
|
+
update: (ranges?: [number, number][]) => void
|
|
21
|
+
stop: () => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type Coverage = { spans: [number, number][], end: number | null }
|
|
25
|
+
|
|
26
|
+
// a gap this close ahead of the playhead is analysed before anything else
|
|
27
|
+
const LEAD_SECONDS = 20
|
|
28
|
+
// a run this far short of a gap reads on to it rather than jumping there
|
|
29
|
+
const CATCH_UP_SECONDS = 5
|
|
30
|
+
// a run for a gap starts this far before it, so the 50 ms a fresh decoder gets wrong falls on analysed audio
|
|
31
|
+
const RUN_LEAD_IN_SECONDS = 1
|
|
32
|
+
const CLOCK_MS = 50
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Analyses `source` ahead of the playhead with a second remuxer, in the worker the normalizer's script starts, and
|
|
36
|
+
* hands the worklet behind `node` the cut it computes. It reads only what `update` says is there, analyses what is
|
|
37
|
+
* nearest the playhead first, then the rest of the file, and drops nothing it learned on a seek. Until a stretch is
|
|
38
|
+
* analysed, and for the whole file where this browser's WebCodecs cannot decode the track, the worklet's live stage
|
|
39
|
+
* plays.
|
|
40
|
+
*/
|
|
41
|
+
export const startLoudnessAnalysis = ({ node, element, scriptUrl, source }: {
|
|
42
|
+
node: AudioWorkletNode
|
|
43
|
+
element: HTMLMediaElement
|
|
44
|
+
scriptUrl: string
|
|
45
|
+
source: LoudnessSource
|
|
46
|
+
}): LoudnessAnalysis => {
|
|
47
|
+
let stopped = false
|
|
48
|
+
let ranges: [number, number][] | undefined
|
|
49
|
+
const waiting = new Set<(abandon: boolean) => void>()
|
|
50
|
+
const wakers = new Set<() => void>()
|
|
51
|
+
const wake = () => {
|
|
52
|
+
for (const waker of [...wakers]) waker()
|
|
53
|
+
wakers.clear()
|
|
54
|
+
}
|
|
55
|
+
const changed = () => new Promise<void>((resolve) => wakers.add(resolve))
|
|
56
|
+
|
|
57
|
+
const readable = (offset: number, size: number) =>
|
|
58
|
+
!ranges || ranges.some(([from, to]) => from <= offset && Math.min(offset + size, source.length) <= to)
|
|
59
|
+
const read = async (offset: number, size: number) => {
|
|
60
|
+
while (!readable(offset, size)) {
|
|
61
|
+
// given up once the task waiting on it has been aborted, or the analysis stopped
|
|
62
|
+
if (stopped || await new Promise<boolean>((resolve) => waiting.add(resolve))) throw new Error('Cancelled')
|
|
63
|
+
}
|
|
64
|
+
return source.read(offset, size)
|
|
65
|
+
}
|
|
66
|
+
const release = (abandon: boolean) => {
|
|
67
|
+
for (const resolve of [...waiting]) resolve(abandon)
|
|
68
|
+
waiting.clear()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let worker: Worker
|
|
72
|
+
try {
|
|
73
|
+
worker = new Worker(scriptUrl, { type: 'module' })
|
|
74
|
+
} catch (error) {
|
|
75
|
+
// a script from another origin, which a worker cannot run: the live stage plays alone
|
|
76
|
+
console.warn('[media-player] the volume normalizer cannot analyse ahead:', error)
|
|
77
|
+
return { update: () => {}, stop: () => {} }
|
|
78
|
+
}
|
|
79
|
+
const channel = new MessageChannel()
|
|
80
|
+
worker.postMessage({ type: 'port', port: channel.port1 }, [channel.port1])
|
|
81
|
+
node.port.postMessage({ analysis: channel.port2 }, [channel.port2])
|
|
82
|
+
|
|
83
|
+
let coverage: Coverage = { spans: [], end: null }
|
|
84
|
+
let version = 0
|
|
85
|
+
const replies = new Map<string, (data: Coverage) => void>()
|
|
86
|
+
const reply = (type: string, run: number) => new Promise<Coverage>((resolve) => replies.set(`${type}:${run}`, resolve))
|
|
87
|
+
let mergedRun = -1
|
|
88
|
+
worker.onmessage = ({ data }) => {
|
|
89
|
+
if (data.type === 'unsupported') {
|
|
90
|
+
stop()
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
if (data.spans) {
|
|
94
|
+
if (JSON.stringify(data.spans) !== JSON.stringify(coverage.spans) || data.end !== coverage.end) version++
|
|
95
|
+
coverage = { spans: data.spans, end: data.end }
|
|
96
|
+
}
|
|
97
|
+
if (data.type === 'merged') mergedRun = data.run
|
|
98
|
+
replies.get(`${data.type}:${data.run}`)?.(coverage)
|
|
99
|
+
replies.delete(`${data.type}:${data.run}`)
|
|
100
|
+
wake()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const context = node.context as AudioContext
|
|
104
|
+
const clock = () => node.port.postMessage({
|
|
105
|
+
media: element.currentTime,
|
|
106
|
+
ctx: context.currentTime,
|
|
107
|
+
rate: element.playbackRate,
|
|
108
|
+
paused: element.paused || element.seeking || element.readyState < 3,
|
|
109
|
+
})
|
|
110
|
+
const playhead = () => worker.postMessage({ type: 'playhead', time: element.currentTime })
|
|
111
|
+
const clockTimer = setInterval(clock, CLOCK_MS)
|
|
112
|
+
const clockEvents = ['play', 'playing', 'pause', 'seeking', 'seeked', 'ratechange', 'waiting', 'ended']
|
|
113
|
+
for (const type of clockEvents) element.addEventListener(type, clock)
|
|
114
|
+
|
|
115
|
+
let duration = Infinity
|
|
116
|
+
// past init: a read that init waits on, Matroska's index at the end of a torrent, is never given up
|
|
117
|
+
let started = false
|
|
118
|
+
// the remuxer's run: where it started and how far it has read, in seconds
|
|
119
|
+
let run = 0
|
|
120
|
+
let active = false
|
|
121
|
+
let runStart = 0
|
|
122
|
+
let position = 0
|
|
123
|
+
const tried = new Map<number, number>()
|
|
124
|
+
|
|
125
|
+
// the first time at or after `time` nothing analysed covers, or null when it is all analysed from there
|
|
126
|
+
const gapFrom = (time: number) => {
|
|
127
|
+
let at = time
|
|
128
|
+
for (const [from, to] of coverage.spans) if (from <= at && at < to) at = to
|
|
129
|
+
return at >= (coverage.end ?? duration) - 0.05 ? null : at
|
|
130
|
+
}
|
|
131
|
+
const attempt = (target: number) => tried.get(Math.round(target * 10)) === version ? 'idle' as const : target
|
|
132
|
+
const decide = (): 'read' | 'idle' | 'done' | number => {
|
|
133
|
+
const time = element.currentTime
|
|
134
|
+
const gap = gapFrom(time)
|
|
135
|
+
const reading = active && mergedRun !== run
|
|
136
|
+
if (gap !== null && gap < time + LEAD_SECONDS) {
|
|
137
|
+
if (reading && runStart <= gap + 1 && gap <= position + CATCH_UP_SECONDS) return 'read'
|
|
138
|
+
return attempt(gap)
|
|
139
|
+
}
|
|
140
|
+
if (reading) return 'read'
|
|
141
|
+
const next = gap ?? gapFrom(0)
|
|
142
|
+
return next === null ? 'done' : attempt(next)
|
|
143
|
+
}
|
|
144
|
+
/*
|
|
145
|
+
* A remuxer call that waits on a download is let go of when a seek wants to move on, and the seek that follows
|
|
146
|
+
* aborts it: libav aborts the task in flight when another starts. Failing the read itself does not end the task,
|
|
147
|
+
* since Matroska's demuxer reads the same bytes again (HOR-498 review, 2026-10-17).
|
|
148
|
+
*/
|
|
149
|
+
let abandon = () => {}
|
|
150
|
+
const abandonable = <T>(task: Promise<T>) => {
|
|
151
|
+
task.catch(() => {})
|
|
152
|
+
return Promise.race([task, new Promise<undefined>((resolve) => { abandon = () => resolve(undefined) })])
|
|
153
|
+
}
|
|
154
|
+
const onSeeking = () => {
|
|
155
|
+
playhead()
|
|
156
|
+
if (started && waiting.size && typeof decide() === 'number') abandon()
|
|
157
|
+
wake()
|
|
158
|
+
}
|
|
159
|
+
const onTime = () => {
|
|
160
|
+
playhead()
|
|
161
|
+
wake()
|
|
162
|
+
}
|
|
163
|
+
element.addEventListener('seeking', onSeeking)
|
|
164
|
+
element.addEventListener('seeked', onTime)
|
|
165
|
+
element.addEventListener('timeupdate', onTime)
|
|
166
|
+
|
|
167
|
+
let remuxer: Awaited<ReturnType<typeof makeRemuxer>> | undefined
|
|
168
|
+
const endRun = async (finished: boolean) => {
|
|
169
|
+
if (!active) return
|
|
170
|
+
active = false
|
|
171
|
+
const ended = reply('ended', run)
|
|
172
|
+
worker.postMessage({ type: 'end', run, finished })
|
|
173
|
+
await ended
|
|
174
|
+
}
|
|
175
|
+
const send = async (result: { data: ArrayBuffer, pts: number, duration: number }) => {
|
|
176
|
+
position = result.pts + result.duration
|
|
177
|
+
if (!result.data.byteLength) return
|
|
178
|
+
const acked = reply('ack', run)
|
|
179
|
+
worker.postMessage({ type: 'fragment', run, data: result.data }, [result.data])
|
|
180
|
+
await acked
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const loop = async () => {
|
|
184
|
+
remuxer = await makeRemuxer({
|
|
185
|
+
publicPath: source.publicPath,
|
|
186
|
+
workerUrl: source.libavWorkerUrl,
|
|
187
|
+
workerOptions: { type: 'module' },
|
|
188
|
+
length: source.length,
|
|
189
|
+
read,
|
|
190
|
+
audioStreamIndex: source.audioStreamIndex,
|
|
191
|
+
})
|
|
192
|
+
// stopped while it was being made, which stop() could not end
|
|
193
|
+
if (stopped) return terminateRemuxer(remuxer)
|
|
194
|
+
const init = await remuxer.init()
|
|
195
|
+
if (stopped) return
|
|
196
|
+
duration = init.info.input.duration || Infinity
|
|
197
|
+
worker.postMessage({ type: 'init', data: init.data, codec: init.info.output.audioMimeType }, [init.data])
|
|
198
|
+
active = true
|
|
199
|
+
started = true
|
|
200
|
+
worker.postMessage({ type: 'run', run, pts: 0, start: true })
|
|
201
|
+
while (!stopped) {
|
|
202
|
+
const next = decide()
|
|
203
|
+
if (next === 'done') break
|
|
204
|
+
if (next === 'idle') {
|
|
205
|
+
await changed()
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
if (next === 'read') {
|
|
210
|
+
const result = await abandonable(remuxer.read())
|
|
211
|
+
if (stopped) return
|
|
212
|
+
if (!result) continue
|
|
213
|
+
await send(result)
|
|
214
|
+
if (result.finished) await endRun(true)
|
|
215
|
+
} else {
|
|
216
|
+
await endRun(false)
|
|
217
|
+
tried.set(Math.round(next * 10), version)
|
|
218
|
+
const seeking = remuxer.seek(Math.max(0, next - RUN_LEAD_IN_SECONDS))
|
|
219
|
+
// what the task it aborted was waiting on
|
|
220
|
+
release(true)
|
|
221
|
+
const result = await abandonable(seeking)
|
|
222
|
+
if (stopped) return
|
|
223
|
+
if (!result) continue
|
|
224
|
+
run++
|
|
225
|
+
active = true
|
|
226
|
+
runStart = result.pts
|
|
227
|
+
worker.postMessage({ type: 'run', run, pts: result.pts, start: result.pts <= 0 })
|
|
228
|
+
await send(result)
|
|
229
|
+
if (result.finished) await endRun(true)
|
|
230
|
+
}
|
|
231
|
+
} catch {
|
|
232
|
+
if (stopped) return
|
|
233
|
+
// a read given up or failed leaves the remuxer wherever it was, so the next one is a seek
|
|
234
|
+
await endRun(false)
|
|
235
|
+
if (typeof decide() !== 'number') await changed()
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// everything is analysed: the worker keeps serving the cut, and the remuxer is no longer needed
|
|
239
|
+
terminateRemuxer(remuxer)
|
|
240
|
+
remuxer = undefined
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const stop = () => {
|
|
244
|
+
if (stopped) return
|
|
245
|
+
stopped = true
|
|
246
|
+
release(true)
|
|
247
|
+
wake()
|
|
248
|
+
clearInterval(clockTimer)
|
|
249
|
+
for (const type of clockEvents) element.removeEventListener(type, clock)
|
|
250
|
+
element.removeEventListener('seeking', onSeeking)
|
|
251
|
+
element.removeEventListener('seeked', onTime)
|
|
252
|
+
element.removeEventListener('timeupdate', onTime)
|
|
253
|
+
if (remuxer) terminateRemuxer(remuxer)
|
|
254
|
+
worker.terminate()
|
|
255
|
+
node.port.postMessage({ analysis: null })
|
|
256
|
+
for (const resolve of replies.values()) resolve(coverage)
|
|
257
|
+
replies.clear()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
loop().catch((error) => {
|
|
261
|
+
if (stopped) return
|
|
262
|
+
console.warn('[media-player] the volume normalizer cannot analyse ahead:', error)
|
|
263
|
+
stop()
|
|
264
|
+
})
|
|
265
|
+
playhead()
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
update: (next) => {
|
|
269
|
+
ranges = next
|
|
270
|
+
release(false)
|
|
271
|
+
},
|
|
272
|
+
stop,
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The worklet module is served as written and never bundled; these are its exports, for the tests that run its
|
|
2
|
+
// analysis directly.
|
|
3
|
+
|
|
4
|
+
export type CurveOptions = {
|
|
5
|
+
short: { seconds: number, headroom: number }[]
|
|
6
|
+
headroom400: number
|
|
7
|
+
headroom3s: number
|
|
8
|
+
maxCutDb: number
|
|
9
|
+
fast: { hold: number, release: number, attack: number, smooth: number }
|
|
10
|
+
medium: { hold: number, release: number, attack: number, smooth: number }
|
|
11
|
+
slow: { hold: number, release: number, attack: number, smooth: number }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const CURVE: CurveOptions
|
|
15
|
+
export const normalLevel: (energy: ArrayLike<number>) => number
|
|
16
|
+
export const gainCurve: (energy: Float32Array, normal: number, options?: CurveOptions) => Float32Array
|
|
17
|
+
export const makeBinner: (
|
|
18
|
+
rate: number,
|
|
19
|
+
channelCount: number,
|
|
20
|
+
emit: (bin: number, energy: number, finger: number) => void,
|
|
21
|
+
) => { push: (planes: Float32Array[], frames: number, time: number) => void, flush: () => void }
|
|
22
|
+
|
|
23
|
+
export type AudioTrack = {
|
|
24
|
+
id: number
|
|
25
|
+
timescale: number
|
|
26
|
+
channels: number
|
|
27
|
+
sampleRate: number
|
|
28
|
+
description: Uint8Array | undefined
|
|
29
|
+
defaultDuration: number
|
|
30
|
+
defaultSize: number
|
|
31
|
+
}
|
|
32
|
+
export const audioTrackOf: (init: ArrayBuffer) => AudioTrack | undefined
|
|
33
|
+
export const audioSamplesOf: (fragment: ArrayBuffer, track: AudioTrack) => { time: number, duration: number, data: Uint8Array }[]
|
|
34
|
+
|
|
35
|
+
export type BlockMessage = { block: number, gain: Float32Array, finger: Float32Array } | { drop: number }
|
|
36
|
+
export type Analysis = {
|
|
37
|
+
startRun: (id: number, time: number, start?: boolean) => void
|
|
38
|
+
push: (planes: Float32Array[], frames: number, rate: number, time: number) => void
|
|
39
|
+
endRun: (finished: boolean) => void
|
|
40
|
+
coverage: () => { spans: [number, number][], end: number | null, normal: number }
|
|
41
|
+
setPlayhead: (time: number) => void
|
|
42
|
+
flushBlocks: () => void
|
|
43
|
+
normal: () => number
|
|
44
|
+
spans: [number, number][]
|
|
45
|
+
}
|
|
46
|
+
export const createAnalysis: (handlers: {
|
|
47
|
+
toWorklet: (message: BlockMessage, transfer?: Transferable[]) => void
|
|
48
|
+
toPage?: (message: { type: string, run?: number, spans?: [number, number][], end?: number | null, normal?: number }) => void
|
|
49
|
+
}) => Analysis
|