@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/README.md +34 -22
- package/dist/audio/loudness-analysis.d.ts +38 -0
- package/dist/audio/playback-tap.d.ts +11 -0
- package/dist/audio/volume-normalizer.d.ts +4 -2
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/playback.d.ts +21 -0
- package/dist/{engine-C4Gj_4zs.js → engine-Cr_7OImR.js} +120 -103
- package/dist/index.d.ts +1 -1
- package/dist/index.js +929 -651
- package/dist/react/hooks/use-playback.d.ts +2 -1
- package/dist/react/hooks/use-volume-normalizer.d.ts +9 -1
- package/dist/react/video-player.d.ts +19 -7
- package/dist/utils/source.d.ts +6 -1
- package/dist/volume-normalizer-worklet.js +1200 -78
- package/package.json +3 -3
- package/src/lib/audio/loudness-analysis.ts +307 -0
- package/src/lib/audio/playback-tap.ts +37 -0
- package/src/lib/audio/volume-normalizer-worklet.d.ts +49 -0
- package/src/lib/audio/volume-normalizer-worklet.js +1200 -78
- package/src/lib/audio/volume-normalizer.ts +5 -3
- package/src/lib/engine/index.ts +1 -1
- package/src/lib/engine/playback.ts +21 -1
- package/src/lib/index.tsx +1 -0
- package/src/lib/react/hooks/use-playback.ts +5 -1
- package/src/lib/react/hooks/use-volume-normalizer.ts +40 -2
- package/src/lib/react/video-player.tsx +55 -15
- package/src/lib/utils/source.ts +6 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@banou/media-player",
|
|
3
|
-
"version": "0.10.
|
|
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.
|
|
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
|