@blockcast/mmt-container 2.1.0 → 2.2.0-main.079cd7664c0b

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 (99) hide show
  1. package/README.md +29 -17
  2. package/dist/browser-runtime.js +1271 -247
  3. package/dist/browser-runtime.js.map +4 -4
  4. package/dist/carp-catalog.js.map +1 -1
  5. package/dist/catalog-fec-params.d.ts +17 -1
  6. package/dist/catalog-fec-params.d.ts.map +1 -1
  7. package/dist/catalog-fec-params.js +15 -0
  8. package/dist/catalog-fec-params.js.map +2 -2
  9. package/dist/catalog-network-source.js.map +1 -1
  10. package/dist/cmcd-producer.d.ts +65 -47
  11. package/dist/cmcd-producer.d.ts.map +1 -1
  12. package/dist/cmcd-producer.js +138 -109
  13. package/dist/cmcd-producer.js.map +2 -2
  14. package/dist/delivery-catalog.js +26 -16
  15. package/dist/delivery-catalog.js.map +2 -2
  16. package/dist/delta-fallback-config.js.map +1 -1
  17. package/dist/fec-manager.d.ts +33 -1
  18. package/dist/fec-manager.d.ts.map +1 -1
  19. package/dist/fec-manager.js +213 -6
  20. package/dist/fec-manager.js.map +2 -2
  21. package/dist/fec-state.js.map +1 -1
  22. package/dist/fec-worker-setup.d.ts +1 -1
  23. package/dist/fec-worker-setup.js +1 -1
  24. package/dist/fec-worker-setup.js.map +1 -1
  25. package/dist/held-layer-admission.js.map +1 -1
  26. package/dist/index.d.ts +5 -4
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +9 -1
  29. package/dist/index.js.map +2 -2
  30. package/dist/iso-block-locator.d.ts +9 -0
  31. package/dist/iso-block-locator.d.ts.map +1 -1
  32. package/dist/iso-block-locator.js +17 -0
  33. package/dist/iso-block-locator.js.map +2 -2
  34. package/dist/iso-bmff-timing.d.ts +10 -1
  35. package/dist/iso-bmff-timing.d.ts.map +1 -1
  36. package/dist/iso-bmff-timing.js +36 -2
  37. package/dist/iso-bmff-timing.js.map +2 -2
  38. package/dist/media-router.js.map +1 -1
  39. package/dist/mfu-reassembler.js +12 -19
  40. package/dist/mfu-reassembler.js.map +2 -2
  41. package/dist/mmtp-container-parser.d.ts +37 -2
  42. package/dist/mmtp-container-parser.d.ts.map +1 -1
  43. package/dist/mmtp-container-parser.js +98 -28
  44. package/dist/mmtp-container-parser.js.map +2 -2
  45. package/dist/mmtp-container-parser.no-modules.js +222 -45
  46. package/dist/mmtp-container-parser.no-modules.js.map +3 -3
  47. package/dist/mmtp-decode-pipeline.js +97 -28
  48. package/dist/mmtp-decode-pipeline.js.map +2 -2
  49. package/dist/mmtp-fec-session.d.ts +41 -3
  50. package/dist/mmtp-fec-session.d.ts.map +1 -1
  51. package/dist/mmtp-fec-session.js +118 -13
  52. package/dist/mmtp-fec-session.js.map +3 -3
  53. package/dist/mmtp-router.d.ts +25 -2
  54. package/dist/mmtp-router.d.ts.map +1 -1
  55. package/dist/mmtp-router.js +1718 -54
  56. package/dist/mmtp-router.js.map +4 -4
  57. package/dist/multicast-auth-catalog.js.map +1 -1
  58. package/dist/multicast-input.js.map +1 -1
  59. package/dist/multicast-manager.d.ts +15 -2
  60. package/dist/multicast-manager.d.ts.map +1 -1
  61. package/dist/multicast-manager.js +15652 -14939
  62. package/dist/multicast-manager.js.map +4 -4
  63. package/dist/multicast-transport.d.ts +11 -3
  64. package/dist/multicast-transport.d.ts.map +1 -1
  65. package/dist/multicast-transport.js +281 -35
  66. package/dist/multicast-transport.js.map +4 -4
  67. package/dist/multicast-unwrap.js +12 -19
  68. package/dist/multicast-unwrap.js.map +2 -2
  69. package/dist/no-modules.js +215 -42
  70. package/dist/no-modules.js.map +3 -3
  71. package/dist/repair-fetch.js.map +1 -1
  72. package/dist/repair-layer-subscriptions.d.ts +82 -0
  73. package/dist/repair-layer-subscriptions.d.ts.map +1 -0
  74. package/dist/repair-layer-subscriptions.js +192 -0
  75. package/dist/repair-layer-subscriptions.js.map +7 -0
  76. package/dist/signaling.js.map +2 -2
  77. package/dist/timestamp-codec.js +12 -19
  78. package/dist/timestamp-codec.js.map +2 -2
  79. package/dist/track-session.d.ts +1 -1
  80. package/dist/track-session.js.map +1 -1
  81. package/dist/wasm-mfu-reassembler.js.map +1 -1
  82. package/dist/wasm-mmtp-container-parser.js +97 -28
  83. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  84. package/package.json +8 -5
  85. package/schemas/catalog.schema.json +4 -0
  86. package/src/catalog-fec-params.ts +36 -1
  87. package/src/cmcd-producer.ts +194 -179
  88. package/src/fec-manager.ts +256 -6
  89. package/src/fec-worker-setup.ts +2 -2
  90. package/src/index.ts +18 -2
  91. package/src/iso-block-locator.ts +19 -0
  92. package/src/iso-bmff-timing.ts +40 -1
  93. package/src/mmtp-container-parser.ts +96 -8
  94. package/src/mmtp-fec-session.ts +115 -15
  95. package/src/mmtp-router.ts +344 -27
  96. package/src/multicast-manager.ts +26 -5
  97. package/src/multicast-transport.ts +44 -20
  98. package/src/repair-layer-subscriptions.ts +261 -0
  99. package/src/track-session.ts +1 -1
@@ -50,7 +50,11 @@ import {
50
50
  type StreamSwitchCommit,
51
51
  type TrackPacketCallback,
52
52
  } from "./mmtp-router.js";
53
- import { isMulticastTrackPacketId, type WindowMulticastGateway } from "@blockcast/mmt-transport";
53
+ import {
54
+ isMulticastTrackPacketId,
55
+ userAgentVerifiedIngress,
56
+ type WindowMulticastGateway,
57
+ } from "@blockcast/mmt-transport";
54
58
  import {
55
59
  MulticastTransport,
56
60
  type MulticastFailbackReason,
@@ -94,6 +98,8 @@ export interface MulticastRepairEndpoint {
94
98
  mode?: string;
95
99
  amtRelay?: string;
96
100
  amtRelayPort?: number;
101
+ /** The media join's `'driad'` discovery (no relay pinned), carried so the repair group rides the same relay. */
102
+ amtDiscovery?: 'driad';
97
103
  /** The repair packet IDs this group carries. */
98
104
  packetIds: number[];
99
105
  /** Each repair packet ID bound to the media packet ID it repairs. */
@@ -2353,10 +2359,20 @@ export class MulticastManager {
2353
2359
  * repair roles and owners are registered here; in-band owners stay the
2354
2360
  * caller's. Joining is best effort: a group that fails to join is logged
2355
2361
  * and leaves the media join as it was.
2362
+ *
2363
+ * `options.gateway` names the gateway a subscribe callback reads through.
2364
+ * When it is the browser's own MulticastReceiver provider, the callback's
2365
+ * packets are fed as admitted by the user agent
2366
+ * (`userAgentVerifiedIngress`); nothing the callback passes can mark them.
2367
+ * The mark rests on this statement, not on the data path: every packet the
2368
+ * callback delivers must come from a subscription opened on
2369
+ * `options.gateway`. A callback that also merges another source (a MoQ or
2370
+ * WebSocket repair feed) must not name a native provider here.
2356
2371
  */
2357
2372
  joinRepairEndpoints(
2358
2373
  endpoints: readonly MulticastRepairEndpoint[],
2359
2374
  provider?: WindowMulticastGateway | MulticastRepairSubscribe,
2375
+ options?: { gateway?: WindowMulticastGateway },
2360
2376
  ): () => void {
2361
2377
  const releases: Array<() => void> = [];
2362
2378
  let released = false;
@@ -2382,22 +2398,26 @@ export class MulticastManager {
2382
2398
  ...(endpoint.flow === undefined ? {} : { flow: endpoint.flow }),
2383
2399
  };
2384
2400
  let sender: string | undefined;
2385
- const onPacket = (packet: Uint8Array, sourceAddress?: string) => {
2401
+ const feed = (packet: Uint8Array, sourceAddress: string | undefined, ingressToFeed: MmtpIngressContext) => {
2386
2402
  if (released) return;
2387
2403
  // The media join's sender owns the repair too; this group's own first
2388
2404
  // sender stands in only until the media join has one.
2389
2405
  if (sourceAddress !== undefined && !sameDatagramSource(sourceAddress, this.#transport.sender ?? (sender ??= sourceAddress))) return;
2390
- this.#router.feedPacket(packet, "multicast", ingress);
2406
+ this.#router.feedPacket(packet, "multicast", ingressToFeed);
2391
2407
  };
2392
2408
  const fail = (error: unknown) =>
2393
2409
  console.warn(`[MulticastManager] repair group ${endpoint.group}:${endpoint.port} not joined`, error);
2394
2410
  if (typeof provider === "function") {
2411
+ const marked = userAgentVerifiedIngress(ingress, options?.gateway);
2412
+ const onPacket = (packet: Uint8Array, sourceAddress?: string) => feed(packet, sourceAddress, marked);
2395
2413
  Promise.resolve()
2396
2414
  .then(() => provider(endpoint, onPacket))
2397
2415
  .then((release) => (released ? safely(release) : releases.push(release)), fail);
2398
2416
  continue;
2399
2417
  }
2400
- const transport = new MulticastTransport((packet) => onPacket(packet), {
2418
+ // The transport marks what it delivers when its gateway is the user
2419
+ // agent's MulticastReceiver provider; feedPacket would not.
2420
+ const transport = new MulticastTransport<MmtpIngressContext>((packet, delivered) => feed(packet, undefined, delivered ?? ingress), {
2401
2421
  gateway: provider,
2402
2422
  lockedTo: () => this.#transport.sender,
2403
2423
  });
@@ -2409,11 +2429,12 @@ export class MulticastManager {
2409
2429
  mode: endpoint.mode as MulticastEndpointConfig["mode"],
2410
2430
  amtRelay: endpoint.amtRelay,
2411
2431
  amtRelayPort: endpoint.amtRelayPort ?? (endpoint.amtRelay?.trim() ? DEFAULT_AMT_RELAY_PORT : undefined),
2432
+ amtDiscovery: endpoint.amtDiscovery,
2412
2433
  packetIds: endpoint.packetIds,
2413
2434
  tracks: endpoint.tracks.map(({ name, packetId }) => ({ name, packetId })),
2414
2435
  // A protected repair group's records ride in band on its port (7.2.3).
2415
2436
  ...(endpoint.flow === undefined ? {} : { protected: true }),
2416
- }).then((joined) => {
2437
+ }, ingress).then((joined) => {
2417
2438
  if (!joined && !released) fail(transport.lastJoinOutcome);
2418
2439
  });
2419
2440
  }
@@ -6,7 +6,10 @@
6
6
 
7
7
  import {
8
8
  isMulticastTrackPacketId,
9
+ isUserAgentVerifiedGateway,
10
+ resolveMulticastGateway,
9
11
  subscriptionDeliversPacketBatches,
12
+ userAgentVerifiedIngress,
10
13
  toWindowMulticastSubscribeConfig,
11
14
  type WindowMulticastDelivery,
12
15
  type WindowMulticastGateway,
@@ -52,7 +55,13 @@ export interface MulticastTransportConfig {
52
55
  protected?: boolean
53
56
  }
54
57
 
55
- export type ResolvedMulticastTransport = 'native' | 'amt'
58
+ /**
59
+ * The leg a join resolved to. `'user-agent'`: the browser's own
60
+ * `MulticastReceiver` delivers it, which picks native or AMT itself and does
61
+ * not say which. CMCD has no user-agent value, so a caller reports a
62
+ * `'user-agent'` join as `'native'` (`TransportSnapshot.transport`).
63
+ */
64
+ export type ResolvedMulticastTransport = 'native' | 'amt' | 'user-agent'
56
65
 
57
66
  /**
58
67
  * Why a joined transport handed control back to MoQ.
@@ -211,16 +220,8 @@ function providerErrorOf(error: unknown): MulticastJoinProviderError | undefined
211
220
  */
212
221
  const MAX_TRACKED_DROPPED_PIDS = 16
213
222
 
214
- type MulticastGlobals = typeof globalThis & {
215
- multicast?: WindowMulticastGateway
216
- }
217
-
218
- function getMulticastGlobals(): MulticastGlobals {
219
- return globalThis as MulticastGlobals
220
- }
221
-
222
223
  function hasMulticastGateway(): boolean {
223
- return typeof getMulticastGlobals().multicast?.subscribe === 'function'
224
+ return typeof resolveMulticastGateway()?.subscribe === 'function'
224
225
  }
225
226
 
226
227
  /**
@@ -371,6 +372,9 @@ export class MulticastTransport<TPacketContext = never> {
371
372
  // listener closure, so an in-place admission change (setContext) applies
372
373
  // from the next packet on without rejoining.
373
374
  #context: TPacketContext | undefined = undefined
375
+ #deliveredContext: TPacketContext | undefined = undefined
376
+ /** The live join's gateway, when it is the user agent's MulticastReceiver provider. */
377
+ #userAgentGateway: WindowMulticastGateway | undefined = undefined
374
378
  // The live join's provider-side filter. An in-place widening must reach it,
375
379
  // or the provider never delivers the new ID; null without a live join.
376
380
  #providerFilter: ProviderFilter | null = null
@@ -415,7 +419,7 @@ export class MulticastTransport<TPacketContext = never> {
415
419
  this.#sender = undefined
416
420
 
417
421
  if (this.#disposed) return this.#declined(generation, 'disposed')
418
- const gateway = this.#gateway ?? getMulticastGlobals().multicast
422
+ const gateway = this.#gateway ?? resolveMulticastGateway()
419
423
  if (typeof gateway?.subscribe !== 'function') {
420
424
  console.warn('[MulticastTransport] Multicast API not available')
421
425
  return this.#declined(generation, 'unavailable')
@@ -435,15 +439,18 @@ export class MulticastTransport<TPacketContext = never> {
435
439
  console.log(
436
440
  `[MulticastTransport] window.multicast.subscribe(${config.sourceAddress ?? config.senderAddress ?? '*'}, ${config.groupAddress}, ${config.port})`,
437
441
  )
442
+ // The browser's MulticastReceiver picks native or AMT, and the relay,
443
+ // itself: ask it for the group only.
444
+ const userAgent = isUserAgentVerifiedGateway(gateway)
438
445
  const subscribeConfig = toWindowMulticastSubscribeConfig({
439
446
  source: config.sourceAddress,
440
447
  senderAddress: config.senderAddress,
441
448
  group: config.groupAddress,
442
449
  port: config.port,
443
- mode: config.mode,
444
- amtRelay: config.amtRelay,
445
- amtRelayPort: config.amtRelayPort,
446
- amtDiscovery: config.amtDiscovery,
450
+ mode: userAgent ? 'native' : config.mode,
451
+ amtRelay: userAgent ? undefined : config.amtRelay,
452
+ amtRelayPort: userAgent ? undefined : config.amtRelayPort,
453
+ amtDiscovery: userAgent ? undefined : config.amtDiscovery,
447
454
  packetIds: config.packetIds,
448
455
  tracks: config.tracks,
449
456
  atsc3: config.atsc3,
@@ -506,7 +513,8 @@ export class MulticastTransport<TPacketContext = never> {
506
513
  acknowledgedUpdate: 0,
507
514
  inFlight: 0,
508
515
  }
509
- this.#context = context
516
+ this.#userAgentGateway = userAgent ? gateway : undefined
517
+ this.#setContext(context)
510
518
  // Seed the allow-list's counters so accepted packets never insert. An
511
519
  // id already counted by an earlier join keeps its count: the counters
512
520
  // are cumulative across rejoins, as bytesReceived is.
@@ -521,7 +529,7 @@ export class MulticastTransport<TPacketContext = never> {
521
529
  // gateway no longer exposes a mutable transport/transportchange
522
530
  // surface, so assign this only after subscribe() has resolved and the
523
531
  // returned subscription has passed the event-surface check above.
524
- this.#resolvedTransport = subscribeConfig.origin === 'amt' ? 'amt' : 'native'
532
+ this.#resolvedTransport = userAgent ? 'user-agent' : subscribeConfig.origin === 'amt' ? 'amt' : 'native'
525
533
  // The provider alone knows whether its packets are independent
526
534
  // datagrams or an ordered relay of one socket; undeclared promises no
527
535
  // ordering. Announce it before the listener can deliver a packet. A
@@ -630,7 +638,7 @@ export class MulticastTransport<TPacketContext = never> {
630
638
  // Read per packet: a callback that changes the admission (an in-place
631
639
  // rendition switch committing on this very packet) takes effect from the
632
640
  // next packet, including the rest of a batch.
633
- this.#onPacket(packet, this.#context)
641
+ this.#onPacket(packet, this.#deliveredContext)
634
642
  return liveness
635
643
  }
636
644
 
@@ -872,8 +880,10 @@ export class MulticastTransport<TPacketContext = never> {
872
880
  }
873
881
 
874
882
  /**
875
- * What the live join delivers with every accepted packet (the join's
876
- * `context`, or the last setContext), or undefined without a live join.
883
+ * The live join's context as the caller gave it (the join's `context`, or
884
+ * the last setContext), or undefined without a live join. On a native
885
+ * provider each accepted packet is delivered with a marked copy of it
886
+ * (`userAgentVerifiedIngress`); this getter never carries the mark.
877
887
  */
878
888
  get context(): TPacketContext | undefined {
879
889
  return this.#context
@@ -899,7 +909,19 @@ export class MulticastTransport<TPacketContext = never> {
899
909
  '[MulticastTransport] setContext needs a live join; join() carries the first context',
900
910
  )
901
911
  }
912
+ this.#setContext(context)
913
+ }
914
+
915
+ /**
916
+ * The join's context, and what the subscription delivers with each packet:
917
+ * marked admitted by the user agent when the gateway is its native
918
+ * MulticastReceiver provider. feedPacket never marks.
919
+ */
920
+ #setContext(context: TPacketContext | undefined): void {
902
921
  this.#context = context
922
+ this.#deliveredContext = this.#userAgentGateway !== undefined && typeof context === 'object' && context !== null
923
+ ? userAgentVerifiedIngress(context, this.#userAgentGateway)
924
+ : context
903
925
  }
904
926
 
905
927
  /**
@@ -1040,6 +1062,8 @@ export class MulticastTransport<TPacketContext = never> {
1040
1062
  this.#matchedSource = undefined
1041
1063
  this.#providerFilter = null
1042
1064
  this.#context = undefined
1065
+ this.#deliveredContext = undefined
1066
+ this.#userAgentGateway = undefined
1043
1067
  this.#withdrawnPacketIds.clear()
1044
1068
  }
1045
1069
 
@@ -0,0 +1,261 @@
1
+ /**
2
+ * MoQ repair-layer subscriptions by measured loss (BLO-35456).
3
+ *
4
+ * MoQ is per-track subscribe, so a repair layer the receiver does not
5
+ * subscribe costs nothing past the relay. This is the mechanism that decides
6
+ * which of a source's repair tracks to hold: it measures loss from the MMTP
7
+ * `packet_sequence_number` gaps the receiver already sees on the source track
8
+ * (no new signalling), holds base layers 0..n-1 (draft-ramadan-moq-fec 6.3,
9
+ * `resolveRepairLayers`), and holds the keyframe overlay track while any base
10
+ * layer is held. The policy here is deliberately the simplest per-layer
11
+ * add/drop; the join policy proper is BLO-35457.
12
+ *
13
+ * Every repair packet is handed to `feed` with the repair track it arrived on,
14
+ * so libmmt routes an overlay packet to the overlay instance and never to the
15
+ * base (`MmtpRouter.feedRepairPacket(..., repairTrackName)`).
16
+ *
17
+ * `stream` delivery means QUIC retransmits: no repair track is ever
18
+ * subscribed (FEC only for unreliable transports). The mode is the source's
19
+ * `deliveryMode` (`catalogTrackDeliveryMode`); absent is `datagram`. It is
20
+ * read from the current source, so on a catalog refresh re-feed the updated
21
+ * source through `switchSource` for a changed `deliveryMode` to take effect.
22
+ *
23
+ * ABR switch R -> R' (`switchSource`): R's subscriptions are closed, the layer
24
+ * count carries to R', and any packet R's subscriptions deliver after the
25
+ * switch is dropped, never fed.
26
+ */
27
+
28
+ import { serial32Distance } from '@blockcast/mmt-base'
29
+ import type { RepairLayerParams, RepairOverlayParams } from './repair-layers.js'
30
+
31
+ export type MoqRepairDelivery = 'datagram' | 'stream'
32
+
33
+ /** The source's FEC shape, as `fecParams` resolves it. */
34
+ export interface RepairLayerSource {
35
+ /** K: source symbols per base block. */
36
+ fecK: number
37
+ /** Base layers, layer 0 first (`TrackFecParams.repairLayers`). */
38
+ repairLayers?: readonly RepairLayerParams[]
39
+ /** Keyframe overlays (`TrackFecParams.repairOverlays`); at most one is used. */
40
+ repairOverlays?: readonly RepairOverlayParams[]
41
+ /** The publisher's MoQ delivery mode; absent is `datagram`. */
42
+ deliveryMode?: MoqRepairDelivery
43
+ }
44
+
45
+ export interface RepairTrackSubscription {
46
+ close(): void
47
+ }
48
+
49
+ export interface RepairLayerSubscriptionsOptions {
50
+ source: RepairLayerSource
51
+ /** Subscribe one repair track; every object payload goes to `onPacket`. */
52
+ subscribe(
53
+ repairTrackName: string,
54
+ priority: number,
55
+ onPacket: (packet: Uint8Array) => void,
56
+ ): RepairTrackSubscription
57
+ /** Hand one repair packet to the decoder, e.g. `processRepairFromMoQ`. */
58
+ feed(packet: Uint8Array, repairTrackName: string): void
59
+ /** Expected source packets per loss window. Default 500. */
60
+ windowPackets?: number
61
+ /** Repair held per unit of measured loss. Default 2. */
62
+ margin?: number
63
+ /** Consecutive windows wanting fewer layers before one is dropped. Default 3. */
64
+ dropAfterWindows?: number
65
+ }
66
+
67
+ export interface RepairLayerSubscriptionsSnapshot {
68
+ delivery: MoqRepairDelivery
69
+ layerCount: number
70
+ subscribedTracks: string[]
71
+ repairPackets: number
72
+ repairBytes: number
73
+ /** Packets a closed subscription delivered after its switch or drop. */
74
+ staleDropped: number
75
+ /** Loss rate of the last closed window; null before the first. */
76
+ lossRate: number | null
77
+ }
78
+
79
+ const SERIAL32_HALF = 0x80000000
80
+
81
+ export class RepairLayerSubscriptions {
82
+ readonly #opts: RepairLayerSubscriptionsOptions
83
+ readonly #windowPackets: number
84
+ readonly #margin: number
85
+ readonly #dropAfterWindows: number
86
+ #source: RepairLayerSource
87
+ #layerCount = 0
88
+ #subs = new Map<string, { sub: RepairTrackSubscription }>()
89
+ #disposed = false
90
+ #lastSeq: number | undefined
91
+ #expected = 0
92
+ #lost = 0
93
+ #wantFewer = 0
94
+ #lossRate: number | null = null
95
+ #repairPackets = 0
96
+ #repairBytes = 0
97
+ #staleDropped = 0
98
+
99
+ constructor(opts: RepairLayerSubscriptionsOptions) {
100
+ this.#opts = opts
101
+ this.#source = opts.source
102
+ this.#windowPackets = opts.windowPackets ?? 500
103
+ this.#margin = opts.margin ?? 2
104
+ this.#dropAfterWindows = opts.dropAfterWindows ?? 3
105
+ }
106
+
107
+ get layerCount(): number {
108
+ return this.#layerCount
109
+ }
110
+
111
+ /**
112
+ * One source packet's MMTP `packet_sequence_number`, in arrival order.
113
+ * The sequence is per `packet_id`: pass the packets of ONE packet_id only,
114
+ * or interleaved sequences read as loss.
115
+ */
116
+ noteSourceSequence(seq: number): void {
117
+ if (this.#disposed || this.#delivery() === 'stream') return
118
+ seq >>>= 0
119
+ if (this.#lastSeq === undefined) {
120
+ this.#lastSeq = seq
121
+ return
122
+ }
123
+ const d = serial32Distance(this.#lastSeq, seq)
124
+ if (d === 0) return
125
+ const back = d >= SERIAL32_HALF ? 2 ** 32 - d : 0
126
+ if (d > this.#windowPackets && (back === 0 || back > this.#windowPackets)) {
127
+ // A step wider than one window, either way, is a discontinuity (a
128
+ // publisher restart), not loss or reorder: restart the window from here.
129
+ // ponytail: a real outage longer than a window is also read as a restart;
130
+ // FEC cannot repair that anyway.
131
+ this.#lastSeq = seq
132
+ this.#expected = 0
133
+ this.#lost = 0
134
+ return
135
+ }
136
+ if (back === 0) {
137
+ this.#expected += d
138
+ this.#lost += d - 1
139
+ this.#lastSeq = seq
140
+ } else if (this.#lost > 0) {
141
+ // A late packet fills a hole counted as lost. Holes are not tracked
142
+ // individually, so one whose window already closed cancels a hole in
143
+ // this window instead: a bias toward lower loss of at most the reorder
144
+ // depth per window.
145
+ this.#lost -= 1
146
+ }
147
+ if (this.#expected >= this.#windowPackets) this.#closeWindow()
148
+ }
149
+
150
+ /** ABR switch R -> R': close R's tracks, carry the layer count to R'. */
151
+ switchSource(next: RepairLayerSource): void {
152
+ if (this.#disposed) return
153
+ this.#closeAll()
154
+ this.#source = next
155
+ this.#lastSeq = undefined
156
+ this.#expected = 0
157
+ this.#lost = 0
158
+ this.#wantFewer = 0
159
+ this.#layerCount = this.#delivery() === 'stream' ? 0 : Math.min(this.#layerCount, this.#layers().length)
160
+ this.#reconcile()
161
+ }
162
+
163
+ snapshot(): RepairLayerSubscriptionsSnapshot {
164
+ return {
165
+ delivery: this.#delivery(),
166
+ layerCount: this.#layerCount,
167
+ subscribedTracks: [...this.#subs.keys()],
168
+ repairPackets: this.#repairPackets,
169
+ repairBytes: this.#repairBytes,
170
+ staleDropped: this.#staleDropped,
171
+ lossRate: this.#lossRate,
172
+ }
173
+ }
174
+
175
+ dispose(): void {
176
+ if (this.#disposed) return
177
+ this.#closeAll()
178
+ this.#disposed = true
179
+ }
180
+
181
+ #delivery(): MoqRepairDelivery {
182
+ return this.#source.deliveryMode === 'stream' ? 'stream' : 'datagram'
183
+ }
184
+
185
+ #layers(): readonly RepairLayerParams[] {
186
+ return this.#source.repairLayers ?? []
187
+ }
188
+
189
+ #closeWindow(): void {
190
+ const rate = this.#lost / this.#expected
191
+ this.#lossRate = rate
192
+ this.#expected = 0
193
+ this.#lost = 0
194
+ const target = this.#targetLayers(rate)
195
+ if (target > this.#layerCount) {
196
+ this.#wantFewer = 0
197
+ this.#layerCount = target
198
+ } else if (target < this.#layerCount) {
199
+ this.#wantFewer += 1
200
+ if (this.#wantFewer >= this.#dropAfterWindows) {
201
+ this.#wantFewer = 0
202
+ this.#layerCount -= 1
203
+ }
204
+ } else {
205
+ this.#wantFewer = 0
206
+ }
207
+ this.#reconcile()
208
+ }
209
+
210
+ /** Fewest base layers whose summed P covers margin x the measured loss of K. */
211
+ #targetLayers(rate: number): number {
212
+ if (rate <= 0) return 0
213
+ const needed = Math.ceil(rate * this.#source.fecK * this.#margin)
214
+ const layers = this.#layers()
215
+ let held = 0
216
+ for (let n = 0; n < layers.length; n++) {
217
+ held += layers[n]!.repairSymbols
218
+ if (held >= needed) return n + 1
219
+ }
220
+ return layers.length
221
+ }
222
+
223
+ /** Stream delivery never leaves layer count 0, so it never subscribes. */
224
+ #reconcile(): void {
225
+ const want = new Map<string, number>()
226
+ for (const layer of this.#layers().slice(0, this.#layerCount)) {
227
+ want.set(layer.repairTrackName, layer.priority)
228
+ }
229
+ const overlay = this.#source.repairOverlays?.[0]
230
+ if (this.#layerCount > 0 && overlay) want.set(overlay.repairTrackName, overlay.priority)
231
+
232
+ for (const [name, entry] of this.#subs) {
233
+ if (!want.has(name)) {
234
+ entry.sub.close()
235
+ this.#subs.delete(name)
236
+ }
237
+ }
238
+ for (const [name, priority] of want) {
239
+ if (this.#subs.has(name)) continue
240
+ const entry = {} as { sub: RepairTrackSubscription }
241
+ entry.sub = this.#opts.subscribe(name, priority, (packet) => this.#onPacket(name, entry, packet))
242
+ this.#subs.set(name, entry)
243
+ }
244
+ }
245
+
246
+ /** Fenced on the entry itself, so a closed subscription is stale even if its track is re-added. */
247
+ #onPacket(name: string, entry: { sub: RepairTrackSubscription }, packet: Uint8Array): void {
248
+ if (this.#disposed || this.#subs.get(name) !== entry) {
249
+ this.#staleDropped += 1
250
+ return
251
+ }
252
+ this.#repairPackets += 1
253
+ this.#repairBytes += packet.byteLength
254
+ this.#opts.feed(packet, name)
255
+ }
256
+
257
+ #closeAll(): void {
258
+ for (const entry of this.#subs.values()) entry.sub.close()
259
+ this.#subs.clear()
260
+ }
261
+ }
@@ -58,7 +58,7 @@ export interface TrackSessionConfig {
58
58
  * This is runtime join state, not catalog geometry: a receiver on a clean
59
59
  * path joins layer 0 only while a lossy one adds layers, so the catalog's
60
60
  * total P is not what this receiver is receiving. Diagnostics that report P
61
- * (the worker snapshot, CMCD `x-bc-fec-p`) must read this.
61
+ * (the worker snapshot, CMCD `com.blockcast-fp`) must read this.
62
62
  *
63
63
  * Absent when the caller joins no layers explicitly, in which case P_0 is
64
64
  * what was joined. That is an identity on a single-layer track, not a