@banou/media-player 0.10.20 → 0.10.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/media-player",
3
- "version": "0.10.20",
3
+ "version": "0.10.22",
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",
@@ -34,12 +34,12 @@
34
34
  "types": "tsc -p tsconfig.lib.json",
35
35
  "copy-assets": "shx mkdir -p public && npm run copy-libav",
36
36
  "copy-libav": "shx cp node_modules/libav-wasm/build/worker.js public/libav-worker.js && shx cp node_modules/libav-wasm/build/libav.wasm public/libav.wasm && shx cp node_modules/libav-wasm/build/libav-jspi.wasm public/libav-jspi.wasm",
37
- "test": "npm run fixtures && vp test run",
37
+ "test": "npm run copy-assets && npm run fixtures && vp test run",
38
38
  "fixtures": "node scripts/fixture.mjs"
39
39
  },
40
40
  "author": "Banou26",
41
41
  "dependencies": {
42
- "@banou/ponyfill": "^0.0.10",
42
+ "@banou/ponyfill": "^0.0.12",
43
43
  "@videojs/core": "10.0.0-beta.26",
44
44
  "@videojs/react": "10.0.0-beta.26",
45
45
  "ass-compiler": "^0.1.16",
@@ -0,0 +1,307 @@
1
+ import type { PlaybackTap } from './playback-tap'
2
+
3
+ import { makeRemuxer } from 'libav-wasm'
4
+
5
+ import { terminateRemuxer } from '../engine/playback'
6
+
7
+ /** The file the analysis reads, the same one playback plays. */
8
+ export type LoudnessSource = {
9
+ read: (offset: number, size: number) => Promise<ArrayBuffer>
10
+ length: number
11
+ publicPath: string
12
+ libavWorkerUrl: string
13
+ /** The input stream index of the audio track playing. */
14
+ audioStreamIndex: number
15
+ }
16
+
17
+ export type LoudnessAnalysis = {
18
+ /**
19
+ * Which bytes can be read without waiting on a download, as [start, end) pairs; undefined when all of them can.
20
+ * Nothing outside them is asked of the source.
21
+ */
22
+ update: (ranges?: [number, number][]) => void
23
+ stop: () => void
24
+ }
25
+
26
+ type Coverage = { spans: [number, number][], end: number | null }
27
+
28
+ // a gap this close ahead of the playhead is analysed before anything else
29
+ const LEAD_SECONDS = 20
30
+ // a run this far short of a gap reads on to it rather than jumping there
31
+ const CATCH_UP_SECONDS = 5
32
+ // a run for a gap starts this far before it, so the 50 ms a fresh decoder gets wrong falls on analysed audio
33
+ const RUN_LEAD_IN_SECONDS = 1
34
+ const CLOCK_MS = 50
35
+
36
+ /**
37
+ * Analyses `source` ahead of the playhead with a second remuxer, in the worker the normalizer's script starts, and
38
+ * hands the worklet behind `node` the cut it computes. It reads only what `update` says is there, analyses what is
39
+ * nearest the playhead first, then the rest of the file, and drops nothing it learned on a seek. Until a stretch is
40
+ * analysed, and for the whole file where this browser's WebCodecs cannot decode the track, the worklet's live stage
41
+ * plays.
42
+ *
43
+ * Given a `tap` instead, it reads nothing and analyses the audio playback remuxed for `audioStreamIndex`, which runs
44
+ * as far ahead of the playhead as playback buffers. It starts at the next fragment playback fetches, so what was
45
+ * buffered before it attached is not analysed.
46
+ */
47
+ export const startLoudnessAnalysis = ({ node, element, scriptUrl, source }: {
48
+ node: AudioWorkletNode
49
+ element: HTMLMediaElement
50
+ scriptUrl: string
51
+ source: LoudnessSource | { tap: PlaybackTap, audioStreamIndex: number }
52
+ }): LoudnessAnalysis => {
53
+ let stopped = false
54
+ let ranges: [number, number][] | undefined
55
+ const waiting = new Set<(abandon: boolean) => void>()
56
+ const wakers = new Set<() => void>()
57
+ const wake = () => {
58
+ for (const waker of [...wakers]) waker()
59
+ wakers.clear()
60
+ }
61
+ const changed = () => new Promise<void>((resolve) => wakers.add(resolve))
62
+
63
+ const read = async (reader: LoudnessSource, offset: number, size: number) => {
64
+ const end = Math.min(offset + size, reader.length)
65
+ while (ranges && !ranges.some(([from, to]) => from <= offset && end <= to)) {
66
+ // given up once the task waiting on it has been aborted, or the analysis stopped
67
+ if (stopped || await new Promise<boolean>((resolve) => waiting.add(resolve))) throw new Error('Cancelled')
68
+ }
69
+ return reader.read(offset, size)
70
+ }
71
+ const release = (abandon: boolean) => {
72
+ for (const resolve of [...waiting]) resolve(abandon)
73
+ waiting.clear()
74
+ }
75
+
76
+ let worker: Worker
77
+ try {
78
+ worker = new Worker(scriptUrl, { type: 'module' })
79
+ } catch (error) {
80
+ // a script from another origin, which a worker cannot run: the live stage plays alone
81
+ console.warn('[media-player] the volume normalizer cannot analyse ahead:', error)
82
+ return { update: () => {}, stop: () => {} }
83
+ }
84
+ const channel = new MessageChannel()
85
+ worker.postMessage({ type: 'port', port: channel.port1 }, [channel.port1])
86
+ node.port.postMessage({ analysis: channel.port2 }, [channel.port2])
87
+
88
+ let coverage: Coverage = { spans: [], end: null }
89
+ let version = 0
90
+ const replies = new Map<string, (data: Coverage) => void>()
91
+ const reply = (type: string, run: number) => new Promise<Coverage>((resolve) => replies.set(`${type}:${run}`, resolve))
92
+ let mergedRun = -1
93
+ worker.onmessage = ({ data }) => {
94
+ if (data.type === 'unsupported') {
95
+ stop()
96
+ return
97
+ }
98
+ if (data.spans) {
99
+ if (JSON.stringify(data.spans) !== JSON.stringify(coverage.spans) || data.end !== coverage.end) version++
100
+ coverage = { spans: data.spans, end: data.end }
101
+ }
102
+ if (data.type === 'merged') mergedRun = data.run
103
+ replies.get(`${data.type}:${data.run}`)?.(coverage)
104
+ replies.delete(`${data.type}:${data.run}`)
105
+ wake()
106
+ }
107
+
108
+ const context = node.context as AudioContext
109
+ const clock = () => node.port.postMessage({
110
+ media: element.currentTime,
111
+ ctx: context.currentTime,
112
+ rate: element.playbackRate,
113
+ paused: element.paused || element.seeking || element.readyState < 3,
114
+ })
115
+ const playhead = () => worker.postMessage({ type: 'playhead', time: element.currentTime })
116
+ const clockTimer = setInterval(clock, CLOCK_MS)
117
+ const clockEvents = ['play', 'playing', 'pause', 'seeking', 'seeked', 'ratechange', 'waiting', 'ended']
118
+ for (const type of clockEvents) element.addEventListener(type, clock)
119
+
120
+ let duration = Infinity
121
+ // past init: a read that init waits on, Matroska's index at the end of a torrent, is never given up
122
+ let started = false
123
+ // the remuxer's run: where it started and how far it has read, in seconds
124
+ let run = 0
125
+ let active = false
126
+ let runStart = 0
127
+ let position = 0
128
+ const tried = new Map<number, number>()
129
+
130
+ // the first time at or after `time` nothing analysed covers, or null when it is all analysed from there
131
+ const gapFrom = (time: number) => {
132
+ let at = time
133
+ for (const [from, to] of coverage.spans) if (from <= at && at < to) at = to
134
+ return at >= (coverage.end ?? duration) - 0.05 ? null : at
135
+ }
136
+ const attempt = (target: number) => tried.get(Math.round(target * 10)) === version ? 'idle' as const : target
137
+ const decide = (): 'read' | 'idle' | 'done' | number => {
138
+ const time = element.currentTime
139
+ const gap = gapFrom(time)
140
+ const reading = active && mergedRun !== run
141
+ if (gap !== null && gap < time + LEAD_SECONDS) {
142
+ if (reading && runStart <= gap + 1 && gap <= position + CATCH_UP_SECONDS) return 'read'
143
+ return attempt(gap)
144
+ }
145
+ if (reading) return 'read'
146
+ const next = gap ?? gapFrom(0)
147
+ return next === null ? 'done' : attempt(next)
148
+ }
149
+ /*
150
+ * A remuxer call that waits on a download is let go of when a seek wants to move on, and the seek that follows
151
+ * aborts it: libav aborts the task in flight when another starts. Failing the read itself does not end the task,
152
+ * since Matroska's demuxer reads the same bytes again (HOR-498 review, 2026-10-17).
153
+ */
154
+ let abandon = () => {}
155
+ const abandonable = <T>(task: Promise<T>) => {
156
+ task.catch(() => {})
157
+ return Promise.race([task, new Promise<undefined>((resolve) => { abandon = () => resolve(undefined) })])
158
+ }
159
+ const onSeeking = () => {
160
+ playhead()
161
+ if (started && waiting.size && typeof decide() === 'number') abandon()
162
+ wake()
163
+ }
164
+ const onTime = () => {
165
+ playhead()
166
+ wake()
167
+ }
168
+ element.addEventListener('seeking', onSeeking)
169
+ element.addEventListener('seeked', onTime)
170
+ element.addEventListener('timeupdate', onTime)
171
+
172
+ let remuxer: Awaited<ReturnType<typeof makeRemuxer>> | undefined
173
+ const endRun = async (finished: boolean) => {
174
+ if (!active) return
175
+ active = false
176
+ const ended = reply('ended', run)
177
+ worker.postMessage({ type: 'end', run, finished })
178
+ await ended
179
+ }
180
+ const send = async (result: { data: ArrayBuffer, pts: number, duration: number }) => {
181
+ position = result.pts + result.duration
182
+ if (!result.data.byteLength) return
183
+ const acked = reply('ack', run)
184
+ worker.postMessage({ type: 'fragment', run, data: result.data }, [result.data])
185
+ await acked
186
+ }
187
+
188
+ const loop = async (reader: LoudnessSource) => {
189
+ remuxer = await makeRemuxer({
190
+ publicPath: reader.publicPath,
191
+ workerUrl: reader.libavWorkerUrl,
192
+ workerOptions: { type: 'module' },
193
+ length: reader.length,
194
+ read: (offset, size) => read(reader, offset, size),
195
+ audioStreamIndex: reader.audioStreamIndex,
196
+ })
197
+ // stopped while it was being made, which stop() could not end
198
+ if (stopped) return terminateRemuxer(remuxer)
199
+ const init = await remuxer.init()
200
+ if (stopped) return
201
+ duration = init.info.input.duration || Infinity
202
+ worker.postMessage({ type: 'init', data: init.data, codec: init.info.output.audioMimeType }, [init.data])
203
+ active = true
204
+ started = true
205
+ worker.postMessage({ type: 'run', run, pts: 0, start: true })
206
+ while (!stopped) {
207
+ const next = decide()
208
+ if (next === 'done') break
209
+ if (next === 'idle') {
210
+ await changed()
211
+ continue
212
+ }
213
+ try {
214
+ if (next === 'read') {
215
+ const result = await abandonable(remuxer.read())
216
+ if (stopped) return
217
+ if (!result) continue
218
+ await send(result)
219
+ if (result.finished) await endRun(true)
220
+ } else {
221
+ await endRun(false)
222
+ tried.set(Math.round(next * 10), version)
223
+ const seeking = remuxer.seek(Math.max(0, next - RUN_LEAD_IN_SECONDS))
224
+ // what the task it aborted was waiting on
225
+ release(true)
226
+ const result = await abandonable(seeking)
227
+ if (stopped) return
228
+ if (!result) continue
229
+ run++
230
+ active = true
231
+ runStart = result.pts
232
+ worker.postMessage({ type: 'run', run, pts: result.pts, start: result.pts <= 0 })
233
+ await send(result)
234
+ if (result.finished) await endRun(true)
235
+ }
236
+ } catch {
237
+ if (stopped) return
238
+ // a read given up or failed leaves the remuxer wherever it was, so the next one is a seek
239
+ await endRun(false)
240
+ if (typeof decide() !== 'number') await changed()
241
+ }
242
+ }
243
+ // everything is analysed: the worker keeps serving the cut, and the remuxer is no longer needed
244
+ terminateRemuxer(remuxer)
245
+ remuxer = undefined
246
+ }
247
+
248
+ let detach = () => {}
249
+ const follow = (tap: PlaybackTap, audioStreamIndex: number) => {
250
+ let open = false
251
+ const end = (finished: boolean) => {
252
+ if (open) worker.postMessage({ type: 'end', run, finished })
253
+ open = false
254
+ }
255
+ detach = tap.attach(audioStreamIndex, (output, join) => {
256
+ if (output.type === 'init') {
257
+ end(false)
258
+ const data = output.data.slice(0)
259
+ worker.postMessage({ type: 'init', data, codec: output.audioMimeType }, [data])
260
+ } else if (output.type === 'run') {
261
+ end(false)
262
+ open = true
263
+ worker.postMessage({ type: 'run', run: ++run, pts: output.pts, start: !join && output.pts <= 0, join })
264
+ } else if (output.type === 'end') end(true)
265
+ else if (open) {
266
+ const data = output.data.slice(0)
267
+ worker.postMessage({ type: 'fragment', run, data }, [data])
268
+ }
269
+ })
270
+ }
271
+
272
+ const stop = () => {
273
+ if (stopped) return
274
+ stopped = true
275
+ release(true)
276
+ wake()
277
+ clearInterval(clockTimer)
278
+ for (const type of clockEvents) element.removeEventListener(type, clock)
279
+ element.removeEventListener('seeking', onSeeking)
280
+ element.removeEventListener('seeked', onTime)
281
+ element.removeEventListener('timeupdate', onTime)
282
+ detach()
283
+ if (remuxer) terminateRemuxer(remuxer)
284
+ worker.terminate()
285
+ node.port.postMessage({ analysis: null })
286
+ for (const resolve of replies.values()) resolve(coverage)
287
+ replies.clear()
288
+ }
289
+
290
+ if ('tap' in source) follow(source.tap, source.audioStreamIndex)
291
+ else {
292
+ loop(source).catch((error) => {
293
+ if (stopped) return
294
+ console.warn('[media-player] the volume normalizer cannot analyse ahead:', error)
295
+ stop()
296
+ })
297
+ }
298
+ playhead()
299
+
300
+ return {
301
+ update: (next) => {
302
+ ranges = next
303
+ release(false)
304
+ },
305
+ stop,
306
+ }
307
+ }
@@ -0,0 +1,37 @@
1
+ import type { PlaybackOutput } from '../engine/playback'
2
+
3
+ /**
4
+ * Playback's output, for an analysis that reads nothing of its own: `push` takes what playback's remuxer produced,
5
+ * and `attach` hands a listener what follows for one input audio stream, starting with the last init segment and the
6
+ * run in progress. `join` marks a run some of whose fragments went by before the listener was attached.
7
+ */
8
+ export type PlaybackTap = {
9
+ push: (output: PlaybackOutput) => void
10
+ attach: (audioStreamIndex: number, listener: (output: PlaybackOutput, join?: boolean) => void) => () => void
11
+ }
12
+
13
+ export const createPlaybackTap = (): PlaybackTap => {
14
+ let init: Extract<PlaybackOutput, { type: 'init' }> | undefined
15
+ let run: { pts: number, begun: boolean } | undefined
16
+ let listener: { audioStreamIndex: number, take: (output: PlaybackOutput, join?: boolean) => void } | undefined
17
+ const live = () => listener && init?.audioStreamIndex === listener.audioStreamIndex ? listener : undefined
18
+ return {
19
+ push: (output) => {
20
+ if (output.type === 'init') {
21
+ init = output
22
+ run = undefined
23
+ } else if (output.type === 'run') run = { pts: output.pts, begun: false }
24
+ else if (output.type === 'fragment' && run) run.begun = true
25
+ live()?.take(output)
26
+ },
27
+ attach: (audioStreamIndex, take) => {
28
+ const mine = { audioStreamIndex, take }
29
+ listener = mine
30
+ if (live() && init) {
31
+ take(init)
32
+ if (run) take({ type: 'run', pts: run.pts }, run.begun)
33
+ }
34
+ return () => { if (listener === mine) listener = undefined }
35
+ },
36
+ }
37
+ }
@@ -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