@blockcast/mmt-render 0.1.0-main.5dd8358ae902
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 +207 -0
- package/dist/.build-stamp +0 -0
- package/dist/audio/audio-buffer-plan.d.ts +27 -0
- package/dist/audio/audio-buffer-plan.d.ts.map +1 -0
- package/dist/audio/audio-render-pipeline.d.ts +83 -0
- package/dist/audio/audio-render-pipeline.d.ts.map +1 -0
- package/dist/audio/audio-render-pipeline.js +372 -0
- package/dist/audio/audio-render-pipeline.js.map +7 -0
- package/dist/audio/audio-ring-buffer.d.ts +55 -0
- package/dist/audio/audio-ring-buffer.d.ts.map +1 -0
- package/dist/audio/audio-ring-buffer.js +255 -0
- package/dist/audio/audio-ring-buffer.js.map +7 -0
- package/dist/audio/index.d.ts +9 -0
- package/dist/audio/index.d.ts.map +1 -0
- package/dist/audio/index.js +640 -0
- package/dist/audio/index.js.map +7 -0
- package/dist/audio/render-messages.d.ts +94 -0
- package/dist/audio/render-messages.d.ts.map +1 -0
- package/dist/audio/render-messages.js +1 -0
- package/dist/audio/render-messages.js.map +7 -0
- package/dist/audio/render-worklet.d.ts +13 -0
- package/dist/audio/render-worklet.d.ts.map +1 -0
- package/dist/audio/worklet-url.d.ts +25 -0
- package/dist/audio/worklet-url.d.ts.map +1 -0
- package/dist/audio/worklet-url.js +11 -0
- package/dist/audio/worklet-url.js.map +7 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/pacing/au-duration.d.ts +18 -0
- package/dist/pacing/au-duration.d.ts.map +1 -0
- package/dist/pacing/au-duration.js +23 -0
- package/dist/pacing/au-duration.js.map +7 -0
- package/dist/pacing/frame-pacer.d.ts +71 -0
- package/dist/pacing/frame-pacer.d.ts.map +1 -0
- package/dist/pacing/frame-pacer.js +89 -0
- package/dist/pacing/frame-pacer.js.map +7 -0
- package/dist/pacing/index.d.ts +6 -0
- package/dist/pacing/index.d.ts.map +1 -0
- package/dist/pacing/index.js +166 -0
- package/dist/pacing/index.js.map +7 -0
- package/dist/pacing/interleave-timing.d.ts +64 -0
- package/dist/pacing/interleave-timing.d.ts.map +1 -0
- package/dist/pacing/interleave-timing.js +58 -0
- package/dist/pacing/interleave-timing.js.map +7 -0
- package/dist/video/index.d.ts +4 -0
- package/dist/video/index.d.ts.map +1 -0
- package/dist/video/index.js +247 -0
- package/dist/video/index.js.map +7 -0
- package/dist/video/keyframe-gate.d.ts +21 -0
- package/dist/video/keyframe-gate.d.ts.map +1 -0
- package/dist/video/keyframe-gate.js +39 -0
- package/dist/video/keyframe-gate.js.map +7 -0
- package/dist/video/video-render-pipeline.d.ts +60 -0
- package/dist/video/video-render-pipeline.d.ts.map +1 -0
- package/dist/video/video-render-pipeline.js +247 -0
- package/dist/video/video-render-pipeline.js.map +7 -0
- package/dist/worklet/render-worklet.js +403 -0
- package/dist/worklet/render-worklet.js.map +7 -0
- package/package.json +101 -0
- package/src/audio/audio-buffer-plan.ts +62 -0
- package/src/audio/audio-render-pipeline.ts +313 -0
- package/src/audio/audio-ring-buffer.ts +281 -0
- package/src/audio/index.ts +14 -0
- package/src/audio/render-messages.ts +110 -0
- package/src/audio/render-worklet.ts +161 -0
- package/src/audio/worklet-url.ts +30 -0
- package/src/index.ts +13 -0
- package/src/pacing/au-duration.ts +34 -0
- package/src/pacing/frame-pacer.ts +129 -0
- package/src/pacing/index.ts +5 -0
- package/src/pacing/interleave-timing.ts +95 -0
- package/src/video/index.ts +7 -0
- package/src/video/keyframe-gate.ts +35 -0
- package/src/video/video-render-pipeline.ts +179 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AudioRenderPipeline — end-to-end audio render for MoQ/MMT players.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline: MoQ object → MmtpDecodePipeline → WebCodecs AudioDecoder
|
|
5
|
+
* → AudioWorkletNode (exact-timestamp FIFO)
|
|
6
|
+
*
|
|
7
|
+
* The consumer:
|
|
8
|
+
* 1. constructs with codec/sampleRate/channels/mapper from catalog
|
|
9
|
+
* 2. awaits ready()
|
|
10
|
+
* 3. calls feedMoqObject(packet) for each arriving MoQ object
|
|
11
|
+
* 4. between objects, awaits paceSleepMs() to throttle burst delivery
|
|
12
|
+
* 5. connects rootNode to an AudioContext destination (or a GainNode)
|
|
13
|
+
* 6. close() on teardown
|
|
14
|
+
*
|
|
15
|
+
* Pacing uses FrameCountPacer (anchor on first decoded AU, frame count × AU
|
|
16
|
+
* duration) with threshold = mapper.pacingThresholdMs — NOT MPU timestamps
|
|
17
|
+
* (ambiguous under FEC interleave).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { MmtpDecodePipeline, type PipelineFecClient } from '@blockcast/mmt-container/mmtp-decode-pipeline'
|
|
21
|
+
import type { MfuReassemblyPolicy } from '@blockcast/mmt-container/mfu-reassembly-policy'
|
|
22
|
+
import type { FecBlockMapper } from '@blockcast/mmt-fec'
|
|
23
|
+
import { FrameCountPacer } from '../pacing/frame-pacer.js'
|
|
24
|
+
import { auDurationMsFromCodec } from '../pacing/au-duration.js'
|
|
25
|
+
import type {
|
|
26
|
+
ToMain,
|
|
27
|
+
DataMessage,
|
|
28
|
+
InitMessage,
|
|
29
|
+
ResetMessage,
|
|
30
|
+
StartMessage,
|
|
31
|
+
} from './render-messages.js'
|
|
32
|
+
import { WORKLET_PROCESSOR_NAME, workletModuleUrl } from './worklet-url.js'
|
|
33
|
+
|
|
34
|
+
export interface AudioRenderPipelineOpts {
|
|
35
|
+
context: AudioContext
|
|
36
|
+
codec: string
|
|
37
|
+
/** MMTP packet_id for this audio track — from catalog. */
|
|
38
|
+
packetId?: number
|
|
39
|
+
sampleRate: number
|
|
40
|
+
channels: number
|
|
41
|
+
description?: Uint8Array
|
|
42
|
+
mapper: FecBlockMapper
|
|
43
|
+
fecClient?: PipelineFecClient
|
|
44
|
+
/** Reliable MoQ is clock-free; datagram deadlines come from catalog/in-band timing. */
|
|
45
|
+
reassemblyPolicy: MfuReassemblyPolicy
|
|
46
|
+
/** Catalog-derived PCM startup buffer in milliseconds. */
|
|
47
|
+
latencyMs: number
|
|
48
|
+
/** Exact PCM startup threshold when catalog grouping is codec-aligned. */
|
|
49
|
+
startupFrames?: number
|
|
50
|
+
/** Exact PCM storage ceiling, including one successor delivery burst. */
|
|
51
|
+
capacityFrames?: number
|
|
52
|
+
/** Enable ring-buffer telemetry messages. Default false. */
|
|
53
|
+
diag?: boolean
|
|
54
|
+
/** Called when worklet reports state (playback timestamp + exact buffer accounting). Optional. */
|
|
55
|
+
onState?: (state: {
|
|
56
|
+
timestampUs: number | undefined
|
|
57
|
+
stalled: boolean
|
|
58
|
+
bufferedFrames: number
|
|
59
|
+
writtenFrames: number
|
|
60
|
+
}) => void
|
|
61
|
+
/** Called when worklet reports ring-buffer diag (telemetry). Optional. */
|
|
62
|
+
onDiag?: (diag: {
|
|
63
|
+
reads: number; avgFill: number; capacity: number
|
|
64
|
+
skipsPerSec: number; dupsPerSec: number; overflowSamplesPerSec: number
|
|
65
|
+
underflowSamplesPerSec: number
|
|
66
|
+
}) => void
|
|
67
|
+
/** Warmup frames before pacing engages. Default 0 (audio tolerates instant pacing). */
|
|
68
|
+
warmupFrames?: number
|
|
69
|
+
/** Audio underflow / config event logger. Optional. */
|
|
70
|
+
onError?: (err: unknown) => void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class AudioRenderPipeline {
|
|
74
|
+
readonly #context: AudioContext
|
|
75
|
+
readonly #pacer: FrameCountPacer
|
|
76
|
+
readonly #mapper: FecBlockMapper
|
|
77
|
+
readonly #codec: string
|
|
78
|
+
readonly #packetId: number | undefined
|
|
79
|
+
readonly #sampleRate: number
|
|
80
|
+
readonly #channels: number
|
|
81
|
+
readonly #description?: Uint8Array
|
|
82
|
+
readonly #latencyMs: number
|
|
83
|
+
readonly #startupFrames?: number
|
|
84
|
+
readonly #startupThresholdFrames: number
|
|
85
|
+
readonly #capacityFrames?: number
|
|
86
|
+
readonly #diag: boolean
|
|
87
|
+
readonly #warmupFrames: number
|
|
88
|
+
readonly #onState?: AudioRenderPipelineOpts['onState']
|
|
89
|
+
readonly #onDiag?: AudioRenderPipelineOpts['onDiag']
|
|
90
|
+
readonly #onError?: AudioRenderPipelineOpts['onError']
|
|
91
|
+
readonly #reassemblyPolicy: MfuReassemblyPolicy
|
|
92
|
+
|
|
93
|
+
#decoder: AudioDecoder | null = null
|
|
94
|
+
#pipeline: MmtpDecodePipeline | null = null
|
|
95
|
+
#worklet: AudioWorkletNode | null = null
|
|
96
|
+
#fecClient?: PipelineFecClient
|
|
97
|
+
#closed = false
|
|
98
|
+
#readyPromise: Promise<void> | null = null
|
|
99
|
+
#workletGeneration = 0
|
|
100
|
+
#startupReleased = false
|
|
101
|
+
|
|
102
|
+
constructor(opts: AudioRenderPipelineOpts) {
|
|
103
|
+
this.#context = opts.context
|
|
104
|
+
this.#mapper = opts.mapper
|
|
105
|
+
this.#codec = opts.codec
|
|
106
|
+
this.#packetId = opts.packetId
|
|
107
|
+
this.#sampleRate = opts.sampleRate
|
|
108
|
+
this.#channels = opts.channels
|
|
109
|
+
this.#description = opts.description
|
|
110
|
+
this.#latencyMs = opts.latencyMs
|
|
111
|
+
this.#startupFrames = opts.startupFrames
|
|
112
|
+
this.#startupThresholdFrames = opts.startupFrames
|
|
113
|
+
?? Math.ceil(opts.sampleRate * (opts.latencyMs / 1000))
|
|
114
|
+
this.#capacityFrames = opts.capacityFrames
|
|
115
|
+
this.#diag = opts.diag ?? false
|
|
116
|
+
this.#warmupFrames = opts.warmupFrames ?? 0
|
|
117
|
+
this.#onState = opts.onState
|
|
118
|
+
this.#onDiag = opts.onDiag
|
|
119
|
+
this.#onError = opts.onError
|
|
120
|
+
this.#fecClient = opts.fecClient
|
|
121
|
+
this.#reassemblyPolicy = opts.reassemblyPolicy
|
|
122
|
+
|
|
123
|
+
// AU duration from codec + sampleRate. Keep inside the lib so consumers
|
|
124
|
+
// don't re-derive it.
|
|
125
|
+
const auDurationMs = auDurationMsFromCodec(opts.codec, opts.sampleRate)
|
|
126
|
+
this.#pacer = new FrameCountPacer({
|
|
127
|
+
frameDurationMs: auDurationMs,
|
|
128
|
+
mapper: opts.mapper,
|
|
129
|
+
warmupFrames: this.#warmupFrames,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Load worklet module + create decoder + pipeline. Call once. */
|
|
134
|
+
async ready(): Promise<void> {
|
|
135
|
+
if (this.#readyPromise) return this.#readyPromise
|
|
136
|
+
this.#readyPromise = this.#initialize()
|
|
137
|
+
return this.#readyPromise
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async #initialize(): Promise<void> {
|
|
141
|
+
// 1. Load shared worklet module.
|
|
142
|
+
await this.#context.audioWorklet.addModule(workletModuleUrl())
|
|
143
|
+
|
|
144
|
+
// 2. Create worklet node.
|
|
145
|
+
const worklet = new AudioWorkletNode(this.#context, WORKLET_PROCESSOR_NAME, {
|
|
146
|
+
numberOfInputs: 0,
|
|
147
|
+
numberOfOutputs: 1,
|
|
148
|
+
outputChannelCount: [this.#channels],
|
|
149
|
+
})
|
|
150
|
+
const initMsg: InitMessage = {
|
|
151
|
+
type: 'init',
|
|
152
|
+
generation: ++this.#workletGeneration,
|
|
153
|
+
channels: this.#channels,
|
|
154
|
+
rate: this.#sampleRate,
|
|
155
|
+
latencyMs: this.#latencyMs,
|
|
156
|
+
startupFrames: this.#startupFrames,
|
|
157
|
+
capacityFrames: this.#capacityFrames,
|
|
158
|
+
holdUntilStart: true,
|
|
159
|
+
diag: this.#diag,
|
|
160
|
+
}
|
|
161
|
+
worklet.port.postMessage(initMsg)
|
|
162
|
+
worklet.port.onmessage = (ev: MessageEvent<ToMain>) => {
|
|
163
|
+
const m = ev.data
|
|
164
|
+
if (m.type === 'state') {
|
|
165
|
+
if (m.generation !== this.#workletGeneration) return
|
|
166
|
+
this.#onState?.({
|
|
167
|
+
timestampUs: m.timestamp,
|
|
168
|
+
stalled: m.stalled,
|
|
169
|
+
bufferedFrames: m.bufferedFrames,
|
|
170
|
+
writtenFrames: m.writtenFrames,
|
|
171
|
+
})
|
|
172
|
+
} else if (m.type === 'ringBufDiag') {
|
|
173
|
+
if (m.generation !== this.#workletGeneration) return
|
|
174
|
+
this.#onDiag?.({
|
|
175
|
+
reads: m.reads, avgFill: m.avgFill, capacity: m.capacity,
|
|
176
|
+
skipsPerSec: m.skipsPerSec, dupsPerSec: m.dupsPerSec,
|
|
177
|
+
overflowSamplesPerSec: m.overflowSamplesPerSec,
|
|
178
|
+
underflowSamplesPerSec: m.underflowSamplesPerSec,
|
|
179
|
+
})
|
|
180
|
+
} else if (m.type === 'writeAck') {
|
|
181
|
+
if (
|
|
182
|
+
m.generation !== this.#workletGeneration ||
|
|
183
|
+
this.#startupReleased ||
|
|
184
|
+
m.bufferedFrames < this.#startupThresholdFrames
|
|
185
|
+
) return
|
|
186
|
+
this.#startupReleased = true
|
|
187
|
+
const start: StartMessage = {
|
|
188
|
+
type: 'start',
|
|
189
|
+
generation: this.#workletGeneration,
|
|
190
|
+
}
|
|
191
|
+
worklet.port.postMessage(start)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
this.#worklet = worklet
|
|
195
|
+
|
|
196
|
+
// 3. Create decoder.
|
|
197
|
+
const decoder = new AudioDecoder({
|
|
198
|
+
output: (data) => this.#onDecoded(data),
|
|
199
|
+
error: (err) => this.#onError?.(err),
|
|
200
|
+
})
|
|
201
|
+
const config: AudioDecoderConfig = {
|
|
202
|
+
codec: this.#codec,
|
|
203
|
+
sampleRate: this.#sampleRate,
|
|
204
|
+
numberOfChannels: this.#channels,
|
|
205
|
+
...(this.#description ? { description: this.#description } : {}),
|
|
206
|
+
}
|
|
207
|
+
decoder.configure(config)
|
|
208
|
+
this.#decoder = decoder
|
|
209
|
+
|
|
210
|
+
// 4. Create decode pipeline.
|
|
211
|
+
const pipeline = new MmtpDecodePipeline()
|
|
212
|
+
pipeline.configure({
|
|
213
|
+
codec: this.#codec,
|
|
214
|
+
packetId: this.#packetId,
|
|
215
|
+
description: this.#description,
|
|
216
|
+
reorderWindow: 0, // audio doesn't need reorder — AAC/Opus frames are self-contained
|
|
217
|
+
reassemblyPolicy: this.#reassemblyPolicy,
|
|
218
|
+
})
|
|
219
|
+
if (this.#fecClient) pipeline.setFecClient(this.#fecClient)
|
|
220
|
+
pipeline.onFrame((codecData, meta) => {
|
|
221
|
+
if (!this.#decoder || this.#decoder.state !== 'configured') return
|
|
222
|
+
if (codecData.byteLength === 0) return
|
|
223
|
+
this.#pacer.notifyFrame()
|
|
224
|
+
try {
|
|
225
|
+
this.#decoder.decode(new EncodedAudioChunk({
|
|
226
|
+
type: 'key',
|
|
227
|
+
data: codecData,
|
|
228
|
+
timestamp: meta.timestamp,
|
|
229
|
+
}))
|
|
230
|
+
} catch (err) {
|
|
231
|
+
this.#onError?.(err)
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
this.#pipeline = pipeline
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Feed one MoQ object (raw MMTP packet). */
|
|
238
|
+
feedMoqObject(packet: Uint8Array): void {
|
|
239
|
+
this.#pipeline?.feedMoqObject(packet)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** ms to sleep before the next object read. 0 when not anchored / not ahead. */
|
|
243
|
+
paceSleepMs(): number {
|
|
244
|
+
return this.#pacer.computeSleepMs()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Pacer snapshot (for diagnostics / UI). */
|
|
248
|
+
get pacerSnapshot() {
|
|
249
|
+
return this.#pacer.snapshot
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Reset pacer on source switch (MoQ ↔ multicast). */
|
|
253
|
+
resetPacer(): void {
|
|
254
|
+
this.#pacer.reset()
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Drop queued PCM and its timestamp epoch at an explicit source boundary. */
|
|
258
|
+
resetPlayout(startUnstalled = false): void {
|
|
259
|
+
this.#pacer.reset()
|
|
260
|
+
// The explicit gate owns only the initial prefill. Source-boundary resets
|
|
261
|
+
// use the worklet's one-frame resume threshold.
|
|
262
|
+
this.#startupReleased = true
|
|
263
|
+
const message: ResetMessage = {
|
|
264
|
+
type: 'reset',
|
|
265
|
+
generation: ++this.#workletGeneration,
|
|
266
|
+
startUnstalled,
|
|
267
|
+
}
|
|
268
|
+
this.#worklet?.port.postMessage(message)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Node to connect to destination / gain. */
|
|
272
|
+
get rootNode(): AudioNode {
|
|
273
|
+
if (!this.#worklet) throw new Error('AudioRenderPipeline.ready() must resolve first')
|
|
274
|
+
return this.#worklet
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
close(): void {
|
|
278
|
+
if (this.#closed) return
|
|
279
|
+
this.#closed = true
|
|
280
|
+
try { this.#decoder?.close() } catch { /* already closed */ }
|
|
281
|
+
try { this.#pipeline?.dispose() } catch { /* noop */ }
|
|
282
|
+
try { this.#worklet?.disconnect() } catch { /* noop */ }
|
|
283
|
+
this.#decoder = null
|
|
284
|
+
this.#pipeline = null
|
|
285
|
+
this.#worklet = null
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ─── Private ─────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
#onDecoded(sample: AudioData): void {
|
|
291
|
+
if (this.#closed || !this.#worklet) { sample.close(); return }
|
|
292
|
+
|
|
293
|
+
const timestamp = sample.timestamp
|
|
294
|
+
const channelData: Float32Array[] = []
|
|
295
|
+
for (let ch = 0; ch < sample.numberOfChannels; ch++) {
|
|
296
|
+
const data = new Float32Array(sample.numberOfFrames)
|
|
297
|
+
sample.copyTo(data, { format: 'f32-planar', planeIndex: ch })
|
|
298
|
+
channelData.push(data)
|
|
299
|
+
}
|
|
300
|
+
sample.close()
|
|
301
|
+
|
|
302
|
+
const msg: DataMessage = {
|
|
303
|
+
type: 'data',
|
|
304
|
+
data: channelData,
|
|
305
|
+
timestamp,
|
|
306
|
+
}
|
|
307
|
+
this.#worklet.port.postMessage(msg, channelData.map((d) => d.buffer))
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// auDurationMsFromCodec lives in ../pacing/au-duration.ts (canonical, no
|
|
312
|
+
// mmt-container dep). Import it from '@blockcast/mmt-render/pacing' directly
|
|
313
|
+
// or via the top-level '@blockcast/mmt-render' export.
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timestamp-anchored FIFO shared by MMT AudioWorklet consumers.
|
|
3
|
+
*
|
|
4
|
+
* PCM remains in decoder emission order. Media timestamps are exact anchors
|
|
5
|
+
* for playout reporting only; they never reposition samples or trigger a
|
|
6
|
+
* guessed discontinuity. The FIFO starts after its full catalog-derived
|
|
7
|
+
* latency is buffered and performs no sample skip/dup clock recovery.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface AudioRingBufferConfig {
|
|
11
|
+
channels: number
|
|
12
|
+
rate: number
|
|
13
|
+
latencyMs: number
|
|
14
|
+
/** Exact PCM startup threshold. Overrides latencyMs when provided. */
|
|
15
|
+
startupFrames?: number
|
|
16
|
+
/** PCM refill threshold after the first underrun. Defaults to startupFrames. */
|
|
17
|
+
resumeFrames?: number
|
|
18
|
+
/** Exact PCM storage ceiling. Must cover the startup and resume thresholds. */
|
|
19
|
+
capacityFrames?: number
|
|
20
|
+
startUnstalled?: boolean
|
|
21
|
+
/** Deprecated compatibility input. Clock recovery is not performed. */
|
|
22
|
+
warmupReads?: number
|
|
23
|
+
diag?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AudioRingBufferDiag {
|
|
27
|
+
reads: number
|
|
28
|
+
avgFill: number
|
|
29
|
+
capacity: number
|
|
30
|
+
skips: number
|
|
31
|
+
dups: number
|
|
32
|
+
overflowSamples: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type TimestampAnchor = {
|
|
36
|
+
sampleIndex: number
|
|
37
|
+
timestampUs: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class AudioRingBuffer {
|
|
41
|
+
readonly channels: number
|
|
42
|
+
readonly rate: number
|
|
43
|
+
|
|
44
|
+
#buffer: Float32Array[]
|
|
45
|
+
#writeIndex = 0
|
|
46
|
+
#readIndex = 0
|
|
47
|
+
#stalled: boolean
|
|
48
|
+
#startupFrames: number
|
|
49
|
+
#resumeFrames: number
|
|
50
|
+
#resumeFollowsStartup: boolean
|
|
51
|
+
#hasStarted: boolean
|
|
52
|
+
#capacityHeadroomFrames: number
|
|
53
|
+
#timestampAnchors: TimestampAnchor[] = []
|
|
54
|
+
|
|
55
|
+
#diagEnabled: boolean
|
|
56
|
+
#diagReads = 0
|
|
57
|
+
#diagFillSum = 0
|
|
58
|
+
#diagOverflowSamples = 0
|
|
59
|
+
|
|
60
|
+
constructor(config: AudioRingBufferConfig) {
|
|
61
|
+
if (!Number.isInteger(config.channels) || config.channels <= 0) {
|
|
62
|
+
throw new Error('invalid channels')
|
|
63
|
+
}
|
|
64
|
+
if (!Number.isFinite(config.rate) || config.rate <= 0) {
|
|
65
|
+
throw new Error('invalid sample rate')
|
|
66
|
+
}
|
|
67
|
+
if (!Number.isFinite(config.latencyMs) || config.latencyMs <= 0) {
|
|
68
|
+
throw new Error('invalid latency')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const latencyFrames = Math.ceil(config.rate * (config.latencyMs / 1000))
|
|
72
|
+
const startupFrames = config.startupFrames ?? latencyFrames
|
|
73
|
+
const resumeFrames = config.resumeFrames ?? startupFrames
|
|
74
|
+
const thresholdFrames = Math.max(startupFrames, resumeFrames)
|
|
75
|
+
const capacityFrames = config.capacityFrames ?? Math.max(latencyFrames, thresholdFrames)
|
|
76
|
+
if (!Number.isSafeInteger(startupFrames) || startupFrames <= 0) {
|
|
77
|
+
throw new Error('invalid startup frames')
|
|
78
|
+
}
|
|
79
|
+
if (!Number.isSafeInteger(resumeFrames) || resumeFrames <= 0) {
|
|
80
|
+
throw new Error('invalid resume frames')
|
|
81
|
+
}
|
|
82
|
+
if (!Number.isSafeInteger(capacityFrames) || capacityFrames < thresholdFrames) {
|
|
83
|
+
throw new Error('invalid capacity frames')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.channels = config.channels
|
|
87
|
+
this.rate = config.rate
|
|
88
|
+
this.#startupFrames = startupFrames
|
|
89
|
+
this.#resumeFrames = resumeFrames
|
|
90
|
+
this.#resumeFollowsStartup = config.resumeFrames === undefined
|
|
91
|
+
this.#hasStarted = config.startUnstalled ?? false
|
|
92
|
+
this.#capacityHeadroomFrames = capacityFrames - thresholdFrames
|
|
93
|
+
this.#stalled = !(config.startUnstalled ?? false)
|
|
94
|
+
this.#diagEnabled = config.diag ?? false
|
|
95
|
+
this.#buffer = Array.from(
|
|
96
|
+
{ length: config.channels },
|
|
97
|
+
() => new Float32Array(capacityFrames),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
get stalled(): boolean { return this.#stalled }
|
|
102
|
+
get startupFrames(): number { return this.#startupFrames }
|
|
103
|
+
get capacity(): number { return this.#buffer[0].length }
|
|
104
|
+
get length(): number { return this.#writeIndex - this.#readIndex }
|
|
105
|
+
|
|
106
|
+
/** Timestamp of the next media sample after the PCM already emitted. */
|
|
107
|
+
get timestampUs(): number | undefined {
|
|
108
|
+
const anchor = this.#timestampAnchors[0]
|
|
109
|
+
if (!anchor || anchor.sampleIndex > this.#readIndex) return undefined
|
|
110
|
+
return anchor.timestampUs + ((this.#readIndex - anchor.sampleIndex) / this.rate) * 1_000_000
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
write(timestampUs: number, data: Float32Array[]): void {
|
|
114
|
+
if (data.length !== this.channels) throw new Error('wrong channel count')
|
|
115
|
+
if (!Number.isFinite(timestampUs)) throw new Error('invalid timestamp')
|
|
116
|
+
const samples = data[0].length
|
|
117
|
+
if (samples === 0) return
|
|
118
|
+
if (data.some((channel) => channel.length !== samples)) {
|
|
119
|
+
throw new Error('mismatched channel lengths')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const start = this.#writeIndex
|
|
123
|
+
const end = start + samples
|
|
124
|
+
this.#timestampAnchors.push({ sampleIndex: start, timestampUs })
|
|
125
|
+
|
|
126
|
+
const overflow = end - this.#readIndex - this.capacity
|
|
127
|
+
if (overflow > 0) {
|
|
128
|
+
this.#stalled = false
|
|
129
|
+
this.#hasStarted = true
|
|
130
|
+
this.#readIndex += overflow
|
|
131
|
+
if (this.#diagEnabled) this.#diagOverflowSamples += overflow
|
|
132
|
+
this.#pruneTimestampAnchors()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const refillFrames = this.#hasStarted ? this.#resumeFrames : this.#startupFrames
|
|
136
|
+
if (this.#stalled && end - this.#readIndex >= refillFrames) {
|
|
137
|
+
this.#stalled = false
|
|
138
|
+
this.#hasStarted = true
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (let channel = 0; channel < this.channels; channel++) {
|
|
142
|
+
const src = data[channel]
|
|
143
|
+
const dst = this.#buffer[channel]
|
|
144
|
+
for (let i = 0; i < samples; i++) {
|
|
145
|
+
dst[(start + i) % dst.length] = src[i]
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
this.#writeIndex = end
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
read(output: Float32Array[]): number {
|
|
152
|
+
if (output.length !== this.channels) throw new Error('wrong channel count')
|
|
153
|
+
if (this.#stalled) return 0
|
|
154
|
+
|
|
155
|
+
const available = this.length
|
|
156
|
+
if (available === 0) {
|
|
157
|
+
this.#stalled = true
|
|
158
|
+
return 0
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const requested = output[0].length
|
|
162
|
+
const readSamples = Math.min(available, requested)
|
|
163
|
+
|
|
164
|
+
for (let channel = 0; channel < this.channels; channel++) {
|
|
165
|
+
const dst = output[channel]
|
|
166
|
+
const src = this.#buffer[channel]
|
|
167
|
+
for (let i = 0; i < readSamples; i++) {
|
|
168
|
+
dst[i] = src[(this.#readIndex + i) % src.length]
|
|
169
|
+
}
|
|
170
|
+
for (let i = readSamples; i < requested; i++) dst[i] = 0
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
this.#readIndex += readSamples
|
|
174
|
+
this.#pruneTimestampAnchors()
|
|
175
|
+
if (readSamples < requested) this.#stalled = true
|
|
176
|
+
|
|
177
|
+
if (this.#diagEnabled) {
|
|
178
|
+
this.#diagReads++
|
|
179
|
+
this.#diagFillSum += available
|
|
180
|
+
}
|
|
181
|
+
return readSamples
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Clear queued PCM; optionally treat the next fill as post-start recovery. */
|
|
185
|
+
reset(startUnstalled = false, resumeAfterReset = false): void {
|
|
186
|
+
this.#writeIndex = 0
|
|
187
|
+
this.#readIndex = 0
|
|
188
|
+
this.#stalled = !startUnstalled
|
|
189
|
+
this.#hasStarted = startUnstalled || resumeAfterReset
|
|
190
|
+
this.#timestampAnchors = []
|
|
191
|
+
this.#diagReads = 0
|
|
192
|
+
this.#diagFillSum = 0
|
|
193
|
+
this.#diagOverflowSamples = 0
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Change the startup latency while preserving the configured storage
|
|
198
|
+
* headroom above its buffering thresholds. Active playout keeps draining
|
|
199
|
+
* retained PCM; a stalled buffer waits for its startup or resume threshold.
|
|
200
|
+
*/
|
|
201
|
+
resize(latencyMs: number): void {
|
|
202
|
+
if (!Number.isFinite(latencyMs) || latencyMs <= 0) {
|
|
203
|
+
throw new Error('invalid latency')
|
|
204
|
+
}
|
|
205
|
+
const newStartupFrames = Math.ceil(this.rate * (latencyMs / 1000))
|
|
206
|
+
if (!Number.isSafeInteger(newStartupFrames) || newStartupFrames <= 0) {
|
|
207
|
+
throw new Error('invalid latency')
|
|
208
|
+
}
|
|
209
|
+
const newResumeFrames = this.#resumeFollowsStartup ? newStartupFrames : this.#resumeFrames
|
|
210
|
+
const newCapacity =
|
|
211
|
+
Math.max(newStartupFrames, newResumeFrames) + this.#capacityHeadroomFrames
|
|
212
|
+
if (
|
|
213
|
+
!Number.isSafeInteger(newCapacity) ||
|
|
214
|
+
newCapacity < Math.max(newStartupFrames, newResumeFrames)
|
|
215
|
+
) {
|
|
216
|
+
throw new Error('invalid capacity frames')
|
|
217
|
+
}
|
|
218
|
+
const wasStalled = this.#stalled
|
|
219
|
+
this.#startupFrames = newStartupFrames
|
|
220
|
+
this.#resumeFrames = newResumeFrames
|
|
221
|
+
if (newCapacity === this.capacity) {
|
|
222
|
+
if (wasStalled && this.length >= this.#refillFrames()) {
|
|
223
|
+
this.#stalled = false
|
|
224
|
+
this.#hasStarted = true
|
|
225
|
+
}
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const next = Array.from(
|
|
230
|
+
{ length: this.channels },
|
|
231
|
+
() => new Float32Array(newCapacity),
|
|
232
|
+
)
|
|
233
|
+
const samplesToKeep = Math.min(this.length, newCapacity)
|
|
234
|
+
const copyStart = this.#writeIndex - samplesToKeep
|
|
235
|
+
for (let channel = 0; channel < this.channels; channel++) {
|
|
236
|
+
const src = this.#buffer[channel]
|
|
237
|
+
const dst = next[channel]
|
|
238
|
+
for (let i = 0; i < samplesToKeep; i++) {
|
|
239
|
+
const sampleIndex = copyStart + i
|
|
240
|
+
dst[sampleIndex % dst.length] = src[sampleIndex % src.length]
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
this.#buffer = next
|
|
245
|
+
this.#readIndex = copyStart
|
|
246
|
+
this.#pruneTimestampAnchors()
|
|
247
|
+
this.#stalled = samplesToKeep === 0 || (wasStalled && samplesToKeep < this.#refillFrames())
|
|
248
|
+
if (!this.#stalled) this.#hasStarted = true
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
getDiagAndReset(): AudioRingBufferDiag | null {
|
|
252
|
+
if (!this.#diagEnabled || this.#diagReads === 0) return null
|
|
253
|
+
const result: AudioRingBufferDiag = {
|
|
254
|
+
reads: this.#diagReads,
|
|
255
|
+
avgFill: Math.round(this.#diagFillSum / this.#diagReads),
|
|
256
|
+
capacity: this.capacity,
|
|
257
|
+
skips: 0,
|
|
258
|
+
dups: 0,
|
|
259
|
+
overflowSamples: this.#diagOverflowSamples,
|
|
260
|
+
}
|
|
261
|
+
this.#diagReads = 0
|
|
262
|
+
this.#diagFillSum = 0
|
|
263
|
+
this.#diagOverflowSamples = 0
|
|
264
|
+
return result
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#pruneTimestampAnchors(): void {
|
|
268
|
+
let keep = 0
|
|
269
|
+
while (
|
|
270
|
+
keep + 1 < this.#timestampAnchors.length &&
|
|
271
|
+
this.#timestampAnchors[keep + 1].sampleIndex <= this.#readIndex
|
|
272
|
+
) {
|
|
273
|
+
keep++
|
|
274
|
+
}
|
|
275
|
+
if (keep > 0) this.#timestampAnchors.splice(0, keep)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
#refillFrames(): number {
|
|
279
|
+
return this.#hasStarted ? this.#resumeFrames : this.#startupFrames
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { AudioRingBuffer } from './audio-ring-buffer.js'
|
|
2
|
+
export type { AudioRingBufferConfig, AudioRingBufferDiag } from './audio-ring-buffer.js'
|
|
3
|
+
export { deriveAudioBurstBufferPlan } from './audio-buffer-plan.js'
|
|
4
|
+
export type { AudioBurstBufferPlan, AudioBurstBufferPlanInput } from './audio-buffer-plan.js'
|
|
5
|
+
export { AudioRenderPipeline } from './audio-render-pipeline.js'
|
|
6
|
+
export type { AudioRenderPipelineOpts } from './audio-render-pipeline.js'
|
|
7
|
+
// auDurationMsFromCodec is canonical at ../pacing/au-duration.ts; re-exported
|
|
8
|
+
// from both pacing/index.ts and the top-level index. Not duplicated here to
|
|
9
|
+
// avoid export conflicts in index.ts's `export *`.
|
|
10
|
+
export type {
|
|
11
|
+
FromMain, ToMain, InitMessage, DataMessage, StartMessage, WriteAckMessage, LatencyMessage,
|
|
12
|
+
ResetMessage, DiagRequestMessage, StateMessage, DiagMessage,
|
|
13
|
+
} from './render-messages.js'
|
|
14
|
+
export { WORKLET_PROCESSOR_NAME, workletModuleUrl } from './worklet-url.js'
|