@blockcast/mmt-container 2.2.0-main.a88c3a0aa4f3 → 2.2.0-main.efb4fa4521af

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 (58) hide show
  1. package/dist/browser-runtime.js +293 -35
  2. package/dist/browser-runtime.js.map +4 -4
  3. package/dist/carp-catalog.js.map +1 -1
  4. package/dist/catalog-fec-params.js.map +1 -1
  5. package/dist/catalog-network-source.js.map +1 -1
  6. package/dist/delivery-catalog.js.map +1 -1
  7. package/dist/delta-fallback-config.js.map +1 -1
  8. package/dist/fec-manager.js.map +2 -2
  9. package/dist/fec-state.js.map +1 -1
  10. package/dist/fec-worker-setup.js.map +1 -1
  11. package/dist/held-layer-admission.js.map +1 -1
  12. package/dist/index.d.ts +2 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +4 -0
  15. package/dist/index.js.map +2 -2
  16. package/dist/iso-block-locator.js.map +1 -1
  17. package/dist/mfu-reassembler.js +12 -19
  18. package/dist/mfu-reassembler.js.map +2 -2
  19. package/dist/mmtp-container-parser.js +12 -19
  20. package/dist/mmtp-container-parser.js.map +2 -2
  21. package/dist/mmtp-container-parser.no-modules.js +133 -34
  22. package/dist/mmtp-container-parser.no-modules.js.map +3 -3
  23. package/dist/mmtp-decode-pipeline.js +12 -19
  24. package/dist/mmtp-decode-pipeline.js.map +2 -2
  25. package/dist/mmtp-fec-session.d.ts +41 -3
  26. package/dist/mmtp-fec-session.d.ts.map +1 -1
  27. package/dist/mmtp-fec-session.js +118 -13
  28. package/dist/mmtp-fec-session.js.map +3 -3
  29. package/dist/mmtp-router.js +12 -19
  30. package/dist/mmtp-router.js.map +2 -2
  31. package/dist/multicast-auth-catalog.js.map +1 -1
  32. package/dist/multicast-input.js.map +1 -1
  33. package/dist/multicast-manager.d.ts +2 -0
  34. package/dist/multicast-manager.d.ts.map +1 -1
  35. package/dist/multicast-manager.js +13 -19
  36. package/dist/multicast-manager.js.map +2 -2
  37. package/dist/multicast-transport.js.map +1 -1
  38. package/dist/multicast-unwrap.js +12 -19
  39. package/dist/multicast-unwrap.js.map +2 -2
  40. package/dist/no-modules.js +130 -33
  41. package/dist/no-modules.js.map +3 -3
  42. package/dist/repair-fetch.js.map +1 -1
  43. package/dist/repair-layer-subscriptions.d.ts +78 -0
  44. package/dist/repair-layer-subscriptions.d.ts.map +1 -0
  45. package/dist/repair-layer-subscriptions.js +189 -0
  46. package/dist/repair-layer-subscriptions.js.map +7 -0
  47. package/dist/signaling.js.map +2 -2
  48. package/dist/timestamp-codec.js +12 -19
  49. package/dist/timestamp-codec.js.map +2 -2
  50. package/dist/wasm-mfu-reassembler.js.map +1 -1
  51. package/dist/wasm-mmtp-container-parser.js +12 -19
  52. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  53. package/package.json +5 -5
  54. package/schemas/catalog.schema.json +4 -0
  55. package/src/index.ts +11 -0
  56. package/src/mmtp-fec-session.ts +115 -15
  57. package/src/multicast-manager.ts +3 -0
  58. package/src/repair-layer-subscriptions.ts +253 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockcast/mmt-container",
3
- "version": "2.2.0-main.a88c3a0aa4f3",
3
+ "version": "2.2.0-main.efb4fa4521af",
4
4
  "description": "MMTP/LOC container parsing for MMT over MoQ and SSM",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -169,10 +169,10 @@
169
169
  "test:watch": "vitest"
170
170
  },
171
171
  "dependencies": {
172
- "@blockcast/fec-worker": "2.2.0-main.a88c3a0aa4f3",
173
- "@blockcast/mmt-base": "2.2.0-main.a88c3a0aa4f3",
174
- "@blockcast/mmt-fec": "2.2.0-main.a88c3a0aa4f3",
175
- "@blockcast/mmt-manifest-verify": "2.2.0-main.a88c3a0aa4f3",
172
+ "@blockcast/fec-worker": "2.2.0-main.efb4fa4521af",
173
+ "@blockcast/mmt-base": "2.2.0-main.efb4fa4521af",
174
+ "@blockcast/mmt-fec": "2.2.0-main.efb4fa4521af",
175
+ "@blockcast/mmt-manifest-verify": "2.2.0-main.efb4fa4521af",
176
176
  "@noble/curves": "^1.9.7",
177
177
  "@noble/hashes": "^1.8.0",
178
178
  "ajv": "^8.17.1",
@@ -138,6 +138,10 @@
138
138
  "$ref": "#/$defs/positiveInteger",
139
139
  "description": "Keyframe interval in the track's timescale ticks (moqcast-draft §4.4.3). Exact where keyframeIntervalMs rounds (e.g. 30000/1001 video); when both are present, keyframeIntervalTicks wins."
140
140
  },
141
+ "deliveryMode": {
142
+ "type": "string",
143
+ "description": "How the publisher sends this track's objects (draft-ramadan-moq-fec-catalog-00 §3.3, proposed in moqcast-draft#26): \"datagram\" or \"stream\". A receiver treats a missing or unrecognized value as \"datagram\", so any string is admitted here rather than rejected. Allowed on source, repair and symbol tracks."
144
+ },
141
145
  "fec": { "$ref": "#/$defs/fec" }
142
146
  },
143
147
  "allOf": [
package/src/index.ts CHANGED
@@ -602,11 +602,22 @@ export {
602
602
  type MmtpFecSessionConfig,
603
603
  type MmtpFecSessionDecoder,
604
604
  type MmtpFecSessionDecoderConstructor,
605
+ type MmtpFecSessionMessageConfig,
606
+ type MmtpFecSessionMessageUpdate,
605
607
  type MmtpFecSessionMixedPathStats,
606
608
  type MmtpFecSessionPath,
607
609
  type MmtpFecSessionPathCounts,
608
610
  type MmtpFecSessionStats,
609
611
  } from "./mmtp-fec-session.js";
612
+ // MoQ repair-layer subscriptions by measured loss (BLO-35456)
613
+ export {
614
+ RepairLayerSubscriptions,
615
+ type MoqRepairDelivery,
616
+ type RepairLayerSource,
617
+ type RepairLayerSubscriptionsOptions,
618
+ type RepairLayerSubscriptionsSnapshot,
619
+ type RepairTrackSubscription,
620
+ } from "./repair-layer-subscriptions.js";
610
621
 
611
622
  export {
612
623
  catalogAmtNetworkSource,
@@ -23,6 +23,15 @@
23
23
  * uses. A packet below B_0 at or after the anchor fails the session closed:
24
24
  * `stats.failClosed` reports why, and every later repair is refused.
25
25
  *
26
+ * Catalog-less (draft-ramadan-moq-mmt-02 8.3, 13.2): a receiver holding
27
+ * layer 0 under the AL-FEC message alone builds the session from the message's
28
+ * fec_flow entry (`alFecFlow`) instead. K, T, the code point and the repair
29
+ * packet_id come from that entry, and layer 0 is held at
30
+ * maximum_p_for_repair_flow, standing for O_0 + P_0, by the anchored clause of
31
+ * draft-ramadan-moq-fec-core-00 7.3.2 alone. A later version that raises the
32
+ * field raises the held total (updateAlFecFlow()); the message carries no
33
+ * millisecond budget, so the caller states the retention (fec-core-00 9.4).
34
+ *
26
35
  * Every repair packet that cannot be used is counted under the reason it was
27
36
  * refused, and every block ends under exactly one outcome, so a stream whose
28
37
  * FEC is doing nothing can be told apart from one with nothing to repair.
@@ -30,6 +39,8 @@
30
39
 
31
40
  import {
32
41
  admitMmtpRepairObject,
42
+ alFecRaptorQFlowConfig,
43
+ FEC_CODE_RAPTORQ,
33
44
  isoBlockContains,
34
45
  isoBlocksBehindLowerBound,
35
46
  parseFecBlockProfile,
@@ -39,6 +50,8 @@ import {
39
50
  serial32Precedes,
40
51
  ssbg1FitsSymbol,
41
52
  trimSsbg1SourceSymbol,
53
+ type AlFecFlow,
54
+ type AlFecRaptorQFlowConfig,
42
55
  type FecBlockProfile,
43
56
  } from '@blockcast/mmt-base'
44
57
  import { deriveFecRetentionBlocks, fecBlockBudgetMs } from '@blockcast/mmt-fec'
@@ -105,6 +118,25 @@ export interface MmtpFecSessionConfig {
105
118
  heldLayers?: readonly number[]
106
119
  }
107
120
 
121
+ /**
122
+ * A catalog-less session: one fec_flow entry of the AL-FEC message
123
+ * (`parseAlFecMessage(...).flows[i]`), draft-ramadan-moq-mmt-02 8.3.
124
+ */
125
+ export interface MmtpFecSessionMessageConfig {
126
+ /** The source track's entry; it must pass `alFecRaptorQFlowConfig`. */
127
+ alFecFlow: AlFecFlow
128
+ /**
129
+ * The decoder retention in blocks. The message carries no elapsed-time
130
+ * budget (mmt-02 7.5), so open-block state is bounded by local policy
131
+ * (fec-core-00 9.4); required, never defaulted.
132
+ */
133
+ retentionBlocks: number
134
+ Decoder: MmtpFecSessionDecoderConstructor
135
+ }
136
+
137
+ /** What updateAlFecFlow() did with a later version of the message. */
138
+ export type MmtpFecSessionMessageUpdate = 'raised' | 'unchanged' | 'refused'
139
+
108
140
  /**
109
141
  * The path a symbol arrived on: its MoQT subscription, or the multicast
110
142
  * group (native SSM or AMT) carrying the same asset. The caller states it;
@@ -197,12 +229,17 @@ export class MmtpFecSession {
197
229
  readonly algorithm: 'raptorq' | 'reed-solomon'
198
230
  readonly sourceSymbols: number
199
231
  /** P_0 = B_0, the held total every repair packet is checked against. */
200
- readonly repairSymbols: number
232
+ get repairSymbols(): number {
233
+ return this.#repairSymbols
234
+ }
201
235
  readonly symbolSize: number
202
236
  /** The decoder's retention, in blocks, derived from the catalog budget. */
203
237
  readonly retentionBlocks: number
204
238
 
205
239
  #decoder: MmtpFecSessionDecoder | null
240
+ #repairSymbols: number
241
+ /** The message entry a catalog-less session was built from; undefined under a catalog. */
242
+ #message: AlFecRaptorQFlowConfig | undefined
206
243
  /** Blocks a repair has defined and that are not settled yet, by SS_Start. */
207
244
  #blocks = new Map<number, BlockState>()
208
245
  /** SS_Starts of settled blocks, so a later repair for one is refused. */
@@ -240,7 +277,26 @@ export class MmtpFecSession {
240
277
  * the scheme, the block profile, K, P_0 (`repairSymbols`), T,
241
278
  * `interleaveDepthMs` or `reorderToleranceMs`. None is defaulted.
242
279
  */
243
- constructor(config: MmtpFecSessionConfig) {
280
+ constructor(config: MmtpFecSessionConfig | MmtpFecSessionMessageConfig) {
281
+ if (config && 'alFecFlow' in config) {
282
+ const message = alFecRaptorQFlowConfig(config.alFecFlow)
283
+ if (!message) {
284
+ throw new Error('MmtpFecSession: the AL-FEC entry is outside draft-ramadan-moq-mmt-02 8.3 (iso-ssbg1-v1)')
285
+ }
286
+ this.#message = message
287
+ // Coding byte 0x14 is the iso-ssbg1-v1 profile (mmt-02 8.3).
288
+ this.blockProfile = parseFecBlockProfile('iso-ssbg1-v1', 'alFecFlow')
289
+ this.algorithm = message.fecCodeId === FEC_CODE_RAPTORQ ? 'raptorq' : 'reed-solomon'
290
+ this.sourceSymbols = message.maximumK
291
+ this.#repairSymbols = message.maximumP
292
+ this.symbolSize = message.repairSymbolSize
293
+ this.retentionBlocks = requirePositiveInteger(config.retentionBlocks, 'retentionBlocks')
294
+ this.#repairPacketId = message.repairFlowId
295
+ this.#heldLayers = layerZero(message.maximumP)
296
+ this.#anchorHorizon = heldLayerAnchorHorizon(this.retentionBlocks, this.sourceSymbols)
297
+ this.#decoder = buildDecoder(config.Decoder, this)
298
+ return
299
+ }
244
300
  const fec = config?.fec
245
301
  if (!fec || typeof fec !== 'object') {
246
302
  throw new Error('MmtpFecSession: the catalog fec instance is required')
@@ -251,7 +307,7 @@ export class MmtpFecSession {
251
307
  }
252
308
  this.algorithm = fec.scheme
253
309
  this.sourceSymbols = requirePositiveInteger(fec.sourceSymbols, 'fec.sourceSymbols')
254
- this.repairSymbols = requirePositiveInteger(fec.repairSymbols, 'fec.repairSymbols')
310
+ this.#repairSymbols = requirePositiveInteger(fec.repairSymbols, 'fec.repairSymbols')
255
311
  this.symbolSize = requirePositiveInteger(fec.symbolSize, 'fec.symbolSize')
256
312
  const interleaveDepthMs = requireNumber(fec.interleaveDepthMs, 'fec.interleaveDepthMs')
257
313
  const reorderToleranceMs = requireNumber(fec.reorderToleranceMs, 'fec.reorderToleranceMs')
@@ -271,17 +327,38 @@ export class MmtpFecSession {
271
327
  config.heldLayers,
272
328
  )
273
329
  this.#anchorHorizon = heldLayerAnchorHorizon(this.retentionBlocks, this.sourceSymbols)
274
- if (typeof config.Decoder !== 'function') {
275
- throw new Error('MmtpFecSession: a decoder constructor (wasm_bindgen.MmtFecDecoder) is required')
276
- }
277
- const decoder = new config.Decoder(this.retentionBlocks, this.blockProfile, this.algorithm)
278
- try {
279
- decoder.configure_params(this.sourceSymbols, this.symbolSize)
280
- } catch (error) {
281
- decoder.free()
282
- throw error
330
+ this.#decoder = buildDecoder(config.Decoder, this)
331
+ }
332
+
333
+ /**
334
+ * A later version of the AL-FEC message, catalog-less sessions only. A
335
+ * maximum_p_for_repair_flow above the held total raises it (`raised`): the
336
+ * anchored check restarts at the new total, so a block generated under the
337
+ * previous one stays valid before the first block carrying the new total
338
+ * (fec-core-00 7.3.2, mmt-02 8.3). A field at or below it changes nothing
339
+ * (`unchanged`): no publisher lowers it, and the check catches a wire that
340
+ * does. An entry with another asset, repair flow, code point, K or T, or
341
+ * one outside 8.3, is `refused`: that is a new source encoding, which needs
342
+ * a new session. A latched fail-closed stays latched either way.
343
+ */
344
+ updateAlFecFlow(flow: AlFecFlow): MmtpFecSessionMessageUpdate {
345
+ const held = this.#message
346
+ if (!held) throw new Error('MmtpFecSession: updateAlFecFlow() needs a session built from an AL-FEC entry')
347
+ const next = alFecRaptorQFlowConfig(flow)
348
+ if (
349
+ !next ||
350
+ next.sourcePacketIds[0] !== held.sourcePacketIds[0] ||
351
+ next.repairFlowId !== held.repairFlowId ||
352
+ next.fecCodeId !== held.fecCodeId ||
353
+ next.maximumK !== held.maximumK ||
354
+ next.repairSymbolSize !== held.repairSymbolSize
355
+ ) {
356
+ return 'refused'
283
357
  }
284
- this.#decoder = decoder
358
+ if (next.maximumP <= this.#repairSymbols) return 'unchanged'
359
+ this.#repairSymbols = next.maximumP
360
+ this.#heldLayers = layerZero(next.maximumP)
361
+ return 'raised'
285
362
  }
286
363
 
287
364
  /** True once release() ran. */
@@ -386,7 +463,7 @@ export class MmtpFecSession {
386
463
  // An RSB_length below a held layer's total goes to the 7.3.2 stop, not
387
464
  // to the discard; the block state is unchanged either way.
388
465
  const verdict = this.#heldLayers.failsClosed(
389
- repairTrackName, frame.ssStart, frame.rsbLength, this.#anchorHorizon, this.#newestSource,
466
+ this.#trackFor(repairTrackName), frame.ssStart, frame.rsbLength, this.#anchorHorizon, this.#newestSource,
390
467
  )
391
468
  if (verdict) {
392
469
  this.#stats.failClosed = verdict
@@ -399,7 +476,7 @@ export class MmtpFecSession {
399
476
  // fec-01 6.3.9, after the 4.5 discards and the size check, before a block
400
477
  // is placed. Without a track name only the anchored rule (b) applies.
401
478
  const verdict = this.#heldLayers.admit(
402
- repairTrackName,
479
+ this.#trackFor(repairTrackName),
403
480
  frame.ssStart,
404
481
  frame.rsbLength,
405
482
  this.#anchorHorizon,
@@ -464,6 +541,11 @@ export class MmtpFecSession {
464
541
  return true
465
542
  }
466
543
 
544
+ /** Catalog-less: layer 0 has no own-track clause (mmt-02 8.3), so no track name is judged. */
545
+ #trackFor(repairTrackName: string | undefined): string | undefined {
546
+ return this.#message ? undefined : repairTrackName
547
+ }
548
+
467
549
  #drop(reason: string): Uint8Array[] {
468
550
  this.#stats.dropped[reason] = (this.#stats.dropped[reason] ?? 0) + 1
469
551
  return []
@@ -600,6 +682,24 @@ export class MmtpFecSession {
600
682
  }
601
683
  }
602
684
 
685
+ function layerZero(total: number): HeldLayerAdmission {
686
+ return new HeldLayerAdmission([{ layer: 0, repairTrackName: '', rsIdOffset: 0, repairSymbols: total }])
687
+ }
688
+
689
+ function buildDecoder(Decoder: MmtpFecSessionDecoderConstructor, session: MmtpFecSession): MmtpFecSessionDecoder {
690
+ if (typeof Decoder !== 'function') {
691
+ throw new Error('MmtpFecSession: a decoder constructor (wasm_bindgen.MmtFecDecoder) is required')
692
+ }
693
+ const decoder = new Decoder(session.retentionBlocks, session.blockProfile, session.algorithm)
694
+ try {
695
+ decoder.configure_params(session.sourceSymbols, session.symbolSize)
696
+ } catch (error) {
697
+ decoder.free()
698
+ throw error
699
+ }
700
+ return decoder
701
+ }
702
+
603
703
  function requirePositiveInteger(value: unknown, path: string): number {
604
704
  if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
605
705
  throw new Error(`MmtpFecSession: ${path} must be a positive integer (got ${String(value)})`)
@@ -98,6 +98,8 @@ export interface MulticastRepairEndpoint {
98
98
  mode?: string;
99
99
  amtRelay?: string;
100
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';
101
103
  /** The repair packet IDs this group carries. */
102
104
  packetIds: number[];
103
105
  /** Each repair packet ID bound to the media packet ID it repairs. */
@@ -2427,6 +2429,7 @@ export class MulticastManager {
2427
2429
  mode: endpoint.mode as MulticastEndpointConfig["mode"],
2428
2430
  amtRelay: endpoint.amtRelay,
2429
2431
  amtRelayPort: endpoint.amtRelayPort ?? (endpoint.amtRelay?.trim() ? DEFAULT_AMT_RELAY_PORT : undefined),
2432
+ amtDiscovery: endpoint.amtDiscovery,
2430
2433
  packetIds: endpoint.packetIds,
2431
2434
  tracks: endpoint.tracks.map(({ name, packetId }) => ({ name, packetId })),
2432
2435
  // A protected repair group's records ride in band on its port (7.2.3).
@@ -0,0 +1,253 @@
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).
19
+ *
20
+ * ABR switch R -> R' (`switchSource`): R's subscriptions are closed, the layer
21
+ * count carries to R', and any packet R's subscriptions deliver after the
22
+ * switch is dropped, never fed.
23
+ */
24
+
25
+ import { serial32Distance } from '@blockcast/mmt-base'
26
+ import type { RepairLayerParams, RepairOverlayParams } from './repair-layers.js'
27
+
28
+ export type MoqRepairDelivery = 'datagram' | 'stream'
29
+
30
+ /** The source's FEC shape, as `fecParams` resolves it. */
31
+ export interface RepairLayerSource {
32
+ /** K: source symbols per base block. */
33
+ fecK: number
34
+ /** Base layers, layer 0 first (`TrackFecParams.repairLayers`). */
35
+ repairLayers?: readonly RepairLayerParams[]
36
+ /** Keyframe overlays (`TrackFecParams.repairOverlays`); at most one is used. */
37
+ repairOverlays?: readonly RepairOverlayParams[]
38
+ }
39
+
40
+ export interface RepairTrackSubscription {
41
+ close(): void
42
+ }
43
+
44
+ export interface RepairLayerSubscriptionsOptions {
45
+ source: RepairLayerSource
46
+ delivery: MoqRepairDelivery
47
+ /** Subscribe one repair track; every object payload goes to `onPacket`. */
48
+ subscribe(
49
+ repairTrackName: string,
50
+ priority: number,
51
+ onPacket: (packet: Uint8Array) => void,
52
+ ): RepairTrackSubscription
53
+ /** Hand one repair packet to the decoder, e.g. `processRepairFromMoQ`. */
54
+ feed(packet: Uint8Array, repairTrackName: string): void
55
+ /** Expected source packets per loss window. Default 500. */
56
+ windowPackets?: number
57
+ /** Repair held per unit of measured loss. Default 2. */
58
+ margin?: number
59
+ /** Consecutive windows wanting fewer layers before one is dropped. Default 3. */
60
+ dropAfterWindows?: number
61
+ }
62
+
63
+ export interface RepairLayerSubscriptionsSnapshot {
64
+ delivery: MoqRepairDelivery
65
+ layerCount: number
66
+ subscribedTracks: string[]
67
+ repairPackets: number
68
+ repairBytes: number
69
+ /** Packets a closed subscription delivered after its switch or drop. */
70
+ staleDropped: number
71
+ /** Loss rate of the last closed window; null before the first. */
72
+ lossRate: number | null
73
+ }
74
+
75
+ const SERIAL32_HALF = 0x80000000
76
+
77
+ export class RepairLayerSubscriptions {
78
+ readonly #opts: RepairLayerSubscriptionsOptions
79
+ readonly #windowPackets: number
80
+ readonly #margin: number
81
+ readonly #dropAfterWindows: number
82
+ #source: RepairLayerSource
83
+ #layerCount = 0
84
+ #subs = new Map<string, { sub: RepairTrackSubscription }>()
85
+ #disposed = false
86
+ #lastSeq: number | undefined
87
+ #expected = 0
88
+ #lost = 0
89
+ #wantFewer = 0
90
+ #lossRate: number | null = null
91
+ #repairPackets = 0
92
+ #repairBytes = 0
93
+ #staleDropped = 0
94
+
95
+ constructor(opts: RepairLayerSubscriptionsOptions) {
96
+ this.#opts = opts
97
+ this.#source = opts.source
98
+ this.#windowPackets = opts.windowPackets ?? 500
99
+ this.#margin = opts.margin ?? 2
100
+ this.#dropAfterWindows = opts.dropAfterWindows ?? 3
101
+ }
102
+
103
+ get layerCount(): number {
104
+ return this.#layerCount
105
+ }
106
+
107
+ /**
108
+ * One source packet's MMTP `packet_sequence_number`, in arrival order.
109
+ * The sequence is per `packet_id`: pass the packets of ONE packet_id only,
110
+ * or interleaved sequences read as loss.
111
+ */
112
+ noteSourceSequence(seq: number): void {
113
+ if (this.#disposed || this.#opts.delivery === 'stream') return
114
+ seq >>>= 0
115
+ if (this.#lastSeq === undefined) {
116
+ this.#lastSeq = seq
117
+ return
118
+ }
119
+ const d = serial32Distance(this.#lastSeq, seq)
120
+ if (d === 0) return
121
+ const back = d >= SERIAL32_HALF ? 2 ** 32 - d : 0
122
+ if (d > this.#windowPackets && (back === 0 || back > this.#windowPackets)) {
123
+ // A step wider than one window, either way, is a discontinuity (a
124
+ // publisher restart), not loss or reorder: restart the window from here.
125
+ // ponytail: a real outage longer than a window is also read as a restart;
126
+ // FEC cannot repair that anyway.
127
+ this.#lastSeq = seq
128
+ this.#expected = 0
129
+ this.#lost = 0
130
+ return
131
+ }
132
+ if (back === 0) {
133
+ this.#expected += d
134
+ this.#lost += d - 1
135
+ this.#lastSeq = seq
136
+ } else if (this.#lost > 0) {
137
+ // A late packet fills a hole counted as lost. Holes are not tracked
138
+ // individually, so one whose window already closed cancels a hole in
139
+ // this window instead: a bias toward lower loss of at most the reorder
140
+ // depth per window.
141
+ this.#lost -= 1
142
+ }
143
+ if (this.#expected >= this.#windowPackets) this.#closeWindow()
144
+ }
145
+
146
+ /** ABR switch R -> R': close R's tracks, carry the layer count to R'. */
147
+ switchSource(next: RepairLayerSource): void {
148
+ if (this.#disposed) return
149
+ this.#closeAll()
150
+ this.#source = next
151
+ this.#lastSeq = undefined
152
+ this.#expected = 0
153
+ this.#lost = 0
154
+ this.#wantFewer = 0
155
+ this.#layerCount = Math.min(this.#layerCount, this.#layers().length)
156
+ this.#reconcile()
157
+ }
158
+
159
+ snapshot(): RepairLayerSubscriptionsSnapshot {
160
+ return {
161
+ delivery: this.#opts.delivery,
162
+ layerCount: this.#layerCount,
163
+ subscribedTracks: [...this.#subs.keys()],
164
+ repairPackets: this.#repairPackets,
165
+ repairBytes: this.#repairBytes,
166
+ staleDropped: this.#staleDropped,
167
+ lossRate: this.#lossRate,
168
+ }
169
+ }
170
+
171
+ dispose(): void {
172
+ if (this.#disposed) return
173
+ this.#closeAll()
174
+ this.#disposed = true
175
+ }
176
+
177
+ #layers(): readonly RepairLayerParams[] {
178
+ return this.#source.repairLayers ?? []
179
+ }
180
+
181
+ #closeWindow(): void {
182
+ const rate = this.#lost / this.#expected
183
+ this.#lossRate = rate
184
+ this.#expected = 0
185
+ this.#lost = 0
186
+ const target = this.#targetLayers(rate)
187
+ if (target > this.#layerCount) {
188
+ this.#wantFewer = 0
189
+ this.#layerCount = target
190
+ } else if (target < this.#layerCount) {
191
+ this.#wantFewer += 1
192
+ if (this.#wantFewer >= this.#dropAfterWindows) {
193
+ this.#wantFewer = 0
194
+ this.#layerCount -= 1
195
+ }
196
+ } else {
197
+ this.#wantFewer = 0
198
+ }
199
+ this.#reconcile()
200
+ }
201
+
202
+ /** Fewest base layers whose summed P covers margin x the measured loss of K. */
203
+ #targetLayers(rate: number): number {
204
+ if (rate <= 0) return 0
205
+ const needed = Math.ceil(rate * this.#source.fecK * this.#margin)
206
+ const layers = this.#layers()
207
+ let held = 0
208
+ for (let n = 0; n < layers.length; n++) {
209
+ held += layers[n]!.repairSymbols
210
+ if (held >= needed) return n + 1
211
+ }
212
+ return layers.length
213
+ }
214
+
215
+ /** Stream delivery never leaves layer count 0, so it never subscribes. */
216
+ #reconcile(): void {
217
+ const want = new Map<string, number>()
218
+ for (const layer of this.#layers().slice(0, this.#layerCount)) {
219
+ want.set(layer.repairTrackName, layer.priority)
220
+ }
221
+ const overlay = this.#source.repairOverlays?.[0]
222
+ if (this.#layerCount > 0 && overlay) want.set(overlay.repairTrackName, overlay.priority)
223
+
224
+ for (const [name, entry] of this.#subs) {
225
+ if (!want.has(name)) {
226
+ entry.sub.close()
227
+ this.#subs.delete(name)
228
+ }
229
+ }
230
+ for (const [name, priority] of want) {
231
+ if (this.#subs.has(name)) continue
232
+ const entry = {} as { sub: RepairTrackSubscription }
233
+ entry.sub = this.#opts.subscribe(name, priority, (packet) => this.#onPacket(name, entry, packet))
234
+ this.#subs.set(name, entry)
235
+ }
236
+ }
237
+
238
+ /** Fenced on the entry itself, so a closed subscription is stale even if its track is re-added. */
239
+ #onPacket(name: string, entry: { sub: RepairTrackSubscription }, packet: Uint8Array): void {
240
+ if (this.#disposed || this.#subs.get(name) !== entry) {
241
+ this.#staleDropped += 1
242
+ return
243
+ }
244
+ this.#repairPackets += 1
245
+ this.#repairBytes += packet.byteLength
246
+ this.#opts.feed(packet, name)
247
+ }
248
+
249
+ #closeAll(): void {
250
+ for (const entry of this.#subs.values()) entry.sub.close()
251
+ this.#subs.clear()
252
+ }
253
+ }