@blockcast/mmt-transport 0.2.0
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/dist/abr-controller.d.ts +94 -0
- package/dist/abr-controller.d.ts.map +1 -0
- package/dist/abr-controller.js +174 -0
- package/dist/abr-controller.js.map +1 -0
- package/dist/amt-gateway.d.ts +160 -0
- package/dist/amt-gateway.d.ts.map +1 -0
- package/dist/amt-gateway.js +390 -0
- package/dist/amt-gateway.js.map +1 -0
- package/dist/clock.d.ts +104 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/clock.js +183 -0
- package/dist/clock.js.map +1 -0
- package/dist/driad-discovery.d.ts +50 -0
- package/dist/driad-discovery.d.ts.map +1 -0
- package/dist/driad-discovery.js +170 -0
- package/dist/driad-discovery.js.map +1 -0
- package/dist/fec-client.d.ts +442 -0
- package/dist/fec-client.d.ts.map +1 -0
- package/dist/fec-client.js +784 -0
- package/dist/fec-client.js.map +1 -0
- package/dist/fec-client.test.d.ts +8 -0
- package/dist/fec-client.test.d.ts.map +1 -0
- package/dist/fec-client.test.js +112 -0
- package/dist/fec-client.test.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/transport-manager.d.ts +114 -0
- package/dist/transport-manager.d.ts.map +1 -0
- package/dist/transport-manager.js +396 -0
- package/dist/transport-manager.js.map +1 -0
- package/dist/types.d.ts +356 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +85 -0
- package/dist/types.js.map +1 -0
- package/package.json +60 -0
- package/src/abr-controller.ts +227 -0
- package/src/amt-gateway.ts +511 -0
- package/src/clock.ts +212 -0
- package/src/driad-discovery.ts +193 -0
- package/src/fec-client.test.ts +140 -0
- package/src/fec-client.ts +1097 -0
- package/src/index.ts +122 -0
- package/src/transport-manager.ts +460 -0
- package/src/types.ts +420 -0
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blockcast/transport - MMT FEC Client
|
|
3
|
+
*
|
|
4
|
+
* MMT FEC Client for Browser Players
|
|
5
|
+
*
|
|
6
|
+
* Integrates libmmt WASM FEC decoder with:
|
|
7
|
+
* - MFU fragment reassembly
|
|
8
|
+
* - window.Multicast API from pim-multicast-gateway
|
|
9
|
+
* - Green-fill fallback for unrecoverable frames
|
|
10
|
+
*
|
|
11
|
+
* Designed for hang and moqtail players.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* MFU Reassembler (TypeScript implementation for WASM compatibility)
|
|
15
|
+
*/
|
|
16
|
+
class MfuReassembler {
|
|
17
|
+
fragmentBuffers = new Map();
|
|
18
|
+
timeouts = new Map();
|
|
19
|
+
timeoutMs;
|
|
20
|
+
maxMfus;
|
|
21
|
+
stats = {
|
|
22
|
+
complete: 0,
|
|
23
|
+
reassembled: 0,
|
|
24
|
+
timedOut: 0,
|
|
25
|
+
};
|
|
26
|
+
constructor(maxMfus = 100, timeoutMs = 1000) {
|
|
27
|
+
this.maxMfus = maxMfus;
|
|
28
|
+
this.timeoutMs = timeoutMs;
|
|
29
|
+
}
|
|
30
|
+
addFragment(fragment) {
|
|
31
|
+
const { mpuSequenceNumber, fragmentationIndicator } = fragment;
|
|
32
|
+
// FI=0: Complete MFU in single packet
|
|
33
|
+
if (fragmentationIndicator === 0) {
|
|
34
|
+
this.stats.complete++;
|
|
35
|
+
return {
|
|
36
|
+
mpuSequenceNumber,
|
|
37
|
+
data: fragment.data,
|
|
38
|
+
timestamp: fragment.timestamp,
|
|
39
|
+
fragmentCount: 1,
|
|
40
|
+
recoveredViaFec: false,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// Get or create buffer for this MPU
|
|
44
|
+
let fragments = this.fragmentBuffers.get(mpuSequenceNumber);
|
|
45
|
+
if (!fragments) {
|
|
46
|
+
fragments = [];
|
|
47
|
+
this.fragmentBuffers.set(mpuSequenceNumber, fragments);
|
|
48
|
+
this.setTimeout(mpuSequenceNumber);
|
|
49
|
+
}
|
|
50
|
+
// Check for duplicate
|
|
51
|
+
const existingIdx = fragments.findIndex((f) => f.fragmentCounter === fragment.fragmentCounter);
|
|
52
|
+
if (existingIdx >= 0) {
|
|
53
|
+
fragments[existingIdx] = fragment;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
fragments.push(fragment);
|
|
57
|
+
}
|
|
58
|
+
// Enforce memory limit
|
|
59
|
+
this.enforceLimit();
|
|
60
|
+
// Try to reassemble
|
|
61
|
+
return this.tryReassemble(mpuSequenceNumber);
|
|
62
|
+
}
|
|
63
|
+
tryReassemble(mpuSeq) {
|
|
64
|
+
const fragments = this.fragmentBuffers.get(mpuSeq);
|
|
65
|
+
if (!fragments || fragments.length === 0)
|
|
66
|
+
return null;
|
|
67
|
+
// Sort by FI
|
|
68
|
+
fragments.sort((a, b) => a.fragmentationIndicator - b.fragmentationIndicator);
|
|
69
|
+
const first = fragments[0].fragmentationIndicator;
|
|
70
|
+
const last = fragments[fragments.length - 1].fragmentationIndicator;
|
|
71
|
+
// Need FI=1 (first) and FI=3 (last)
|
|
72
|
+
if (first !== 1 || last !== 3)
|
|
73
|
+
return null;
|
|
74
|
+
// Verify middles are FI=2
|
|
75
|
+
for (let i = 1; i < fragments.length - 1; i++) {
|
|
76
|
+
if (fragments[i].fragmentationIndicator !== 2)
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
// Reassemble
|
|
80
|
+
const totalSize = fragments.reduce((sum, f) => sum + f.data.length, 0);
|
|
81
|
+
const reassembled = new Uint8Array(totalSize);
|
|
82
|
+
let offset = 0;
|
|
83
|
+
for (const fragment of fragments) {
|
|
84
|
+
reassembled.set(fragment.data, offset);
|
|
85
|
+
offset += fragment.data.length;
|
|
86
|
+
}
|
|
87
|
+
// Cleanup
|
|
88
|
+
this.clearTimeout(mpuSeq);
|
|
89
|
+
this.fragmentBuffers.delete(mpuSeq);
|
|
90
|
+
this.stats.reassembled++;
|
|
91
|
+
return {
|
|
92
|
+
mpuSequenceNumber: mpuSeq,
|
|
93
|
+
data: reassembled,
|
|
94
|
+
timestamp: fragments[0].timestamp,
|
|
95
|
+
fragmentCount: fragments.length,
|
|
96
|
+
recoveredViaFec: false,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
setTimeout(mpuSeq) {
|
|
100
|
+
const id = setTimeout(() => this.onTimeout(mpuSeq), this.timeoutMs);
|
|
101
|
+
this.timeouts.set(mpuSeq, id);
|
|
102
|
+
}
|
|
103
|
+
clearTimeout(mpuSeq) {
|
|
104
|
+
const id = this.timeouts.get(mpuSeq);
|
|
105
|
+
if (id !== undefined) {
|
|
106
|
+
clearTimeout(id);
|
|
107
|
+
this.timeouts.delete(mpuSeq);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
onTimeout(mpuSeq) {
|
|
111
|
+
this.fragmentBuffers.delete(mpuSeq);
|
|
112
|
+
this.timeouts.delete(mpuSeq);
|
|
113
|
+
this.stats.timedOut++;
|
|
114
|
+
}
|
|
115
|
+
enforceLimit() {
|
|
116
|
+
if (this.fragmentBuffers.size <= this.maxMfus)
|
|
117
|
+
return;
|
|
118
|
+
const sequences = Array.from(this.fragmentBuffers.keys()).sort((a, b) => a - b);
|
|
119
|
+
const toRemove = sequences.slice(0, sequences.length - this.maxMfus);
|
|
120
|
+
for (const seq of toRemove) {
|
|
121
|
+
this.clearTimeout(seq);
|
|
122
|
+
this.fragmentBuffers.delete(seq);
|
|
123
|
+
this.stats.timedOut++;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
flush() {
|
|
127
|
+
for (const seq of this.fragmentBuffers.keys()) {
|
|
128
|
+
this.clearTimeout(seq);
|
|
129
|
+
}
|
|
130
|
+
this.fragmentBuffers.clear();
|
|
131
|
+
this.timeouts.clear();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* MMT FEC Client
|
|
136
|
+
*
|
|
137
|
+
* Combines WASM FEC decoder with MFU reassembler for complete
|
|
138
|
+
* MMT packet processing in browser players.
|
|
139
|
+
*/
|
|
140
|
+
export class MmtFecClient {
|
|
141
|
+
fecDecoder = null;
|
|
142
|
+
mfuReassembler;
|
|
143
|
+
options;
|
|
144
|
+
initialized = false;
|
|
145
|
+
greenFillCount = 0;
|
|
146
|
+
cleanupInterval = null;
|
|
147
|
+
// WASM module references (set via init)
|
|
148
|
+
wasmInit;
|
|
149
|
+
wasmFecDecoder;
|
|
150
|
+
wasmParseFecPayloadId;
|
|
151
|
+
wasmIsSimdEnabled;
|
|
152
|
+
constructor(options = {}) {
|
|
153
|
+
this.options = {
|
|
154
|
+
interleaveDepth: options.interleaveDepth ?? 30,
|
|
155
|
+
blockTimeoutMs: options.blockTimeoutMs ?? 2000,
|
|
156
|
+
mfuTimeoutMs: options.mfuTimeoutMs ?? 1000,
|
|
157
|
+
greenFillEnabled: options.greenFillEnabled ?? true,
|
|
158
|
+
onMfuReady: options.onMfuReady ?? (() => { }),
|
|
159
|
+
onGreenFill: options.onGreenFill ?? (() => { }),
|
|
160
|
+
onStatsUpdate: options.onStatsUpdate ?? (() => { }),
|
|
161
|
+
};
|
|
162
|
+
this.mfuReassembler = new MfuReassembler(100, this.options.mfuTimeoutMs);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Set WASM module functions (called before init)
|
|
166
|
+
*/
|
|
167
|
+
setWasmModule(module) {
|
|
168
|
+
this.wasmInit = module.init;
|
|
169
|
+
this.wasmFecDecoder = module.MmtFecDecoder;
|
|
170
|
+
this.wasmParseFecPayloadId = module.parse_fec_payload_id;
|
|
171
|
+
this.wasmIsSimdEnabled = module.is_simd_enabled;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Initialize the WASM module
|
|
175
|
+
*/
|
|
176
|
+
async init() {
|
|
177
|
+
if (this.initialized)
|
|
178
|
+
return;
|
|
179
|
+
if (!this.wasmInit || !this.wasmFecDecoder) {
|
|
180
|
+
throw new Error("WASM module not set. Call setWasmModule() first.");
|
|
181
|
+
}
|
|
182
|
+
await this.wasmInit();
|
|
183
|
+
this.fecDecoder = new this.wasmFecDecoder(this.options.interleaveDepth);
|
|
184
|
+
this.fecDecoder.set_timeout(this.options.blockTimeoutMs);
|
|
185
|
+
this.fecDecoder.set_green_fill(this.options.greenFillEnabled);
|
|
186
|
+
// Start periodic cleanup
|
|
187
|
+
this.cleanupInterval = setInterval(() => {
|
|
188
|
+
this.cleanup();
|
|
189
|
+
}, 500);
|
|
190
|
+
this.initialized = true;
|
|
191
|
+
console.log(`[MmtFecClient] Initialized (SIMD: ${this.wasmIsSimdEnabled?.() ?? false})`);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Configure FEC from OTI
|
|
195
|
+
*/
|
|
196
|
+
configureFec(oti) {
|
|
197
|
+
if (!this.fecDecoder) {
|
|
198
|
+
throw new Error("MmtFecClient not initialized");
|
|
199
|
+
}
|
|
200
|
+
this.fecDecoder.configure(oti);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Process an MMTP packet
|
|
204
|
+
*
|
|
205
|
+
* Handles FEC and MFU reassembly automatically.
|
|
206
|
+
* Calls onMfuReady when a complete MFU is available.
|
|
207
|
+
*/
|
|
208
|
+
processPacket(packet) {
|
|
209
|
+
if (!this.fecDecoder) {
|
|
210
|
+
throw new Error("MmtFecClient not initialized");
|
|
211
|
+
}
|
|
212
|
+
const timestamp = performance.now();
|
|
213
|
+
// Parse MMTP header (12 bytes minimum)
|
|
214
|
+
if (packet.length < 12)
|
|
215
|
+
return;
|
|
216
|
+
const fecType = (packet[0] >> 3) & 0x03;
|
|
217
|
+
const mmtpTimestamp = BigInt((packet[4] << 24) | (packet[5] << 16) | (packet[6] << 8) | packet[7]);
|
|
218
|
+
const payload = packet.subarray(12);
|
|
219
|
+
if (payload.length < 8)
|
|
220
|
+
return;
|
|
221
|
+
// Check if FEC packet
|
|
222
|
+
if (fecType !== 0) {
|
|
223
|
+
this.processFecPacket(payload, timestamp, mmtpTimestamp);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
// Parse MPU header
|
|
227
|
+
const mpuSeq = (payload[0] << 24) | (payload[1] << 16) | (payload[2] << 8) | payload[3];
|
|
228
|
+
const fi = payload[5] & 0x03;
|
|
229
|
+
const fragCounter = (payload[6] << 8) | payload[7];
|
|
230
|
+
// MFU DU header present for FI=0 (complete) or FI=1 (first)
|
|
231
|
+
const hasMfuHeader = fi === 0 || fi === 1;
|
|
232
|
+
const headerSize = 8 + (hasMfuHeader ? 14 : 0);
|
|
233
|
+
if (payload.length < headerSize)
|
|
234
|
+
return;
|
|
235
|
+
const data = payload.subarray(headerSize);
|
|
236
|
+
// Add fragment to reassembler
|
|
237
|
+
const fragment = {
|
|
238
|
+
mpuSequenceNumber: mpuSeq,
|
|
239
|
+
fragmentationIndicator: fi,
|
|
240
|
+
fragmentCounter: fragCounter,
|
|
241
|
+
data: new Uint8Array(data),
|
|
242
|
+
timestamp: mmtpTimestamp,
|
|
243
|
+
};
|
|
244
|
+
const mfu = this.mfuReassembler.addFragment(fragment);
|
|
245
|
+
if (mfu) {
|
|
246
|
+
this.options.onMfuReady(mfu);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Process FEC repair packet
|
|
251
|
+
*/
|
|
252
|
+
processFecPacket(payload, timestamp, mmtpTimestamp) {
|
|
253
|
+
if (!this.fecDecoder || !this.wasmParseFecPayloadId || payload.length < 4)
|
|
254
|
+
return;
|
|
255
|
+
try {
|
|
256
|
+
const { sbn, esi } = this.wasmParseFecPayloadId(payload.subarray(0, 4));
|
|
257
|
+
const data = payload.subarray(4);
|
|
258
|
+
// Add repair packet to FEC decoder
|
|
259
|
+
const decoded = this.fecDecoder.add_repair(sbn, esi, data, timestamp);
|
|
260
|
+
if (decoded && decoded.length > 0) {
|
|
261
|
+
// FEC recovery successful - create MFU from decoded data
|
|
262
|
+
const mfu = {
|
|
263
|
+
mpuSequenceNumber: sbn, // Use SBN as sequence
|
|
264
|
+
data: new Uint8Array(decoded),
|
|
265
|
+
timestamp: mmtpTimestamp,
|
|
266
|
+
fragmentCount: 0,
|
|
267
|
+
recoveredViaFec: true,
|
|
268
|
+
};
|
|
269
|
+
this.options.onMfuReady(mfu);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
console.warn("[MmtFecClient] FEC packet parse error:", e);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Cleanup expired blocks and MFUs
|
|
278
|
+
*/
|
|
279
|
+
cleanup() {
|
|
280
|
+
if (!this.fecDecoder)
|
|
281
|
+
return;
|
|
282
|
+
const greenFills = this.fecDecoder.cleanup(performance.now());
|
|
283
|
+
if (greenFills.length > 0 && this.options.greenFillEnabled) {
|
|
284
|
+
this.greenFillCount++;
|
|
285
|
+
this.options.onGreenFill(BigInt(performance.now()));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Get current statistics
|
|
290
|
+
*/
|
|
291
|
+
getStats() {
|
|
292
|
+
const fecStats = this.fecDecoder?.get_stats();
|
|
293
|
+
const mfuStats = this.mfuReassembler.stats;
|
|
294
|
+
return {
|
|
295
|
+
mfuComplete: mfuStats.complete,
|
|
296
|
+
mfuReassembled: mfuStats.reassembled,
|
|
297
|
+
mfuTimedOut: mfuStats.timedOut,
|
|
298
|
+
fecSourcePackets: Number(fecStats?.source_packets ?? 0n),
|
|
299
|
+
fecRepairPackets: Number(fecStats?.repair_packets ?? 0n),
|
|
300
|
+
fecBlocksComplete: Number(fecStats?.blocks_complete ?? 0n),
|
|
301
|
+
fecBlocksRecovered: Number(fecStats?.blocks_recovered ?? 0n),
|
|
302
|
+
fecBlocksFailed: Number(fecStats?.blocks_failed ?? 0n),
|
|
303
|
+
fecRecoveryRate: fecStats?.recovery_rate() ?? 100,
|
|
304
|
+
greenFillFrames: this.greenFillCount,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Reset statistics
|
|
309
|
+
*/
|
|
310
|
+
resetStats() {
|
|
311
|
+
this.fecDecoder?.reset_stats();
|
|
312
|
+
this.greenFillCount = 0;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Get current FEC configuration
|
|
316
|
+
* Returns the OTI parameters and interleave depth
|
|
317
|
+
*/
|
|
318
|
+
getConfig() {
|
|
319
|
+
return {
|
|
320
|
+
symbolSize: this.fecDecoder?.get_symbol_size() ?? 1280,
|
|
321
|
+
sourceBlocks: this.fecDecoder?.get_source_blocks() ?? 1,
|
|
322
|
+
subBlocks: this.fecDecoder?.get_sub_blocks() ?? 1,
|
|
323
|
+
alignment: this.fecDecoder?.get_alignment() ?? 8,
|
|
324
|
+
interleaveDepth: this.fecDecoder?.get_interleave_depth() ?? this.options.interleaveDepth,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Set interleave depth
|
|
329
|
+
*
|
|
330
|
+
* The interleave depth determines how many blocks can be "in flight" at once.
|
|
331
|
+
* Higher values provide better burst loss protection but increase latency.
|
|
332
|
+
*
|
|
333
|
+
* @param depth - Number of interleaved blocks (min: 1, typical: 30 for ~1s at 30fps)
|
|
334
|
+
*/
|
|
335
|
+
setInterleaveDepth(depth) {
|
|
336
|
+
if (!this.fecDecoder)
|
|
337
|
+
return;
|
|
338
|
+
this.fecDecoder.set_interleave_depth(Math.max(1, depth));
|
|
339
|
+
this.options.interleaveDepth = Math.max(1, depth);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Cleanup blocks that are too old based on interleave depth
|
|
343
|
+
*
|
|
344
|
+
* This method expires blocks that are more than `interleaveDepth` behind
|
|
345
|
+
* the current block number. Call this when processing a new source block
|
|
346
|
+
* to maintain memory bounds.
|
|
347
|
+
*
|
|
348
|
+
* @param currentSbn - Current source block number being processed
|
|
349
|
+
* @returns Array of expired block numbers
|
|
350
|
+
*/
|
|
351
|
+
cleanupByInterleave(currentSbn) {
|
|
352
|
+
if (!this.fecDecoder)
|
|
353
|
+
return [];
|
|
354
|
+
return Array.from(this.fecDecoder.cleanup_by_interleave(currentSbn));
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Check if initialized
|
|
358
|
+
*/
|
|
359
|
+
isInitialized() {
|
|
360
|
+
return this.initialized;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Dispose of resources
|
|
364
|
+
*/
|
|
365
|
+
dispose() {
|
|
366
|
+
if (this.cleanupInterval) {
|
|
367
|
+
clearInterval(this.cleanupInterval);
|
|
368
|
+
}
|
|
369
|
+
this.mfuReassembler.flush();
|
|
370
|
+
this.fecDecoder?.flush();
|
|
371
|
+
this.initialized = false;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Integration with window.Multicast API (pim-multicast-gateway)
|
|
376
|
+
*/
|
|
377
|
+
export function createMulticastFecHandler(client, options) {
|
|
378
|
+
return (data) => {
|
|
379
|
+
const packet = new Uint8Array(data);
|
|
380
|
+
client.processPacket(packet);
|
|
381
|
+
options?.onPacket?.(data);
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Quick setup for hang/moqtail integration
|
|
386
|
+
*/
|
|
387
|
+
export async function setupMmtFec(options) {
|
|
388
|
+
const client = new MmtFecClient(options);
|
|
389
|
+
// Integrate with window.Multicast if available
|
|
390
|
+
if (typeof window !== "undefined" && window.Multicast) {
|
|
391
|
+
console.log("[MmtFecClient] window.Multicast detected, ready for integration");
|
|
392
|
+
}
|
|
393
|
+
return client;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* MoQ FEC Client
|
|
397
|
+
*
|
|
398
|
+
* Wraps mmt-wasm MoQ FEC bindings for use in TypeScript applications.
|
|
399
|
+
* Designed for WebTransport-based MoQ streaming with FEC protection.
|
|
400
|
+
*
|
|
401
|
+
* ## Usage
|
|
402
|
+
*
|
|
403
|
+
* ```typescript
|
|
404
|
+
* import { MoqFecClient } from '@blockcast/ssm-transport';
|
|
405
|
+
*
|
|
406
|
+
* const client = new MoqFecClient({
|
|
407
|
+
* symbolSize: 1280,
|
|
408
|
+
* k: 10,
|
|
409
|
+
* p: 3,
|
|
410
|
+
* interleaveDepth: 30,
|
|
411
|
+
* });
|
|
412
|
+
*
|
|
413
|
+
* await client.init();
|
|
414
|
+
*
|
|
415
|
+
* // Encoder (publisher side)
|
|
416
|
+
* const encoded = client.encodeBlock(groupData);
|
|
417
|
+
* encoded.sourceSymbols.forEach((symbol, esi) => {
|
|
418
|
+
* sendSourceSymbol(encoded.blockId, esi, symbol);
|
|
419
|
+
* });
|
|
420
|
+
* encoded.repairSymbols.forEach((symbol, i) => {
|
|
421
|
+
* sendRepairSymbol(encoded.blockId, encoded.k + i, symbol);
|
|
422
|
+
* });
|
|
423
|
+
*
|
|
424
|
+
* // Decoder (subscriber side)
|
|
425
|
+
* client.setOti(encoded.oti);
|
|
426
|
+
* onSourceSymbol((blockId, esi, symbol) => {
|
|
427
|
+
* const decoded = client.addSourceSymbol(blockId, esi, symbol);
|
|
428
|
+
* if (decoded) processGroup(decoded);
|
|
429
|
+
* });
|
|
430
|
+
* ```
|
|
431
|
+
*/
|
|
432
|
+
// Global WASM module cache
|
|
433
|
+
let wasmModuleCache = null;
|
|
434
|
+
/**
|
|
435
|
+
* Load the mmt-wasm module dynamically
|
|
436
|
+
*
|
|
437
|
+
* @param wasmPath - Path to the mmt-wasm module (default: tries common locations)
|
|
438
|
+
* @returns The loaded WASM module
|
|
439
|
+
*/
|
|
440
|
+
export async function loadMmtWasm(wasmPath) {
|
|
441
|
+
if (wasmModuleCache) {
|
|
442
|
+
return wasmModuleCache;
|
|
443
|
+
}
|
|
444
|
+
// Try multiple paths to find the WASM module
|
|
445
|
+
const paths = wasmPath
|
|
446
|
+
? [wasmPath]
|
|
447
|
+
: [
|
|
448
|
+
// NPM package names (preferred - resolved by bundler/node)
|
|
449
|
+
"mmt-wasm",
|
|
450
|
+
"@mmt/wasm",
|
|
451
|
+
"@blockcast/mmt-wasm",
|
|
452
|
+
// Relative paths from common project structures
|
|
453
|
+
"../mmt-wasm/pkg/mmt_wasm.js",
|
|
454
|
+
"../../mmt-wasm/pkg/mmt_wasm.js",
|
|
455
|
+
"../../../libmmt/mmt-wasm/pkg/mmt_wasm.js",
|
|
456
|
+
// Absolute paths for web serving
|
|
457
|
+
"/libs/mmt-wasm/pkg/mmt_wasm.js",
|
|
458
|
+
"/mmt-wasm/pkg/mmt_wasm.js",
|
|
459
|
+
"/pkg/mmt_wasm.js",
|
|
460
|
+
"./mmt_wasm.js",
|
|
461
|
+
];
|
|
462
|
+
for (const path of paths) {
|
|
463
|
+
try {
|
|
464
|
+
// Dynamic import
|
|
465
|
+
const module = await import(/* webpackIgnore: true */ path);
|
|
466
|
+
// Check if it has the expected exports
|
|
467
|
+
if (module.MoqFecConfig && module.MoqFecEncoder && module.MoqFecDecoder) {
|
|
468
|
+
// Initialize the WASM module
|
|
469
|
+
if (module.default) {
|
|
470
|
+
await module.default();
|
|
471
|
+
}
|
|
472
|
+
wasmModuleCache = {
|
|
473
|
+
init: async () => {
|
|
474
|
+
/* already initialized */
|
|
475
|
+
},
|
|
476
|
+
MoqFecConfig: module.MoqFecConfig,
|
|
477
|
+
MoqFecEncoder: module.MoqFecEncoder,
|
|
478
|
+
MoqFecDecoder: module.MoqFecDecoder,
|
|
479
|
+
};
|
|
480
|
+
console.log(`[MoqFecClient] Loaded mmt-wasm from ${path}`);
|
|
481
|
+
return wasmModuleCache;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
// Try next path
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
throw new Error(`Failed to load mmt-wasm module. Tried paths: ${paths.join(", ")}. ` +
|
|
490
|
+
`Make sure mmt-wasm is built and accessible.`);
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Create a MoqFecClient with auto-loaded WASM module
|
|
494
|
+
*
|
|
495
|
+
* @param config - FEC configuration
|
|
496
|
+
* @param wasmPath - Optional path to mmt-wasm module
|
|
497
|
+
* @returns Initialized MoqFecClient
|
|
498
|
+
*/
|
|
499
|
+
export async function createMoqFecClient(config = {}, wasmPath) {
|
|
500
|
+
const client = new MoqFecClient(config);
|
|
501
|
+
try {
|
|
502
|
+
const wasmModule = await loadMmtWasm(wasmPath);
|
|
503
|
+
if (wasmModule) {
|
|
504
|
+
client.setWasmModule(wasmModule);
|
|
505
|
+
await client.init();
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
catch (err) {
|
|
509
|
+
console.warn("[MoqFecClient] Failed to auto-load WASM module:", err);
|
|
510
|
+
// Client is created but not initialized - caller can set WASM module manually
|
|
511
|
+
}
|
|
512
|
+
return client;
|
|
513
|
+
}
|
|
514
|
+
export class MoqFecClient {
|
|
515
|
+
config;
|
|
516
|
+
encoder = null;
|
|
517
|
+
decoder = null;
|
|
518
|
+
wasmConfig = null;
|
|
519
|
+
oti = null;
|
|
520
|
+
initialized = false;
|
|
521
|
+
// Callbacks for decoded blocks
|
|
522
|
+
onBlockDecoded;
|
|
523
|
+
// WASM module references
|
|
524
|
+
wasmInit;
|
|
525
|
+
wasmMoqFecConfig;
|
|
526
|
+
wasmMoqFecEncoder;
|
|
527
|
+
wasmMoqFecDecoder;
|
|
528
|
+
constructor(config = {}) {
|
|
529
|
+
this.config = {
|
|
530
|
+
symbolSize: config.symbolSize ?? 1280,
|
|
531
|
+
k: config.k ?? 10,
|
|
532
|
+
p: config.p ?? 3,
|
|
533
|
+
interleaveDepth: config.interleaveDepth ?? 30,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Create and initialize a MoqFecClient with auto-loaded WASM
|
|
538
|
+
*
|
|
539
|
+
* This is a convenience static method that handles WASM loading automatically.
|
|
540
|
+
*
|
|
541
|
+
* @param config - FEC configuration
|
|
542
|
+
* @param wasmPath - Optional path to mmt-wasm module
|
|
543
|
+
* @returns Initialized MoqFecClient
|
|
544
|
+
*/
|
|
545
|
+
static async create(config = {}, wasmPath) {
|
|
546
|
+
return createMoqFecClient(config, wasmPath);
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Set WASM module functions (called before init)
|
|
550
|
+
*/
|
|
551
|
+
setWasmModule(module) {
|
|
552
|
+
this.wasmInit = module.init;
|
|
553
|
+
this.wasmMoqFecConfig = module.MoqFecConfig;
|
|
554
|
+
this.wasmMoqFecEncoder = module.MoqFecEncoder;
|
|
555
|
+
this.wasmMoqFecDecoder = module.MoqFecDecoder;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Initialize the WASM module and create encoder
|
|
559
|
+
*/
|
|
560
|
+
async init() {
|
|
561
|
+
if (this.initialized)
|
|
562
|
+
return;
|
|
563
|
+
if (!this.wasmInit || !this.wasmMoqFecConfig || !this.wasmMoqFecEncoder) {
|
|
564
|
+
throw new Error("WASM module not set. Call setWasmModule() first.");
|
|
565
|
+
}
|
|
566
|
+
await this.wasmInit();
|
|
567
|
+
this.wasmConfig = new this.wasmMoqFecConfig(this.config.symbolSize, this.config.k, this.config.p, this.config.interleaveDepth);
|
|
568
|
+
this.encoder = new this.wasmMoqFecEncoder(this.wasmConfig);
|
|
569
|
+
this.oti = new Uint8Array(this.encoder.getOti());
|
|
570
|
+
this.initialized = true;
|
|
571
|
+
console.log(`[MoqFecClient] Initialized: K=${this.config.k}, P=${this.config.p}, ` +
|
|
572
|
+
`symbolSize=${this.config.symbolSize}, depth=${this.config.interleaveDepth}`);
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Set OTI from encoder (required for decoder)
|
|
576
|
+
* Call this when receiving FEC config from publisher
|
|
577
|
+
*/
|
|
578
|
+
setOti(oti) {
|
|
579
|
+
if (oti.length !== 12) {
|
|
580
|
+
throw new Error("OTI must be exactly 12 bytes");
|
|
581
|
+
}
|
|
582
|
+
this.oti = new Uint8Array(oti);
|
|
583
|
+
// Create decoder with OTI
|
|
584
|
+
if (this.wasmMoqFecDecoder && this.wasmConfig) {
|
|
585
|
+
this.decoder = new this.wasmMoqFecDecoder(this.wasmConfig, this.oti);
|
|
586
|
+
console.log("[MoqFecClient] Decoder initialized with OTI");
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Get the current OTI
|
|
591
|
+
*/
|
|
592
|
+
getOti() {
|
|
593
|
+
return this.oti;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Set callback for decoded blocks
|
|
597
|
+
*/
|
|
598
|
+
setBlockDecodedCallback(callback) {
|
|
599
|
+
this.onBlockDecoded = callback;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Encode a data block (publisher side)
|
|
603
|
+
*
|
|
604
|
+
* @param data - The data block to encode (e.g., MoQ Group)
|
|
605
|
+
* @returns Encoded block with source and repair symbols
|
|
606
|
+
*/
|
|
607
|
+
encodeBlock(data) {
|
|
608
|
+
if (!this.encoder) {
|
|
609
|
+
throw new Error("MoqFecClient not initialized");
|
|
610
|
+
}
|
|
611
|
+
const encoded = this.encoder.encodeBlock(data);
|
|
612
|
+
// Collect source symbols
|
|
613
|
+
const sourceSymbols = [];
|
|
614
|
+
for (let i = 0; i < encoded.sourceSymbolCount(); i++) {
|
|
615
|
+
const symbol = encoded.getSourceSymbol(i);
|
|
616
|
+
if (symbol)
|
|
617
|
+
sourceSymbols.push(new Uint8Array(symbol));
|
|
618
|
+
}
|
|
619
|
+
// Collect repair symbols
|
|
620
|
+
const repairSymbols = [];
|
|
621
|
+
for (let i = 0; i < encoded.repairSymbolCount(); i++) {
|
|
622
|
+
const symbol = encoded.getRepairSymbol(i);
|
|
623
|
+
if (symbol)
|
|
624
|
+
repairSymbols.push(new Uint8Array(symbol));
|
|
625
|
+
}
|
|
626
|
+
return {
|
|
627
|
+
blockId: encoded.block_id,
|
|
628
|
+
originalLen: encoded.original_len,
|
|
629
|
+
k: encoded.k,
|
|
630
|
+
p: encoded.p,
|
|
631
|
+
oti: new Uint8Array(encoded.getOti()),
|
|
632
|
+
sourceSymbols,
|
|
633
|
+
repairSymbols,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Add a source symbol (subscriber side)
|
|
638
|
+
*
|
|
639
|
+
* @param blockId - Block this symbol belongs to
|
|
640
|
+
* @param esi - Encoding Symbol ID (0 to K-1)
|
|
641
|
+
* @param symbol - Symbol data
|
|
642
|
+
* @param originalLen - Original data length (optional)
|
|
643
|
+
* @returns Decoded data if block is complete, null otherwise
|
|
644
|
+
*/
|
|
645
|
+
addSourceSymbol(blockId, esi, symbol, originalLen) {
|
|
646
|
+
if (!this.decoder) {
|
|
647
|
+
console.warn("[MoqFecClient] Decoder not initialized, call setOti() first");
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
const decoded = this.decoder.addSourceSymbol(blockId, esi, symbol, originalLen);
|
|
651
|
+
if (decoded) {
|
|
652
|
+
this.onBlockDecoded?.(blockId, decoded);
|
|
653
|
+
}
|
|
654
|
+
return decoded;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Add a repair symbol (subscriber side)
|
|
658
|
+
*
|
|
659
|
+
* @param blockId - Block this symbol belongs to
|
|
660
|
+
* @param esi - Encoding Symbol ID (K to K+P-1)
|
|
661
|
+
* @param symbol - Symbol data
|
|
662
|
+
* @returns Decoded data if block is recovered, null otherwise
|
|
663
|
+
*/
|
|
664
|
+
addRepairSymbol(blockId, esi, symbol) {
|
|
665
|
+
if (!this.decoder) {
|
|
666
|
+
console.warn("[MoqFecClient] Decoder not initialized");
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
const decoded = this.decoder.addRepairSymbol(blockId, esi, symbol);
|
|
670
|
+
if (decoded) {
|
|
671
|
+
this.onBlockDecoded?.(blockId, decoded);
|
|
672
|
+
}
|
|
673
|
+
return decoded;
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Check if a block is complete
|
|
677
|
+
*/
|
|
678
|
+
isBlockComplete(blockId) {
|
|
679
|
+
return this.decoder?.isBlockComplete(blockId) ?? false;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Get decoded data for a complete block
|
|
683
|
+
*/
|
|
684
|
+
getBlockData(blockId) {
|
|
685
|
+
if (!this.decoder)
|
|
686
|
+
return null;
|
|
687
|
+
try {
|
|
688
|
+
return this.decoder.getBlockData(blockId);
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Reset a block slot for reuse
|
|
696
|
+
*/
|
|
697
|
+
resetBlock(blockId) {
|
|
698
|
+
this.decoder?.resetBlock(blockId);
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Get FEC statistics for ABR decisions
|
|
702
|
+
*/
|
|
703
|
+
getStats() {
|
|
704
|
+
if (!this.decoder) {
|
|
705
|
+
return {
|
|
706
|
+
blocksComplete: 0,
|
|
707
|
+
blocksIncomplete: 0,
|
|
708
|
+
totalSymbolsReceived: 0,
|
|
709
|
+
k: this.config.k,
|
|
710
|
+
p: this.config.p,
|
|
711
|
+
interleaveDepth: this.config.interleaveDepth,
|
|
712
|
+
effectiveLossRate: 0,
|
|
713
|
+
recoveryRate: 1,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
const stats = this.decoder.getStats();
|
|
717
|
+
return {
|
|
718
|
+
blocksComplete: stats.blocksComplete,
|
|
719
|
+
blocksIncomplete: stats.blocksIncomplete,
|
|
720
|
+
totalSymbolsReceived: stats.totalSymbolsReceived,
|
|
721
|
+
k: stats.k,
|
|
722
|
+
p: stats.p,
|
|
723
|
+
interleaveDepth: stats.interleaveDepth,
|
|
724
|
+
effectiveLossRate: stats.effectiveLossRate(),
|
|
725
|
+
recoveryRate: stats.recoveryRate(),
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Get ABR-compatible statistics
|
|
730
|
+
* Returns stats in format suitable for ABR controller
|
|
731
|
+
*/
|
|
732
|
+
getAbrStats() {
|
|
733
|
+
const stats = this.getStats();
|
|
734
|
+
const totalBlocks = stats.blocksComplete + stats.blocksIncomplete;
|
|
735
|
+
const rawLossRate = totalBlocks > 0 ? stats.blocksIncomplete / totalBlocks : 0;
|
|
736
|
+
return {
|
|
737
|
+
effectiveLossRate: stats.effectiveLossRate,
|
|
738
|
+
rawLossRate,
|
|
739
|
+
recoveryRate: stats.recoveryRate,
|
|
740
|
+
fecEnabled: this.initialized && this.decoder !== null,
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Get the FEC configuration
|
|
745
|
+
*/
|
|
746
|
+
getConfig() {
|
|
747
|
+
return { ...this.config };
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Get max block size
|
|
751
|
+
*/
|
|
752
|
+
getMaxBlockSize() {
|
|
753
|
+
return this.wasmConfig?.maxBlockSize() ?? this.config.k * this.config.symbolSize;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Get overhead ratio (P/K)
|
|
757
|
+
*/
|
|
758
|
+
getOverheadRatio() {
|
|
759
|
+
return this.wasmConfig?.overheadRatio() ?? this.config.p / this.config.k;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Check if initialized
|
|
763
|
+
*/
|
|
764
|
+
isInitialized() {
|
|
765
|
+
return this.initialized;
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Check if decoder is ready
|
|
769
|
+
*/
|
|
770
|
+
isDecoderReady() {
|
|
771
|
+
return this.decoder !== null;
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Dispose of resources
|
|
775
|
+
*/
|
|
776
|
+
dispose() {
|
|
777
|
+
this.encoder = null;
|
|
778
|
+
this.decoder = null;
|
|
779
|
+
this.wasmConfig = null;
|
|
780
|
+
this.oti = null;
|
|
781
|
+
this.initialized = false;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
//# sourceMappingURL=fec-client.js.map
|