@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/media-player",
3
- "version": "0.10.19",
3
+ "version": "0.10.21",
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",
@@ -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