@blockcast/mmt-render 0.1.0-main.c0e9e40265a1

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.
Files changed (76) hide show
  1. package/README.md +207 -0
  2. package/dist/.build-stamp +0 -0
  3. package/dist/audio/audio-buffer-plan.d.ts +27 -0
  4. package/dist/audio/audio-buffer-plan.d.ts.map +1 -0
  5. package/dist/audio/audio-render-pipeline.d.ts +83 -0
  6. package/dist/audio/audio-render-pipeline.d.ts.map +1 -0
  7. package/dist/audio/audio-render-pipeline.js +372 -0
  8. package/dist/audio/audio-render-pipeline.js.map +7 -0
  9. package/dist/audio/audio-ring-buffer.d.ts +55 -0
  10. package/dist/audio/audio-ring-buffer.d.ts.map +1 -0
  11. package/dist/audio/audio-ring-buffer.js +255 -0
  12. package/dist/audio/audio-ring-buffer.js.map +7 -0
  13. package/dist/audio/index.d.ts +9 -0
  14. package/dist/audio/index.d.ts.map +1 -0
  15. package/dist/audio/index.js +640 -0
  16. package/dist/audio/index.js.map +7 -0
  17. package/dist/audio/render-messages.d.ts +94 -0
  18. package/dist/audio/render-messages.d.ts.map +1 -0
  19. package/dist/audio/render-messages.js +1 -0
  20. package/dist/audio/render-messages.js.map +7 -0
  21. package/dist/audio/render-worklet.d.ts +13 -0
  22. package/dist/audio/render-worklet.d.ts.map +1 -0
  23. package/dist/audio/worklet-url.d.ts +25 -0
  24. package/dist/audio/worklet-url.d.ts.map +1 -0
  25. package/dist/audio/worklet-url.js +11 -0
  26. package/dist/audio/worklet-url.js.map +7 -0
  27. package/dist/index.d.ts +13 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +5 -0
  30. package/dist/index.js.map +7 -0
  31. package/dist/pacing/au-duration.d.ts +18 -0
  32. package/dist/pacing/au-duration.d.ts.map +1 -0
  33. package/dist/pacing/au-duration.js +23 -0
  34. package/dist/pacing/au-duration.js.map +7 -0
  35. package/dist/pacing/frame-pacer.d.ts +71 -0
  36. package/dist/pacing/frame-pacer.d.ts.map +1 -0
  37. package/dist/pacing/frame-pacer.js +89 -0
  38. package/dist/pacing/frame-pacer.js.map +7 -0
  39. package/dist/pacing/index.d.ts +6 -0
  40. package/dist/pacing/index.d.ts.map +1 -0
  41. package/dist/pacing/index.js +166 -0
  42. package/dist/pacing/index.js.map +7 -0
  43. package/dist/pacing/interleave-timing.d.ts +64 -0
  44. package/dist/pacing/interleave-timing.d.ts.map +1 -0
  45. package/dist/pacing/interleave-timing.js +58 -0
  46. package/dist/pacing/interleave-timing.js.map +7 -0
  47. package/dist/video/index.d.ts +4 -0
  48. package/dist/video/index.d.ts.map +1 -0
  49. package/dist/video/index.js +247 -0
  50. package/dist/video/index.js.map +7 -0
  51. package/dist/video/keyframe-gate.d.ts +21 -0
  52. package/dist/video/keyframe-gate.d.ts.map +1 -0
  53. package/dist/video/keyframe-gate.js +39 -0
  54. package/dist/video/keyframe-gate.js.map +7 -0
  55. package/dist/video/video-render-pipeline.d.ts +60 -0
  56. package/dist/video/video-render-pipeline.d.ts.map +1 -0
  57. package/dist/video/video-render-pipeline.js +247 -0
  58. package/dist/video/video-render-pipeline.js.map +7 -0
  59. package/dist/worklet/render-worklet.js +403 -0
  60. package/dist/worklet/render-worklet.js.map +7 -0
  61. package/package.json +101 -0
  62. package/src/audio/audio-buffer-plan.ts +62 -0
  63. package/src/audio/audio-render-pipeline.ts +313 -0
  64. package/src/audio/audio-ring-buffer.ts +281 -0
  65. package/src/audio/index.ts +14 -0
  66. package/src/audio/render-messages.ts +110 -0
  67. package/src/audio/render-worklet.ts +161 -0
  68. package/src/audio/worklet-url.ts +30 -0
  69. package/src/index.ts +13 -0
  70. package/src/pacing/au-duration.ts +34 -0
  71. package/src/pacing/frame-pacer.ts +129 -0
  72. package/src/pacing/index.ts +5 -0
  73. package/src/pacing/interleave-timing.ts +95 -0
  74. package/src/video/index.ts +7 -0
  75. package/src/video/keyframe-gate.ts +35 -0
  76. 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'