@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.
Files changed (46) hide show
  1. package/dist/abr-controller.d.ts +94 -0
  2. package/dist/abr-controller.d.ts.map +1 -0
  3. package/dist/abr-controller.js +174 -0
  4. package/dist/abr-controller.js.map +1 -0
  5. package/dist/amt-gateway.d.ts +160 -0
  6. package/dist/amt-gateway.d.ts.map +1 -0
  7. package/dist/amt-gateway.js +390 -0
  8. package/dist/amt-gateway.js.map +1 -0
  9. package/dist/clock.d.ts +104 -0
  10. package/dist/clock.d.ts.map +1 -0
  11. package/dist/clock.js +183 -0
  12. package/dist/clock.js.map +1 -0
  13. package/dist/driad-discovery.d.ts +50 -0
  14. package/dist/driad-discovery.d.ts.map +1 -0
  15. package/dist/driad-discovery.js +170 -0
  16. package/dist/driad-discovery.js.map +1 -0
  17. package/dist/fec-client.d.ts +442 -0
  18. package/dist/fec-client.d.ts.map +1 -0
  19. package/dist/fec-client.js +784 -0
  20. package/dist/fec-client.js.map +1 -0
  21. package/dist/fec-client.test.d.ts +8 -0
  22. package/dist/fec-client.test.d.ts.map +1 -0
  23. package/dist/fec-client.test.js +112 -0
  24. package/dist/fec-client.test.js.map +1 -0
  25. package/dist/index.d.ts +34 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +43 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/transport-manager.d.ts +114 -0
  30. package/dist/transport-manager.d.ts.map +1 -0
  31. package/dist/transport-manager.js +396 -0
  32. package/dist/transport-manager.js.map +1 -0
  33. package/dist/types.d.ts +356 -0
  34. package/dist/types.d.ts.map +1 -0
  35. package/dist/types.js +85 -0
  36. package/dist/types.js.map +1 -0
  37. package/package.json +60 -0
  38. package/src/abr-controller.ts +227 -0
  39. package/src/amt-gateway.ts +511 -0
  40. package/src/clock.ts +212 -0
  41. package/src/driad-discovery.ts +193 -0
  42. package/src/fec-client.test.ts +140 -0
  43. package/src/fec-client.ts +1097 -0
  44. package/src/index.ts +122 -0
  45. package/src/transport-manager.ts +460 -0
  46. package/src/types.ts +420 -0
@@ -0,0 +1,511 @@
1
+ /**
2
+ * @blockcast/transport - AMT Gateway
3
+ *
4
+ * Consolidated AMT (Automatic Multicast Tunneling) implementation
5
+ * supporting both Service Worker and Direct Sockets approaches.
6
+ *
7
+ * RFC 7450: Automatic Multicast Tunneling
8
+ */
9
+
10
+ import type { GroupKey, PacketMetadata, AmtRelayInfo } from "./types.js";
11
+ import { DRIADDiscovery } from "./driad-discovery.js";
12
+
13
+ /** Default AMT port (RFC 7450) */
14
+ const DEFAULT_AMT_PORT = 2268;
15
+
16
+ /** AMT message types */
17
+ const AMT_MESSAGE_TYPES = {
18
+ RELAY_DISCOVERY: 1,
19
+ RELAY_ADVERTISEMENT: 2,
20
+ REQUEST: 3,
21
+ MEMBERSHIP_QUERY: 4,
22
+ MEMBERSHIP_UPDATE: 5,
23
+ MULTICAST_DATA: 6,
24
+ TEARDOWN: 7,
25
+ } as const;
26
+
27
+ /** AMT gateway states */
28
+ const GATEWAY_STATES = {
29
+ IDLE: "idle",
30
+ DISCOVERING: "discovering",
31
+ REQUESTING: "requesting",
32
+ QUERYING: "querying",
33
+ ACTIVE: "active",
34
+ LEAVING: "leaving",
35
+ CLOSED: "closed",
36
+ } as const;
37
+
38
+ type GatewayState = (typeof GATEWAY_STATES)[keyof typeof GATEWAY_STATES];
39
+
40
+ /** AMT subscription configuration */
41
+ export interface AMTSubscriptionConfig {
42
+ key: GroupKey;
43
+ relayAddress?: string;
44
+ relayPort?: number;
45
+ onPacket: (data: ArrayBuffer, metadata: PacketMetadata) => void;
46
+ onError: (error: Error) => void;
47
+ }
48
+
49
+ /** AMT packet info */
50
+ export interface AMTPacketInfo {
51
+ data: Uint8Array;
52
+ srcAddress: string;
53
+ dstAddress: string;
54
+ srcPort: number;
55
+ dstPort: number;
56
+ groupKey: string;
57
+ timestamp: number;
58
+ }
59
+
60
+ /** AMT Gateway options */
61
+ export interface AMTGatewayOptions {
62
+ relayAddress: string;
63
+ relayPort?: number;
64
+ onPacket?: (packet: AMTPacketInfo) => void;
65
+ onStateChange?: (newState: GatewayState, oldState: GatewayState) => void;
66
+ onError?: (error: Error) => void;
67
+ onLog?: (message: string) => void;
68
+ }
69
+
70
+ /** Active AMT subscription */
71
+ interface AMTSubscription {
72
+ key: GroupKey;
73
+ subscriptionId: string;
74
+ config: AMTSubscriptionConfig;
75
+ relay?: AmtRelayInfo;
76
+ handshakeComplete: boolean;
77
+ keepAliveTimer?: ReturnType<typeof setInterval>;
78
+ lastKeepAlive?: number;
79
+ }
80
+
81
+ /** Check if address is IPv4 */
82
+ function isIPv4(address: string): boolean {
83
+ return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(address);
84
+ }
85
+
86
+ /**
87
+ * AMT Gateway Manager (Service Worker based)
88
+ *
89
+ * Manages AMT tunnel subscriptions using Service Worker for UDP communication.
90
+ * Handles DRIAD discovery, AMT handshake, and packet decapsulation.
91
+ */
92
+ export class AMTGatewayManager {
93
+ private subscriptions = new Map<string, AMTSubscription>();
94
+ private messageHandler?: (event: MessageEvent) => void;
95
+ private pendingPackets = new Map<string, Array<{ data: ArrayBuffer; remoteAddress: string; remotePort: number }>>();
96
+ private driad = new DRIADDiscovery();
97
+
98
+ constructor() {
99
+ // Set up message handler for packets from service worker
100
+ this.messageHandler = (event: MessageEvent) => {
101
+ const data = event.data as { type: string; subscriptionId?: string; data?: unknown; error?: string; message?: string };
102
+ console.log(`[AMT] SW message received:`, data.type);
103
+ if (data.type === "UDP_PACKET") {
104
+ this.handlePacket(data);
105
+ } else if (data.type === "SOCKET_ERROR") {
106
+ this.handleSocketError(data);
107
+ } else if (data.type === "DEBUG_LOG") {
108
+ console.log(`[AMT SW Debug] ${data.message}`);
109
+ }
110
+ };
111
+
112
+ // Register message handler
113
+ if (typeof navigator !== "undefined" && navigator.serviceWorker) {
114
+ navigator.serviceWorker.addEventListener("message", this.messageHandler);
115
+ console.log("[AMT] Registered service worker message handler");
116
+ } else {
117
+ console.warn("[AMT] Service worker not available");
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Subscribe via AMT tunnel
123
+ */
124
+ async subscribe(config: AMTSubscriptionConfig): Promise<string> {
125
+ const { key } = config;
126
+ const keyStr = this.getKeyString(key);
127
+
128
+ if (this.subscriptions.has(keyStr)) {
129
+ throw new Error(`Already subscribed to ${keyStr}`);
130
+ }
131
+
132
+ try {
133
+ let relayAddress: string;
134
+ let discoveryMethod: "driad" | "manual";
135
+ const relayPort = config.relayPort || DEFAULT_AMT_PORT;
136
+
137
+ if (config.relayAddress) {
138
+ relayAddress = config.relayAddress;
139
+ discoveryMethod = "manual";
140
+ console.log(`[AMT] Using configured relay ${relayAddress}:${relayPort}`);
141
+ } else if (key.source) {
142
+ // DRIAD (RFC 8777) discovers relays based on SOURCE address, not group
143
+ console.log(`[AMT] No relay configured, discovering via DRIAD for source ${key.source}...`);
144
+ const relays = await this.driad.discoverRelays(key.source);
145
+ const bestRelay = await this.driad.selectBestRelay(relays);
146
+
147
+ if (!bestRelay) {
148
+ throw new Error(
149
+ `AMT relay discovery failed for source ${key.source}. ` +
150
+ `Please specify a relay address in the subscription configuration.`,
151
+ );
152
+ }
153
+
154
+ relayAddress = bestRelay.host;
155
+ discoveryMethod = "driad";
156
+ console.log(`[AMT] Discovered relay via DRIAD: ${relayAddress}:${relayPort}`);
157
+ } else {
158
+ throw new Error(
159
+ `AMT relay discovery requires a source address for SSM (RFC 8777). ` +
160
+ `Please specify either a relay address or source address.`,
161
+ );
162
+ }
163
+
164
+ const subscriptionId = keyStr;
165
+
166
+ // Initialize packet queue for handshake
167
+ this.pendingPackets.set(subscriptionId, []);
168
+
169
+ // Create subscription
170
+ const subscription: AMTSubscription = {
171
+ key,
172
+ subscriptionId,
173
+ config,
174
+ handshakeComplete: false,
175
+ relay: {
176
+ address: relayAddress,
177
+ port: relayPort,
178
+ discoveryMethod,
179
+ discoveredAt: Date.now(),
180
+ },
181
+ };
182
+
183
+ this.subscriptions.set(keyStr, subscription);
184
+
185
+ // Note: Actual UDP socket creation and handshake would require
186
+ // Service Worker communication - this is a simplified version
187
+ console.log(`[AMT] Subscription created for ${keyStr} via ${relayAddress}:${relayPort}`);
188
+
189
+ return keyStr;
190
+ } catch (error) {
191
+ const subscription = this.subscriptions.get(keyStr);
192
+ if (subscription) {
193
+ this.subscriptions.delete(keyStr);
194
+ this.pendingPackets.delete(subscription.subscriptionId);
195
+ }
196
+
197
+ throw new Error(`Failed to subscribe via AMT: ${error instanceof Error ? error.message : String(error)}`);
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Unsubscribe from AMT tunnel
203
+ */
204
+ async unsubscribe(handle: string): Promise<void> {
205
+ const subscription = this.subscriptions.get(handle);
206
+ if (!subscription) {
207
+ throw new Error(`Subscription ${handle} not found`);
208
+ }
209
+
210
+ try {
211
+ // Stop keep-alive timer
212
+ if (subscription.keepAliveTimer) {
213
+ clearInterval(subscription.keepAliveTimer);
214
+ }
215
+
216
+ console.log(`[AMT] Unsubscribed from ${handle}`);
217
+ } catch (error) {
218
+ console.error("[AMT] Error during unsubscribe:", error);
219
+ } finally {
220
+ this.subscriptions.delete(handle);
221
+ this.pendingPackets.delete(subscription.subscriptionId);
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Get list of active subscriptions
227
+ */
228
+ getSubscriptions(): string[] {
229
+ return Array.from(this.subscriptions.keys());
230
+ }
231
+
232
+ /**
233
+ * Handle packet from service worker
234
+ */
235
+ private handlePacket(message: { subscriptionId?: string; data?: unknown }): void {
236
+ const { subscriptionId, data } = message;
237
+ if (!subscriptionId) return;
238
+
239
+ const subscription = this.subscriptions.get(subscriptionId);
240
+ if (!subscription) {
241
+ console.warn(`[AMT] Received packet for unknown subscription: ${subscriptionId}`);
242
+ return;
243
+ }
244
+
245
+ // During handshake, queue packets
246
+ if (!subscription.handshakeComplete) {
247
+ const queue = this.pendingPackets.get(subscriptionId);
248
+ const packetData = data as { data: ArrayBuffer; remoteAddress: string; remotePort: number };
249
+ if (queue) {
250
+ queue.push({
251
+ data: packetData.data,
252
+ remoteAddress: packetData.remoteAddress,
253
+ remotePort: packetData.remotePort,
254
+ });
255
+ }
256
+ return;
257
+ }
258
+
259
+ // After handshake, forward to callback
260
+ const packetData = data as { data: ArrayBuffer };
261
+ const metadata: PacketMetadata = {
262
+ sourceAddress: subscription.relay?.address || "unknown",
263
+ sourcePort: subscription.relay?.port || 0,
264
+ transport: "tunnel",
265
+ timestamp: performance.now(),
266
+ };
267
+
268
+ subscription.config.onPacket(packetData.data, metadata);
269
+ }
270
+
271
+ /**
272
+ * Handle socket error from service worker
273
+ */
274
+ private handleSocketError(message: { subscriptionId?: string; error?: string }): void {
275
+ const { subscriptionId, error } = message;
276
+ if (!subscriptionId) return;
277
+
278
+ const subscription = this.subscriptions.get(subscriptionId);
279
+ if (!subscription) {
280
+ console.warn(`[AMT] Received error for unknown subscription: ${subscriptionId}`);
281
+ return;
282
+ }
283
+
284
+ subscription.config.onError(new Error(error || "Unknown error"));
285
+ }
286
+
287
+ /**
288
+ * Cleanup on destroy
289
+ */
290
+ destroy(): void {
291
+ if (this.messageHandler && typeof navigator !== "undefined" && navigator.serviceWorker) {
292
+ navigator.serviceWorker.removeEventListener("message", this.messageHandler);
293
+ }
294
+
295
+ const handles = Array.from(this.subscriptions.keys());
296
+ for (const handle of handles) {
297
+ this.unsubscribe(handle).catch((err) => {
298
+ console.error(`[AMT] Error unsubscribing ${handle}:`, err);
299
+ });
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Get key string for HashMap
305
+ */
306
+ private getKeyString(key: GroupKey): string {
307
+ const source = key.source ? `@${key.source}` : "";
308
+ return `${key.group}:${key.port}${source}`;
309
+ }
310
+ }
311
+
312
+ /**
313
+ * AMT Gateway Client (Direct Sockets based)
314
+ *
315
+ * Connects to an AMT relay to receive multicast data via unicast tunnel.
316
+ * Implements the gateway side of RFC 7450.
317
+ * Supports multiple (S,G) groups over a single AMT tunnel.
318
+ */
319
+ export class AMTGateway {
320
+ private relayAddress: string;
321
+ private relayPort: number;
322
+ private groups = new Map<
323
+ string,
324
+ {
325
+ groupPort: number;
326
+ onPacket: ((packet: AMTPacketInfo) => void) | null;
327
+ joinedAt: number;
328
+ }
329
+ >();
330
+
331
+ // Callbacks
332
+ private onPacket: (packet: AMTPacketInfo) => void;
333
+ private onStateChange: (newState: GatewayState, oldState: GatewayState) => void;
334
+ private onError: (error: Error) => void;
335
+ private onLog: (message: string) => void;
336
+
337
+ // State
338
+ private state: GatewayState = GATEWAY_STATES.IDLE;
339
+ private nonce: Uint8Array | null = null;
340
+ private responseMAC: Uint8Array | null = null;
341
+ private relayConfirmedAddress: string | null = null;
342
+ private lastDataTime = 0;
343
+
344
+ // Stats
345
+ private stats = {
346
+ packetsReceived: 0,
347
+ bytesReceived: 0,
348
+ queriesReceived: 0,
349
+ updatesSet: 0,
350
+ };
351
+
352
+ constructor(options: AMTGatewayOptions) {
353
+ this.relayAddress = options.relayAddress;
354
+ this.relayPort = options.relayPort || DEFAULT_AMT_PORT;
355
+
356
+ this.onPacket = options.onPacket || (() => {});
357
+ this.onStateChange = options.onStateChange || (() => {});
358
+ this.onError = options.onError || ((err) => console.error("AMT Error:", err));
359
+ this.onLog = options.onLog || ((msg) => console.log("AMT:", msg));
360
+ }
361
+
362
+ /**
363
+ * Generate random nonce
364
+ */
365
+ private generateNonce(): Uint8Array {
366
+ const nonce = new Uint8Array(4);
367
+ crypto.getRandomValues(nonce);
368
+ return nonce;
369
+ }
370
+
371
+ /**
372
+ * Set gateway state
373
+ */
374
+ private setState(newState: GatewayState): void {
375
+ const oldState = this.state;
376
+ this.state = newState;
377
+ this.onLog(`State: ${oldState} -> ${newState}`);
378
+ this.onStateChange(newState, oldState);
379
+ }
380
+
381
+ /**
382
+ * Join a multicast group
383
+ */
384
+ async joinGroup(
385
+ groupAddress: string,
386
+ groupPort: number,
387
+ sourceAddress: string | null = null,
388
+ onPacket: ((packet: AMTPacketInfo) => void) | null = null,
389
+ ): Promise<string> {
390
+ const groupKey = `${sourceAddress || "*"},${groupAddress}`;
391
+
392
+ if (this.groups.has(groupKey)) {
393
+ this.onLog(`Group already joined: ${groupKey}`);
394
+ return groupKey;
395
+ }
396
+
397
+ this.groups.set(groupKey, {
398
+ groupPort,
399
+ onPacket,
400
+ joinedAt: Date.now(),
401
+ });
402
+
403
+ this.onLog(`Joined group: ${groupKey} (port ${groupPort})`);
404
+ return groupKey;
405
+ }
406
+
407
+ /**
408
+ * Leave a multicast group
409
+ */
410
+ async leaveGroup(groupAddress: string, sourceAddress: string | null = null): Promise<boolean> {
411
+ const groupKey = `${sourceAddress || "*"},${groupAddress}`;
412
+
413
+ if (!this.groups.has(groupKey)) {
414
+ this.onLog(`Group not found: ${groupKey}`);
415
+ return false;
416
+ }
417
+
418
+ this.groups.delete(groupKey);
419
+ this.onLog(`Left group: ${groupKey} (${this.groups.size} remaining)`);
420
+
421
+ if (this.groups.size === 0) {
422
+ this.onLog("No groups remaining - closing AMT tunnel");
423
+ await this.close();
424
+ }
425
+
426
+ return true;
427
+ }
428
+
429
+ /**
430
+ * Get list of active groups
431
+ */
432
+ getGroups(): Array<{
433
+ groupKey: string;
434
+ groupAddress: string;
435
+ sourceAddress: string | null;
436
+ groupPort: number;
437
+ joinedAt: number;
438
+ }> {
439
+ return Array.from(this.groups.entries()).map(([key, info]) => {
440
+ const [source, group] = key.split(",");
441
+ return {
442
+ groupKey: key,
443
+ groupAddress: group,
444
+ sourceAddress: source !== "*" ? source : null,
445
+ groupPort: info.groupPort,
446
+ joinedAt: info.joinedAt,
447
+ };
448
+ });
449
+ }
450
+
451
+ /**
452
+ * Open connection to AMT relay
453
+ */
454
+ async open(): Promise<void> {
455
+ if (this.state !== GATEWAY_STATES.IDLE) {
456
+ throw new Error(`Cannot open in state: ${this.state}`);
457
+ }
458
+
459
+ this.setState(GATEWAY_STATES.DISCOVERING);
460
+ this.onLog(`Connecting to AMT relay ${this.relayAddress}:${this.relayPort}`);
461
+ this.onLog(`Active groups: ${this.groups.size}`);
462
+
463
+ // Generate session nonce
464
+ this.nonce = this.generateNonce();
465
+ this.onLog(`Generated nonce: ${Array.from(this.nonce).map((b) => b.toString(16).padStart(2, "0")).join("")}`);
466
+
467
+ // Note: Actual UDPSocket connection would go here
468
+ // This is a simplified version showing the state machine
469
+ }
470
+
471
+ /**
472
+ * Close connection
473
+ */
474
+ async close(): Promise<void> {
475
+ if (this.state === GATEWAY_STATES.CLOSED) {
476
+ return;
477
+ }
478
+
479
+ this.setState(GATEWAY_STATES.LEAVING);
480
+
481
+ // Clear all groups
482
+ this.groups.clear();
483
+
484
+ this.setState(GATEWAY_STATES.CLOSED);
485
+ this.onLog("AMT Gateway closed");
486
+ }
487
+
488
+ /**
489
+ * Get current statistics
490
+ */
491
+ getStats(): typeof this.stats {
492
+ return { ...this.stats };
493
+ }
494
+
495
+ /**
496
+ * Get current state
497
+ */
498
+ getState(): GatewayState {
499
+ return this.state;
500
+ }
501
+
502
+ /**
503
+ * Check if gateway is active and receiving data
504
+ */
505
+ isActive(): boolean {
506
+ return this.state === GATEWAY_STATES.ACTIVE;
507
+ }
508
+ }
509
+
510
+ // Export singleton manager instance
511
+ export const amtGatewayManager = new AMTGatewayManager();
package/src/clock.ts ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * @blockcast/transport - SharedClock
3
+ *
4
+ * A/V Synchronization via First Sample Anchor
5
+ *
6
+ * Based on ExoPlayer MMT Plugin implementation:
7
+ * - MMTExtractor.java:46-47 (SystemClockAnchor, MfuClockAnchor)
8
+ * - MMTExtractor.java:625-631 (anchor setting)
9
+ * - SystemClockSynchronizedSimpleDecoderAudioRenderer.java:576 (getPositionUs)
10
+ *
11
+ * This class provides a shared clock anchor across video and audio tracks
12
+ * to ensure A/V synchronization. The anchor is set on the first sample
13
+ * received from ANY track, establishing a common reference point.
14
+ */
15
+
16
+ import type { Micro, SyncDecision } from "./types.js";
17
+
18
+ /** Sync tolerance window in microseconds (66ms = ~2 video frames at 30fps) */
19
+ export const SYNC_TOLERANCE_US = 66000 as Micro;
20
+
21
+ /** Default buffer delay in milliseconds.
22
+ * For live streaming without B-frames, use lower value (100ms).
23
+ * ExoPlayer uses 750ms for broadcast with B-frames, but that's too high for live.
24
+ */
25
+ const DEFAULT_BUFFER_DELAY_MS = 100;
26
+
27
+ /** PTS offset for decoder timing (from ExoPlayer MMTExtractor.java:37) */
28
+ export const PTS_OFFSET_US = 266000 as Micro;
29
+
30
+ /**
31
+ * Shared clock for A/V synchronization.
32
+ *
33
+ * Usage:
34
+ * ```typescript
35
+ * const clock = new SharedClock();
36
+ *
37
+ * // In video/audio source, on first decoded frame:
38
+ * clock.setAnchor(frame.timestamp);
39
+ *
40
+ * // Before rendering each frame:
41
+ * const decision = clock.shouldRender(frame.timestamp);
42
+ * if (decision === "render") { ... }
43
+ * else if (decision === "hold") { await sleep(10); retry(); }
44
+ * else { frame.close(); } // discard
45
+ * ```
46
+ */
47
+ export class SharedClock {
48
+ /** Wall clock time when anchor was set (performance.now()) */
49
+ #anchorWallTime: DOMHighResTimeStamp | undefined;
50
+
51
+ /** Media timestamp when anchor was set (in microseconds) */
52
+ #anchorMediaTime: Micro | undefined;
53
+
54
+ /** Buffer delay in milliseconds to account for decode latency */
55
+ #bufferDelayMs: number;
56
+
57
+ /** Track minimum MFU timestamp across all tracks for shared reference */
58
+ #mfuClockAnchor: Micro = 0 as Micro;
59
+
60
+ constructor(bufferDelayMs: number = DEFAULT_BUFFER_DELAY_MS) {
61
+ this.#bufferDelayMs = bufferDelayMs;
62
+ }
63
+
64
+ /**
65
+ * Set the anchor on first sample received from ANY track.
66
+ * This should be called once per session, typically on the first keyframe.
67
+ *
68
+ * @param mediaTimestamp - The presentation timestamp of the first sample (microseconds)
69
+ */
70
+ setAnchor(mediaTimestamp: Micro): void {
71
+ if (this.#anchorWallTime === undefined) {
72
+ this.#anchorWallTime = performance.now();
73
+ this.#anchorMediaTime = mediaTimestamp;
74
+ console.log(`[SharedClock] Anchor set: wall=${this.#anchorWallTime.toFixed(2)}ms, media=${mediaTimestamp}us`);
75
+ }
76
+
77
+ // Track minimum MFU timestamp for shared reference
78
+ if (this.#mfuClockAnchor === 0 || mediaTimestamp < this.#mfuClockAnchor) {
79
+ this.#mfuClockAnchor = mediaTimestamp;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Get the current playback position in microseconds.
85
+ *
86
+ * @returns Current position in microseconds, or 0 if anchor not set
87
+ */
88
+ getPositionUs(): Micro {
89
+ if (this.#anchorWallTime === undefined || this.#anchorMediaTime === undefined) {
90
+ return 0 as Micro;
91
+ }
92
+
93
+ const elapsedMs = performance.now() - this.#anchorWallTime;
94
+ const elapsedUs = elapsedMs * 1000;
95
+
96
+ // Subtract buffer delay and add anchor media time
97
+ return (elapsedUs - this.#bufferDelayMs * 1000 + this.#anchorMediaTime) as Micro;
98
+ }
99
+
100
+ /**
101
+ * Determine whether a frame should be rendered, held, or discarded.
102
+ *
103
+ * - HOLD: Frame is ahead of clock by more than 66ms (wait for clock)
104
+ * - DISCARD: Frame is behind clock by more than 66ms (too late)
105
+ * - RENDER: Within +/-66ms tolerance window
106
+ *
107
+ * @param frameTimestamp - The presentation timestamp of the frame (microseconds)
108
+ * @returns "render", "hold", or "discard"
109
+ */
110
+ shouldRender(frameTimestamp: Micro): SyncDecision {
111
+ const positionUs = this.getPositionUs();
112
+ const deltaUs = (frameTimestamp - positionUs) as Micro;
113
+
114
+ if (deltaUs > SYNC_TOLERANCE_US) {
115
+ // Frame is ahead - hold it, wait for clock to catch up
116
+ return "hold";
117
+ } else if (deltaUs < -SYNC_TOLERANCE_US) {
118
+ // Frame is late - discard it
119
+ return "discard";
120
+ } else {
121
+ // Within tolerance - render
122
+ return "render";
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Get how long to wait before a held frame can be rendered.
128
+ *
129
+ * @param frameTimestamp - The presentation timestamp of the frame (microseconds)
130
+ * @returns Wait time in milliseconds, or 0 if frame should be rendered now
131
+ */
132
+ getWaitTimeMs(frameTimestamp: Micro): number {
133
+ const positionUs = this.getPositionUs();
134
+ const deltaUs = frameTimestamp - positionUs;
135
+
136
+ if (deltaUs > 0) {
137
+ return deltaUs / 1000; // Convert us to ms
138
+ }
139
+ return 0;
140
+ }
141
+
142
+ /**
143
+ * Get the delta between a frame timestamp and current position.
144
+ * Positive = frame is ahead, negative = frame is behind.
145
+ *
146
+ * @param frameTimestamp - The presentation timestamp of the frame (microseconds)
147
+ * @returns Delta in microseconds
148
+ */
149
+ getDeltaUs(frameTimestamp: Micro): number {
150
+ return frameTimestamp - this.getPositionUs();
151
+ }
152
+
153
+ /**
154
+ * Check if the anchor has been set.
155
+ */
156
+ hasAnchor(): boolean {
157
+ return this.#anchorWallTime !== undefined;
158
+ }
159
+
160
+ /**
161
+ * Get the MFU clock anchor (minimum PTS across all tracks).
162
+ * Used for A/V sync reference point.
163
+ */
164
+ getMfuClockAnchor(): Micro {
165
+ return this.#mfuClockAnchor;
166
+ }
167
+
168
+ /**
169
+ * Reset the clock anchor (e.g., on seek or error recovery).
170
+ */
171
+ reset(): void {
172
+ this.#anchorWallTime = undefined;
173
+ this.#anchorMediaTime = undefined;
174
+ this.#mfuClockAnchor = 0 as Micro;
175
+ console.log("[SharedClock] Anchor reset");
176
+ }
177
+
178
+ /**
179
+ * Get debug info about the current clock state.
180
+ */
181
+ getDebugInfo(): {
182
+ hasAnchor: boolean;
183
+ positionUs: Micro;
184
+ mfuClockAnchor: Micro;
185
+ bufferDelayMs: number;
186
+ } {
187
+ return {
188
+ hasAnchor: this.hasAnchor(),
189
+ positionUs: this.getPositionUs(),
190
+ mfuClockAnchor: this.#mfuClockAnchor,
191
+ bufferDelayMs: this.#bufferDelayMs,
192
+ };
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Factory function to create a shared clock instance.
198
+ * Use this to ensure a single clock is shared across video and audio sources.
199
+ */
200
+ let globalClock: SharedClock | undefined;
201
+
202
+ export function getSharedClock(bufferDelayMs?: number): SharedClock {
203
+ if (!globalClock) {
204
+ globalClock = new SharedClock(bufferDelayMs);
205
+ }
206
+ return globalClock;
207
+ }
208
+
209
+ export function resetSharedClock(): void {
210
+ globalClock?.reset();
211
+ globalClock = undefined;
212
+ }