@blockcast/fec-worker 0.1.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.
Files changed (53) hide show
  1. package/README.md +213 -0
  2. package/dist/.build-stamp +0 -0
  3. package/dist/fec-worker-client.d.ts +80 -0
  4. package/dist/fec-worker-client.d.ts.map +1 -0
  5. package/dist/fec-worker-client.js +270 -0
  6. package/dist/fec-worker-client.js.map +1 -0
  7. package/dist/fec-worker-types.d.ts +252 -0
  8. package/dist/fec-worker-types.d.ts.map +1 -0
  9. package/dist/fec-worker-types.js +11 -0
  10. package/dist/fec-worker-types.js.map +1 -0
  11. package/dist/fec-worker.d.ts +14 -0
  12. package/dist/fec-worker.d.ts.map +1 -0
  13. package/dist/fec-worker.js +565 -0
  14. package/dist/fec-worker.js.map +7 -0
  15. package/dist/index.d.ts +10 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +8 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/shred-fec-worker.d.ts +20 -0
  20. package/dist/shred-fec-worker.d.ts.map +1 -0
  21. package/dist/shred-fec-worker.js +349 -0
  22. package/dist/shred-fec-worker.js.map +7 -0
  23. package/dist/shred-worker-client.d.ts +104 -0
  24. package/dist/shred-worker-client.d.ts.map +1 -0
  25. package/dist/shred-worker-client.js +181 -0
  26. package/dist/shred-worker-client.js.map +1 -0
  27. package/dist/shred-worker-types.d.ts +179 -0
  28. package/dist/shred-worker-types.d.ts.map +1 -0
  29. package/dist/shred-worker-types.js +47 -0
  30. package/dist/shred-worker-types.js.map +1 -0
  31. package/dist/transfer.d.ts +9 -0
  32. package/dist/transfer.d.ts.map +1 -0
  33. package/dist/transfer.js +13 -0
  34. package/dist/transfer.js.map +1 -0
  35. package/package.json +54 -0
  36. package/src/__tests__/fec-worker-alta.test.ts +231 -0
  37. package/src/__tests__/fec-worker-cleanup-integration.test.ts +250 -0
  38. package/src/__tests__/fec-worker-pending-queue.test.ts +315 -0
  39. package/src/__tests__/fec-worker-repair-size.test.ts +1029 -0
  40. package/src/__tests__/fec-worker-wasm-contract.test.ts +84 -0
  41. package/src/__tests__/shred-fec-worker.test.ts +448 -0
  42. package/src/fec-worker-client.test.ts +530 -0
  43. package/src/fec-worker-client.ts +309 -0
  44. package/src/fec-worker-types.test.ts +400 -0
  45. package/src/fec-worker-types.ts +268 -0
  46. package/src/fec-worker.ts +1048 -0
  47. package/src/index.ts +32 -0
  48. package/src/shred-fec-worker.ts +558 -0
  49. package/src/shred-worker-client.test.ts +182 -0
  50. package/src/shred-worker-client.ts +222 -0
  51. package/src/shred-worker-types.ts +194 -0
  52. package/src/transfer.test.ts +24 -0
  53. package/src/transfer.ts +12 -0
@@ -0,0 +1,309 @@
1
+ /**
2
+ * @blockcast/fec-worker — Main-Thread Client
3
+ *
4
+ * Wraps one Worker per track instance. Provides typed API for the
5
+ * FecWorkerCommand/FecWorkerEvent protocol with zero-copy ArrayBuffer transfers.
6
+ *
7
+ * Constructor accepts either a URL (creates Worker internally) or a pre-created
8
+ * Worker instance (for testing via constructor injection).
9
+ */
10
+
11
+ import type {
12
+ FecWorkerCommand,
13
+ FecWorkerEvent,
14
+ FecTrackConfig,
15
+ FecTrackStats,
16
+ FecBlockSnapshot,
17
+ FrameMeta,
18
+ FecWorkerErrorCode,
19
+ } from "./fec-worker-types.js";
20
+
21
+ export class FecWorkerClient {
22
+ readonly #worker: Worker;
23
+ #onFrame: ((data: ArrayBuffer, meta: FrameMeta) => void) | null = null;
24
+ #onSnapshot: ((stats: FecTrackStats) => void) | null = null;
25
+ #onBlockUpdate: ((block: FecBlockSnapshot) => void) | null = null;
26
+ #onGreenFill: ((sbn: number, data: ArrayBuffer) => void) | null = null;
27
+ #onCleanupComplete: ((sbn: number) => void) | null = null;
28
+ #onError: ((message: string, code?: FecWorkerErrorCode) => void) | null = null;
29
+ #cleanupInFlight: Set<number> | null = null;
30
+ #queuedCleanupSbns = new Set<number>();
31
+ #pendingResets = 0;
32
+ #resetFailed = false;
33
+ #disposed = false;
34
+
35
+ /**
36
+ * Create a FecWorkerClient.
37
+ * @param workerOrUrl - Either a Worker instance (for testing) or a URL to create one
38
+ */
39
+ constructor(workerOrUrl: Worker | URL) {
40
+ if (workerOrUrl instanceof URL) {
41
+ this.#worker = new Worker(workerOrUrl, { type: "module" });
42
+ } else {
43
+ this.#worker = workerOrUrl;
44
+ }
45
+ this.#worker.onmessage = (e: MessageEvent<FecWorkerEvent>) =>
46
+ this.#handleEvent(e.data);
47
+ this.#worker.onerror = (e) => {
48
+ this.#onError?.(formatWorkerError(e), "worker-runtime");
49
+ };
50
+ this.#worker.onmessageerror = (e) => {
51
+ this.#onError?.(formatWorkerMessageError(e), "worker-message");
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Configure the Worker with FEC parameters and WASM module.
57
+ * wasmModule is transferred (not neutered — WebAssembly.Module is shareable).
58
+ * altaPublicKey is PKCS#8 SPKI DER bytes (self-describing — algorithm detected from OID).
59
+ * Transferred (detached from sender).
60
+ * altaWasmModule is structured-cloneable (NOT transferable — do not add to transfer list).
61
+ */
62
+ configure(
63
+ config: FecTrackConfig,
64
+ wasmModule: WebAssembly.Module,
65
+ altaPublicKey?: ArrayBuffer,
66
+ altaWasmModule?: WebAssembly.Module,
67
+ ): void {
68
+ if (this.#disposed) throw new Error("[FecWorkerClient] disposed");
69
+ const cmd: FecWorkerCommand = {
70
+ type: "configure",
71
+ config,
72
+ wasmModule,
73
+ altaPublicKey,
74
+ altaWasmModule,
75
+ };
76
+ // WebAssembly.Module is structured-cloneable, NOT transferable.
77
+ // Pass it in the message body (Chrome clones it automatically).
78
+ // Only ArrayBuffers (like altaPublicKey) go in the transfer list.
79
+ const transferList: Transferable[] = [];
80
+ if (altaPublicKey) transferList.push(altaPublicKey);
81
+ // altaWasmModule is structured-cloneable — NOT added to transferList
82
+ this.#worker.postMessage(cmd, transferList);
83
+ }
84
+
85
+ /**
86
+ * Feed a source symbol to the Worker. data ArrayBuffer is transferred (zero-copy).
87
+ * Caller must not access data after this call — it is detached.
88
+ *
89
+ * Optional `alta` carries the detached ALTA authenticator bytes and the
90
+ * byte offset in `data` where the signed payload ends. The auth buffer is
91
+ * transferred zero-copy alongside `data`. Use {@link getMmtpAltaTrailer}
92
+ * upstream (mmtp-router) to extract it from the wire packet.
93
+ */
94
+ feedSource(
95
+ ssId: number,
96
+ data: ArrayBuffer,
97
+ ts: number,
98
+ alta?: { auth: ArrayBuffer; payloadEnd: number },
99
+ ): void {
100
+ if (this.#disposed) return;
101
+ const cmd: FecWorkerCommand = alta
102
+ ? {
103
+ type: "feedSource",
104
+ ssId,
105
+ data,
106
+ ts,
107
+ altaAuth: alta.auth,
108
+ altaPayloadEnd: alta.payloadEnd,
109
+ }
110
+ : { type: "feedSource", ssId, data, ts };
111
+ const transfer: Transferable[] = alta ? [data, alta.auth] : [data];
112
+ this.#worker.postMessage(cmd, transfer);
113
+ }
114
+
115
+ /**
116
+ * Feed a repair symbol to the Worker. data ArrayBuffer is transferred (zero-copy).
117
+ * Caller must not access data after this call — it is detached.
118
+ *
119
+ * Carries the wire-level Repair FEC Payload ID fields (ISO 23008-1 §C.5.3):
120
+ * - ssStart: SS_ID of first source symbol in the block (= SBN * SSB_length)
121
+ * - ssbLength: K for this block
122
+ * - rsId: repair symbol index within the block
123
+ */
124
+ feedRepair(
125
+ ssStart: number,
126
+ ssbLength: number,
127
+ rsId: number,
128
+ data: ArrayBuffer,
129
+ ts: number,
130
+ ): void {
131
+ if (this.#disposed) return;
132
+ this.#worker.postMessage(
133
+ {
134
+ type: "feedRepair",
135
+ ssStart,
136
+ ssbLength,
137
+ rsId,
138
+ data,
139
+ ts,
140
+ } satisfies FecWorkerCommand,
141
+ [data],
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Submit relay block signature for verification. sig and hash are transferred.
147
+ */
148
+ relayBlockSig(sbn: number, sig: ArrayBuffer, hash: ArrayBuffer): void {
149
+ if (this.#disposed) return;
150
+ this.#worker.postMessage(
151
+ { type: "relayBlockSig", sbn, sig, hash } satisfies FecWorkerCommand,
152
+ [sig, hash],
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Trigger cleanup after a block's inactivity deadline.
158
+ *
159
+ * While one queue-drain barrier is in flight, later SBNs are coalesced into
160
+ * the next command without losing their individual acknowledgement identity.
161
+ */
162
+ cleanup(currentSbn: number): void {
163
+ if (this.#disposed) return;
164
+ if (
165
+ !Number.isInteger(currentSbn) ||
166
+ currentSbn < 0 ||
167
+ currentSbn > 0xffff_ffff
168
+ ) {
169
+ throw new Error("[FecWorkerClient] cleanup: SBN must be a uint32");
170
+ }
171
+ if (this.#cleanupInFlight) {
172
+ this.#queuedCleanupSbns.add(currentSbn);
173
+ return;
174
+ }
175
+ this.#postCleanup(new Set([currentSbn]));
176
+ }
177
+
178
+ /** Reset this track's decoder epoch while preserving Worker configuration. */
179
+ reset(): void {
180
+ if (this.#disposed) return;
181
+ this.#pendingResets++;
182
+ this.#cleanupInFlight = null;
183
+ this.#queuedCleanupSbns.clear();
184
+ this.#worker.postMessage({ type: "reset" } satisfies FecWorkerCommand);
185
+ }
186
+
187
+ #postCleanup(sbns: Set<number>): void {
188
+ this.#cleanupInFlight = sbns;
189
+ const orderedSbns = [...sbns].sort((a, b) => a - b);
190
+ this.#worker.postMessage(
191
+ { type: "cleanup", sbns: orderedSbns } satisfies FecWorkerCommand,
192
+ );
193
+ }
194
+
195
+ /** Register callback for decoded/recovered frames (FEC-03). */
196
+ onFrame(cb: (data: ArrayBuffer, meta: FrameMeta) => void): void {
197
+ this.#onFrame = cb;
198
+ }
199
+
200
+ /** Register callback for periodic stats snapshots (FEC-04). */
201
+ onSnapshot(cb: (stats: FecTrackStats) => void): void {
202
+ this.#onSnapshot = cb;
203
+ }
204
+
205
+ /** Register callback for individual block state changes. */
206
+ onBlockUpdate(cb: (block: FecBlockSnapshot) => void): void {
207
+ this.#onBlockUpdate = cb;
208
+ }
209
+
210
+ /** Register callback for green-fill frames. */
211
+ onGreenFill(cb: (sbn: number, data: ArrayBuffer) => void): void {
212
+ this.#onGreenFill = cb;
213
+ }
214
+
215
+ /** Register callback fired after the Worker drains all commands queued before cleanup. */
216
+ onCleanupComplete(cb: (sbn: number) => void): void {
217
+ this.#onCleanupComplete = cb;
218
+ }
219
+
220
+ /** Register callback for Worker errors. */
221
+ onError(cb: (message: string, code?: FecWorkerErrorCode) => void): void {
222
+ this.#onError = cb;
223
+ }
224
+
225
+ /**
226
+ * Dispose the Worker. Sends dispose command then terminates.
227
+ * Idempotent — safe to call multiple times.
228
+ */
229
+ dispose(): void {
230
+ if (this.#disposed) return;
231
+ this.#disposed = true;
232
+ this.#cleanupInFlight = null;
233
+ this.#queuedCleanupSbns.clear();
234
+ this.#worker.postMessage({ type: "dispose" } satisfies FecWorkerCommand);
235
+ this.#worker.terminate();
236
+ }
237
+
238
+ #handleEvent(event: FecWorkerEvent): void {
239
+ if (event.type === "resetComplete") {
240
+ this.#pendingResets = Math.max(0, this.#pendingResets - 1);
241
+ this.#resetFailed = false;
242
+ return;
243
+ }
244
+ if (event.type === "error") {
245
+ if (event.code === "reset-failed") {
246
+ this.#pendingResets = Math.max(0, this.#pendingResets - 1);
247
+ this.#resetFailed = true;
248
+ }
249
+ if (event.code) this.#onError?.(event.message, event.code);
250
+ else this.#onError?.(event.message);
251
+ return;
252
+ }
253
+ if (this.#pendingResets > 0 || this.#resetFailed) return;
254
+ switch (event.type) {
255
+ case "frame":
256
+ this.#onFrame?.(event.data, event.meta);
257
+ break;
258
+ case "snapshot":
259
+ this.#onSnapshot?.(event.stats);
260
+ break;
261
+ case "blockUpdate":
262
+ this.#onBlockUpdate?.(event.block);
263
+ break;
264
+ case "greenFill":
265
+ this.#onGreenFill?.(event.sbn, event.data);
266
+ break;
267
+ case "cleanupComplete":
268
+ for (const sbn of event.sbns) {
269
+ this.#onCleanupComplete?.(sbn);
270
+ }
271
+ this.#cleanupInFlight = null;
272
+ if (this.#queuedCleanupSbns.size > 0) {
273
+ const next = this.#queuedCleanupSbns;
274
+ this.#queuedCleanupSbns = new Set();
275
+ this.#postCleanup(next);
276
+ }
277
+ break;
278
+ }
279
+ }
280
+ }
281
+
282
+ function formatWorkerError(event: ErrorEvent): string {
283
+ const parts = ["Worker error"];
284
+ if (event.message) parts.push(event.message);
285
+ if (event.filename) {
286
+ const location = [event.filename];
287
+ if (event.lineno) location.push(String(event.lineno));
288
+ if (event.colno) location.push(String(event.colno));
289
+ parts.push(`at ${location.join(":")}`);
290
+ }
291
+ if (event.error instanceof Error) {
292
+ if (event.error.message && event.error.message !== event.message) {
293
+ parts.push(event.error.message);
294
+ }
295
+ if (event.error.stack) parts.push(event.error.stack);
296
+ } else if (event.error != null) {
297
+ parts.push(String(event.error));
298
+ }
299
+ return parts.join(": ");
300
+ }
301
+
302
+ function formatWorkerMessageError(event: MessageEvent): string {
303
+ const data = event.data;
304
+ const detail =
305
+ data == null ? "" :
306
+ data instanceof Error ? `: ${data.message}` :
307
+ `: ${Object.prototype.toString.call(data)}`;
308
+ return `Worker messageerror: failed to deserialize worker message${detail}`;
309
+ }
@@ -0,0 +1,400 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type {
3
+ FecWorkerCommand,
4
+ FecWorkerEvent,
5
+ FecTrackConfig,
6
+ FecBlockSnapshot,
7
+ FecTrackStats,
8
+ FrameMeta,
9
+ } from "./fec-worker-types.js";
10
+
11
+ // Helper: compile-time type assertion — if this file compiles, types are correct
12
+ function assertType<T>(_value: T): void {
13
+ /* compile-time only */
14
+ }
15
+
16
+ describe("FecWorkerCommand", () => {
17
+ it("configure includes wasmModule and optional altaPublicKey (AUTH-03)", () => {
18
+ const cmd: FecWorkerCommand = {
19
+ type: "configure",
20
+ config: {
21
+ codec: "avc1.64001f",
22
+ trackName: "video",
23
+ trackType: "video",
24
+ algorithm: "raptor",
25
+ k: 32,
26
+ repairCount: 8,
27
+ symbolSize: 1320,
28
+ interleaveDepth: 4,
29
+ interleaveMs: 133,
30
+ deliveryWindowMs: 1000,
31
+ fecMode: "subframe",
32
+ altaEnabled: false,
33
+ relayBlockSigEnabled: false,
34
+ },
35
+ wasmModule: {} as WebAssembly.Module,
36
+ altaPublicKey: new ArrayBuffer(32),
37
+ };
38
+ expect(cmd.type).toBe("configure");
39
+ assertType<WebAssembly.Module>(cmd.wasmModule);
40
+ });
41
+
42
+ it("feedSource carries ssId, data ArrayBuffer, ts (matches WASM add_source signature)", () => {
43
+ const cmd: FecWorkerCommand = {
44
+ type: "feedSource",
45
+ ssId: 0,
46
+ data: new ArrayBuffer(1320),
47
+ ts: 1000,
48
+ };
49
+ expect(cmd.type).toBe("feedSource");
50
+ assertType<ArrayBuffer>(cmd.data);
51
+ assertType<number>(cmd.ssId);
52
+ });
53
+
54
+ it("feedRepair carries ssStart, ssbLength, rsId, data, ts (matches WASM add_repair)", () => {
55
+ const cmd: FecWorkerCommand = {
56
+ type: "feedRepair",
57
+ ssStart: 0,
58
+ ssbLength: 4,
59
+ rsId: 0,
60
+ data: new ArrayBuffer(1320),
61
+ ts: 1000,
62
+ };
63
+ expect(cmd.type).toBe("feedRepair");
64
+ assertType<ArrayBuffer>(cmd.data);
65
+ assertType<number>(cmd.ssStart);
66
+ assertType<number>(cmd.ssbLength);
67
+ assertType<number>(cmd.rsId);
68
+ });
69
+
70
+ it("relayBlockSig carries sbn, sig, hash as ArrayBuffers", () => {
71
+ const cmd: FecWorkerCommand = {
72
+ type: "relayBlockSig",
73
+ sbn: 5,
74
+ sig: new ArrayBuffer(64),
75
+ hash: new ArrayBuffer(32),
76
+ };
77
+ expect(cmd.type).toBe("relayBlockSig");
78
+ assertType<ArrayBuffer>(cmd.sig);
79
+ assertType<ArrayBuffer>(cmd.hash);
80
+ });
81
+
82
+ it("cleanup carries every coalesced SBN", () => {
83
+ const cmd: FecWorkerCommand = {
84
+ type: "cleanup",
85
+ sbns: [10, 11],
86
+ };
87
+ expect(cmd.type).toBe("cleanup");
88
+ expect(cmd.sbns).toEqual([10, 11]);
89
+ });
90
+
91
+ it("dispose has no additional fields", () => {
92
+ const cmd: FecWorkerCommand = { type: "dispose" };
93
+ expect(cmd.type).toBe("dispose");
94
+ });
95
+
96
+ it("discriminates all 6 command types", () => {
97
+ const types = [
98
+ "configure",
99
+ "feedSource",
100
+ "feedRepair",
101
+ "relayBlockSig",
102
+ "cleanup",
103
+ "dispose",
104
+ ] as const;
105
+ // Verify each type string is assignable to FecWorkerCommand['type']
106
+ for (const t of types) {
107
+ assertType<FecWorkerCommand["type"]>(t);
108
+ }
109
+ expect(types).toHaveLength(6);
110
+ });
111
+ });
112
+
113
+ describe("FecWorkerEvent", () => {
114
+ it("frame event carries data ArrayBuffer and FrameMeta", () => {
115
+ const evt: FecWorkerEvent = {
116
+ type: "frame",
117
+ data: new ArrayBuffer(1320),
118
+ meta: { altaVerified: null, recovered: false, sbn: 0 },
119
+ };
120
+ expect(evt.type).toBe("frame");
121
+ });
122
+
123
+ it("snapshot event carries FecTrackStats", () => {
124
+ const config: FecTrackConfig = {
125
+ codec: "avc1.64001f",
126
+ trackName: "video",
127
+ trackType: "video",
128
+ algorithm: "raptor",
129
+ k: 32,
130
+ repairCount: 8,
131
+ symbolSize: 1320,
132
+ interleaveDepth: 4,
133
+ interleaveMs: 133,
134
+ deliveryWindowMs: 1000,
135
+ fecMode: "subframe",
136
+ altaEnabled: false,
137
+ relayBlockSigEnabled: false,
138
+ };
139
+ const evt: FecWorkerEvent = {
140
+ type: "snapshot",
141
+ stats: {
142
+ config,
143
+ sourceSymbols: 0,
144
+ repairSymbols: 0,
145
+ blocksComplete: 0,
146
+ blocksRecovered: 0,
147
+ blocksFailed: 0,
148
+ greenFillFrames: 0,
149
+ recoveryRate: 0,
150
+ altaVerified: 0,
151
+ altaFailed: 0,
152
+ relayBlocksVerified: 0,
153
+ relayBlocksFailed: 0,
154
+ liveSbn: 0,
155
+ blocks: [],
156
+ interleaveWindowBlocks: 0,
157
+ avgRecoveryMs: 0,
158
+ maxRecoveryMs: 0,
159
+ },
160
+ };
161
+ expect(evt.type).toBe("snapshot");
162
+ });
163
+
164
+ it("blockUpdate event carries FecBlockSnapshot", () => {
165
+ const evt: FecWorkerEvent = {
166
+ type: "blockUpdate",
167
+ block: {
168
+ sbn: 0,
169
+ state: "collecting",
170
+ sourceReceived: 5,
171
+ repairReceived: 0,
172
+ k: 32,
173
+ repairCount: 8,
174
+ firstSymbolTs: 1000,
175
+ lastSymbolTs: 1100,
176
+ deadlineTs: 2000,
177
+ altaVerified: 5,
178
+ relayBlockSigValid: null,
179
+ },
180
+ };
181
+ expect(evt.type).toBe("blockUpdate");
182
+ });
183
+
184
+ it("greenFill event carries sbn and data", () => {
185
+ const evt: FecWorkerEvent = {
186
+ type: "greenFill",
187
+ sbn: 3,
188
+ data: new ArrayBuffer(0),
189
+ };
190
+ expect(evt.type).toBe("greenFill");
191
+ });
192
+
193
+ it("cleanupComplete event carries every acknowledged SBN", () => {
194
+ const evt: FecWorkerEvent = {
195
+ type: "cleanupComplete",
196
+ sbns: [3, 4],
197
+ };
198
+ expect(evt.type).toBe("cleanupComplete");
199
+ expect(evt.sbns).toEqual([3, 4]);
200
+ });
201
+
202
+ it("error event carries message string", () => {
203
+ const evt: FecWorkerEvent = {
204
+ type: "error",
205
+ code: "command-failed",
206
+ message: "WASM init failed",
207
+ };
208
+ expect(evt.type).toBe("error");
209
+ expect(evt.message).toBe("WASM init failed");
210
+ expect(evt.code).toBe("command-failed");
211
+ });
212
+
213
+ it("discriminates all 6 event types", () => {
214
+ const types = [
215
+ "frame",
216
+ "snapshot",
217
+ "blockUpdate",
218
+ "greenFill",
219
+ "cleanupComplete",
220
+ "error",
221
+ ] as const;
222
+ for (const t of types) {
223
+ assertType<FecWorkerEvent["type"]>(t);
224
+ }
225
+ expect(types).toHaveLength(6);
226
+ });
227
+ });
228
+
229
+ describe("FecTrackConfig", () => {
230
+ it("contains altaEnabled boolean (AUTH-01)", () => {
231
+ const config: FecTrackConfig = {
232
+ codec: "mp4a.40.2",
233
+ trackName: "audio",
234
+ trackType: "audio",
235
+ sampleRate: 48000,
236
+ algorithm: "raptor",
237
+ k: 16,
238
+ repairCount: 4,
239
+ symbolSize: 512,
240
+ interleaveDepth: 2,
241
+ interleaveMs: 42,
242
+ deliveryWindowMs: 500,
243
+ fecMode: "object",
244
+ altaEnabled: true,
245
+ relayBlockSigEnabled: false,
246
+ };
247
+ assertType<boolean>(config.altaEnabled);
248
+ expect(config.altaEnabled).toBe(true);
249
+ });
250
+
251
+ it("contains relayBlockSigEnabled boolean (AUTH-01)", () => {
252
+ const config: FecTrackConfig = {
253
+ codec: "avc1.64001f",
254
+ trackName: "video",
255
+ trackType: "video",
256
+ resolution: { w: 1280, h: 720 },
257
+ framerate: 30,
258
+ algorithm: "raptor",
259
+ k: 32,
260
+ repairCount: 8,
261
+ symbolSize: 1320,
262
+ interleaveDepth: 4,
263
+ interleaveMs: 133,
264
+ deliveryWindowMs: 1000,
265
+ fecMode: "subframe",
266
+ altaEnabled: false,
267
+ relayBlockSigEnabled: true,
268
+ };
269
+ assertType<boolean>(config.relayBlockSigEnabled);
270
+ expect(config.relayBlockSigEnabled).toBe(true);
271
+ });
272
+ });
273
+
274
+ describe("FecBlockSnapshot", () => {
275
+ it("contains altaVerified number (AUTH-02)", () => {
276
+ const block: FecBlockSnapshot = {
277
+ sbn: 0,
278
+ state: "complete",
279
+ sourceReceived: 32,
280
+ repairReceived: 0,
281
+ k: 32,
282
+ repairCount: 8,
283
+ firstSymbolTs: 100,
284
+ lastSymbolTs: 200,
285
+ completedTs: 250,
286
+ deadlineTs: 500,
287
+ altaVerified: 30,
288
+ relayBlockSigValid: true,
289
+ };
290
+ assertType<number>(block.altaVerified);
291
+ expect(block.altaVerified).toBe(30);
292
+ });
293
+
294
+ it("relayBlockSigValid can be boolean or null (AUTH-02)", () => {
295
+ const pending: FecBlockSnapshot = {
296
+ sbn: 1,
297
+ state: "collecting",
298
+ sourceReceived: 10,
299
+ repairReceived: 2,
300
+ k: 32,
301
+ repairCount: 8,
302
+ firstSymbolTs: 300,
303
+ lastSymbolTs: 400,
304
+ deadlineTs: 700,
305
+ altaVerified: 8,
306
+ relayBlockSigValid: null,
307
+ };
308
+ assertType<boolean | null>(pending.relayBlockSigValid);
309
+ expect(pending.relayBlockSigValid).toBeNull();
310
+ });
311
+ });
312
+
313
+ describe("FecTrackStats", () => {
314
+ it("contains all required aggregate fields", () => {
315
+ const config: FecTrackConfig = {
316
+ codec: "avc1.64001f",
317
+ trackName: "video",
318
+ trackType: "video",
319
+ algorithm: "raptor",
320
+ k: 32,
321
+ repairCount: 8,
322
+ symbolSize: 1320,
323
+ interleaveDepth: 4,
324
+ interleaveMs: 133,
325
+ deliveryWindowMs: 1000,
326
+ fecMode: "subframe",
327
+ altaEnabled: false,
328
+ relayBlockSigEnabled: false,
329
+ };
330
+ const stats: FecTrackStats = {
331
+ config,
332
+ sourceSymbols: 1240,
333
+ repairSymbols: 312,
334
+ blocksComplete: 380,
335
+ blocksRecovered: 18,
336
+ blocksFailed: 2,
337
+ greenFillFrames: 2,
338
+ recoveryRate: 90,
339
+ altaVerified: 12400,
340
+ altaFailed: 3,
341
+ relayBlocksVerified: 380,
342
+ relayBlocksFailed: 0,
343
+ liveSbn: 400,
344
+ blocks: [],
345
+ interleaveWindowBlocks: 4,
346
+ avgRecoveryMs: 42,
347
+ maxRecoveryMs: 180,
348
+ };
349
+ assertType<FecTrackConfig>(stats.config);
350
+ assertType<number>(stats.sourceSymbols);
351
+ assertType<number>(stats.repairSymbols);
352
+ assertType<number>(stats.blocksComplete);
353
+ assertType<number>(stats.blocksRecovered);
354
+ assertType<number>(stats.blocksFailed);
355
+ assertType<number>(stats.greenFillFrames);
356
+ assertType<number>(stats.recoveryRate);
357
+ assertType<number>(stats.altaVerified);
358
+ assertType<number>(stats.altaFailed);
359
+ assertType<number>(stats.liveSbn);
360
+ assertType<FecBlockSnapshot[]>(stats.blocks);
361
+ assertType<number>(stats.interleaveWindowBlocks);
362
+ assertType<number>(stats.avgRecoveryMs);
363
+ assertType<number>(stats.maxRecoveryMs);
364
+ expect(stats.recoveryRate).toBe(90);
365
+ });
366
+ });
367
+
368
+ describe("FrameMeta", () => {
369
+ it("altaVerified is boolean | null (AUTH-04)", () => {
370
+ const metaWithAlta: FrameMeta = {
371
+ altaVerified: true,
372
+ recovered: false,
373
+ sbn: 5,
374
+ };
375
+ assertType<boolean | null>(metaWithAlta.altaVerified);
376
+ expect(metaWithAlta.altaVerified).toBe(true);
377
+
378
+ const metaWithoutAlta: FrameMeta = {
379
+ altaVerified: null,
380
+ recovered: true,
381
+ sbn: 5,
382
+ };
383
+ expect(metaWithoutAlta.altaVerified).toBeNull();
384
+ });
385
+
386
+ it("contains recovered boolean plus block and symbol coordinates", () => {
387
+ const meta: FrameMeta = {
388
+ altaVerified: null,
389
+ recovered: true,
390
+ sbn: 42,
391
+ esi: 3,
392
+ };
393
+ assertType<boolean>(meta.recovered);
394
+ assertType<number>(meta.sbn);
395
+ assertType<number | undefined>(meta.esi);
396
+ expect(meta.recovered).toBe(true);
397
+ expect(meta.sbn).toBe(42);
398
+ expect(meta.esi).toBe(3);
399
+ });
400
+ });