@blockcast/mmt-container 2.2.0-main.73af0eedcbc8 → 2.2.0-main.75b39a2cdddf

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 (93) hide show
  1. package/README.md +29 -17
  2. package/dist/browser-runtime.js +1001 -215
  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 +60 -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 +222 -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 +58 -10
  42. package/dist/mmtp-container-parser.d.ts.map +1 -1
  43. package/dist/mmtp-container-parser.js +105 -34
  44. package/dist/mmtp-container-parser.js.map +2 -2
  45. package/dist/mmtp-container-parser.no-modules.js +229 -51
  46. package/dist/mmtp-container-parser.no-modules.js.map +3 -3
  47. package/dist/mmtp-decode-pipeline.js +104 -34
  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 +18 -1
  54. package/dist/mmtp-router.d.ts.map +1 -1
  55. package/dist/mmtp-router.js +323 -64
  56. package/dist/mmtp-router.js.map +2 -2
  57. package/dist/multicast-auth-catalog.js.map +1 -1
  58. package/dist/multicast-input.js.map +1 -1
  59. package/dist/multicast-manager.js +547 -72
  60. package/dist/multicast-manager.js.map +2 -2
  61. package/dist/multicast-transport.js +28 -18
  62. package/dist/multicast-transport.js.map +2 -2
  63. package/dist/multicast-unwrap.js +12 -19
  64. package/dist/multicast-unwrap.js.map +2 -2
  65. package/dist/no-modules.js +222 -48
  66. package/dist/no-modules.js.map +3 -3
  67. package/dist/repair-fetch.js.map +1 -1
  68. package/dist/repair-layer-subscriptions.d.ts +82 -0
  69. package/dist/repair-layer-subscriptions.d.ts.map +1 -0
  70. package/dist/repair-layer-subscriptions.js +192 -0
  71. package/dist/repair-layer-subscriptions.js.map +7 -0
  72. package/dist/signaling.js.map +1 -1
  73. package/dist/timestamp-codec.js +12 -19
  74. package/dist/timestamp-codec.js.map +2 -2
  75. package/dist/track-session.d.ts +1 -1
  76. package/dist/track-session.js.map +1 -1
  77. package/dist/wasm-mfu-reassembler.js.map +1 -1
  78. package/dist/wasm-mmtp-container-parser.js +104 -34
  79. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  80. package/package.json +8 -5
  81. package/schemas/catalog.schema.json +4 -0
  82. package/src/catalog-fec-params.ts +36 -1
  83. package/src/cmcd-producer.ts +189 -179
  84. package/src/fec-manager.ts +277 -6
  85. package/src/fec-worker-setup.ts +2 -2
  86. package/src/index.ts +18 -2
  87. package/src/iso-block-locator.ts +19 -0
  88. package/src/iso-bmff-timing.ts +40 -1
  89. package/src/mmtp-container-parser.ts +125 -19
  90. package/src/mmtp-fec-session.ts +115 -15
  91. package/src/mmtp-router.ts +350 -13
  92. package/src/repair-layer-subscriptions.ts +261 -0
  93. package/src/track-session.ts +1 -1
@@ -248,7 +248,10 @@ export interface FecReleaseGate {
248
248
  export interface MmtpTimingBudgetStats {
249
249
  /** Slots currently charged to declared-but-undelivered samples. */
250
250
  pending: number
251
- /** Total slots available; `pending === capacity` rejects every new moof. */
251
+ /**
252
+ * Slots each stream's window holds (`maxBufferedMfus`). A stream at
253
+ * capacity displaces its own oldest timing; `pending` sums every stream.
254
+ */
252
255
  capacity: number
253
256
  /** Slots returned by samples the combiner abandoned (evicted or timed out). */
254
257
  reclaimed: number
@@ -259,7 +262,13 @@ export interface MmtpTimingBudgetStats {
259
262
  * up, `evicted` is loss that was never observable at all.
260
263
  */
261
264
  evicted: number
262
- /** Movie fragments refused because the budget had no room for their samples. */
265
+ /**
266
+ * Movie fragments refused because the budget had no room for their samples.
267
+ * Stays 0 with a window per stream (BLO-52085): a stream at capacity
268
+ * displaces its own oldest timing to make room, and a moof declaring more
269
+ * samples than a whole window holds is refused as invalid before any budget
270
+ * is consulted. Kept as a guard, and because it is public.
271
+ */
263
272
  rejectedMovieFragments: number
264
273
  }
265
274
 
@@ -365,10 +374,22 @@ export type MmtpFeedDropReason =
365
374
  /** No timing resolver, or it refused the initialization segment. */
366
375
  | 'init-timing-refused'
367
376
  | 'fragment-unexpected-fragmentation'
377
+ /** FT=1 before an FT=0 has bound the stream's timing track. */
368
378
  | 'fragment-no-resolver'
369
- /** addMovieFragment refused — includes the pending-timing-sample budget. */
379
+ /** addMovieFragment refused: the moof does not parse against the stream's
380
+ * track, conflicts with a pending copy of itself, or repeats a pending
381
+ * copy whose samples are partly consumed. Never the timing budget: a full
382
+ * window displaces the stream's own oldest timing. */
370
383
  | 'fragment-timing-refused'
371
384
  | 'unknown-fragment-type'
385
+ /** Stable code, no longer produced: an FT=2 with no timing (before the
386
+ * stream's first FT=0, or after setTrack dropped it) now waits for its
387
+ * timing like one whose FT=1 has not arrived. A head counts as
388
+ * `drop:mfu-sample-timing-missing` (parked) and a FI=2/FI=3 continuation
389
+ * as the `pending` record. A continuation that completes a head the
390
+ * reassembler buffered before the timing was dropped counts as
391
+ * `drop:assembled-timing-missing`. A panel keyed on this code reads zero:
392
+ * a re-attribution, not lost coverage. Re-key on both drop codes. */
372
393
  | 'mfu-no-resolver'
373
394
  | 'mfu-empty-payload'
374
395
  | 'mfu-du-header-missing'
@@ -679,7 +700,10 @@ class FrameSequencer {
679
700
  // ── FecReleaseSequencer ─────────────────────────────────────────────
680
701
 
681
702
  /** Authored decode-time adjacency: the next frame starts where this one ends. */
682
- function authoredAdjacent(before: FrameMeta, after: FrameMeta): boolean {
703
+ export function authoredAdjacent(
704
+ before: Pick<FrameMeta, 'authoredTiming'>,
705
+ after: Pick<FrameMeta, 'authoredTiming'>,
706
+ ): boolean {
683
707
  const a = before.authoredTiming
684
708
  const b = after.authoredTiming
685
709
  if (!a || !b || a.mediaTimescale !== b.mediaTimescale) return false
@@ -1089,9 +1113,30 @@ interface AwaitingTimingHead {
1089
1113
  mpuSequenceNumber: number
1090
1114
  movieFragmentSequenceNumber: number
1091
1115
  sampleNumber: number
1116
+ /** MMTP packet sequence number; a recovered copy carries its source's. */
1117
+ sequenceNumber: number
1092
1118
  arrivedAt: number
1093
1119
  }
1094
1120
 
1121
+ /** FEC-recovered heads that went through the awaiting-timing queue. Every
1122
+ * count but `pending` is cumulative. A held head is pending or has left the
1123
+ * queue exactly once, so `held - expired - discarded - superseded - pending`
1124
+ * is the number fed again once their FT=1 landed. That feed can still refuse
1125
+ * one (size, offset, dispose from a frame callback); the refusal is counted in
1126
+ * `feedStats.deferredOutcomes`, not here. */
1127
+ export interface MmtpRecoveredHeadTimingStats {
1128
+ /** Held because their sample's FT=1 had not arrived yet. */
1129
+ held: number
1130
+ /** Left without timing: the reassembler-timeout deadline, or pushed out by the queue's capacity. */
1131
+ expired: number
1132
+ /** Dropped with their stream (track reconfigure, reset or retire) or by dispose(). */
1133
+ discarded: number
1134
+ /** Gave way to their arriving source copy, which takes their place in the queue. */
1135
+ superseded: number
1136
+ /** In the queue now; current occupancy, not cumulative. */
1137
+ pending: number
1138
+ }
1139
+
1095
1140
  const MAX_AWAITING_TIMING_HEADS = 8
1096
1141
 
1097
1142
  // ── MmtpContainerParser ─────────────────────────────────────────────
@@ -1129,6 +1174,9 @@ export class MmtpContainerParser {
1129
1174
  #reclaimedTimingSamples = 0
1130
1175
  #evictedTimingSamples = 0
1131
1176
  #rejectedMovieFragments = 0
1177
+ #recoveredHeadsAwaitingTiming: Omit<MmtpRecoveredHeadTimingStats, 'pending'> = {
1178
+ held: 0, expired: 0, discarded: 0, superseded: 0,
1179
+ }
1132
1180
  #sequencers = new Map<string, { packetId: number; sequencer: FrameSequencer }>()
1133
1181
  #fecGate: FecReleaseGate | null = null
1134
1182
  #fecSequencer: FecReleaseSequencer | null = null
@@ -1330,11 +1378,12 @@ export class MmtpContainerParser {
1330
1378
  /**
1331
1379
  * Typed view of the ISO-BMFF timing-sample budget.
1332
1380
  *
1333
- * This is the acceptance signal for the budget ratchet: `pending` walking
1334
- * to `capacity` while `rejectedMovieFragments` climbs is the stream wedging,
1335
- * and it is observable here rather than only as media silently stopping.
1336
- * Per repo policy this is a producer-owned counter -- never infer budget
1337
- * state by parsing logs.
1381
+ * Each stream admits timing against its own window of `capacity` slots
1382
+ * (BLO-52085). A stream at capacity displaces its own oldest timing to
1383
+ * admit the moof in hand, and `evicted` counts the slots so displaced:
1384
+ * timing a stream declared and never delivered shows up there. `pending`
1385
+ * sums every stream, so it can exceed `capacity`. Per repo policy this is
1386
+ * a producer-owned counter -- never infer budget state by parsing logs.
1338
1387
  */
1339
1388
  get timingBudgetStats(): Readonly<MmtpTimingBudgetStats> {
1340
1389
  return {
@@ -1346,6 +1395,15 @@ export class MmtpContainerParser {
1346
1395
  }
1347
1396
  }
1348
1397
 
1398
+ /** Recovered heads parked for FT=1 timing (BLO-53369). Survives dispose(). */
1399
+ get recoveredHeadsAwaitingTiming(): Readonly<MmtpRecoveredHeadTimingStats> {
1400
+ let pending = 0
1401
+ for (const { awaitingTiming } of this.#timingStreams.values()) {
1402
+ for (const head of awaitingTiming) if (head.options?.recovered) pending++
1403
+ }
1404
+ return { ...this.#recoveredHeadsAwaitingTiming, pending }
1405
+ }
1406
+
1349
1407
  get reorderStats(): Readonly<MmtpReorderStats> {
1350
1408
  let bufferedFrames = this.#fecSequencer?.buffered ?? 0
1351
1409
  for (const { sequencer } of this.#sequencers.values()) bufferedFrames += sequencer.buffered
@@ -1541,7 +1599,7 @@ export class MmtpContainerParser {
1541
1599
  const now = Date.now()
1542
1600
  for (const { awaitingTiming } of this.#timingStreams.values()) {
1543
1601
  while (awaitingTiming.length > 0 && now - awaitingTiming[0]!.arrivedAt > timeoutMs) {
1544
- awaitingTiming.shift()
1602
+ this.#leaveAwaitingTiming(awaitingTiming.shift()!, 'expired')
1545
1603
  }
1546
1604
  }
1547
1605
  }
@@ -1711,17 +1769,27 @@ export class MmtpContainerParser {
1711
1769
  if (mpu.fragmentationIndicator !== 0 || mpu.fragmentCounter !== 0)
1712
1770
  return this.#drop('fragment-unexpected-fragmentation')
1713
1771
  const resolver = this.#timingResolver(streamKey, info.packetId, false)
1714
- if (!resolver) return this.#drop('fragment-no-resolver')
1772
+ if (!resolver?.initialized) return this.#drop('fragment-no-resolver')
1715
1773
  const pendingBefore = resolver.pendingSampleCount
1716
1774
  const evictedBefore = resolver.evictedSampleCount
1717
- const remaining = this.#maxPendingTimingSamples - this.#pendingTimingSamples
1718
- const admitted = resolver.addMovieFragment(mpu.mpuSequenceNumber, data, remaining)
1775
+ // Each stream's window is the whole budget. A resolver can displace only
1776
+ // its own timing, so a pool shared across streams let one stream's
1777
+ // unconsumed slots refuse another stream's moofs with nothing to
1778
+ // displace: BLO-52085 G7b, overlay-rebuilt video keyframes refusing audio.
1779
+ const remaining = this.#maxPendingTimingSamples - resolver.pendingSampleCount
1780
+ const admitted = resolver.addMovieFragment(
1781
+ mpu.mpuSequenceNumber,
1782
+ data,
1783
+ remaining,
1784
+ options?.recovered === true,
1785
+ )
1719
1786
  // Sync on BOTH paths: the resolver can displace stale timing and still
1720
- // refuse the fragment in hand, and a global count that missed those
1721
- // freed slots would keep refusing on a budget that is no longer full.
1787
+ // refuse the fragment in hand, and the total must count those freed slots.
1722
1788
  this.#pendingTimingSamples += resolver.pendingSampleCount - pendingBefore
1723
1789
  this.#evictedTimingSamples += resolver.evictedSampleCount - evictedBefore
1724
1790
  if (!admitted) {
1791
+ // Never 'budget' with a window per stream (see
1792
+ // `rejectedMovieFragments`); kept as a guard.
1725
1793
  if (resolver.lastMovieFragmentRejection === 'budget') {
1726
1794
  this.#rejectedMovieFragments++
1727
1795
  }
@@ -1739,7 +1807,11 @@ export class MmtpContainerParser {
1739
1807
  }
1740
1808
  if (fragmentType !== 2) return this.#drop('unknown-fragment-type')
1741
1809
 
1742
- const resolver = this.#timingResolver(streamKey, info.packetId, false)
1810
+ // An FT=2 before the stream's first FT=0 (a join, a track reset) is taken
1811
+ // like one whose FT=1 has not arrived: a head waits for its timing, a
1812
+ // continuation parks in the reassembler, and a later or FEC-recovered
1813
+ // FT=0/FT=1 can still time them.
1814
+ const resolver = this.#timingResolver(streamKey, info.packetId, true)
1743
1815
  if (!resolver) return this.#drop('mfu-no-resolver')
1744
1816
  if (!Number.isSafeInteger(data.byteLength) || data.byteLength <= 0)
1745
1817
  return this.#drop('mfu-empty-payload')
@@ -1761,6 +1833,7 @@ export class MmtpContainerParser {
1761
1833
  mpuSequenceNumber: mpu.mpuSequenceNumber,
1762
1834
  movieFragmentSequenceNumber: mfuDuHeader.movieFragmentSequenceNumber,
1763
1835
  sampleNumber: mfuDuHeader.sampleNumber,
1836
+ sequenceNumber: info.sequenceNumber,
1764
1837
  arrivedAt: Date.now(),
1765
1838
  })
1766
1839
  return this.#drop('mfu-sample-timing-missing')
@@ -2146,6 +2219,18 @@ export class MmtpContainerParser {
2146
2219
  this.#emitFrame(data, meta)
2147
2220
  }
2148
2221
 
2222
+ /** Whether FT=1 metadata already names this sample, without claiming it. */
2223
+ hasAuthoredTimingFor(
2224
+ packetId: number,
2225
+ mpuSequenceNumber: number,
2226
+ movieFragmentSequenceNumber: number,
2227
+ sampleNumber: number,
2228
+ catalogTrackName?: string,
2229
+ ): boolean {
2230
+ return this.#timingResolver(this.#streamKey(packetId, catalogTrackName), packetId, false)
2231
+ ?.sampleTiming(mpuSequenceNumber, movieFragmentSequenceNumber, sampleNumber) != null
2232
+ }
2233
+
2149
2234
  /**
2150
2235
  * Claim and return the authored timing for a recovered sample. Recovery does
2151
2236
  * not pass through the ordinary `feed()` completion path, so it must retire
@@ -2216,6 +2301,9 @@ export class MmtpContainerParser {
2216
2301
  for (const { sequencer } of this.#sequencers.values()) sequencer.discard()
2217
2302
  this.#sequencers.clear()
2218
2303
  this.#fecSequencer?.discard()
2304
+ for (const { awaitingTiming } of this.#timingStreams.values()) {
2305
+ for (const head of awaitingTiming) this.#leaveAwaitingTiming(head, 'discarded')
2306
+ }
2219
2307
  this.#timingStreams.clear()
2220
2308
  this.#pendingTimingSamples = 0
2221
2309
  this.#reliableMoqStartupGate.clear()
@@ -2241,10 +2329,27 @@ export class MmtpContainerParser {
2241
2329
  }
2242
2330
 
2243
2331
  #awaitTiming(streamKey: string, head: AwaitingTimingHead): void {
2244
- const awaitingTiming = this.#timingStreams.get(streamKey)?.awaitingTiming
2245
- if (!awaitingTiming) return
2332
+ const state = this.#timingStreams.get(streamKey)
2333
+ if (!state) return
2334
+ // The source copy of a held recovered head takes its place, so the frame
2335
+ // is delivered with source provenance once FT=1 lands.
2336
+ if (!head.options?.recovered) {
2337
+ const kept = state.awaitingTiming.filter(queued =>
2338
+ !queued.options?.recovered || queued.sequenceNumber !== head.sequenceNumber)
2339
+ this.#recoveredHeadsAwaitingTiming.superseded += state.awaitingTiming.length - kept.length
2340
+ state.awaitingTiming = kept
2341
+ }
2342
+ const awaitingTiming = state.awaitingTiming
2246
2343
  awaitingTiming.push(head)
2247
- if (awaitingTiming.length > MAX_AWAITING_TIMING_HEADS) awaitingTiming.shift()
2344
+ if (head.options?.recovered) this.#recoveredHeadsAwaitingTiming.held++
2345
+ if (awaitingTiming.length > MAX_AWAITING_TIMING_HEADS) {
2346
+ this.#leaveAwaitingTiming(awaitingTiming.shift()!, 'expired')
2347
+ }
2348
+ }
2349
+
2350
+ /** Count a recovered head leaving the queue other than by replay or its source copy. */
2351
+ #leaveAwaitingTiming(head: AwaitingTimingHead, outcome: 'expired' | 'discarded'): void {
2352
+ if (head.options?.recovered) this.#recoveredHeadsAwaitingTiming[outcome]++
2248
2353
  }
2249
2354
 
2250
2355
  /**
@@ -2274,6 +2379,7 @@ export class MmtpContainerParser {
2274
2379
  const state = this.#timingStreams.get(streamKey)
2275
2380
  if (!state) return
2276
2381
  this.#timingStreams.delete(streamKey)
2382
+ for (const head of state.awaitingTiming) this.#leaveAwaitingTiming(head, 'discarded')
2277
2383
  this.#pendingTimingSamples -= state.resolver.pendingSampleCount
2278
2384
  if (this.#pendingTimingSamples < 0) {
2279
2385
  throw new Error('[MmtpContainerParser] pending timing sample accounting underflow')
@@ -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)})`)