@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,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
|
+
}
|