@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.
Files changed (76) hide show
  1. package/README.md +208 -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 +341 -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 +221 -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 +592 -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 +92 -0
  36. package/dist/pacing/frame-pacer.d.ts.map +1 -0
  37. package/dist/pacing/frame-pacer.js +93 -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 +181 -0
  42. package/dist/pacing/index.js.map +7 -0
  43. package/dist/pacing/interleave-timing.d.ts +73 -0
  44. package/dist/pacing/interleave-timing.d.ts.map +1 -0
  45. package/dist/pacing/interleave-timing.js +69 -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 +237 -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 +27 -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 +237 -0
  58. package/dist/video/video-render-pipeline.js.map +7 -0
  59. package/dist/worklet/render-worklet.js +355 -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 +156 -0
  72. package/src/pacing/index.ts +5 -0
  73. package/src/pacing/interleave-timing.ts +122 -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
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