@blockcast/mmt-render 0.1.0-main.00b4b0b04934
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 +208 -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 +341 -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 +221 -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 +592 -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 +92 -0
- package/dist/pacing/frame-pacer.d.ts.map +1 -0
- package/dist/pacing/frame-pacer.js +93 -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 +181 -0
- package/dist/pacing/index.js.map +7 -0
- package/dist/pacing/interleave-timing.d.ts +73 -0
- package/dist/pacing/interleave-timing.d.ts.map +1 -0
- package/dist/pacing/interleave-timing.js +69 -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 +237 -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 +27 -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 +237 -0
- package/dist/video/video-render-pipeline.js.map +7 -0
- package/dist/worklet/render-worklet.js +355 -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 +156 -0
- package/src/pacing/index.ts +5 -0
- package/src/pacing/interleave-timing.ts +122 -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
package/README.md
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# @blockcast/mmt-render
|
|
2
|
+
|
|
3
|
+
Shared render pipeline for MMT players: FEC-aware wall-clock pacing, per-AU presentation-time reconstruction under FEC interleave, an `AudioWorkletProcessor` with an exact-timestamp PCM FIFO, and a keyframe-gated `VideoDecoder` driver. The package is the canonical substrate for moqtail, `hang-mmt-fec`, and Shaka integrations so they do not independently invent timing, buffering, or sample-correction policy. It is *not* a "reference renderer + diagnostics UI" — there is no DOM, chart, or catalog browser. Diagnostics are structured snapshots and worklet telemetry messages that callers can render however they like.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @blockcast/mmt-render
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Peer-installs through normal deps: `@blockcast/mmt-container` (provides `MmtpDecodePipeline` and the FEC-aware MFU reassembler) and `@blockcast/mmt-fec` (provides `FecBlockMapper`, which carries `pacingThresholdMs`, `K`, `D` and the catalog's required `fec.blockProfile`).
|
|
12
|
+
|
|
13
|
+
## Quick start (audio)
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { AudioRenderPipeline } from "@blockcast/mmt-render/audio"
|
|
17
|
+
import { FecBlockMapper } from "@blockcast/mmt-fec"
|
|
18
|
+
|
|
19
|
+
const ctx = new AudioContext({ sampleRate: 48000 })
|
|
20
|
+
// blockProfile is the catalog `fec.blockProfile`: required, never defaulted.
|
|
21
|
+
const mapper = new FecBlockMapper({ blockProfile: "k-padded-v1", interleaveDepthMs: 66, gopDurationMs: 21.33, k: 32 })
|
|
22
|
+
|
|
23
|
+
const audio = new AudioRenderPipeline({
|
|
24
|
+
context: ctx,
|
|
25
|
+
codec: "mp4a.40.2",
|
|
26
|
+
packetId: 0x0011,
|
|
27
|
+
sampleRate: 48000,
|
|
28
|
+
channels: 2,
|
|
29
|
+
description: aacEsdsDescription, // from catalog / init segment
|
|
30
|
+
mapper,
|
|
31
|
+
reassemblyPolicy: { transport: "reliable-moq" },
|
|
32
|
+
latencyMs: mapper.interleaveDepthMs, // one catalog FEC block
|
|
33
|
+
diag: true,
|
|
34
|
+
onDiag: (d) => console.log("ring", d),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
await audio.ready()
|
|
38
|
+
audio.rootNode.connect(ctx.destination)
|
|
39
|
+
|
|
40
|
+
for await (const obj of moqSubscription) {
|
|
41
|
+
audio.feedMoqObject(obj.payload)
|
|
42
|
+
const sleep = audio.paceSleepMs()
|
|
43
|
+
if (sleep > 0) await new Promise((r) => setTimeout(r, sleep))
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick start (video)
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { VideoRenderPipeline } from "@blockcast/mmt-render/video"
|
|
51
|
+
|
|
52
|
+
const video = new VideoRenderPipeline({
|
|
53
|
+
codec: "hev1.1.6.L120.90",
|
|
54
|
+
packetId: 0x0010,
|
|
55
|
+
description: hvccBox,
|
|
56
|
+
width: 1920,
|
|
57
|
+
height: 1080,
|
|
58
|
+
framerate: 30,
|
|
59
|
+
mapper,
|
|
60
|
+
reassemblyPolicy: { transport: "reliable-moq" },
|
|
61
|
+
onFrame: (frame) => {
|
|
62
|
+
canvas.getContext("2d")?.drawImage(frame, 0, 0)
|
|
63
|
+
frame.close()
|
|
64
|
+
},
|
|
65
|
+
onError: (e) => console.warn(e),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
for await (const obj of moqSubscription) {
|
|
69
|
+
video.feedMoqObject(obj.payload)
|
|
70
|
+
const sleep = video.paceSleepMs()
|
|
71
|
+
if (sleep > 0) await new Promise((r) => setTimeout(r, sleep))
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Exports
|
|
76
|
+
|
|
77
|
+
Per `package.json`:
|
|
78
|
+
|
|
79
|
+
| Subpath | Symbols |
|
|
80
|
+
|---------|---------|
|
|
81
|
+
| `@blockcast/mmt-render` | re-exports of `./pacing`, `./audio`, `./video` |
|
|
82
|
+
| `@blockcast/mmt-render/pacing` | `FrameCountPacer`, `InterleaveTiming`, `auDurationMsFromCodec`, `pictureDurationMs` |
|
|
83
|
+
| `@blockcast/mmt-render/pacing/frame-pacer` | `FrameCountPacer` only |
|
|
84
|
+
| `@blockcast/mmt-render/pacing/interleave-timing` | `InterleaveTiming` only |
|
|
85
|
+
| `@blockcast/mmt-render/audio` | `AudioRenderPipeline`, `AudioRingBuffer`, message types, `WORKLET_PROCESSOR_NAME`, `workletModuleUrl` |
|
|
86
|
+
| `@blockcast/mmt-render/audio/ring-buffer` | `AudioRingBuffer` only |
|
|
87
|
+
| `@blockcast/mmt-render/audio/render-messages` | `FromMain` / `ToMain` message types |
|
|
88
|
+
| `@blockcast/mmt-render/audio/render-pipeline` | `AudioRenderPipeline` only |
|
|
89
|
+
| `@blockcast/mmt-render/audio/worklet-url` | `workletModuleUrl()`, `WORKLET_PROCESSOR_NAME` |
|
|
90
|
+
| `@blockcast/mmt-render/audio/worklet.js` | Prebuilt self-contained worklet IIFE — pass to `audioWorklet.addModule()` |
|
|
91
|
+
| `@blockcast/mmt-render/video` | `VideoRenderPipeline`, `KeyframeGate`, frame meta types |
|
|
92
|
+
| `@blockcast/mmt-render/video/keyframe-gate` | `KeyframeGate` only |
|
|
93
|
+
| `@blockcast/mmt-render/video/render-pipeline` | `VideoRenderPipeline` only |
|
|
94
|
+
|
|
95
|
+
The fine-grained subpaths exist so a lean consumer (e.g. moqtail's `player.ts`) can import just the pacer + AU-duration helpers without pulling in `@blockcast/mmt-container` (see `src/audio/index.ts:5-7`).
|
|
96
|
+
|
|
97
|
+
## Components
|
|
98
|
+
|
|
99
|
+
### `FrameCountPacer` (`src/pacing/frame-pacer.ts`)
|
|
100
|
+
|
|
101
|
+
Media-agnostic wall-clock pacer. Anchors on the first `notifyFrame()` call; computes media time as `frameCount × frameDurationMs`. Sleeps the read loop when the decoder runs ahead of wall-clock by more than `mapper.pacingThresholdMs`. Returns `0` for `NullBlockMapper` (pacing disabled). Why not derive media time from MPU timestamps? Under FEC interleaving, encoders pack multiple AUs per MMTP MPU with a single `mpu_timestamp`; the observed delta is a fraction of the real per-AU duration, so timestamp-driven pacing never sleeps and lets burst delivery saturate the decoder. See the file-level comment for the Sony libatsc3 reference.
|
|
102
|
+
|
|
103
|
+
`PacerSnapshot` exposes `frameCount`, `mediaMs`, `wallMs`, `aheadMs`, `thresholdMs`, `anchored` for diagnostics overlays.
|
|
104
|
+
|
|
105
|
+
### `InterleaveTiming` (`src/pacing/interleave-timing.ts`)
|
|
106
|
+
|
|
107
|
+
Per-AU presentation-time reconstruction from FEC coordinates: the `k-padded-v1` K-stride pacing model of `draft-ramadan-moq-fec-00 §8.3`, with `symbolsPerGroup = ceil(K / D)` computed here. It is pacing arithmetic, not an FEC block locator, and refuses a mapper under any other `fec.blockProfile`:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
au_index_in_block = groupInBlock × symbolsPerGroup + objectId
|
|
111
|
+
ssId = sbn × K + au_index_in_block
|
|
112
|
+
presentationUs = anchorMpuUs + ssId × frameDurationUs
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Same answer whether the AU arrived via MoQ unicast (group_id/object_id) or multicast UDP (SS_ID from the FEC Payload ID). Methods: `presentationUs(groupId, objectId)`, `presentationUsFromSsId(ssId)`, `frameAtOffset(offsetUs)` (inverse), `blockTimeRangeUs(sbn)`. Consumers: CMAF segment cut points, debug overlays, cross-path FEC combining.
|
|
116
|
+
|
|
117
|
+
### `auDurationMsFromCodec` / `pictureDurationMs` (`src/pacing/au-duration.ts`)
|
|
118
|
+
|
|
119
|
+
Nominal AU duration helpers. AAC-LC = 1024 samples; Opus default = 960; FLAC = 4096. Throws on unknown codec — no silent fallback. Lives in the pacing subpath specifically to avoid pulling in `mmt-container`.
|
|
120
|
+
|
|
121
|
+
### `AudioRingBuffer` (`src/audio/audio-ring-buffer.ts`)
|
|
122
|
+
|
|
123
|
+
Timestamp-anchored PCM FIFO. Decoder output stays in emission order, and every decoded chunk adds an exact MMTP timestamp anchor for playout reporting. Timestamps never reposition samples, guess discontinuities, or trigger sample correction. Startup waits for the full caller-supplied, catalog-derived capacity. Underflow re-stalls until that capacity is full again. Source changes use an explicit `reset`; there is no elapsed-time heuristic. The compatibility `skips` and `dups` telemetry fields remain present but are always zero.
|
|
124
|
+
|
|
125
|
+
Optional telemetry via `getDiagAndReset()`: `reads`, `avgFill`, `capacity`, `skips`, `dups`, `overflowSamples`.
|
|
126
|
+
|
|
127
|
+
### `AudioRenderPipeline` (`src/audio/audio-render-pipeline.ts`)
|
|
128
|
+
|
|
129
|
+
End-to-end glue: `MoQ object → MmtpDecodePipeline → WebCodecs AudioDecoder → AudioWorkletNode (ring buffer)`. Owns the worklet, decoder, decode pipeline, and pacer. Audio uses `reorderWindow: 0` because AAC/Opus frames are self-contained.
|
|
130
|
+
|
|
131
|
+
API:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
await pipeline.ready() // load worklet + create decoder
|
|
135
|
+
pipeline.feedMoqObject(packet) // hot path
|
|
136
|
+
pipeline.paceSleepMs() // throttle read loop
|
|
137
|
+
pipeline.rootNode.connect(destination) // connect to AudioContext
|
|
138
|
+
pipeline.resetPacer() // on source switch (MoQ ↔ multicast)
|
|
139
|
+
pipeline.resetPlayout() // drop queued PCM + old timestamp epoch
|
|
140
|
+
pipeline.close() // teardown
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`onState` and `onDiag` callbacks surface worklet messages (`StateMessage`, `DiagMessage`) for player UIs.
|
|
144
|
+
|
|
145
|
+
### `KeyframeGate` (`src/video/keyframe-gate.ts`)
|
|
146
|
+
|
|
147
|
+
Tiny state machine. Drops delta frames until the first keyframe arrives, then opens permanently. `reset()` re-arms after a decoder error so the next IDR reopens the gate. Required for mid-stream subscribe — relays may deliver P/B frames from the middle of a GOP before the first IDR, which yields "key frame required" decoder errors otherwise.
|
|
148
|
+
|
|
149
|
+
### `VideoRenderPipeline` (`src/video/video-render-pipeline.ts`)
|
|
150
|
+
|
|
151
|
+
End-to-end glue for video: `MoQ object → MmtpDecodePipeline → KeyframeGate → WebCodecs VideoDecoder → onFrame(VideoFrame)`. Datagram delivery uses a catalog-derived frame reorder window and explicit catalog/in-band deadline. Reliable MoQ uses protocol-verified sequential groups with no frame window or timeout. Decoder errors trigger an internal `reset()` that closes the gate, resets the pacer, and reconfigures the decoder so the next keyframe re-opens playback.
|
|
152
|
+
|
|
153
|
+
This replaces the `decodeQueueSize > 10 drop-deltas` workaround in moqtail's `webcodecs-backend.ts` and the `reorderWindow = 0 // TODO` in hang-mmt-fec's `video/decoder.ts` (see file header at `src/video/video-render-pipeline.ts:1-15`).
|
|
154
|
+
|
|
155
|
+
## AudioWorklet bundle
|
|
156
|
+
|
|
157
|
+
`AudioWorkletGlobalScope` cannot resolve bare specifiers, so the worklet is shipped as a self-contained IIFE at `dist/worklet/render-worklet.js`. Two ways to load it:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
// 1. Vite-style static URL (recommended for bundled apps)
|
|
161
|
+
import workletUrl from "@blockcast/mmt-render/audio/worklet.js?url"
|
|
162
|
+
await ctx.audioWorklet.addModule(workletUrl)
|
|
163
|
+
|
|
164
|
+
// 2. import.meta.url resolution (no bundler)
|
|
165
|
+
import { workletModuleUrl } from "@blockcast/mmt-render/audio/worklet-url"
|
|
166
|
+
await ctx.audioWorklet.addModule(workletModuleUrl())
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`AudioRenderPipeline.ready()` uses option (2) internally. Processor name is exported as `WORKLET_PROCESSOR_NAME = "mmt-render"`.
|
|
170
|
+
|
|
171
|
+
## Diagnostics surface
|
|
172
|
+
|
|
173
|
+
The package does not render its own UI. It exposes typed data the host can render:
|
|
174
|
+
|
|
175
|
+
- `FrameCountPacer.snapshot` → `PacerSnapshot` (frameCount, mediaMs, wallMs, aheadMs, thresholdMs, anchored)
|
|
176
|
+
- `AudioRingBuffer.getDiagAndReset()` → `AudioRingBufferDiag` (reads, avgFill, capacity, skips, dups, overflowSamples)
|
|
177
|
+
- Worklet `StateMessage` (exact timestamp, stalled, bufferedFrames) every ~13 ms
|
|
178
|
+
- Worklet `DiagMessage` (fill/overflow/underflow telemetry; skip/dup remain zero) when `diag: true` was passed at init
|
|
179
|
+
- `audio underflow: <ms>ms` `console.warn` when the worklet runs dry (`src/audio/render-worklet.ts:58`)
|
|
180
|
+
|
|
181
|
+
There is **no CMCD `x-bc-*` emission, no catalog (CMSF) inspector, and no MPU/MFU packet inspector** in this package. CMCD lives in `@blockcast/mmt-container/cmcd-producer`; catalog parsing lives in `@blockcast/mmt-container/carp`. This package is purely the render side.
|
|
182
|
+
|
|
183
|
+
## Use cases
|
|
184
|
+
|
|
185
|
+
- **Player implementations.** moqtail and hang-mmt-fec both consume this — adding a third player only requires wiring a transport into `feedMoqObject()`.
|
|
186
|
+
- **Debugging multicast losses.** `PacerSnapshot.aheadMs` going negative + ring-buffer underflow warns indicate the decoder can't keep up; large positive `aheadMs` indicates burst delivery and pacing is throttling correctly.
|
|
187
|
+
- **Customer demos / overlays.** `onState` + `onDiag` callbacks feed real-time HUDs without the host having to instrument the pipeline.
|
|
188
|
+
- **Source-switch testing.** `resetPlayout()` / `VideoRenderPipeline.reset()` clear the old epoch when flipping between MoQ unicast and SSM multicast.
|
|
189
|
+
|
|
190
|
+
## Related packages
|
|
191
|
+
|
|
192
|
+
- `@blockcast/mmt-container` — `MmtpDecodePipeline` (consumed for AU framing), MFU reassembly, catalog parser
|
|
193
|
+
- `@blockcast/mmt-fec` — `FecBlockMapper` (provides `pacingThresholdMs`, `K`, `D`, `blockProfile`); `NullBlockMapper` disables pacing
|
|
194
|
+
- `@blockcast/mmt-transport` — produces the MoQ objects that feed `feedMoqObject()`
|
|
195
|
+
|
|
196
|
+
## Testing
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
pnpm test # vitest, runs src/__tests__/*.test.ts
|
|
200
|
+
pnpm test:watch
|
|
201
|
+
pnpm build # tsc --emitDeclarationOnly + esbuild bundles (incl. worklet IIFE)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Existing suites: `frame-pacer.test.ts`, `interleave-timing.test.ts`, `audio-ring-buffer.test.ts`, `keyframe-gate.test.ts`. The pacer suite uses `vi.spyOn(performance, "now")` to drive deterministic wall time. There is no DOM / `AudioContext` integration test — the worklet is exercised in-browser by the consuming players' E2E suites.
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
Apache-2.0 — see `LICENSE`. (libmmt npm packages are all Apache-2.0; the Rust crates are dual `MIT OR Apache-2.0`.)
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface AudioBurstBufferPlanInput {
|
|
2
|
+
sampleRate: number;
|
|
3
|
+
chunkFrames: number;
|
|
4
|
+
/** Catalog media-group duration. Integer milliseconds may be rounded. */
|
|
5
|
+
groupDurationMs: number;
|
|
6
|
+
/** Optional catalog-derived datagram/FEC delivery span. */
|
|
7
|
+
deliveryWindowMs?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface AudioBurstBufferPlan {
|
|
10
|
+
/** PCM required before playout starts or resumes. */
|
|
11
|
+
startupFrames: number;
|
|
12
|
+
/** Maximum retained PCM, including the next complete delivery burst. */
|
|
13
|
+
capacityFrames: number;
|
|
14
|
+
/** Maximum PCM frames delivered as one catalog-defined burst. */
|
|
15
|
+
deliveryFrames: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Convert catalog delivery timing into exact codec-frame FIFO geometry.
|
|
19
|
+
*
|
|
20
|
+
* `groupDurationMs` is commonly rounded to an integer, so media-group units
|
|
21
|
+
* use nearest-unit rounding. FEC delivery windows are upper bounds and use
|
|
22
|
+
* ceiling. Storage holds the startup burst plus one complete successor burst;
|
|
23
|
+
* this prevents an atomic MoQ group from overwriting PCM while the worklet is
|
|
24
|
+
* draining the preceding group without adding a guessed time margin.
|
|
25
|
+
*/
|
|
26
|
+
export declare function deriveAudioBurstBufferPlan(input: AudioBurstBufferPlanInput): AudioBurstBufferPlan;
|
|
27
|
+
//# sourceMappingURL=audio-buffer-plan.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audio-buffer-plan.d.ts","sourceRoot":"","sources":["../../src/audio/audio-buffer-plan.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,yBAAyB;IACxC,UAAU,EAAE,MAAM,CAAA;IAClB,WAAW,EAAE,MAAM,CAAA;IACnB,yEAAyE;IACzE,eAAe,EAAE,MAAM,CAAA;IACvB,2DAA2D;IAC3D,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B;AAED,MAAM,WAAW,oBAAoB;IACnC,qDAAqD;IACrD,aAAa,EAAE,MAAM,CAAA;IACrB,wEAAwE;IACxE,cAAc,EAAE,MAAM,CAAA;IACtB,iEAAiE;IACjE,cAAc,EAAE,MAAM,CAAA;CACvB;AAOD;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CACxC,KAAK,EAAE,yBAAyB,GAC/B,oBAAoB,CA2BtB"}
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
import { type PipelineFecClient } from '@blockcast/mmt-container/mmtp-decode-pipeline';
|
|
20
|
+
import type { MfuReassemblyPolicy } from '@blockcast/mmt-container/mfu-reassembly-policy';
|
|
21
|
+
import type { FecBlockMapper } from '@blockcast/mmt-fec';
|
|
22
|
+
export interface AudioRenderPipelineOpts {
|
|
23
|
+
context: AudioContext;
|
|
24
|
+
codec: string;
|
|
25
|
+
/** MMTP packet_id for this audio track — from catalog. */
|
|
26
|
+
packetId?: number;
|
|
27
|
+
sampleRate: number;
|
|
28
|
+
channels: number;
|
|
29
|
+
description?: Uint8Array;
|
|
30
|
+
mapper: FecBlockMapper;
|
|
31
|
+
fecClient?: PipelineFecClient;
|
|
32
|
+
/** Reliable MoQ is clock-free; datagram deadlines come from catalog/in-band timing. */
|
|
33
|
+
reassemblyPolicy: MfuReassemblyPolicy;
|
|
34
|
+
/** Catalog-derived PCM startup buffer in milliseconds. */
|
|
35
|
+
latencyMs: number;
|
|
36
|
+
/** Exact PCM startup threshold when catalog grouping is codec-aligned. */
|
|
37
|
+
startupFrames?: number;
|
|
38
|
+
/** Exact PCM storage ceiling, including one successor delivery burst. */
|
|
39
|
+
capacityFrames?: number;
|
|
40
|
+
/** Enable ring-buffer telemetry messages. Default false. */
|
|
41
|
+
diag?: boolean;
|
|
42
|
+
/** Called when worklet reports state (playback timestamp + exact buffer accounting). Optional. */
|
|
43
|
+
onState?: (state: {
|
|
44
|
+
timestampUs: number | undefined;
|
|
45
|
+
stalled: boolean;
|
|
46
|
+
bufferedFrames: number;
|
|
47
|
+
writtenFrames: number;
|
|
48
|
+
}) => void;
|
|
49
|
+
/** Called when worklet reports ring-buffer diag (telemetry). Optional. */
|
|
50
|
+
onDiag?: (diag: {
|
|
51
|
+
reads: number;
|
|
52
|
+
avgFill: number;
|
|
53
|
+
capacity: number;
|
|
54
|
+
skipsPerSec: number;
|
|
55
|
+
dupsPerSec: number;
|
|
56
|
+
overflowSamplesPerSec: number;
|
|
57
|
+
underflowSamplesPerSec: number;
|
|
58
|
+
}) => void;
|
|
59
|
+
/** Warmup frames before pacing engages. Default 0 (audio tolerates instant pacing). */
|
|
60
|
+
warmupFrames?: number;
|
|
61
|
+
/** Audio underflow / config event logger. Optional. */
|
|
62
|
+
onError?: (err: unknown) => void;
|
|
63
|
+
}
|
|
64
|
+
export declare class AudioRenderPipeline {
|
|
65
|
+
#private;
|
|
66
|
+
constructor(opts: AudioRenderPipelineOpts);
|
|
67
|
+
/** Load worklet module + create decoder + pipeline. Call once. */
|
|
68
|
+
ready(): Promise<void>;
|
|
69
|
+
/** Feed one MoQ object (raw MMTP packet). */
|
|
70
|
+
feedMoqObject(packet: Uint8Array): void;
|
|
71
|
+
/** ms to sleep before the next object read. 0 when not anchored / not ahead. */
|
|
72
|
+
paceSleepMs(): number;
|
|
73
|
+
/** Pacer snapshot (for diagnostics / UI). */
|
|
74
|
+
get pacerSnapshot(): import("../pacing/frame-pacer.js").PacerSnapshot;
|
|
75
|
+
/** Reset pacer on source switch (MoQ ↔ multicast). */
|
|
76
|
+
resetPacer(): void;
|
|
77
|
+
/** Drop queued PCM and its timestamp epoch at an explicit source boundary. */
|
|
78
|
+
resetPlayout(startUnstalled?: boolean): void;
|
|
79
|
+
/** Node to connect to destination / gain. */
|
|
80
|
+
get rootNode(): AudioNode;
|
|
81
|
+
close(): void;
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=audio-render-pipeline.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audio-render-pipeline.d.ts","sourceRoot":"","sources":["../../src/audio/audio-render-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAsB,KAAK,iBAAiB,EAAE,MAAM,+CAA+C,CAAA;AAC1G,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gDAAgD,CAAA;AACzF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAYxD,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,YAAY,CAAA;IACrB,KAAK,EAAE,MAAM,CAAA;IACb,0DAA0D;IAC1D,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,CAAC,EAAE,UAAU,CAAA;IACxB,MAAM,EAAE,cAAc,CAAA;IACtB,SAAS,CAAC,EAAE,iBAAiB,CAAA;IAC7B,uFAAuF;IACvF,gBAAgB,EAAE,mBAAmB,CAAA;IACrC,0DAA0D;IAC1D,SAAS,EAAE,MAAM,CAAA;IACjB,0EAA0E;IAC1E,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,yEAAyE;IACzE,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,4DAA4D;IAC5D,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,kGAAkG;IAClG,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE;QAChB,WAAW,EAAE,MAAM,GAAG,SAAS,CAAA;QAC/B,OAAO,EAAE,OAAO,CAAA;QAChB,cAAc,EAAE,MAAM,CAAA;QACtB,aAAa,EAAE,MAAM,CAAA;KACtB,KAAK,IAAI,CAAA;IACV,0EAA0E;IAC1E,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE;QACd,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;QAChD,WAAW,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,qBAAqB,EAAE,MAAM,CAAA;QACtE,sBAAsB,EAAE,MAAM,CAAA;KAC/B,KAAK,IAAI,CAAA;IACV,uFAAuF;IACvF,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,uDAAuD;IACvD,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,CAAA;CACjC;AAED,qBAAa,mBAAmB;;gBA6BlB,IAAI,EAAE,uBAAuB;IA+BzC,kEAAkE;IAC5D,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAuG5B,6CAA6C;IAC7C,aAAa,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI;IAIvC,gFAAgF;IAChF,WAAW,IAAI,MAAM;IAIrB,6CAA6C;IAC7C,IAAI,aAAa,qDAEhB;IAED,sDAAsD;IACtD,UAAU,IAAI,IAAI;IAIlB,8EAA8E;IAC9E,YAAY,CAAC,cAAc,UAAQ,GAAG,IAAI;IAa1C,6CAA6C;IAC7C,IAAI,QAAQ,IAAI,SAAS,CAGxB;IAED,KAAK,IAAI,IAAI;CAgCd"}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// src/audio/audio-render-pipeline.ts
|
|
2
|
+
import { MmtpDecodePipeline } from "@blockcast/mmt-container/mmtp-decode-pipeline";
|
|
3
|
+
|
|
4
|
+
// src/pacing/frame-pacer.ts
|
|
5
|
+
var FrameCountPacer = class {
|
|
6
|
+
#frameCount = 0;
|
|
7
|
+
#anchorWallMs = 0;
|
|
8
|
+
#anchored = false;
|
|
9
|
+
constructor(opts) {
|
|
10
|
+
if (!(opts.frameDurationMs > 0)) {
|
|
11
|
+
throw new Error("frameDurationMs must be > 0");
|
|
12
|
+
}
|
|
13
|
+
this.frameDurationMs = opts.frameDurationMs;
|
|
14
|
+
this.thresholdMs = opts.mapper.pacingThresholdMs;
|
|
15
|
+
this.warmupFrames = Math.max(0, opts.warmupFrames ?? 0);
|
|
16
|
+
}
|
|
17
|
+
/** Call on every emitted decoded frame (AU or picture). Anchors on first call. */
|
|
18
|
+
notifyFrame() {
|
|
19
|
+
if (!this.#anchored) {
|
|
20
|
+
this.#anchorWallMs = now();
|
|
21
|
+
this.#anchored = true;
|
|
22
|
+
}
|
|
23
|
+
this.#frameCount++;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Compute sleep ms before reading the next MoQ object.
|
|
27
|
+
* Returns 0 when:
|
|
28
|
+
* - pacing is disabled (NullBlockMapper → threshold=0)
|
|
29
|
+
* - the anchor is not yet set
|
|
30
|
+
* - still in warmup
|
|
31
|
+
* - the caller reports a standing backlog (see `observedDelayMs`)
|
|
32
|
+
* - decoder is not ahead of wall clock by more than threshold
|
|
33
|
+
* Otherwise returns aheadMs - thresholdMs/2 (leaves half the threshold as headroom).
|
|
34
|
+
*
|
|
35
|
+
* `observedDelayMs` is how far this frame is behind the producer's own clock,
|
|
36
|
+
* measured by the caller against a producer-supplied sending instant (for
|
|
37
|
+
* MMTP, the header send-wall timestamp). It is optional because the anchored
|
|
38
|
+
* rate below is blind to it: `#anchorWallMs` is the wall time of the FIRST
|
|
39
|
+
* frame read, so `aheadMs` measures consumption against the join instant and
|
|
40
|
+
* not against the producer. Once `aheadMs` reaches the threshold the loop
|
|
41
|
+
* consumes at exactly the source rate, and recovering latency present at join
|
|
42
|
+
* requires consuming FASTER than the source rate. So without this argument a
|
|
43
|
+
* consumer that joined a live stream mid-block keeps that join latency for the
|
|
44
|
+
* life of the subscription.
|
|
45
|
+
*
|
|
46
|
+
* A caller that can measure the real distance from the producer passes it and
|
|
47
|
+
* gets a drain: while the backlog exceeds the threshold there is nothing to
|
|
48
|
+
* pace against, so return 0 and let the transport set the rate. At the live
|
|
49
|
+
* edge the anchored-rate behavior below applies unchanged.
|
|
50
|
+
*
|
|
51
|
+
* Callers with no producer clock omit it and keep the pure ATSC3-style
|
|
52
|
+
* anchored pacing (see the class doc), where a first-frame anchor is correct
|
|
53
|
+
* because a broadcast push has no live edge to catch up to.
|
|
54
|
+
*/
|
|
55
|
+
computeSleepMs(observedDelayMs) {
|
|
56
|
+
if (this.thresholdMs <= 0) return 0;
|
|
57
|
+
if (!this.#anchored) return 0;
|
|
58
|
+
if (this.#frameCount <= this.warmupFrames) return 0;
|
|
59
|
+
if (observedDelayMs !== void 0 && Number.isFinite(observedDelayMs) && observedDelayMs > this.thresholdMs) {
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
const mediaMs = this.#frameCount * this.frameDurationMs;
|
|
63
|
+
const wallMs = now() - this.#anchorWallMs;
|
|
64
|
+
const aheadMs = mediaMs - wallMs;
|
|
65
|
+
if (aheadMs <= this.thresholdMs) return 0;
|
|
66
|
+
return aheadMs - this.thresholdMs / 2;
|
|
67
|
+
}
|
|
68
|
+
/** Reset anchor + counter. Call on source switch (MoQ ↔ multicast). */
|
|
69
|
+
reset() {
|
|
70
|
+
this.#frameCount = 0;
|
|
71
|
+
this.#anchorWallMs = 0;
|
|
72
|
+
this.#anchored = false;
|
|
73
|
+
}
|
|
74
|
+
get snapshot() {
|
|
75
|
+
const mediaMs = this.#frameCount * this.frameDurationMs;
|
|
76
|
+
const wallMs = this.#anchored ? now() - this.#anchorWallMs : 0;
|
|
77
|
+
return {
|
|
78
|
+
frameCount: this.#frameCount,
|
|
79
|
+
mediaMs,
|
|
80
|
+
wallMs,
|
|
81
|
+
aheadMs: mediaMs - wallMs,
|
|
82
|
+
thresholdMs: this.thresholdMs,
|
|
83
|
+
anchored: this.#anchored
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
function now() {
|
|
88
|
+
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
|
89
|
+
return performance.now();
|
|
90
|
+
}
|
|
91
|
+
return Date.now();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/pacing/au-duration.ts
|
|
95
|
+
function auDurationMsFromCodec(codec, sampleRate) {
|
|
96
|
+
if (!(sampleRate > 0)) throw new Error("sampleRate must be > 0");
|
|
97
|
+
if (codec.startsWith("opus") || codec.startsWith("Opus")) {
|
|
98
|
+
return 960 / sampleRate * 1e3;
|
|
99
|
+
}
|
|
100
|
+
if (codec.startsWith("mp4a") || codec.startsWith("AAC") || codec.startsWith("aac")) {
|
|
101
|
+
return 1024 / sampleRate * 1e3;
|
|
102
|
+
}
|
|
103
|
+
if (codec.startsWith("flac") || codec.startsWith("fLaC")) {
|
|
104
|
+
return 4096 / sampleRate * 1e3;
|
|
105
|
+
}
|
|
106
|
+
throw new Error(`unknown audio codec for AU duration derivation: ${codec}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/audio/worklet-url.ts
|
|
110
|
+
function workletModuleUrl() {
|
|
111
|
+
const base = new URL("../worklet/render-worklet.js", import.meta.url);
|
|
112
|
+
return base.href;
|
|
113
|
+
}
|
|
114
|
+
var WORKLET_PROCESSOR_NAME = "mmt-render";
|
|
115
|
+
|
|
116
|
+
// src/audio/audio-render-pipeline.ts
|
|
117
|
+
var AudioRenderPipeline = class {
|
|
118
|
+
#context;
|
|
119
|
+
#pacer;
|
|
120
|
+
#mapper;
|
|
121
|
+
#codec;
|
|
122
|
+
#packetId;
|
|
123
|
+
#sampleRate;
|
|
124
|
+
#channels;
|
|
125
|
+
#description;
|
|
126
|
+
#latencyMs;
|
|
127
|
+
#startupFrames;
|
|
128
|
+
#startupThresholdFrames;
|
|
129
|
+
#capacityFrames;
|
|
130
|
+
#diag;
|
|
131
|
+
#warmupFrames;
|
|
132
|
+
#onState;
|
|
133
|
+
#onDiag;
|
|
134
|
+
#onError;
|
|
135
|
+
#reassemblyPolicy;
|
|
136
|
+
#decoder = null;
|
|
137
|
+
#pipeline = null;
|
|
138
|
+
#worklet = null;
|
|
139
|
+
#fecClient;
|
|
140
|
+
#closed = false;
|
|
141
|
+
#readyPromise = null;
|
|
142
|
+
#workletGeneration = 0;
|
|
143
|
+
#startupReleased = false;
|
|
144
|
+
constructor(opts) {
|
|
145
|
+
this.#context = opts.context;
|
|
146
|
+
this.#mapper = opts.mapper;
|
|
147
|
+
this.#codec = opts.codec;
|
|
148
|
+
this.#packetId = opts.packetId;
|
|
149
|
+
this.#sampleRate = opts.sampleRate;
|
|
150
|
+
this.#channels = opts.channels;
|
|
151
|
+
this.#description = opts.description;
|
|
152
|
+
this.#latencyMs = opts.latencyMs;
|
|
153
|
+
this.#startupFrames = opts.startupFrames;
|
|
154
|
+
this.#startupThresholdFrames = opts.startupFrames ?? Math.ceil(opts.sampleRate * (opts.latencyMs / 1e3));
|
|
155
|
+
this.#capacityFrames = opts.capacityFrames;
|
|
156
|
+
this.#diag = opts.diag ?? false;
|
|
157
|
+
this.#warmupFrames = opts.warmupFrames ?? 0;
|
|
158
|
+
this.#onState = opts.onState;
|
|
159
|
+
this.#onDiag = opts.onDiag;
|
|
160
|
+
this.#onError = opts.onError;
|
|
161
|
+
this.#fecClient = opts.fecClient;
|
|
162
|
+
this.#reassemblyPolicy = opts.reassemblyPolicy;
|
|
163
|
+
const auDurationMs = auDurationMsFromCodec(opts.codec, opts.sampleRate);
|
|
164
|
+
this.#pacer = new FrameCountPacer({
|
|
165
|
+
frameDurationMs: auDurationMs,
|
|
166
|
+
mapper: opts.mapper,
|
|
167
|
+
warmupFrames: this.#warmupFrames
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/** Load worklet module + create decoder + pipeline. Call once. */
|
|
171
|
+
async ready() {
|
|
172
|
+
if (this.#readyPromise) return this.#readyPromise;
|
|
173
|
+
this.#readyPromise = this.#initialize();
|
|
174
|
+
return this.#readyPromise;
|
|
175
|
+
}
|
|
176
|
+
async #initialize() {
|
|
177
|
+
await this.#context.audioWorklet.addModule(workletModuleUrl());
|
|
178
|
+
const worklet = new AudioWorkletNode(this.#context, WORKLET_PROCESSOR_NAME, {
|
|
179
|
+
numberOfInputs: 0,
|
|
180
|
+
numberOfOutputs: 1,
|
|
181
|
+
outputChannelCount: [this.#channels]
|
|
182
|
+
});
|
|
183
|
+
const initMsg = {
|
|
184
|
+
type: "init",
|
|
185
|
+
generation: ++this.#workletGeneration,
|
|
186
|
+
channels: this.#channels,
|
|
187
|
+
rate: this.#sampleRate,
|
|
188
|
+
latencyMs: this.#latencyMs,
|
|
189
|
+
startupFrames: this.#startupFrames,
|
|
190
|
+
capacityFrames: this.#capacityFrames,
|
|
191
|
+
holdUntilStart: true,
|
|
192
|
+
diag: this.#diag
|
|
193
|
+
};
|
|
194
|
+
worklet.port.postMessage(initMsg);
|
|
195
|
+
worklet.port.onmessage = (ev) => {
|
|
196
|
+
const m = ev.data;
|
|
197
|
+
if (m.type === "state") {
|
|
198
|
+
if (m.generation !== this.#workletGeneration) return;
|
|
199
|
+
this.#onState?.({
|
|
200
|
+
timestampUs: m.timestamp,
|
|
201
|
+
stalled: m.stalled,
|
|
202
|
+
bufferedFrames: m.bufferedFrames,
|
|
203
|
+
writtenFrames: m.writtenFrames
|
|
204
|
+
});
|
|
205
|
+
} else if (m.type === "ringBufDiag") {
|
|
206
|
+
if (m.generation !== this.#workletGeneration) return;
|
|
207
|
+
this.#onDiag?.({
|
|
208
|
+
reads: m.reads,
|
|
209
|
+
avgFill: m.avgFill,
|
|
210
|
+
capacity: m.capacity,
|
|
211
|
+
skipsPerSec: m.skipsPerSec,
|
|
212
|
+
dupsPerSec: m.dupsPerSec,
|
|
213
|
+
overflowSamplesPerSec: m.overflowSamplesPerSec,
|
|
214
|
+
underflowSamplesPerSec: m.underflowSamplesPerSec
|
|
215
|
+
});
|
|
216
|
+
} else if (m.type === "writeAck") {
|
|
217
|
+
if (m.generation !== this.#workletGeneration || this.#startupReleased || m.bufferedFrames < this.#startupThresholdFrames) return;
|
|
218
|
+
this.#startupReleased = true;
|
|
219
|
+
const start = {
|
|
220
|
+
type: "start",
|
|
221
|
+
generation: this.#workletGeneration
|
|
222
|
+
};
|
|
223
|
+
worklet.port.postMessage(start);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
this.#worklet = worklet;
|
|
227
|
+
const decoder = new AudioDecoder({
|
|
228
|
+
output: (data) => this.#onDecoded(data),
|
|
229
|
+
error: (err) => this.#onError?.(err)
|
|
230
|
+
});
|
|
231
|
+
const config = {
|
|
232
|
+
codec: this.#codec,
|
|
233
|
+
sampleRate: this.#sampleRate,
|
|
234
|
+
numberOfChannels: this.#channels,
|
|
235
|
+
...this.#description ? { description: this.#description } : {}
|
|
236
|
+
};
|
|
237
|
+
decoder.configure(config);
|
|
238
|
+
this.#decoder = decoder;
|
|
239
|
+
const pipeline = new MmtpDecodePipeline();
|
|
240
|
+
pipeline.configure({
|
|
241
|
+
codec: this.#codec,
|
|
242
|
+
packetId: this.#packetId,
|
|
243
|
+
description: this.#description,
|
|
244
|
+
reorderWindow: 0,
|
|
245
|
+
// audio doesn't need reorder — AAC/Opus frames are self-contained
|
|
246
|
+
reassemblyPolicy: this.#reassemblyPolicy
|
|
247
|
+
});
|
|
248
|
+
if (this.#fecClient) pipeline.setFecClient(this.#fecClient);
|
|
249
|
+
pipeline.onFrame((codecData, meta) => {
|
|
250
|
+
if (!this.#decoder || this.#decoder.state !== "configured") return;
|
|
251
|
+
if (codecData.byteLength === 0) return;
|
|
252
|
+
this.#pacer.notifyFrame();
|
|
253
|
+
try {
|
|
254
|
+
this.#decoder.decode(new EncodedAudioChunk({
|
|
255
|
+
type: "key",
|
|
256
|
+
data: codecData,
|
|
257
|
+
timestamp: meta.timestamp
|
|
258
|
+
}));
|
|
259
|
+
} catch (err) {
|
|
260
|
+
this.#onError?.(err);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
this.#pipeline = pipeline;
|
|
264
|
+
}
|
|
265
|
+
/** Feed one MoQ object (raw MMTP packet). */
|
|
266
|
+
feedMoqObject(packet) {
|
|
267
|
+
this.#pipeline?.feedMoqObject(packet);
|
|
268
|
+
}
|
|
269
|
+
/** ms to sleep before the next object read. 0 when not anchored / not ahead. */
|
|
270
|
+
paceSleepMs() {
|
|
271
|
+
return this.#pacer.computeSleepMs();
|
|
272
|
+
}
|
|
273
|
+
/** Pacer snapshot (for diagnostics / UI). */
|
|
274
|
+
get pacerSnapshot() {
|
|
275
|
+
return this.#pacer.snapshot;
|
|
276
|
+
}
|
|
277
|
+
/** Reset pacer on source switch (MoQ ↔ multicast). */
|
|
278
|
+
resetPacer() {
|
|
279
|
+
this.#pacer.reset();
|
|
280
|
+
}
|
|
281
|
+
/** Drop queued PCM and its timestamp epoch at an explicit source boundary. */
|
|
282
|
+
resetPlayout(startUnstalled = false) {
|
|
283
|
+
this.#pacer.reset();
|
|
284
|
+
this.#startupReleased = true;
|
|
285
|
+
const message = {
|
|
286
|
+
type: "reset",
|
|
287
|
+
generation: ++this.#workletGeneration,
|
|
288
|
+
startUnstalled
|
|
289
|
+
};
|
|
290
|
+
this.#worklet?.port.postMessage(message);
|
|
291
|
+
}
|
|
292
|
+
/** Node to connect to destination / gain. */
|
|
293
|
+
get rootNode() {
|
|
294
|
+
if (!this.#worklet) throw new Error("AudioRenderPipeline.ready() must resolve first");
|
|
295
|
+
return this.#worklet;
|
|
296
|
+
}
|
|
297
|
+
close() {
|
|
298
|
+
if (this.#closed) return;
|
|
299
|
+
this.#closed = true;
|
|
300
|
+
try {
|
|
301
|
+
this.#decoder?.close();
|
|
302
|
+
} catch {
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
this.#pipeline?.dispose();
|
|
306
|
+
} catch {
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
this.#worklet?.disconnect();
|
|
310
|
+
} catch {
|
|
311
|
+
}
|
|
312
|
+
this.#decoder = null;
|
|
313
|
+
this.#pipeline = null;
|
|
314
|
+
this.#worklet = null;
|
|
315
|
+
}
|
|
316
|
+
// ─── Private ─────────────────────────────────────────────────────
|
|
317
|
+
#onDecoded(sample) {
|
|
318
|
+
if (this.#closed || !this.#worklet) {
|
|
319
|
+
sample.close();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const timestamp = sample.timestamp;
|
|
323
|
+
const channelData = [];
|
|
324
|
+
for (let ch = 0; ch < sample.numberOfChannels; ch++) {
|
|
325
|
+
const data = new Float32Array(sample.numberOfFrames);
|
|
326
|
+
sample.copyTo(data, { format: "f32-planar", planeIndex: ch });
|
|
327
|
+
channelData.push(data);
|
|
328
|
+
}
|
|
329
|
+
sample.close();
|
|
330
|
+
const msg = {
|
|
331
|
+
type: "data",
|
|
332
|
+
data: channelData,
|
|
333
|
+
timestamp
|
|
334
|
+
};
|
|
335
|
+
this.#worklet.port.postMessage(msg, channelData.map((d) => d.buffer));
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
export {
|
|
339
|
+
AudioRenderPipeline
|
|
340
|
+
};
|
|
341
|
+
//# sourceMappingURL=audio-render-pipeline.js.map
|