@blockcast/mmt-container 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (799) hide show
  1. package/README.md +524 -0
  2. package/dist/.build-stamp +0 -0
  3. package/dist/__tests__/test-utils.d.ts +71 -0
  4. package/dist/__tests__/test-utils.d.ts.map +1 -0
  5. package/dist/align.d.ts +152 -0
  6. package/dist/align.d.ts.map +1 -0
  7. package/dist/align.js +88 -0
  8. package/dist/align.js.map +7 -0
  9. package/dist/audio-init-parser.d.ts +66 -0
  10. package/dist/audio-init-parser.d.ts.map +1 -0
  11. package/dist/audio-init-parser.js +118 -0
  12. package/dist/audio-init-parser.js.map +7 -0
  13. package/dist/audio-ring-buffer.d.ts +50 -0
  14. package/dist/audio-ring-buffer.d.ts.map +1 -0
  15. package/dist/audio-ring-buffer.js +80 -0
  16. package/dist/audio-ring-buffer.js.map +7 -0
  17. package/dist/authored-timing.d.ts +32 -0
  18. package/dist/authored-timing.d.ts.map +1 -0
  19. package/dist/authored-timing.js +32 -0
  20. package/dist/authored-timing.js.map +7 -0
  21. package/dist/block-policy.d.ts +314 -0
  22. package/dist/block-policy.d.ts.map +1 -0
  23. package/dist/block-policy.js +466 -0
  24. package/dist/block-policy.js.map +7 -0
  25. package/dist/browser-runtime.js +26549 -0
  26. package/dist/browser-runtime.js.map +7 -0
  27. package/dist/carp-catalog.d.ts +429 -0
  28. package/dist/carp-catalog.d.ts.map +1 -0
  29. package/dist/carp-catalog.js +985 -0
  30. package/dist/carp-catalog.js.map +7 -0
  31. package/dist/catalog-fec-params.d.ts +275 -0
  32. package/dist/catalog-fec-params.d.ts.map +1 -0
  33. package/dist/catalog-fec-params.js +702 -0
  34. package/dist/catalog-fec-params.js.map +7 -0
  35. package/dist/catalog-network-source.d.ts +42 -0
  36. package/dist/catalog-network-source.d.ts.map +1 -0
  37. package/dist/catalog-network-source.js +47 -0
  38. package/dist/catalog-network-source.js.map +7 -0
  39. package/dist/catalog-validator.d.ts +18 -0
  40. package/dist/catalog-validator.d.ts.map +1 -0
  41. package/dist/catalog-validator.js +7747 -0
  42. package/dist/catalog-validator.js.map +7 -0
  43. package/dist/cmaf-block-assembler.d.ts +285 -0
  44. package/dist/cmaf-block-assembler.d.ts.map +1 -0
  45. package/dist/cmaf-block-assembler.js +1966 -0
  46. package/dist/cmaf-block-assembler.js.map +7 -0
  47. package/dist/cmaf-group-batcher.d.ts +38 -0
  48. package/dist/cmaf-group-batcher.d.ts.map +1 -0
  49. package/dist/cmaf-group-batcher.js +176 -0
  50. package/dist/cmaf-group-batcher.js.map +7 -0
  51. package/dist/cmcd-producer.d.ts +104 -0
  52. package/dist/cmcd-producer.d.ts.map +1 -0
  53. package/dist/cmcd-producer.js +183 -0
  54. package/dist/cmcd-producer.js.map +7 -0
  55. package/dist/codec-utils.d.ts +384 -0
  56. package/dist/codec-utils.d.ts.map +1 -0
  57. package/dist/codec-utils.js +1546 -0
  58. package/dist/codec-utils.js.map +7 -0
  59. package/dist/conceal-timeline.d.ts +146 -0
  60. package/dist/conceal-timeline.d.ts.map +1 -0
  61. package/dist/conceal-timeline.js +137 -0
  62. package/dist/conceal-timeline.js.map +7 -0
  63. package/dist/delivery-gap.d.ts +75 -0
  64. package/dist/delivery-gap.d.ts.map +1 -0
  65. package/dist/delivery-gap.js +88 -0
  66. package/dist/delivery-gap.js.map +7 -0
  67. package/dist/duplicate-ledger.d.ts +197 -0
  68. package/dist/duplicate-ledger.d.ts.map +1 -0
  69. package/dist/duplicate-ledger.js +226 -0
  70. package/dist/duplicate-ledger.js.map +7 -0
  71. package/dist/fec-codec.d.ts +32 -0
  72. package/dist/fec-codec.d.ts.map +1 -0
  73. package/dist/fec-codec.js +47 -0
  74. package/dist/fec-codec.js.map +7 -0
  75. package/dist/fec-manager.d.ts +583 -0
  76. package/dist/fec-manager.d.ts.map +1 -0
  77. package/dist/fec-manager.js +3899 -0
  78. package/dist/fec-manager.js.map +7 -0
  79. package/dist/fec-starvation-detector.d.ts +59 -0
  80. package/dist/fec-starvation-detector.d.ts.map +1 -0
  81. package/dist/fec-starvation-detector.js +60 -0
  82. package/dist/fec-starvation-detector.js.map +7 -0
  83. package/dist/fec-state.d.ts +131 -0
  84. package/dist/fec-state.d.ts.map +1 -0
  85. package/dist/fec-state.js +87 -0
  86. package/dist/fec-state.js.map +7 -0
  87. package/dist/fec-worker-setup.d.ts +94 -0
  88. package/dist/fec-worker-setup.d.ts.map +1 -0
  89. package/dist/fec-worker-setup.js +673 -0
  90. package/dist/fec-worker-setup.js.map +7 -0
  91. package/dist/fmp4-completion.d.ts +30 -0
  92. package/dist/fmp4-completion.d.ts.map +1 -0
  93. package/dist/fmp4-completion.js +21 -0
  94. package/dist/fmp4-completion.js.map +7 -0
  95. package/dist/fmp4-reader.d.ts +37 -0
  96. package/dist/fmp4-reader.d.ts.map +1 -0
  97. package/dist/fmp4-reader.js +100 -0
  98. package/dist/fmp4-reader.js.map +7 -0
  99. package/dist/index.d.ts +44 -3
  100. package/dist/index.d.ts.map +1 -1
  101. package/dist/index.js +460 -14
  102. package/dist/index.js.map +7 -1
  103. package/dist/iso-block-locator.d.ts +118 -0
  104. package/dist/iso-block-locator.d.ts.map +1 -0
  105. package/dist/iso-block-locator.js +289 -0
  106. package/dist/iso-block-locator.js.map +7 -0
  107. package/dist/iso-bmff-timing.d.ts +78 -0
  108. package/dist/iso-bmff-timing.d.ts.map +1 -0
  109. package/dist/iso-bmff-timing.js +593 -0
  110. package/dist/iso-bmff-timing.js.map +7 -0
  111. package/dist/loc.d.ts +70 -75
  112. package/dist/loc.d.ts.map +1 -1
  113. package/dist/loc.js +106 -192
  114. package/dist/loc.js.map +7 -1
  115. package/dist/manifest-auth-plan.d.ts +30 -0
  116. package/dist/manifest-auth-plan.d.ts.map +1 -0
  117. package/dist/manifest-auth-plan.js +1266 -0
  118. package/dist/manifest-auth-plan.js.map +7 -0
  119. package/dist/media-router.d.ts +262 -0
  120. package/dist/media-router.d.ts.map +1 -0
  121. package/dist/media-router.js +584 -0
  122. package/dist/media-router.js.map +7 -0
  123. package/dist/mfu-reassembler.d.ts +313 -9
  124. package/dist/mfu-reassembler.d.ts.map +1 -1
  125. package/dist/mfu-reassembler.js +3051 -285
  126. package/dist/mfu-reassembler.js.map +7 -1
  127. package/dist/mfu-reassembly-policy.d.ts +24 -0
  128. package/dist/mfu-reassembly-policy.d.ts.map +1 -0
  129. package/dist/mfu-reassembly-policy.js +99 -0
  130. package/dist/mfu-reassembly-policy.js.map +7 -0
  131. package/dist/mmtp-container-parser.d.ts +568 -0
  132. package/dist/mmtp-container-parser.d.ts.map +1 -0
  133. package/dist/mmtp-container-parser.js +6580 -0
  134. package/dist/mmtp-container-parser.js.map +7 -0
  135. package/dist/mmtp-container-parser.no-modules.js +9616 -0
  136. package/dist/mmtp-container-parser.no-modules.js.map +7 -0
  137. package/dist/mmtp-decode-pipeline.d.ts +138 -0
  138. package/dist/mmtp-decode-pipeline.d.ts.map +1 -0
  139. package/dist/mmtp-decode-pipeline.js +6814 -0
  140. package/dist/mmtp-decode-pipeline.js.map +7 -0
  141. package/dist/mmtp-router.d.ts +679 -0
  142. package/dist/mmtp-router.d.ts.map +1 -0
  143. package/dist/mmtp-router.js +11278 -0
  144. package/dist/mmtp-router.js.map +7 -0
  145. package/dist/mmtp-to-cmaf.d.ts +187 -0
  146. package/dist/mmtp-to-cmaf.d.ts.map +1 -0
  147. package/dist/mmtp-to-cmaf.js +716 -0
  148. package/dist/mmtp-to-cmaf.js.map +7 -0
  149. package/dist/multicast-auth-catalog.d.ts +115 -0
  150. package/dist/multicast-auth-catalog.d.ts.map +1 -0
  151. package/dist/multicast-auth-catalog.js +202 -0
  152. package/dist/multicast-auth-catalog.js.map +7 -0
  153. package/dist/multicast-input.d.ts +141 -0
  154. package/dist/multicast-input.d.ts.map +1 -0
  155. package/dist/multicast-input.js +390 -0
  156. package/dist/multicast-input.js.map +7 -0
  157. package/dist/multicast-manager.d.ts +669 -0
  158. package/dist/multicast-manager.d.ts.map +1 -0
  159. package/dist/multicast-manager.js +19101 -0
  160. package/dist/multicast-manager.js.map +7 -0
  161. package/dist/multicast-transport.d.ts +271 -0
  162. package/dist/multicast-transport.d.ts.map +1 -0
  163. package/dist/multicast-transport.js +2264 -0
  164. package/dist/multicast-transport.js.map +7 -0
  165. package/dist/multicast-unwrap.d.ts +57 -0
  166. package/dist/multicast-unwrap.d.ts.map +1 -0
  167. package/dist/multicast-unwrap.js +418 -0
  168. package/dist/multicast-unwrap.js.map +7 -0
  169. package/dist/no-modules.d.ts +10 -0
  170. package/dist/no-modules.d.ts.map +1 -0
  171. package/dist/no-modules.js +9491 -0
  172. package/dist/no-modules.js.map +7 -0
  173. package/dist/phase14-catalog-override.d.ts +126 -0
  174. package/dist/phase14-catalog-override.d.ts.map +1 -0
  175. package/dist/phase14-catalog-override.js +155 -0
  176. package/dist/phase14-catalog-override.js.map +7 -0
  177. package/dist/repair-fetch.d.ts +270 -0
  178. package/dist/repair-fetch.d.ts.map +1 -0
  179. package/dist/repair-fetch.js +381 -0
  180. package/dist/repair-fetch.js.map +7 -0
  181. package/dist/repair-layers.d.ts +158 -0
  182. package/dist/repair-layers.d.ts.map +1 -0
  183. package/dist/repair-layers.js +344 -0
  184. package/dist/repair-layers.js.map +7 -0
  185. package/dist/sample-combiner.d.ts +212 -0
  186. package/dist/sample-combiner.d.ts.map +1 -0
  187. package/dist/sample-combiner.js +595 -0
  188. package/dist/sample-combiner.js.map +7 -0
  189. package/dist/signaling.d.ts +29 -0
  190. package/dist/signaling.d.ts.map +1 -0
  191. package/dist/signaling.js +572 -0
  192. package/dist/signaling.js.map +7 -0
  193. package/dist/source-auth.d.ts +2 -0
  194. package/dist/source-auth.d.ts.map +1 -0
  195. package/dist/source-auth.js +1209 -0
  196. package/dist/source-auth.js.map +7 -0
  197. package/dist/switching-set-alignment.d.ts +61 -0
  198. package/dist/switching-set-alignment.d.ts.map +1 -0
  199. package/dist/switching-set-alignment.js +70 -0
  200. package/dist/switching-set-alignment.js.map +7 -0
  201. package/dist/timestamp-codec.d.ts +34 -0
  202. package/dist/timestamp-codec.d.ts.map +1 -0
  203. package/dist/timestamp-codec.js +473 -0
  204. package/dist/timestamp-codec.js.map +7 -0
  205. package/dist/track-pipeline-setup.d.ts +83 -0
  206. package/dist/track-pipeline-setup.d.ts.map +1 -0
  207. package/dist/track-pipeline-setup.js +291 -0
  208. package/dist/track-pipeline-setup.js.map +7 -0
  209. package/dist/track-pipeline.d.ts +121 -0
  210. package/dist/track-pipeline.d.ts.map +1 -0
  211. package/dist/track-pipeline.js +209 -0
  212. package/dist/track-pipeline.js.map +7 -0
  213. package/dist/track-session.d.ts +414 -0
  214. package/dist/track-session.d.ts.map +1 -0
  215. package/dist/track-session.js +754 -0
  216. package/dist/track-session.js.map +7 -0
  217. package/dist/transport-pool.d.ts +69 -0
  218. package/dist/transport-pool.d.ts.map +1 -0
  219. package/dist/transport-pool.js +74 -0
  220. package/dist/transport-pool.js.map +7 -0
  221. package/dist/wasm-mfu-reassembler.d.ts +97 -0
  222. package/dist/wasm-mfu-reassembler.d.ts.map +1 -0
  223. package/dist/wasm-mfu-reassembler.js +2133 -0
  224. package/dist/wasm-mfu-reassembler.js.map +7 -0
  225. package/dist/wasm-mmtp-container-parser.d.ts +10 -0
  226. package/dist/wasm-mmtp-container-parser.d.ts.map +1 -0
  227. package/dist/wasm-mmtp-container-parser.js +6834 -0
  228. package/dist/wasm-mmtp-container-parser.js.map +7 -0
  229. package/node_modules/@blockcast/fec-worker/README.md +223 -0
  230. package/node_modules/@blockcast/fec-worker/dist/.build-stamp +0 -0
  231. package/node_modules/@blockcast/fec-worker/dist/block-order.d.ts +38 -0
  232. package/node_modules/@blockcast/fec-worker/dist/block-order.d.ts.map +1 -0
  233. package/node_modules/@blockcast/fec-worker/dist/block-order.js +78 -0
  234. package/node_modules/@blockcast/fec-worker/dist/block-order.js.map +1 -0
  235. package/node_modules/@blockcast/fec-worker/dist/fec-worker-client.d.ts +93 -0
  236. package/node_modules/@blockcast/fec-worker/dist/fec-worker-client.d.ts.map +1 -0
  237. package/node_modules/@blockcast/fec-worker/dist/fec-worker-client.js +346 -0
  238. package/node_modules/@blockcast/fec-worker/dist/fec-worker-client.js.map +1 -0
  239. package/node_modules/@blockcast/fec-worker/dist/fec-worker-types.d.ts +380 -0
  240. package/node_modules/@blockcast/fec-worker/dist/fec-worker-types.d.ts.map +1 -0
  241. package/node_modules/@blockcast/fec-worker/dist/fec-worker-types.js +11 -0
  242. package/node_modules/@blockcast/fec-worker/dist/fec-worker-types.js.map +1 -0
  243. package/node_modules/@blockcast/fec-worker/dist/fec-worker.d.ts +14 -0
  244. package/node_modules/@blockcast/fec-worker/dist/fec-worker.d.ts.map +1 -0
  245. package/node_modules/@blockcast/fec-worker/dist/fec-worker.js +859 -0
  246. package/node_modules/@blockcast/fec-worker/dist/fec-worker.js.map +7 -0
  247. package/node_modules/@blockcast/fec-worker/dist/index.d.ts +10 -0
  248. package/node_modules/@blockcast/fec-worker/dist/index.d.ts.map +1 -0
  249. package/node_modules/@blockcast/fec-worker/dist/index.js +8 -0
  250. package/node_modules/@blockcast/fec-worker/dist/index.js.map +1 -0
  251. package/node_modules/@blockcast/fec-worker/dist/shred-fec-worker.d.ts +20 -0
  252. package/node_modules/@blockcast/fec-worker/dist/shred-fec-worker.d.ts.map +1 -0
  253. package/node_modules/@blockcast/fec-worker/dist/shred-fec-worker.js +370 -0
  254. package/node_modules/@blockcast/fec-worker/dist/shred-fec-worker.js.map +7 -0
  255. package/node_modules/@blockcast/fec-worker/dist/shred-worker-client.d.ts +104 -0
  256. package/node_modules/@blockcast/fec-worker/dist/shred-worker-client.d.ts.map +1 -0
  257. package/node_modules/@blockcast/fec-worker/dist/shred-worker-client.js +181 -0
  258. package/node_modules/@blockcast/fec-worker/dist/shred-worker-client.js.map +1 -0
  259. package/node_modules/@blockcast/fec-worker/dist/shred-worker-types.d.ts +194 -0
  260. package/node_modules/@blockcast/fec-worker/dist/shred-worker-types.d.ts.map +1 -0
  261. package/node_modules/@blockcast/fec-worker/dist/shred-worker-types.js +62 -0
  262. package/node_modules/@blockcast/fec-worker/dist/shred-worker-types.js.map +1 -0
  263. package/node_modules/@blockcast/fec-worker/dist/transfer.d.ts +9 -0
  264. package/node_modules/@blockcast/fec-worker/dist/transfer.d.ts.map +1 -0
  265. package/node_modules/@blockcast/fec-worker/dist/transfer.js +13 -0
  266. package/node_modules/@blockcast/fec-worker/dist/transfer.js.map +1 -0
  267. package/node_modules/@blockcast/fec-worker/package.json +59 -0
  268. package/node_modules/@blockcast/fec-worker/src/block-order.ts +93 -0
  269. package/node_modules/@blockcast/fec-worker/src/fec-worker-client.ts +392 -0
  270. package/node_modules/@blockcast/fec-worker/src/fec-worker-types.ts +399 -0
  271. package/node_modules/@blockcast/fec-worker/src/fec-worker.ts +1502 -0
  272. package/node_modules/@blockcast/fec-worker/src/index.ts +35 -0
  273. package/node_modules/@blockcast/fec-worker/src/shred-fec-worker.ts +589 -0
  274. package/node_modules/@blockcast/fec-worker/src/shred-worker-client.ts +222 -0
  275. package/node_modules/@blockcast/fec-worker/src/shred-worker-types.ts +209 -0
  276. package/node_modules/@blockcast/fec-worker/src/transfer.ts +12 -0
  277. package/node_modules/@blockcast/mmt-alta-parse/README.md +181 -0
  278. package/node_modules/@blockcast/mmt-alta-parse/dist/.build-stamp +0 -0
  279. package/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts +57 -0
  280. package/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts.map +1 -0
  281. package/node_modules/@blockcast/mmt-alta-parse/dist/index.js +115 -0
  282. package/node_modules/@blockcast/mmt-alta-parse/dist/index.js.map +1 -0
  283. package/node_modules/@blockcast/mmt-alta-parse/package.json +50 -0
  284. package/node_modules/@blockcast/mmt-alta-parse/src/index.ts +136 -0
  285. package/node_modules/@blockcast/mmt-base/README.md +134 -0
  286. package/node_modules/@blockcast/mmt-base/dist/.build-stamp +0 -0
  287. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts +55 -0
  288. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts.map +1 -0
  289. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js +128 -0
  290. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js.map +1 -0
  291. package/node_modules/@blockcast/mmt-base/dist/index.d.ts +7 -0
  292. package/node_modules/@blockcast/mmt-base/dist/index.d.ts.map +1 -0
  293. package/node_modules/@blockcast/mmt-base/dist/index.js +7 -0
  294. package/node_modules/@blockcast/mmt-base/dist/index.js.map +1 -0
  295. package/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts +292 -0
  296. package/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts.map +1 -0
  297. package/node_modules/@blockcast/mmt-base/dist/mmtp.js +612 -0
  298. package/node_modules/@blockcast/mmt-base/dist/mmtp.js.map +1 -0
  299. package/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts +18 -0
  300. package/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts.map +1 -0
  301. package/node_modules/@blockcast/mmt-base/dist/net-constants.js +18 -0
  302. package/node_modules/@blockcast/mmt-base/dist/net-constants.js.map +1 -0
  303. package/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts +47 -0
  304. package/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts.map +1 -0
  305. package/node_modules/@blockcast/mmt-base/dist/ntp-short.js +54 -0
  306. package/node_modules/@blockcast/mmt-base/dist/ntp-short.js.map +1 -0
  307. package/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts +57 -0
  308. package/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts.map +1 -0
  309. package/node_modules/@blockcast/mmt-base/dist/serial-number.js +98 -0
  310. package/node_modules/@blockcast/mmt-base/dist/serial-number.js.map +1 -0
  311. package/node_modules/@blockcast/mmt-base/dist/signalling.d.ts +312 -0
  312. package/node_modules/@blockcast/mmt-base/dist/signalling.d.ts.map +1 -0
  313. package/node_modules/@blockcast/mmt-base/dist/signalling.js +637 -0
  314. package/node_modules/@blockcast/mmt-base/dist/signalling.js.map +1 -0
  315. package/node_modules/@blockcast/mmt-base/package.json +49 -0
  316. package/node_modules/@blockcast/mmt-base/src/fec-block-profile.ts +145 -0
  317. package/node_modules/@blockcast/mmt-base/src/index.ts +6 -0
  318. package/node_modules/@blockcast/mmt-base/src/mmtp.ts +892 -0
  319. package/node_modules/@blockcast/mmt-base/src/net-constants.ts +17 -0
  320. package/node_modules/@blockcast/mmt-base/src/ntp-short.ts +67 -0
  321. package/node_modules/@blockcast/mmt-base/src/serial-number.ts +107 -0
  322. package/node_modules/@blockcast/mmt-base/src/signalling.ts +862 -0
  323. package/node_modules/@blockcast/mmt-fec/README.md +271 -0
  324. package/node_modules/@blockcast/mmt-fec/dist/.build-stamp +0 -0
  325. package/node_modules/@blockcast/mmt-fec/dist/block-manager.d.ts +82 -0
  326. package/node_modules/@blockcast/mmt-fec/dist/block-manager.d.ts.map +1 -0
  327. package/node_modules/@blockcast/mmt-fec/dist/block-manager.js +296 -0
  328. package/node_modules/@blockcast/mmt-fec/dist/block-manager.js.map +1 -0
  329. package/node_modules/@blockcast/mmt-fec/dist/block-processor.d.ts +106 -0
  330. package/node_modules/@blockcast/mmt-fec/dist/block-processor.d.ts.map +1 -0
  331. package/node_modules/@blockcast/mmt-fec/dist/block-processor.js +288 -0
  332. package/node_modules/@blockcast/mmt-fec/dist/block-processor.js.map +1 -0
  333. package/node_modules/@blockcast/mmt-fec/dist/cmaf-recovery.d.ts +46 -0
  334. package/node_modules/@blockcast/mmt-fec/dist/cmaf-recovery.d.ts.map +1 -0
  335. package/node_modules/@blockcast/mmt-fec/dist/cmaf-recovery.js +96 -0
  336. package/node_modules/@blockcast/mmt-fec/dist/cmaf-recovery.js.map +1 -0
  337. package/node_modules/@blockcast/mmt-fec/dist/cmaf-symbols.d.ts +33 -0
  338. package/node_modules/@blockcast/mmt-fec/dist/cmaf-symbols.d.ts.map +1 -0
  339. package/node_modules/@blockcast/mmt-fec/dist/cmaf-symbols.js +57 -0
  340. package/node_modules/@blockcast/mmt-fec/dist/cmaf-symbols.js.map +1 -0
  341. package/node_modules/@blockcast/mmt-fec/dist/fec-block-mapper.d.ts +205 -0
  342. package/node_modules/@blockcast/mmt-fec/dist/fec-block-mapper.d.ts.map +1 -0
  343. package/node_modules/@blockcast/mmt-fec/dist/fec-block-mapper.js +240 -0
  344. package/node_modules/@blockcast/mmt-fec/dist/fec-block-mapper.js.map +1 -0
  345. package/node_modules/@blockcast/mmt-fec/dist/index.d.ts +28 -0
  346. package/node_modules/@blockcast/mmt-fec/dist/index.d.ts.map +1 -0
  347. package/node_modules/@blockcast/mmt-fec/dist/index.js +28 -0
  348. package/node_modules/@blockcast/mmt-fec/dist/index.js.map +1 -0
  349. package/node_modules/@blockcast/mmt-fec/dist/loc-symbols.d.ts +20 -0
  350. package/node_modules/@blockcast/mmt-fec/dist/loc-symbols.d.ts.map +1 -0
  351. package/node_modules/@blockcast/mmt-fec/dist/loc-symbols.js +28 -0
  352. package/node_modules/@blockcast/mmt-fec/dist/loc-symbols.js.map +1 -0
  353. package/node_modules/@blockcast/mmt-fec/dist/oti.d.ts +258 -0
  354. package/node_modules/@blockcast/mmt-fec/dist/oti.d.ts.map +1 -0
  355. package/node_modules/@blockcast/mmt-fec/dist/oti.js +382 -0
  356. package/node_modules/@blockcast/mmt-fec/dist/oti.js.map +1 -0
  357. package/node_modules/@blockcast/mmt-fec/dist/reorder-buffer.d.ts +41 -0
  358. package/node_modules/@blockcast/mmt-fec/dist/reorder-buffer.d.ts.map +1 -0
  359. package/node_modules/@blockcast/mmt-fec/dist/reorder-buffer.js +81 -0
  360. package/node_modules/@blockcast/mmt-fec/dist/reorder-buffer.js.map +1 -0
  361. package/node_modules/@blockcast/mmt-fec/dist/repair-group.d.ts +82 -0
  362. package/node_modules/@blockcast/mmt-fec/dist/repair-group.d.ts.map +1 -0
  363. package/node_modules/@blockcast/mmt-fec/dist/repair-group.js +170 -0
  364. package/node_modules/@blockcast/mmt-fec/dist/repair-group.js.map +1 -0
  365. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/README.md +181 -0
  366. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/dist/.build-stamp +0 -0
  367. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts +57 -0
  368. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts.map +1 -0
  369. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/dist/index.js +115 -0
  370. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/dist/index.js.map +1 -0
  371. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/package.json +50 -0
  372. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/src/index.ts +136 -0
  373. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/README.md +134 -0
  374. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/.build-stamp +0 -0
  375. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts +55 -0
  376. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts.map +1 -0
  377. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js +128 -0
  378. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js.map +1 -0
  379. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/index.d.ts +7 -0
  380. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/index.d.ts.map +1 -0
  381. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/index.js +7 -0
  382. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/index.js.map +1 -0
  383. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts +292 -0
  384. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts.map +1 -0
  385. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/mmtp.js +612 -0
  386. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/mmtp.js.map +1 -0
  387. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts +18 -0
  388. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts.map +1 -0
  389. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/net-constants.js +18 -0
  390. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/net-constants.js.map +1 -0
  391. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts +47 -0
  392. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts.map +1 -0
  393. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/ntp-short.js +54 -0
  394. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/ntp-short.js.map +1 -0
  395. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts +57 -0
  396. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts.map +1 -0
  397. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/serial-number.js +98 -0
  398. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/serial-number.js.map +1 -0
  399. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/signalling.d.ts +312 -0
  400. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/signalling.d.ts.map +1 -0
  401. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/signalling.js +637 -0
  402. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/signalling.js.map +1 -0
  403. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/package.json +49 -0
  404. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/fec-block-profile.ts +145 -0
  405. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/index.ts +6 -0
  406. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/mmtp.ts +892 -0
  407. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/net-constants.ts +17 -0
  408. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/ntp-short.ts +67 -0
  409. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/serial-number.ts +107 -0
  410. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/signalling.ts +862 -0
  411. package/node_modules/@blockcast/mmt-fec/package.json +79 -0
  412. package/node_modules/@blockcast/mmt-fec/src/block-manager.ts +371 -0
  413. package/node_modules/@blockcast/mmt-fec/src/block-processor.ts +439 -0
  414. package/node_modules/@blockcast/mmt-fec/src/cmaf-recovery.ts +127 -0
  415. package/node_modules/@blockcast/mmt-fec/src/cmaf-symbols.ts +68 -0
  416. package/node_modules/@blockcast/mmt-fec/src/fec-block-mapper.ts +310 -0
  417. package/node_modules/@blockcast/mmt-fec/src/index.ts +94 -0
  418. package/node_modules/@blockcast/mmt-fec/src/loc-symbols.ts +34 -0
  419. package/node_modules/@blockcast/mmt-fec/src/oti.ts +463 -0
  420. package/node_modules/@blockcast/mmt-fec/src/reorder-buffer.ts +101 -0
  421. package/node_modules/@blockcast/mmt-fec/src/repair-group.ts +195 -0
  422. package/node_modules/@blockcast/mmt-manifest-verify/dist/.build-stamp +0 -0
  423. package/node_modules/@blockcast/mmt-manifest-verify/dist/admission.d.ts +28 -0
  424. package/node_modules/@blockcast/mmt-manifest-verify/dist/admission.d.ts.map +1 -0
  425. package/node_modules/@blockcast/mmt-manifest-verify/dist/admission.js +128 -0
  426. package/node_modules/@blockcast/mmt-manifest-verify/dist/admission.js.map +1 -0
  427. package/node_modules/@blockcast/mmt-manifest-verify/dist/index.d.ts +4 -0
  428. package/node_modules/@blockcast/mmt-manifest-verify/dist/index.d.ts.map +1 -0
  429. package/node_modules/@blockcast/mmt-manifest-verify/dist/index.js +4 -0
  430. package/node_modules/@blockcast/mmt-manifest-verify/dist/index.js.map +1 -0
  431. package/node_modules/@blockcast/mmt-manifest-verify/dist/source-auth.d.ts +37 -0
  432. package/node_modules/@blockcast/mmt-manifest-verify/dist/source-auth.d.ts.map +1 -0
  433. package/node_modules/@blockcast/mmt-manifest-verify/dist/source-auth.js +179 -0
  434. package/node_modules/@blockcast/mmt-manifest-verify/dist/source-auth.js.map +1 -0
  435. package/node_modules/@blockcast/mmt-manifest-verify/dist/verifier.d.ts +99 -0
  436. package/node_modules/@blockcast/mmt-manifest-verify/dist/verifier.d.ts.map +1 -0
  437. package/node_modules/@blockcast/mmt-manifest-verify/dist/verifier.js +492 -0
  438. package/node_modules/@blockcast/mmt-manifest-verify/dist/verifier.js.map +1 -0
  439. package/node_modules/@blockcast/mmt-manifest-verify/package.json +45 -0
  440. package/node_modules/@blockcast/mmt-manifest-verify/src/admission.ts +171 -0
  441. package/node_modules/@blockcast/mmt-manifest-verify/src/index.ts +3 -0
  442. package/node_modules/@blockcast/mmt-manifest-verify/src/source-auth.ts +230 -0
  443. package/node_modules/@blockcast/mmt-manifest-verify/src/verifier.ts +669 -0
  444. package/node_modules/@noble/hashes/LICENSE +21 -0
  445. package/node_modules/@noble/hashes/README.md +521 -0
  446. package/node_modules/@noble/hashes/_assert.d.ts +17 -0
  447. package/node_modules/@noble/hashes/_assert.d.ts.map +1 -0
  448. package/node_modules/@noble/hashes/_assert.js +18 -0
  449. package/node_modules/@noble/hashes/_assert.js.map +1 -0
  450. package/node_modules/@noble/hashes/_blake.d.ts +14 -0
  451. package/node_modules/@noble/hashes/_blake.d.ts.map +1 -0
  452. package/node_modules/@noble/hashes/_blake.js +50 -0
  453. package/node_modules/@noble/hashes/_blake.js.map +1 -0
  454. package/node_modules/@noble/hashes/_md.d.ts +51 -0
  455. package/node_modules/@noble/hashes/_md.d.ts.map +1 -0
  456. package/node_modules/@noble/hashes/_md.js +162 -0
  457. package/node_modules/@noble/hashes/_md.js.map +1 -0
  458. package/node_modules/@noble/hashes/_u64.d.ts +55 -0
  459. package/node_modules/@noble/hashes/_u64.d.ts.map +1 -0
  460. package/node_modules/@noble/hashes/_u64.js +90 -0
  461. package/node_modules/@noble/hashes/_u64.js.map +1 -0
  462. package/node_modules/@noble/hashes/argon2.d.ts +32 -0
  463. package/node_modules/@noble/hashes/argon2.d.ts.map +1 -0
  464. package/node_modules/@noble/hashes/argon2.js +401 -0
  465. package/node_modules/@noble/hashes/argon2.js.map +1 -0
  466. package/node_modules/@noble/hashes/blake1.d.ts +106 -0
  467. package/node_modules/@noble/hashes/blake1.d.ts.map +1 -0
  468. package/node_modules/@noble/hashes/blake1.js +459 -0
  469. package/node_modules/@noble/hashes/blake1.js.map +1 -0
  470. package/node_modules/@noble/hashes/blake2.d.ts +116 -0
  471. package/node_modules/@noble/hashes/blake2.d.ts.map +1 -0
  472. package/node_modules/@noble/hashes/blake2.js +420 -0
  473. package/node_modules/@noble/hashes/blake2.js.map +1 -0
  474. package/node_modules/@noble/hashes/blake2b.d.ts +11 -0
  475. package/node_modules/@noble/hashes/blake2b.d.ts.map +1 -0
  476. package/node_modules/@noble/hashes/blake2b.js +14 -0
  477. package/node_modules/@noble/hashes/blake2b.js.map +1 -0
  478. package/node_modules/@noble/hashes/blake2s.d.ts +20 -0
  479. package/node_modules/@noble/hashes/blake2s.d.ts.map +1 -0
  480. package/node_modules/@noble/hashes/blake2s.js +24 -0
  481. package/node_modules/@noble/hashes/blake2s.js.map +1 -0
  482. package/node_modules/@noble/hashes/blake3.d.ts +54 -0
  483. package/node_modules/@noble/hashes/blake3.d.ts.map +1 -0
  484. package/node_modules/@noble/hashes/blake3.js +255 -0
  485. package/node_modules/@noble/hashes/blake3.js.map +1 -0
  486. package/node_modules/@noble/hashes/crypto.d.ts +2 -0
  487. package/node_modules/@noble/hashes/crypto.d.ts.map +1 -0
  488. package/node_modules/@noble/hashes/crypto.js +5 -0
  489. package/node_modules/@noble/hashes/crypto.js.map +1 -0
  490. package/node_modules/@noble/hashes/cryptoNode.d.ts +2 -0
  491. package/node_modules/@noble/hashes/cryptoNode.d.ts.map +1 -0
  492. package/node_modules/@noble/hashes/cryptoNode.js +18 -0
  493. package/node_modules/@noble/hashes/cryptoNode.js.map +1 -0
  494. package/node_modules/@noble/hashes/eskdf.d.ts +47 -0
  495. package/node_modules/@noble/hashes/eskdf.d.ts.map +1 -0
  496. package/node_modules/@noble/hashes/eskdf.js +166 -0
  497. package/node_modules/@noble/hashes/eskdf.js.map +1 -0
  498. package/node_modules/@noble/hashes/esm/_assert.d.ts +17 -0
  499. package/node_modules/@noble/hashes/esm/_assert.d.ts.map +1 -0
  500. package/node_modules/@noble/hashes/esm/_assert.js +15 -0
  501. package/node_modules/@noble/hashes/esm/_assert.js.map +1 -0
  502. package/node_modules/@noble/hashes/esm/_blake.d.ts +14 -0
  503. package/node_modules/@noble/hashes/esm/_blake.d.ts.map +1 -0
  504. package/node_modules/@noble/hashes/esm/_blake.js +45 -0
  505. package/node_modules/@noble/hashes/esm/_blake.js.map +1 -0
  506. package/node_modules/@noble/hashes/esm/_md.d.ts +51 -0
  507. package/node_modules/@noble/hashes/esm/_md.d.ts.map +1 -0
  508. package/node_modules/@noble/hashes/esm/_md.js +155 -0
  509. package/node_modules/@noble/hashes/esm/_md.js.map +1 -0
  510. package/node_modules/@noble/hashes/esm/_u64.d.ts +55 -0
  511. package/node_modules/@noble/hashes/esm/_u64.d.ts.map +1 -0
  512. package/node_modules/@noble/hashes/esm/_u64.js +67 -0
  513. package/node_modules/@noble/hashes/esm/_u64.js.map +1 -0
  514. package/node_modules/@noble/hashes/esm/argon2.d.ts +32 -0
  515. package/node_modules/@noble/hashes/esm/argon2.d.ts.map +1 -0
  516. package/node_modules/@noble/hashes/esm/argon2.js +392 -0
  517. package/node_modules/@noble/hashes/esm/argon2.js.map +1 -0
  518. package/node_modules/@noble/hashes/esm/blake1.d.ts +106 -0
  519. package/node_modules/@noble/hashes/esm/blake1.d.ts.map +1 -0
  520. package/node_modules/@noble/hashes/esm/blake1.js +452 -0
  521. package/node_modules/@noble/hashes/esm/blake1.js.map +1 -0
  522. package/node_modules/@noble/hashes/esm/blake2.d.ts +116 -0
  523. package/node_modules/@noble/hashes/esm/blake2.d.ts.map +1 -0
  524. package/node_modules/@noble/hashes/esm/blake2.js +413 -0
  525. package/node_modules/@noble/hashes/esm/blake2.js.map +1 -0
  526. package/node_modules/@noble/hashes/esm/blake2b.d.ts +11 -0
  527. package/node_modules/@noble/hashes/esm/blake2b.d.ts.map +1 -0
  528. package/node_modules/@noble/hashes/esm/blake2b.js +11 -0
  529. package/node_modules/@noble/hashes/esm/blake2b.js.map +1 -0
  530. package/node_modules/@noble/hashes/esm/blake2s.d.ts +20 -0
  531. package/node_modules/@noble/hashes/esm/blake2s.d.ts.map +1 -0
  532. package/node_modules/@noble/hashes/esm/blake2s.js +21 -0
  533. package/node_modules/@noble/hashes/esm/blake2s.js.map +1 -0
  534. package/node_modules/@noble/hashes/esm/blake3.d.ts +54 -0
  535. package/node_modules/@noble/hashes/esm/blake3.d.ts.map +1 -0
  536. package/node_modules/@noble/hashes/esm/blake3.js +251 -0
  537. package/node_modules/@noble/hashes/esm/blake3.js.map +1 -0
  538. package/node_modules/@noble/hashes/esm/crypto.d.ts +2 -0
  539. package/node_modules/@noble/hashes/esm/crypto.d.ts.map +1 -0
  540. package/node_modules/@noble/hashes/esm/crypto.js +2 -0
  541. package/node_modules/@noble/hashes/esm/crypto.js.map +1 -0
  542. package/node_modules/@noble/hashes/esm/cryptoNode.d.ts +2 -0
  543. package/node_modules/@noble/hashes/esm/cryptoNode.d.ts.map +1 -0
  544. package/node_modules/@noble/hashes/esm/cryptoNode.js +15 -0
  545. package/node_modules/@noble/hashes/esm/cryptoNode.js.map +1 -0
  546. package/node_modules/@noble/hashes/esm/eskdf.d.ts +47 -0
  547. package/node_modules/@noble/hashes/esm/eskdf.d.ts.map +1 -0
  548. package/node_modules/@noble/hashes/esm/eskdf.js +160 -0
  549. package/node_modules/@noble/hashes/esm/eskdf.js.map +1 -0
  550. package/node_modules/@noble/hashes/esm/hkdf.d.ts +36 -0
  551. package/node_modules/@noble/hashes/esm/hkdf.d.ts.map +1 -0
  552. package/node_modules/@noble/hashes/esm/hkdf.js +82 -0
  553. package/node_modules/@noble/hashes/esm/hkdf.js.map +1 -0
  554. package/node_modules/@noble/hashes/esm/hmac.d.ts +35 -0
  555. package/node_modules/@noble/hashes/esm/hmac.d.ts.map +1 -0
  556. package/node_modules/@noble/hashes/esm/hmac.js +86 -0
  557. package/node_modules/@noble/hashes/esm/hmac.js.map +1 -0
  558. package/node_modules/@noble/hashes/esm/index.d.ts +2 -0
  559. package/node_modules/@noble/hashes/esm/index.d.ts.map +1 -0
  560. package/node_modules/@noble/hashes/esm/index.js +33 -0
  561. package/node_modules/@noble/hashes/esm/index.js.map +1 -0
  562. package/node_modules/@noble/hashes/esm/legacy.d.ts +71 -0
  563. package/node_modules/@noble/hashes/esm/legacy.d.ts.map +1 -0
  564. package/node_modules/@noble/hashes/esm/legacy.js +281 -0
  565. package/node_modules/@noble/hashes/esm/legacy.js.map +1 -0
  566. package/node_modules/@noble/hashes/esm/package.json +10 -0
  567. package/node_modules/@noble/hashes/esm/pbkdf2.d.ts +23 -0
  568. package/node_modules/@noble/hashes/esm/pbkdf2.d.ts.map +1 -0
  569. package/node_modules/@noble/hashes/esm/pbkdf2.js +97 -0
  570. package/node_modules/@noble/hashes/esm/pbkdf2.js.map +1 -0
  571. package/node_modules/@noble/hashes/esm/ripemd160.d.ts +13 -0
  572. package/node_modules/@noble/hashes/esm/ripemd160.d.ts.map +1 -0
  573. package/node_modules/@noble/hashes/esm/ripemd160.js +13 -0
  574. package/node_modules/@noble/hashes/esm/ripemd160.js.map +1 -0
  575. package/node_modules/@noble/hashes/esm/scrypt.d.ts +34 -0
  576. package/node_modules/@noble/hashes/esm/scrypt.d.ts.map +1 -0
  577. package/node_modules/@noble/hashes/esm/scrypt.js +228 -0
  578. package/node_modules/@noble/hashes/esm/scrypt.js.map +1 -0
  579. package/node_modules/@noble/hashes/esm/sha1.d.ts +11 -0
  580. package/node_modules/@noble/hashes/esm/sha1.d.ts.map +1 -0
  581. package/node_modules/@noble/hashes/esm/sha1.js +11 -0
  582. package/node_modules/@noble/hashes/esm/sha1.js.map +1 -0
  583. package/node_modules/@noble/hashes/esm/sha2.d.ts +159 -0
  584. package/node_modules/@noble/hashes/esm/sha2.d.ts.map +1 -0
  585. package/node_modules/@noble/hashes/esm/sha2.js +375 -0
  586. package/node_modules/@noble/hashes/esm/sha2.js.map +1 -0
  587. package/node_modules/@noble/hashes/esm/sha256.d.ts +20 -0
  588. package/node_modules/@noble/hashes/esm/sha256.d.ts.map +1 -0
  589. package/node_modules/@noble/hashes/esm/sha256.js +20 -0
  590. package/node_modules/@noble/hashes/esm/sha256.js.map +1 -0
  591. package/node_modules/@noble/hashes/esm/sha3-addons.d.ts +142 -0
  592. package/node_modules/@noble/hashes/esm/sha3-addons.d.ts.map +1 -0
  593. package/node_modules/@noble/hashes/esm/sha3-addons.js +393 -0
  594. package/node_modules/@noble/hashes/esm/sha3-addons.js.map +1 -0
  595. package/node_modules/@noble/hashes/esm/sha3.d.ts +53 -0
  596. package/node_modules/@noble/hashes/esm/sha3.d.ts.map +1 -0
  597. package/node_modules/@noble/hashes/esm/sha3.js +234 -0
  598. package/node_modules/@noble/hashes/esm/sha3.js.map +1 -0
  599. package/node_modules/@noble/hashes/esm/sha512.d.ts +26 -0
  600. package/node_modules/@noble/hashes/esm/sha512.d.ts.map +1 -0
  601. package/node_modules/@noble/hashes/esm/sha512.js +26 -0
  602. package/node_modules/@noble/hashes/esm/sha512.js.map +1 -0
  603. package/node_modules/@noble/hashes/esm/utils.d.ts +161 -0
  604. package/node_modules/@noble/hashes/esm/utils.d.ts.map +1 -0
  605. package/node_modules/@noble/hashes/esm/utils.js +281 -0
  606. package/node_modules/@noble/hashes/esm/utils.js.map +1 -0
  607. package/node_modules/@noble/hashes/hkdf.d.ts +36 -0
  608. package/node_modules/@noble/hashes/hkdf.d.ts.map +1 -0
  609. package/node_modules/@noble/hashes/hkdf.js +88 -0
  610. package/node_modules/@noble/hashes/hkdf.js.map +1 -0
  611. package/node_modules/@noble/hashes/hmac.d.ts +35 -0
  612. package/node_modules/@noble/hashes/hmac.d.ts.map +1 -0
  613. package/node_modules/@noble/hashes/hmac.js +91 -0
  614. package/node_modules/@noble/hashes/hmac.js.map +1 -0
  615. package/node_modules/@noble/hashes/index.d.ts +1 -0
  616. package/node_modules/@noble/hashes/index.d.ts.map +1 -0
  617. package/node_modules/@noble/hashes/index.js +33 -0
  618. package/node_modules/@noble/hashes/index.js.map +1 -0
  619. package/node_modules/@noble/hashes/legacy.d.ts +71 -0
  620. package/node_modules/@noble/hashes/legacy.d.ts.map +1 -0
  621. package/node_modules/@noble/hashes/legacy.js +287 -0
  622. package/node_modules/@noble/hashes/legacy.js.map +1 -0
  623. package/node_modules/@noble/hashes/package.json +266 -0
  624. package/node_modules/@noble/hashes/pbkdf2.d.ts +23 -0
  625. package/node_modules/@noble/hashes/pbkdf2.d.ts.map +1 -0
  626. package/node_modules/@noble/hashes/pbkdf2.js +101 -0
  627. package/node_modules/@noble/hashes/pbkdf2.js.map +1 -0
  628. package/node_modules/@noble/hashes/ripemd160.d.ts +13 -0
  629. package/node_modules/@noble/hashes/ripemd160.d.ts.map +1 -0
  630. package/node_modules/@noble/hashes/ripemd160.js +16 -0
  631. package/node_modules/@noble/hashes/ripemd160.js.map +1 -0
  632. package/node_modules/@noble/hashes/scrypt.d.ts +34 -0
  633. package/node_modules/@noble/hashes/scrypt.d.ts.map +1 -0
  634. package/node_modules/@noble/hashes/scrypt.js +232 -0
  635. package/node_modules/@noble/hashes/scrypt.js.map +1 -0
  636. package/node_modules/@noble/hashes/sha1.d.ts +11 -0
  637. package/node_modules/@noble/hashes/sha1.d.ts.map +1 -0
  638. package/node_modules/@noble/hashes/sha1.js +14 -0
  639. package/node_modules/@noble/hashes/sha1.js.map +1 -0
  640. package/node_modules/@noble/hashes/sha2.d.ts +159 -0
  641. package/node_modules/@noble/hashes/sha2.d.ts.map +1 -0
  642. package/node_modules/@noble/hashes/sha2.js +384 -0
  643. package/node_modules/@noble/hashes/sha2.js.map +1 -0
  644. package/node_modules/@noble/hashes/sha256.d.ts +20 -0
  645. package/node_modules/@noble/hashes/sha256.d.ts.map +1 -0
  646. package/node_modules/@noble/hashes/sha256.js +23 -0
  647. package/node_modules/@noble/hashes/sha256.js.map +1 -0
  648. package/node_modules/@noble/hashes/sha3-addons.d.ts +142 -0
  649. package/node_modules/@noble/hashes/sha3-addons.d.ts.map +1 -0
  650. package/node_modules/@noble/hashes/sha3-addons.js +402 -0
  651. package/node_modules/@noble/hashes/sha3-addons.js.map +1 -0
  652. package/node_modules/@noble/hashes/sha3.d.ts +53 -0
  653. package/node_modules/@noble/hashes/sha3.d.ts.map +1 -0
  654. package/node_modules/@noble/hashes/sha3.js +239 -0
  655. package/node_modules/@noble/hashes/sha3.js.map +1 -0
  656. package/node_modules/@noble/hashes/sha512.d.ts +26 -0
  657. package/node_modules/@noble/hashes/sha512.d.ts.map +1 -0
  658. package/node_modules/@noble/hashes/sha512.js +29 -0
  659. package/node_modules/@noble/hashes/sha512.js.map +1 -0
  660. package/node_modules/@noble/hashes/src/_assert.ts +22 -0
  661. package/node_modules/@noble/hashes/src/_blake.ts +50 -0
  662. package/node_modules/@noble/hashes/src/_md.ts +176 -0
  663. package/node_modules/@noble/hashes/src/_u64.ts +91 -0
  664. package/node_modules/@noble/hashes/src/argon2.ts +497 -0
  665. package/node_modules/@noble/hashes/src/blake1.ts +534 -0
  666. package/node_modules/@noble/hashes/src/blake2.ts +486 -0
  667. package/node_modules/@noble/hashes/src/blake2b.ts +10 -0
  668. package/node_modules/@noble/hashes/src/blake2s.ts +20 -0
  669. package/node_modules/@noble/hashes/src/blake3.ts +272 -0
  670. package/node_modules/@noble/hashes/src/crypto.ts +9 -0
  671. package/node_modules/@noble/hashes/src/cryptoNode.ts +15 -0
  672. package/node_modules/@noble/hashes/src/eskdf.ts +187 -0
  673. package/node_modules/@noble/hashes/src/hkdf.ts +88 -0
  674. package/node_modules/@noble/hashes/src/hmac.ts +94 -0
  675. package/node_modules/@noble/hashes/src/index.ts +31 -0
  676. package/node_modules/@noble/hashes/src/legacy.ts +293 -0
  677. package/node_modules/@noble/hashes/src/pbkdf2.ts +122 -0
  678. package/node_modules/@noble/hashes/src/ripemd160.ts +12 -0
  679. package/node_modules/@noble/hashes/src/scrypt.ts +257 -0
  680. package/node_modules/@noble/hashes/src/sha1.ts +10 -0
  681. package/node_modules/@noble/hashes/src/sha2.ts +402 -0
  682. package/node_modules/@noble/hashes/src/sha256.ts +24 -0
  683. package/node_modules/@noble/hashes/src/sha3-addons.ts +499 -0
  684. package/node_modules/@noble/hashes/src/sha3.ts +258 -0
  685. package/node_modules/@noble/hashes/src/sha512.ts +34 -0
  686. package/node_modules/@noble/hashes/src/utils.ts +395 -0
  687. package/node_modules/@noble/hashes/utils.d.ts +161 -0
  688. package/node_modules/@noble/hashes/utils.d.ts.map +1 -0
  689. package/node_modules/@noble/hashes/utils.js +313 -0
  690. package/node_modules/@noble/hashes/utils.js.map +1 -0
  691. package/package.json +179 -8
  692. package/schemas/catalog.schema.json +357 -0
  693. package/src/align.ts +239 -0
  694. package/src/audio-init-parser.ts +189 -0
  695. package/src/audio-ring-buffer.ts +120 -0
  696. package/src/authored-timing.ts +63 -0
  697. package/src/block-policy.ts +903 -0
  698. package/src/carp-catalog.ts +1065 -0
  699. package/src/catalog-fec-params.ts +729 -0
  700. package/src/catalog-network-source.ts +74 -0
  701. package/src/catalog-validator.ts +34 -0
  702. package/src/cmaf-block-assembler.ts +1255 -0
  703. package/src/cmaf-group-batcher.ts +104 -0
  704. package/src/cmcd-producer.ts +329 -0
  705. package/src/codec-utils.ts +1984 -0
  706. package/src/conceal-timeline.ts +293 -0
  707. package/src/delivery-gap.ts +147 -0
  708. package/src/duplicate-ledger.ts +372 -0
  709. package/src/fec-codec.ts +61 -0
  710. package/src/fec-manager.ts +4298 -0
  711. package/src/fec-starvation-detector.ts +128 -0
  712. package/src/fec-state.ts +219 -0
  713. package/src/fec-worker-setup.ts +283 -0
  714. package/src/fmp4-completion.ts +47 -0
  715. package/src/fmp4-reader.ts +112 -0
  716. package/src/index.ts +590 -21
  717. package/src/iso-block-locator.ts +329 -0
  718. package/src/iso-bmff-timing.ts +813 -0
  719. package/src/loc.ts +128 -154
  720. package/src/manifest-auth-plan.ts +107 -0
  721. package/src/media-router.ts +841 -0
  722. package/src/mfu-reassembler.ts +3480 -155
  723. package/src/mfu-reassembly-policy.ts +134 -0
  724. package/src/mmtp-container-parser.ts +2380 -0
  725. package/src/mmtp-decode-pipeline.ts +406 -0
  726. package/src/mmtp-router.ts +4860 -0
  727. package/src/mmtp-to-cmaf.ts +977 -0
  728. package/src/multicast-auth-catalog.ts +340 -0
  729. package/src/multicast-input.ts +793 -0
  730. package/src/multicast-manager.ts +2783 -0
  731. package/src/multicast-transport.ts +1058 -0
  732. package/src/multicast-unwrap.ts +132 -0
  733. package/src/no-modules.ts +21 -0
  734. package/src/phase14-catalog-override.ts +336 -0
  735. package/src/repair-fetch.ts +772 -0
  736. package/src/repair-layers.ts +533 -0
  737. package/src/sample-combiner.ts +938 -0
  738. package/src/signaling.ts +78 -0
  739. package/src/source-auth.ts +1 -0
  740. package/src/switching-set-alignment.ts +109 -0
  741. package/src/timestamp-codec.ts +265 -0
  742. package/src/track-pipeline-setup.ts +205 -0
  743. package/src/track-pipeline.ts +332 -0
  744. package/src/track-session.ts +1010 -0
  745. package/src/transport-pool.ts +128 -0
  746. package/src/wasm-mfu-reassembler.ts +405 -0
  747. package/src/wasm-mmtp-container-parser.ts +23 -0
  748. package/test-vectors/align.json +199 -0
  749. package/test-vectors/catalog/README.md +106 -0
  750. package/test-vectors/catalog/negative/archive-origin-with-path.json +33 -0
  751. package/test-vectors/catalog/negative/archive-unknown-field.json +34 -0
  752. package/test-vectors/catalog/negative/fec-block-profile-missing.json +51 -0
  753. package/test-vectors/catalog/negative/fec-block-profile-unknown.json +52 -0
  754. package/test-vectors/catalog/negative/fec-enhancement-repair-removed.json +28 -0
  755. package/test-vectors/catalog/negative/fec-repair-layer-geometry.json +33 -0
  756. package/test-vectors/catalog/negative/fec-repair-layer-missing-symbols.json +31 -0
  757. package/test-vectors/catalog/negative/fec-repair-multiple-depends.json +32 -0
  758. package/test-vectors/catalog/negative/fec-repair-overlay-with-layer.json +34 -0
  759. package/test-vectors/catalog/negative/fec-repair-priority-out-of-band.json +24 -0
  760. package/test-vectors/catalog/negative/fec-repair-source-symbols-without-scope.json +33 -0
  761. package/test-vectors/catalog/negative/legacy-selection-params.json +15 -0
  762. package/test-vectors/catalog/negative/media-track-repair-layer.json +16 -0
  763. package/test-vectors/catalog/negative/missing-mmtp-fields.json +12 -0
  764. package/test-vectors/catalog/negative/multicast-auth-padded-key.json +37 -0
  765. package/test-vectors/catalog/negative/multicast-auth-reused-track-format.json +53 -0
  766. package/test-vectors/catalog/negative/multicast-auth-trailing-pad-bits.json +37 -0
  767. package/test-vectors/catalog/negative/multicast-endpoint-missing-protocol-and-source.json +24 -0
  768. package/test-vectors/catalog/negative/multicast-network-source-empty.json +44 -0
  769. package/test-vectors/catalog/negative/multicast-packet-id-signaling.json +26 -0
  770. package/test-vectors/catalog/negative/network-source-object.json +25 -0
  771. package/test-vectors/catalog/negative/raptorq-source-symbols-over-max.json +24 -0
  772. package/test-vectors/catalog/negative/raptorq-unaligned-symbol.json +27 -0
  773. package/test-vectors/catalog/negative/repair-track-legacy-shape.json +19 -0
  774. package/test-vectors/catalog/negative/track-role-empty.json +16 -0
  775. package/test-vectors/catalog/negative/track-role-non-string.json +16 -0
  776. package/test-vectors/catalog/positive/archive-origin.json +34 -0
  777. package/test-vectors/catalog/positive/fec-iso-ssbg1.json +52 -0
  778. package/test-vectors/catalog/positive/fec-layered-repair.json +106 -0
  779. package/test-vectors/catalog/positive/fec-multicast.json +52 -0
  780. package/test-vectors/catalog/positive/flat-av.json +30 -0
  781. package/test-vectors/catalog/positive/multicast-auth-rotation.json +55 -0
  782. package/test-vectors/catalog/positive/multicast-native-ssm-no-network-source.json +43 -0
  783. package/test-vectors/catalog/positive/ticks-and-mixed-timescales.json +39 -0
  784. package/test-vectors/catalog/positive/track-role.json +49 -0
  785. package/test-vectors/catalog/releases/moqcast-native-v16-v1/catalog.json +65 -0
  786. package/test-vectors/catalog/releases/moqcast-native-v16-v1.archive.sha256 +1 -0
  787. package/test-vectors/catalog-multicast-auth-v1.json +26 -0
  788. package/test-vectors/catalog-validation.json +732 -0
  789. package/test-vectors/catalog-validator-public-api.mjs +25 -0
  790. package/test-vectors/generate-source-auth-fixture.mjs +114 -0
  791. package/test-vectors/hevc-x265-repeat-headers-v1.json +40 -0
  792. package/test-vectors/source-auth-v1.json +61 -0
  793. package/test-vectors/source-contiguity-v1.json +131 -0
  794. package/test-vectors/subsample-placement-v1.json +230 -0
  795. package/dist/mmtp.d.ts +0 -73
  796. package/dist/mmtp.d.ts.map +0 -1
  797. package/dist/mmtp.js +0 -210
  798. package/dist/mmtp.js.map +0 -1
  799. package/src/mmtp.ts +0 -277
@@ -0,0 +1,4298 @@
1
+ /**
2
+ * FEC Manager — codec-agnostic FEC decode service.
3
+ *
4
+ * Receives (packetId, sbn, esi, symbolData) tuples from any transport
5
+ * (MMTP, LOC, etc.) and manages FEC block lifecycle: source counting,
6
+ * WASM RaptorQ decode, block timeout, and recovery/failure notification.
7
+ *
8
+ * Extracted from MulticastManager to decouple FEC block logic from
9
+ * MMTP packet parsing and frame routing.
10
+ */
11
+
12
+ import { computeFecDeliveryWindowMs } from './track-session.js'
13
+ import { DeliveryGapEstimator } from './delivery-gap.js'
14
+ import {
15
+ deriveFecInterleaveDepth,
16
+ deriveFecRetentionBlocks,
17
+ tryDeriveFecRetentionBlocks,
18
+ } from '@blockcast/mmt-fec'
19
+ import {
20
+ isKStrideBlock,
21
+ isoBlockContains,
22
+ isoBlocksBehindLowerBound,
23
+ kStrideBlockPrecedes,
24
+ kStrideBlocksBehind,
25
+ kStrideNextBlock,
26
+ parseFecBlockProfile,
27
+ serial32Distance,
28
+ serial32Precedes,
29
+ ssbg1FitsSymbol,
30
+ type FecBlockProfile,
31
+ } from '@blockcast/mmt-base'
32
+ import { IsoBlockLocator, type IsoRepairPlacement } from './iso-block-locator.js'
33
+ import { MIN_INTERLEAVE_DEPTH_MS } from './fec-state.js'
34
+ import {
35
+ BlockClassifier,
36
+ type BlockClass,
37
+ type BlockClassifierGeometry,
38
+ type ClassifierChange,
39
+ type ClassifierMode,
40
+ type ClassifierReason,
41
+ type ReclassificationEvidence,
42
+ type SourceSymbolEvidence,
43
+ type TrackKind,
44
+ } from './block-policy.js'
45
+
46
+ import type { MmtpDeliveryProvenance } from './mfu-reassembler.js'
47
+ import type {
48
+ FecWorkerClient,
49
+ FecTrackStats,
50
+ FecBlockSnapshot,
51
+ FrameMeta,
52
+ FecWorkerErrorCode,
53
+ } from '@blockcast/fec-worker'
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Constants
57
+ // ---------------------------------------------------------------------------
58
+
59
+ // MIN_INTERLEAVE_DEPTH_MS lives with the catalog FEC predicate that also
60
+ // needs it (fec-state.ts); re-exported here for existing importers.
61
+ export { MIN_INTERLEAVE_DEPTH_MS }
62
+ const WORKER_ERROR_LOG_CAP = 5
63
+ const WORKER_ERROR_KIND_CAP = 32
64
+ const WORKER_ERROR_OVERFLOW_KIND = 'other'
65
+ const STARTUP_REPAIR_WAIT_SOURCE_BOUNDARIES = 2
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Types
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * Algorithm-agnostic FEC decoder backend interface.
73
+ *
74
+ * Implementations:
75
+ * - RaptorQ WASM (mmt_wasm.js) — RFC 6330
76
+ * - Reed-Solomon WASM — future
77
+ * - XOR — future
78
+ *
79
+ * Source/repair symbol IDs follow ISO 23008-1 §C.5.2/C.5.3:
80
+ * source: SS_ID (flat 32-bit symbol identifier)
81
+ * repair: SS_Start + SSB_length + RS_ID (block-scoped repair coords)
82
+ */
83
+ export type WasmFecDecoder = {
84
+ add_source(ss_id: number, data: Uint8Array, ts: number): Uint8Array | null
85
+ add_repair(ss_start: number, ssb_length: number, rs_id: number, data: Uint8Array, ts: number): Uint8Array | null
86
+ configure(oti: Uint8Array): void
87
+ cleanup(current_time: number): Uint8Array
88
+ /** Retire the named blocks from the decoder (last-chance decode + stats). */
89
+ cleanup_blocks_detailed(sbns: Uint32Array): unknown
90
+ /**
91
+ * iso-ssbg1-v1 only: retire the source symbols the decoder holds before a
92
+ * repair states their block, once no repair can place them any more, by the
93
+ * decoder's own horizon behind `current`, the newest SS_ID seen. Retires no
94
+ * block: the manager retires blocks itself, by their deadlines. Returns how
95
+ * many it retired. The manager calls it after every feed that grows the
96
+ * decoder and on block retirement. (`MmtFecDecoder.retire_unplaced`; the
97
+ * worker path uses `cleanup_by_ss_start`, which applies the same rule.)
98
+ */
99
+ retire_unplaced(current: number): number
100
+ flush?(): void
101
+ reset_stats?(): void
102
+ }
103
+
104
+ /** Algorithm-agnostic FEC stats snapshot. */
105
+ export interface FecStats {
106
+ sourcePackets: number
107
+ repairPackets: number
108
+ duplicateSymbols: number
109
+ blocksClean: number
110
+ blocksRecovered: number
111
+ blocksFailed: number
112
+ blocksComplete: number
113
+ mixedPathBlocks: number
114
+ recentBlockPathCounts: FecBlockPathCount[]
115
+ k: number
116
+ /** Of blocks with loss, what percentage were recovered (0-100). */
117
+ recoveryRate: number
118
+ /** What percentage of blocks had any loss (0-100). */
119
+ packetLossRate: number
120
+ /** Per-MMTP-packet-ID counters for the synchronous WASM decoder path. */
121
+ tracks: FecPacketStats[]
122
+ /** Latest Worker/WASM snapshot for each packet ID. */
123
+ workers: Array<FecTrackStats & { packetId: number }>
124
+ /** Main-thread repair symbols rejected before dispatch because their size did not match T. */
125
+ repairSymbolSizeMismatches: number
126
+ /** Bounded Worker error counts keyed by stable code, or message text for legacy errors. */
127
+ workerErrors: Record<string, number>
128
+ }
129
+
130
+ export interface FecPacketStats {
131
+ packetId: number
132
+ sourcePackets: number
133
+ repairPackets: number
134
+ duplicateSymbols: number
135
+ blocksClean: number
136
+ blocksRecovered: number
137
+ blocksFailed: number
138
+ blocksComplete: number
139
+ k: number
140
+ recoveryRate: number
141
+ packetLossRate: number
142
+ }
143
+
144
+ export type FecSymbolSource = 'multicast' | 'moq'
145
+
146
+ /**
147
+ * One iso-ssbg1-v1 first-arrival source symbol MmtpRouter holds until a repair
148
+ * symbol states its block: the exact protected packet (no SS_ID, unpadded),
149
+ * the transport that delivered it and its classification evidence.
150
+ */
151
+ export interface IsoHeldSource {
152
+ readonly symbolData: Uint8Array
153
+ readonly source: FecSymbolSource
154
+ readonly evidence: SourceSymbolEvidence
155
+ /** Larger than one ssbg_mode1 symbol: counted, never decoded. */
156
+ readonly oversized: boolean
157
+ }
158
+
159
+ /**
160
+ * How the multicast leg hands its symbols to this manager.
161
+ *
162
+ * - `'datagram'`: every symbol is an independent datagram from the receiving
163
+ * socket. A pause in arrivals is the only loss signal the receiver has, so a
164
+ * block that stays idle past the catalog-derived inactivity deadline fails.
165
+ * - `'ordered-relay'`: the provider relays one receiving socket over a
166
+ * reliable, ordered stream (the BCDS bridge's WebTransport uni stream). The
167
+ * delivered sequence is a subsequence of the socket's receive order, so a
168
+ * pause carries no loss information at all: the relay's own scheduling
169
+ * decides when bytes arrive. What does carry it is order. Once the stream
170
+ * has advanced more than the decoder's interleave horizon past a block, no
171
+ * symbol of that block can still be in flight, and the block's fate follows
172
+ * from the symbols it holds (in-band SS_ID/SS_Start and SSB_length), not
173
+ * from a wall clock tuned for raw datagrams.
174
+ *
175
+ * Only the provider can know which of the two it is delivering; an undeclared
176
+ * provider promises no ordering and keeps datagram semantics.
177
+ */
178
+ export type MulticastSymbolDelivery = 'datagram' | 'ordered-relay'
179
+
180
+ function isMulticastSymbolDelivery(value: unknown): value is MulticastSymbolDelivery {
181
+ return value === 'datagram' || value === 'ordered-relay'
182
+ }
183
+
184
+ export interface FecBlockPathCount {
185
+ packetId: number
186
+ sbn: number
187
+ catalogTrackName?: string
188
+ multicastSymbols: number
189
+ moqSymbols: number
190
+ complete: boolean
191
+ }
192
+
193
+ type FecLifecycleTrace = {
194
+ atMs: number
195
+ event:
196
+ | 'repair-requested'
197
+ | 'repair-arrived'
198
+ | 'timeout-queued'
199
+ | 'horizon-settled'
200
+ | 'cleanup-ack'
201
+ | 'cleanup-ack-timeout'
202
+ | 'recovered'
203
+ | 'failed'
204
+ | 'decoder-unavailable'
205
+ packetId: number
206
+ sbn: number
207
+ catalogTrackName?: string
208
+ sourceCount: number
209
+ sourceEsis: number[]
210
+ repairRsIds: number[]
211
+ decoderPath: 'worker' | 'sync' | 'none'
212
+ deadlineRemainingMs?: number
213
+ pendingCleanup: boolean
214
+ rsId?: number
215
+ }
216
+
217
+ export interface FECManagerConfig {
218
+ /**
219
+ * The catalog `fec.blockProfile` of the track (BLO-35597). Required for every
220
+ * configured track: there is no default, and nothing infers it.
221
+ * `k-padded-v1` keeps the legacy K-stride geometry; `iso-ssbg1-v1` is
222
+ * ISO/IEC 23008-1 ssbg_mode1 with K' = SSB_length blocks located by
223
+ * `(SS_Start, SSB_length)` range membership, `k` being only their maximum.
224
+ */
225
+ blockProfile?: FecBlockProfile
226
+ k?: number
227
+ /** Symbol size in bytes (T). NOT repair count. */
228
+ symbolSize?: number
229
+ interleaveDepthMs?: number
230
+ fps?: number
231
+ /** Guarded block inactivity deadline derived from the full delivery geometry. */
232
+ timeoutMs?: number
233
+ /** Media kind of the track (block class `audio` vs video classification). */
234
+ trackKind?: TrackKind
235
+ /** Catalog codec string. */
236
+ codec?: string
237
+ /**
238
+ * Block classifier geometry derived from raw catalog timing
239
+ * (`deriveBlockClassifierGeometry`). Absent geometry classifies every video
240
+ * block `unknown` (fail-closed: protected).
241
+ */
242
+ geometry?: BlockClassifierGeometry
243
+ }
244
+
245
+ interface ResolvedFECTrackConfig {
246
+ blockProfile?: FecBlockProfile
247
+ k: number
248
+ symbolSize: number
249
+ interleaveDepthMs: number
250
+ timeoutMs: number
251
+ fps?: number
252
+ trackKind?: TrackKind
253
+ codec?: string
254
+ geometry?: BlockClassifierGeometry
255
+ }
256
+
257
+ /** Which symbol path completed a short block. */
258
+ export type RepairVia = 'inband' | 'quic'
259
+
260
+ /** Lifecycle resolution of one FEC block, for in-order frame release. */
261
+ export type FecBlockResolution = 'pending' | 'complete' | 'failed' | 'untracked'
262
+
263
+ interface MutableFecCounters {
264
+ sourcePackets: number
265
+ repairPackets: number
266
+ duplicateSymbols: number
267
+ blocksClean: number
268
+ blocksRecovered: number
269
+ blocksFailed: number
270
+ }
271
+
272
+ function newFecCounters(): MutableFecCounters {
273
+ return {
274
+ sourcePackets: 0,
275
+ repairPackets: 0,
276
+ duplicateSymbols: 0,
277
+ blocksClean: 0,
278
+ blocksRecovered: 0,
279
+ blocksFailed: 0,
280
+ }
281
+ }
282
+
283
+ /**
284
+ * The one timer armed for a block deadline (BLO-36599).
285
+ *
286
+ * Symbols move a block's deadline later many times per block. Re-arming a
287
+ * browser timer for each of them cost ~41 us per clear+set pair on a Kirin 659,
288
+ * so the deadline itself is stored as a performance.now() timestamp and this
289
+ * timer only guarantees a wake-up no later than it. When the timer fires it
290
+ * re-checks the stored deadline and re-arms for the remainder if activity
291
+ * moved it. `fireAtMs` is what the armed timer was scheduled for; `handle` is
292
+ * null once it fired without re-arming.
293
+ */
294
+ interface DeadlineTimer {
295
+ handle: ReturnType<typeof setTimeout> | null
296
+ fireAtMs: number
297
+ }
298
+
299
+ /**
300
+ * Delete the `count` oldest (first-inserted) keys of `collection` in place
301
+ * and return them.
302
+ */
303
+ function dropOldest(
304
+ collection: Set<string> | Map<string, unknown>,
305
+ count: number,
306
+ ): string[] {
307
+ const dropped: string[] = []
308
+ for (const key of collection.keys()) {
309
+ if (dropped.length >= count) break
310
+ dropped.push(key)
311
+ }
312
+ for (const key of dropped) collection.delete(key)
313
+ return dropped
314
+ }
315
+
316
+ /** A block key whose terminal state expires at `expiresAtMs`. */
317
+ interface TerminalRecord {
318
+ readonly key: string
319
+ readonly expiresAtMs: number
320
+ }
321
+
322
+ /** FIFO of terminal records in the order blocks became terminal. */
323
+ class TerminalQueue {
324
+ #records: TerminalRecord[] = []
325
+ #head = 0
326
+
327
+ push(record: TerminalRecord): void {
328
+ this.#records.push(record)
329
+ }
330
+
331
+ peek(): TerminalRecord | undefined {
332
+ return this.#records[this.#head]
333
+ }
334
+
335
+ shift(): void {
336
+ this.#head++
337
+ // Compact once the consumed prefix outweighs the live tail, so the array
338
+ // never holds more than twice the records still in the window.
339
+ if (this.#head * 2 >= this.#records.length) {
340
+ this.#records = this.#records.slice(this.#head)
341
+ this.#head = 0
342
+ }
343
+ }
344
+ }
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // FECManager
348
+ // ---------------------------------------------------------------------------
349
+
350
+ /** Why a stream's Worker decoder stopped being routable. */
351
+ export type FecDecoderUnavailableReason = 'cleared' | 'disposed'
352
+
353
+ /**
354
+ * What still decodes a stream after its Worker left routing: the synchronous
355
+ * WASM decoder registered for the same stream (the Worker path took precedence
356
+ * over it while both were live), or nothing.
357
+ */
358
+ export type FecFallbackDecoder = 'sync' | 'none'
359
+
360
+ export interface FecDecoderUnavailableEvent {
361
+ readonly packetId: number
362
+ readonly catalogTrackName?: string
363
+ readonly reason: FecDecoderUnavailableReason
364
+ /**
365
+ * What decodes the stream at the moment the Worker left. A synchronous
366
+ * decoder discarded later (a resetTrack flush failure drops it without an
367
+ * event) is not reported here; a consumer that keeps a stream alive on
368
+ * 'sync' owns that decoder's lifecycle itself.
369
+ */
370
+ readonly fallbackDecoder: FecFallbackDecoder
371
+ }
372
+
373
+ export class FECManager {
374
+ // Per-track WASM RaptorQ decoders, keyed by physical stream identity.
375
+ #fecDecoders = new Map<string, { client: WasmFecDecoder; k: number }>()
376
+ /** @deprecated Use setFecDecoder(packetId, ...). Backwards-compat shim. */
377
+
378
+ // Per-track Worker-backed FEC decoders, keyed by physical stream identity.
379
+ // Worker-backed tracks use async decode — recovered frames arrive via onFrame callback,
380
+ // not synchronous return from feedSourceSymbol/feedRepairSymbol.
381
+ #workerClients = new Map<string, FecWorkerClient>()
382
+ // Worker clients whose streams releasePacket ended. A Worker posts
383
+ // asynchronously, so a message it sent before its reset can still arrive;
384
+ // the stream it describes is gone, and letting it in would rebuild state
385
+ // (block ledgers, statistics, lifecycle events) for a retired packet ID.
386
+ #releasedWorkerClients = new WeakSet<FecWorkerClient>()
387
+ // Worker setup can finish while an interleaved block is already collecting
388
+ // on the sync decoder. Keep that block on its original decoder so later
389
+ // source/repair symbols do not split across two RaptorQ states.
390
+ #syncDecoderBlockOwners = new Map<string, WasmFecDecoder>()
391
+ #decoderBlockOwners = new WeakMap<object, Map<number, {
392
+ catalogTrackName?: string
393
+ conflicted: boolean
394
+ }>>()
395
+
396
+ #fecK = 4
397
+ #fecSymbolSize = 0
398
+ #fecTimeoutMs = 500 // default 500ms; refined by setFramerate() when fps known
399
+ #trackConfigs = new Map<number, ResolvedFECTrackConfig>()
400
+ #fecStats = newFecCounters()
401
+ #fecTrackStats = new Map<number, MutableFecCounters>()
402
+ #fecWorkerStats = new Map<number, FecTrackStats>()
403
+ #fecStatsObservers = new Set<(packetId: number, stats: FecTrackStats) => void>()
404
+
405
+ // Block inactivity tracking: (packetId, SBN) -> the one armed timer. The
406
+ // deadline is derived from catalog geometry and refreshed on progress; only
407
+ // an idle block fails. #activeBlockDeadlinesMs is the authoritative
408
+ // deadline; the timer re-checks it when it fires (see DeadlineTimer).
409
+ #activeBlocks = new Map<string, DeadlineTimer>()
410
+ #activeBlockDeadlinesMs = new Map<string, number>()
411
+ // The two terms #activeBlockDeadlinesMs is the later of (BLO-35597):
412
+ // - the multicast late-symbol wait, refreshed by every accepted symbol;
413
+ // - a D-28 repair hold, set by the repair scheduler only while a repair
414
+ // FETCH it admitted can still land before the block's presentation
415
+ // deadline. The multicast span elapsing never ends a fetch the scheduler
416
+ // judged able to arrive in time.
417
+ #blockWaitDeadlinesMs = new Map<string, number>()
418
+ #repairHoldUntilMs = new Map<string, number>()
419
+ // Receiver-measured multicast delivery gap: the late-symbol wait's jitter
420
+ // term (see DeliveryGapEstimator for the statistic and why).
421
+ #deliveryGaps = new DeliveryGapEstimator()
422
+ #lifecycleTrace: FecLifecycleTrace[] = []
423
+ // Worker-backed timeouts enqueue cleanup behind all prior symbol messages.
424
+ // Failure remains pending until cleanupComplete acknowledges that queue drain.
425
+ #pendingWorkerCleanups = new Set<string>()
426
+ #pendingWorkerCleanupClients = new Map<string, FecWorkerClient>()
427
+ // One bound per owed cleanup: the acknowledgement is awaited for at most one
428
+ // catalog-derived block lifetime (fail bound plus repair grace).
429
+ // A Worker that hangs or drops one message must not leave the block
430
+ // 'pending' forever, which would park every later frame of its stream in
431
+ // the in-order release gate. Each entry owns its handle; the callback
432
+ // checks it still owns the key, so a settled or reset key is never touched.
433
+ #pendingWorkerCleanupBounds = new Map<string, { handle: ReturnType<typeof setTimeout> }>()
434
+ // Source symbol count per track-scoped block — marks complete when K symbols arrive.
435
+ #blockSourceCounts = new Map<string, number>()
436
+ #blockSourceEsis = new Map<string, Set<number>>()
437
+ // Authored SSB_length per block (ISO/IEC 23008-1 §C.5.3), learned from the
438
+ // first repair symbol. The FFmpeg moq_mmt publisher closes a block every D
439
+ // media units, usually before it holds K symbols, and fec-raptorq
440
+ // force_complete() encodes it as a K-symbol RaptorQ block whose ESIs
441
+ // SSB_length..K-1 are zero padding that is never sent. Such a block is clean
442
+ // at SSB_length source arrivals and recoverable from SSB_length received
443
+ // symbols; waiting for K fails every one of them.
444
+ #blockAuthoredLengths = new Map<string, number>()
445
+ #ssbLengthConflictCount = 0
446
+ #blockRepairRsIds = new Map<string, Set<number>>()
447
+ // Blocks already recovered — prevents late source symbols from restarting timeouts.
448
+ // WASM removes blocks after recovery; late symbols create empty blocks returning null,
449
+ // which would falsely start new timeouts without this guard.
450
+ //
451
+ // Every insertion goes through #markTerminal, which retires the key once its
452
+ // catalog-derived retention window (terminalRetentionMsForPacket) passes. The
453
+ // previous bound (trim to 500 at 1000) lived in cancelBlockTimeout alone, so
454
+ // a stream whose blocks only failed grew this set without limit (BLO-36599).
455
+ #completedBlocks = new Set<string>()
456
+ #terminalExpiryMs = new Map<string, number>()
457
+ #terminalQueues = new Map<number, TerminalQueue>()
458
+ // sbn -> (block key -> packetId) for every key held by #activeBlocks,
459
+ // #blockSourceCounts, #blockCatalogTrackNames or #completedBlocks. A lookup
460
+ // without a catalog name used to string-parse every key of all of those on
461
+ // each source packet, which is O(retained blocks) per packet (BLO-36599).
462
+ #blockKeysBySbn = new Map<number, Map<string, number>>()
463
+ // Stable per-name encodings for stream and block keys, bounded by the
464
+ // catalog's track names; #blockKey is built several times per packet.
465
+ #encodedTrackNames = new Map<string, string>()
466
+ // The last block and stream key built. One packet asks for the same block's
467
+ // key from every FECManager entry point it passes through (~6 per source
468
+ // symbol), so each key is built once per packet rather than once per call
469
+ // (BLO-36599). Both keys are pure functions of their arguments.
470
+ #lastBlockKeySbn = -1
471
+ #lastBlockKeyPacketId = -1
472
+ #lastBlockKeyTrackName: string | undefined = undefined
473
+ #lastBlockKey = ''
474
+ #lastStreamKeyPacketId = -1
475
+ #lastStreamKeyTrackName: string | undefined = undefined
476
+ #lastStreamKey = ''
477
+ #seenSymbols = new Set<string>()
478
+ #blockPathCounts = new Map<string, FecBlockPathCount>()
479
+ // Receiver-local first-accepted-symbol time per block, bounded like
480
+ // #blockPathCounts (see blockAnchorAtMs).
481
+ #blockAnchorsMs = new Map<string, number>()
482
+ // Exact catalog identity observed on accepted symbols for each FEC block.
483
+ // Conflicting identities collapse to null so recovery fails closed.
484
+ #blockCatalogTrackNames = new Map<string, string | null>()
485
+ // A receiver joins an already-running live stream in the middle of D
486
+ // interleaved source blocks. This applies to both multicast and reliable MoQ:
487
+ // a MoQ group boundary is not an FEC ESI=0 boundary. Those blocks are not
488
+ // packet loss because their earlier ESIs predate the subscription. Ignore
489
+ // each such block until ESI=0 establishes a complete boundary for the track.
490
+ // After that boundary, a first observed ESI>0 is real delivery loss and must
491
+ // continue through normal FEC recovery/failure accounting.
492
+ #startupAlignedPackets = new Set<string>()
493
+ #startupPartialBlocks = new Set<string>()
494
+ // A live multicast join can observe an ESI=0 source boundary before the
495
+ // repair lane catches up. Returning false here also drops that media packet
496
+ // at MmtpRouter, so the readiness probe is multicast-only and bounded: an
497
+ // actual multicast repair unlocks the next boundary, while a second source
498
+ // boundary fails open. Reliable MoQ media continues through normal startup
499
+ // alignment and retained MoQ repair cannot prove multicast readiness.
500
+ #startupRequireRepairReady = false
501
+ #startupRepairReadyPackets = new Set<string>()
502
+ #startupRepairBoundaryCounts = new Map<string, number>()
503
+ #startupRepairFailOpenPackets = new Set<string>()
504
+ // SBNs that reached K source-symbol arrivals via trackBlock. Used by the
505
+ // worker recovery suppression check (see onFrame handler below) so we only
506
+ // suppress `recovered=true` emits for blocks that ALSO completed cleanly
507
+ // via source symbols. Separating this from #completedBlocks (which is also
508
+ // populated by every worker frame emit, by failed timeouts, and by external
509
+ // callers) was the BLO-3323 L15 fix: the prior predicate caused blocksRecovered
510
+ // to stay at 0 because the SBN was always in #completedBlocks by the time the
511
+ // worker fired recovered=true.
512
+ #cleanlyCompletedBlocks = new Set<string>()
513
+ // D-28: blocks whose repair deadline already fired onRepairNeeded. At most
514
+ // one repair request per block; entries are removed when the block
515
+ // completes, recovers, or fails.
516
+ #repairRequestedBlocks = new Set<string>()
517
+ // D-28: repair deadlines refreshed by accepted source progress until the
518
+ // first repair request. A `null` value marks a deadline that already fired
519
+ // and requested repair (never re-armed). At most K accepted source symbols
520
+ // can refresh the horizon, so browser scheduling delay cannot create an
521
+ // early loss signal and repair traffic cannot keep a block alive
522
+ // indefinitely. #repairDeadlineAtMs is the authoritative deadline; the one
523
+ // armed timer re-checks it when it fires (see DeadlineTimer).
524
+ #repairDeadlines = new Map<string, DeadlineTimer | null>()
525
+ #repairDeadlineAtMs = new Map<string, number>()
526
+ // Worker recovery emits all K source symbols. Track recovery once per block
527
+ // while routing only ESI slots that were absent from the direct source path.
528
+ #workerRecoveredBlocks = new Set<string>()
529
+
530
+ // How the multicast leg delivers symbols (see MulticastSymbolDelivery). The
531
+ // provider declares it per transport join; absence keeps datagram semantics.
532
+ #multicastSymbolDelivery: MulticastSymbolDelivery = 'datagram'
533
+ // ordered-relay: per stream, the highest SBN any multicast symbol carried.
534
+ // The relay preserves the receiving socket's order, so every block more than
535
+ // the decoder's interleave horizon behind this frontier has received every
536
+ // symbol it will ever receive over multicast.
537
+ #orderedFrontiers = new Map<string, number>()
538
+ // ordered-relay: blocks with multicast symbols that are neither terminal nor
539
+ // handed to a decoder cleanup, per stream, keyed by SBN. They carry no
540
+ // timer; the frontier settles them. At most one interleave horizon of blocks
541
+ // per stream is ever here.
542
+ #orderedPendingBlocks = new Map<string, Map<number, string>>()
543
+
544
+ // ── Keyframe-protected repair policy (block-policy.ts) ──
545
+ // One classifier per physical stream, built from that stream's geometry.
546
+ #classifiers = new Map<string, BlockClassifier>()
547
+ // Geometry a stream-scoped configuration gave one stream (streamKey). ABR
548
+ // rungs can share a packetId with a different framerate or GOP, so the
549
+ // packet-level geometry is only the geometry of a packet whose streams
550
+ // were never configured apart (see #geometryFor).
551
+ #streamGeometries = new Map<string, BlockClassifierGeometry>()
552
+ // Terminal outcomes retained for in-order frame release: a failed block is
553
+ // resolved-with-loss, a completed one resolved-clean.
554
+ #failedBlocks = new Set<string>()
555
+ // A block's class at resolution, so events emitted after the classifier
556
+ // forgot the block (the router counts a recovery after cancelling the
557
+ // block) still carry it.
558
+ #resolvedBlockClasses = new Map<string, BlockClass>()
559
+ // Blocks already reported through onBlockRepaired (once per block).
560
+ #repairedBlocks = new Set<string>()
561
+ // Blocks created without any symbol because every symbol vanished.
562
+ #materializedBlocks = new Set<string>()
563
+ // packetIds whose ordered-relay horizon became underivable and fell back to
564
+ // the datagram deadline. Bounds the warn in #derivableOrderedHorizon to once
565
+ // per stream. Not only throws: the ordinary out-of-range case is detected by
566
+ // arithmetic and never throws at all.
567
+ #orderedHorizonWarnedFor = new Set<number>()
568
+ // Highest SBN seen per stream and when, for vanished-block detection.
569
+ #highestSbn = new Map<string, { sbn: number; atMs: number }>()
570
+ // iso-ssbg1-v1 block location per stream (streamKey). A source belongs to a
571
+ // block only once a repair symbol stated the block's (SS_Start, SSB_length).
572
+ #isoLocators = new Map<string, IsoBlockLocator<IsoHeldSource>>()
573
+ #isoRepairRefusedCount = 0
574
+ #isoSourceOversizeCount = 0
575
+ // Held sources a locator's capacity cap evicted (IsoBlockLocator.heldCapacity):
576
+ // only a stream whose SS_IDs are not consecutive reaches it. Cumulative,
577
+ // unlike the locators, which a reset discards.
578
+ #isoHeldEvictedCount = 0
579
+ // Sources a synchronous decoder held unplaced and retired at its horizon
580
+ // (WasmFecDecoder.retire_unplaced): placed sources whose block's repair
581
+ // never reached that decoder. Cumulative.
582
+ #isoDecoderUnplacedRetired = 0
583
+ // Failed retire_unplaced calls. The sweep runs on every feed, so its warn is
584
+ // rate-limited like the other per-symbol warns here. Cumulative.
585
+ #isoDecoderUnplacedRetireFailed = 0
586
+ // The latest source symbol per stream that carried its MMTP
587
+ // packet_sequence_number: the anchor that bounds how many blocks the
588
+ // producer can have started before a later source symbol.
589
+ #sourceAnchors = new Map<string, { sbn: number; psn: number; atMs: number }>()
590
+ // Catalog/reset epoch per packetId. Gap decisions carry it for fencing.
591
+ #packetEpochs = new Map<number, number>()
592
+
593
+ // Framerate detection state
594
+ #fpsDetected = false
595
+ #fecFps?: number
596
+ #fecInterleaveDepthMs = 0
597
+
598
+ // Worker error log rate-limiter — bounds console.warn output and exported
599
+ // error cardinality when the FEC Worker emits the same failure repeatedly
600
+ // with volatile packet details (e.g. ssStart) in the message text.
601
+ // Stable Worker codes aggregate those messages; uncoded/legacy messages are
602
+ // capped into an `other` bucket. This also covers wasm-bindgen
603
+ // "recursive use of an object" bubbled from add_source/add_repair during
604
+ // overlapping message processing). Without this, Point 5 of the 7-point
605
+ // validation harness — which caps headless warnings at 500 — fails even
606
+ // though the errors are duplicates of a single underlying event.
607
+ #workerErrorCounts = new Map<string, number>()
608
+ #decoderUnavailableEvents = 0
609
+ #repairSymbolSizeMismatchCount = 0
610
+
611
+ // Event callbacks — block lifecycle notifications for MmtpRouter wiring.
612
+ // NOTE: For sync WASM path, recovery routing is handled by MmtpRouter from
613
+ // feedSourceSymbol/feedRepairSymbol return values. For Worker path, recovery
614
+ // arrives via onBlockRecovered callback.
615
+ onBlockStarted:
616
+ | ((sbn: number, packetId: number, catalogTrackName?: string) => void)
617
+ | null = null
618
+ onBlockComplete:
619
+ | ((sbn: number, packetId: number, catalogTrackName?: string) => void)
620
+ | null = null
621
+ /** A block reached its fail bound. `blockClass` is its class at failure. */
622
+ onBlockFailed:
623
+ | ((
624
+ sbn: number,
625
+ packetId: number,
626
+ catalogTrackName: string | undefined,
627
+ blockClass: BlockClass,
628
+ ) => void)
629
+ | null = null
630
+ /**
631
+ * Fired once per stream when its Worker decoder stops being routable.
632
+ * `reason` is 'cleared' when clearFecWorker() removed it and 'disposed' when
633
+ * a symbol or reset found the registered client already disposed and
634
+ * evicted it. `fallbackDecoder` is 'sync' when a synchronous WASM decoder
635
+ * registered for the same stream keeps decoding its symbols, and 'none'
636
+ * when nothing does. Only 'none' leaves the stream unprotected: the host
637
+ * must then register a decoder again or leave the stream, because counting
638
+ * its symbols as handled would hide the loss.
639
+ */
640
+ onDecoderUnavailable: ((event: FecDecoderUnavailableEvent) => void) | null = null
641
+
642
+ // D-28 on-demand repair callbacks. When onRepairNeeded is registered, each
643
+ // block arms a one-shot repair deadline after the derived K-symbol source
644
+ // horizon; a block still short of K when it fires requests a per-SBN MoQ
645
+ // repair fetch upstream. The inactivity fail bound is unchanged either way —
646
+ // repair and failure are orthogonal timers, so wiring D-28 never delays
647
+ // failure accounting. onRepairResolved fires when a repair-requested block
648
+ // leaves the active set, with the outcome ("completed" after clean completion
649
+ // or recovery, "failed" at the fail bound) so the upstream in-flight slot
650
+ // (MulticastManager.clearRepairForBlock) can be released and logged.
651
+ onRepairNeeded:
652
+ | ((
653
+ sbn: number,
654
+ packetId: number,
655
+ catalogTrackName: string | undefined,
656
+ blockClass: BlockClass,
657
+ ) => void)
658
+ | null = null
659
+ onRepairResolved:
660
+ | ((
661
+ sbn: number,
662
+ packetId: number,
663
+ outcome: 'completed' | 'failed',
664
+ catalogTrackName: string | undefined,
665
+ via: RepairVia | null,
666
+ ) => void)
667
+ | null = null
668
+
669
+ /**
670
+ * A block that could not complete from its first-arrival source symbols
671
+ * alone was completed anyway: by FEC decode (in-band or QUIC repair
672
+ * symbols) or, after a D-28 request, by late symbols. Fired once per block.
673
+ */
674
+ onBlockRepaired:
675
+ | ((
676
+ sbn: number,
677
+ packetId: number,
678
+ catalogTrackName: string | undefined,
679
+ blockClass: BlockClass,
680
+ via: RepairVia,
681
+ ) => void)
682
+ | null = null
683
+ /** A block's class changed on new evidence or when classification was disabled. */
684
+ onBlockReclassified:
685
+ | ((
686
+ sbn: number,
687
+ packetId: number,
688
+ catalogTrackName: string | undefined,
689
+ from: BlockClass,
690
+ to: BlockClass,
691
+ evidence: ReclassificationEvidence,
692
+ ) => void)
693
+ | null = null
694
+ /** A video stream's classifier mode was established or changed. */
695
+ onClassifierMode:
696
+ | ((
697
+ packetId: number,
698
+ catalogTrackName: string | undefined,
699
+ mode: ClassifierMode,
700
+ reason: ClassifierReason | null,
701
+ ) => void)
702
+ | null = null
703
+ /** A block every symbol of which vanished was created by SBN evidence. */
704
+ onBlockVanished:
705
+ | ((sbn: number, packetId: number, catalogTrackName: string | undefined) => void)
706
+ | null = null
707
+ /** A block reached a terminal state (completed, recovered, or failed). */
708
+ onBlockResolved:
709
+ | ((sbn: number, packetId: number, catalogTrackName: string | undefined) => void)
710
+ | null = null
711
+ /** A stream's SBN went backwards (or jumped implausibly): the source restarted. */
712
+ onSourceDiscontinuity:
713
+ | ((packetId: number, catalogTrackName: string | undefined) => void)
714
+ | null = null
715
+
716
+ // Worker-path callbacks — async recovery and stats from FecWorkerClient.
717
+ onBlockRecovered: ((sbn: number, packetId: number, data: ArrayBuffer, meta: FrameMeta, catalogTrackName?: string) => void) | null = null
718
+ onFecStatsUpdate: ((packetId: number, stats: FecTrackStats) => void) | null = null
719
+
720
+ #encodeTrackName(catalogTrackName: string): string {
721
+ let encoded = this.#encodedTrackNames.get(catalogTrackName)
722
+ if (encoded === undefined) {
723
+ encoded = encodeURIComponent(catalogTrackName)
724
+ this.#encodedTrackNames.set(catalogTrackName, encoded)
725
+ }
726
+ return encoded
727
+ }
728
+
729
+ #streamKey(packetId: number, catalogTrackName?: string): string {
730
+ if (
731
+ packetId === this.#lastStreamKeyPacketId &&
732
+ catalogTrackName === this.#lastStreamKeyTrackName
733
+ ) {
734
+ return this.#lastStreamKey
735
+ }
736
+ const key = catalogTrackName === undefined
737
+ ? `${packetId}`
738
+ : `${packetId}:${this.#encodeTrackName(catalogTrackName)}`
739
+ this.#lastStreamKeyPacketId = packetId
740
+ this.#lastStreamKeyTrackName = catalogTrackName
741
+ this.#lastStreamKey = key
742
+ return key
743
+ }
744
+
745
+ #belongsToPacket(key: string, packetId: number): boolean {
746
+ return Number(key.split(':', 1)[0]) === packetId
747
+ }
748
+
749
+ #belongsToStream(key: string, packetId: number, catalogTrackName: string): boolean {
750
+ if (!this.#belongsToPacket(key, packetId)) return false
751
+ const parts = key.split(':')
752
+ const encodedTrackName = this.#encodeTrackName(catalogTrackName)
753
+ if (parts.length === 2) return parts[1] === encodedTrackName
754
+ if (parts.length >= 3) return parts[2] === encodedTrackName
755
+ return false
756
+ }
757
+
758
+ #packetIdsForStreamKeys(keys: Set<string>): number[] {
759
+ return [...new Set([...keys].map((key) => Number(key.split(':', 1)[0])))]
760
+ }
761
+
762
+ #parseStreamKey(key: string): { packetId: number; catalogTrackName?: string } {
763
+ const separator = key.indexOf(':')
764
+ if (separator < 0) return { packetId: Number(key) || 0 }
765
+ return {
766
+ packetId: Number(key.slice(0, separator)) || 0,
767
+ catalogTrackName: decodeURIComponent(key.slice(separator + 1)),
768
+ }
769
+ }
770
+
771
+ #blockKey(sbn: number, packetId: number, catalogTrackName?: string): string {
772
+ if (
773
+ sbn === this.#lastBlockKeySbn &&
774
+ packetId === this.#lastBlockKeyPacketId &&
775
+ catalogTrackName === this.#lastBlockKeyTrackName
776
+ ) {
777
+ return this.#lastBlockKey
778
+ }
779
+ const base = `${packetId}:${sbn}`
780
+ const key = catalogTrackName === undefined
781
+ ? base
782
+ : `${base}:${this.#encodeTrackName(catalogTrackName)}`
783
+ this.#lastBlockKeySbn = sbn
784
+ this.#lastBlockKeyPacketId = packetId
785
+ this.#lastBlockKeyTrackName = catalogTrackName
786
+ this.#lastBlockKey = key
787
+ return key
788
+ }
789
+
790
+ /** Record that `key` (block `sbn` on `packetId`) is held by a lookup set. */
791
+ #indexBlockKey(key: string, sbn: number, packetId: number): void {
792
+ let keys = this.#blockKeysBySbn.get(sbn)
793
+ if (!keys) {
794
+ keys = new Map()
795
+ this.#blockKeysBySbn.set(sbn, keys)
796
+ }
797
+ keys.set(key, packetId)
798
+ }
799
+
800
+ /** True while any set a catalog-name-less lookup consults still holds `key`. */
801
+ #isBlockKeyHeld(key: string): boolean {
802
+ return this.#activeBlocks.has(key) ||
803
+ this.#blockSourceCounts.has(key) ||
804
+ this.#blockCatalogTrackNames.has(key) ||
805
+ this.#completedBlocks.has(key)
806
+ }
807
+
808
+ /** Drop `key` from the SBN index once no lookup set holds it any more. */
809
+ #unindexBlockKey(key: string): void {
810
+ if (this.#isBlockKeyHeld(key)) return
811
+ const sbn = this.#parseBlockKey(key).sbn
812
+ const keys = this.#blockKeysBySbn.get(sbn)
813
+ if (!keys) return
814
+ keys.delete(key)
815
+ if (keys.size === 0) this.#blockKeysBySbn.delete(sbn)
816
+ }
817
+
818
+ /**
819
+ * Make `key` terminal: late symbols for it are duplicates from now on. The
820
+ * key is retired terminalRetentionMsForPacket later, when no copy of any of
821
+ * its symbols can still be in flight on either transport and no frame can
822
+ * still be held behind it. Retirement is swept lazily here, on the stream's
823
+ * own terminal events, so it needs no timer.
824
+ */
825
+ #markTerminal(key: string, sbn: number, packetId: number, clean: boolean): void {
826
+ const nowMs = performance.now()
827
+ this.#retireTerminalBlocks(packetId, nowMs)
828
+ this.#forgetOrderedPending(key)
829
+ this.#completedBlocks.add(key)
830
+ if (clean) this.#cleanlyCompletedBlocks.add(key)
831
+ this.#indexBlockKey(key, sbn, packetId)
832
+ const expiresAtMs = nowMs + this.terminalRetentionMsForPacket(packetId)
833
+ this.#terminalExpiryMs.set(key, expiresAtMs)
834
+ let queue = this.#terminalQueues.get(packetId)
835
+ if (!queue) {
836
+ queue = new TerminalQueue()
837
+ this.#terminalQueues.set(packetId, queue)
838
+ }
839
+ queue.push({ key, expiresAtMs })
840
+ }
841
+
842
+ #retireTerminalBlocks(packetId: number, nowMs: number): void {
843
+ const queue = this.#terminalQueues.get(packetId)
844
+ if (!queue) return
845
+ for (let record = queue.peek(); record !== undefined; record = queue.peek()) {
846
+ if (record.expiresAtMs > nowMs) return
847
+ // A key made terminal again later (or reset and reused) owns a newer
848
+ // record; this one no longer describes it.
849
+ const current = this.#terminalExpiryMs.get(record.key) === record.expiresAtMs
850
+ // An ordered relay can still hand over the rest of a terminal block's
851
+ // symbols (a clean block's remaining repair symbols) for as long as the
852
+ // relay pauses. Until the stream passes the block's interleave horizon
853
+ // those symbols are late copies, not a new block, so the key stays.
854
+ if (current && this.#withinOrderedHorizon(record.key)) return
855
+ queue.shift()
856
+ if (!current) continue
857
+ this.#terminalExpiryMs.delete(record.key)
858
+ this.#completedBlocks.delete(record.key)
859
+ this.#cleanlyCompletedBlocks.delete(record.key)
860
+ this.#workerRecoveredBlocks.delete(record.key)
861
+ // The policy's terminal ledgers describe the same terminal state and
862
+ // retire with it: a failed block must never outlive its failure record
863
+ // as 'complete' (that would hide a loss from the release gate).
864
+ this.#failedBlocks.delete(record.key)
865
+ this.#resolvedBlockClasses.delete(record.key)
866
+ this.#repairedBlocks.delete(record.key)
867
+ this.#unindexBlockKey(record.key)
868
+ }
869
+ }
870
+
871
+ // -----------------------------------------------------------------------
872
+ // Ordered-relay settlement (MulticastSymbolDelivery 'ordered-relay')
873
+ // -----------------------------------------------------------------------
874
+
875
+ /**
876
+ * The decoder's interleave horizon for one stream, in blocks: the same
877
+ * retention the Worker and the WASM decoder are configured with, because it
878
+ * comes from the same function they do (`deriveFecRetentionBlocks`, over the
879
+ * block's delivery window and its span). The decoder retires every block
880
+ * more than this many SBNs behind the newest one it saw (`cleanup_by_sbn`),
881
+ * so settling earlier than this would decide a block the decoder would still
882
+ * have recovered.
883
+ *
884
+ * This used to call `deriveFecInterleaveDepth`, which returns D -- MEDIA
885
+ * UNITS per block, not a block count -- and a comment here asserted the two
886
+ * matched. They did, only while the decoder's retention was also (wrongly)
887
+ * sized in D. Once BLO-35597 sized retention as a real block count this
888
+ * settled 2-5 blocks early on the live ISS catalog at reorderToleranceMs=100
889
+ * (720p 1 vs 6, audio 2 vs 5), making the manager the binding constraint and
890
+ * the decoder fix inert on ordered relays. Both sides now consume one
891
+ * derivation; do not re-derive a horizon at either call site.
892
+ */
893
+ #orderedHorizonBlocksFor(packetId: number, config: ResolvedFECTrackConfig): number {
894
+ if (!FECManager.#hasOrderedGeometry(config)) {
895
+ throw new Error(
896
+ `[FECManager] packetId=${packetId}: ordered-relay settlement derives the ` +
897
+ `decoder's interleave horizon from the catalog interleaveDepthMs ` +
898
+ `(>= ${MIN_INTERLEAVE_DEPTH_MS}ms), the media-unit rate and the block ` +
899
+ `delivery window; got interleaveDepthMs=${config.interleaveDepthMs} ` +
900
+ `fps=${String(config.fps)} timeoutMs=${config.timeoutMs}.`,
901
+ )
902
+ }
903
+ // Config time is where failing closed is right, so a > 255 geometry throws
904
+ // from here with the message oti.ts wrote: it names interleaveDepthMs and
905
+ // the catalog fix. #derivableOrderedHorizon deliberately does not.
906
+ return deriveFecRetentionBlocks(config.timeoutMs, config.interleaveDepthMs)
907
+ }
908
+
909
+ /**
910
+ * Whether a stream's geometry can define an ordered-relay horizon at all.
911
+ * One definition, shared by the config-time and packet-feed callers, so the
912
+ * two can never disagree about which streams settle by stream order.
913
+ */
914
+ static #hasOrderedGeometry(config: ResolvedFECTrackConfig): boolean {
915
+ return (
916
+ config.interleaveDepthMs >= MIN_INTERLEAVE_DEPTH_MS &&
917
+ // `fps` is no longer an input to the horizon, but it still gates WHICH
918
+ // streams settle by stream order at all: a track with no usable
919
+ // media-unit rate fell back to datagram semantics before this and must
920
+ // keep doing so. Removing this widens ordered-relay settlement to streams
921
+ // that never had it. Load-bearing — do not drop it as unused.
922
+ config.fps !== undefined &&
923
+ Number.isFinite(config.fps) &&
924
+ config.fps > 0 &&
925
+ Number.isFinite(config.timeoutMs) &&
926
+ config.timeoutMs > 0
927
+ )
928
+ }
929
+
930
+ /**
931
+ * The horizon for one stream, or undefined when its geometry cannot define
932
+ * one.
933
+ *
934
+ * `config.timeoutMs` is the block's delivery window. On the live path
935
+ * multicast-manager computes it with `jitterBufferMs: fec.reorderToleranceMs`
936
+ * and passes it to configure(), which is the same `deliveryWindowMs`
937
+ * fec-worker-setup sizes decoder retention from, so the two horizons are
938
+ * equal there.
939
+ *
940
+ * They are NOT equal on the fallback path, and the skew runs in the
941
+ * direction this file's invariant calls unsafe. `jitterBufferMs` reaches
942
+ * fec-manager only inside an explicit configure({timeoutMs}); all four of
943
+ * its own computeFecDeliveryWindowMs calls (one in #computeTimeout, one in
944
+ * configure(), two in setFramerate) omit it, so a manager-derived
945
+ * timeoutMs is tolerance-free and yields a
946
+ * horizon NARROWER than the decoder's retention — the direction that settles
947
+ * a block the decoder would still have recovered. At 720p/33ms the decoder
948
+ * retains ceil(133/33)+1 = 6 while a tolerance-free 33ms window gives
949
+ * ceil(33/33)+1 = 2. Two writes can also raise a validated timeoutMs
950
+ * afterwards without re-validating (configure()'s widening loop and
951
+ * setFramerate).
952
+ *
953
+ * Not fixed here: the real fix is to thread the tolerance into those four
954
+ * calls so both sides derive from the same quantity, which is a change to
955
+ * inputs fec-manager does not currently receive. Tracked in BLO-38250. The
956
+ * live ISS path does not hit it — multicast-manager always supplies both
957
+ * `timeoutMs` and `fps`, and setFramerate skips tracks that already carry
958
+ * `fps` — so this is a latent skew on the fallback, not a live defect. Do
959
+ * not read the old "can only skew narrower, never wider" note as a safety
960
+ * argument: narrower IS the failing direction.
961
+ */
962
+ #derivableOrderedHorizon(config: ResolvedFECTrackConfig, packetId: number): number | undefined {
963
+ if (!FECManager.#hasOrderedGeometry(config)) return undefined
964
+ try {
965
+ // `tryDeriveFecRetentionBlocks`, not the throwing variant: this is the
966
+ // per-symbol path (#settlesByOrder -> #orderedHorizonBlocks, from
967
+ // noteBlockActivity on every source and repair symbol), and a geometry
968
+ // past the 255-block ceiling is STEADY STATE, not a transient --
969
+ // `timeoutMs` does not narrow back on its own. Throwing here would
970
+ // construct an Error and capture a stack in oti.ts, then unwind it,
971
+ // for every symbol on the stream, and #advanceOrderedFrontier's
972
+ // `blocks === undefined` branch re-enters noteBlockActivity per pending
973
+ // SBN and would multiply it again. The ceiling itself stays owned by
974
+ // oti.ts -- open-coding the comparison here would re-create the second
975
+ // derivation this change exists to remove.
976
+ const blocks = tryDeriveFecRetentionBlocks(config.timeoutMs, config.interleaveDepthMs)
977
+ if (blocks === undefined) {
978
+ // Falls back to datagram semantics through the `blocks === undefined`
979
+ // branch already written for it in #advanceOrderedFrontier. Two writes
980
+ // can raise a track's timeoutMs after #orderedHorizonBlocksFor
981
+ // validated it (configure()'s widening loop and setFramerate's), and
982
+ // neither re-validates, so this is reachable without a second
983
+ // config-time check.
984
+ this.#noteLeftOrderedSettlement(
985
+ packetId,
986
+ `geometry cannot define a retention horizon within the 255-block ceiling ` +
987
+ `(timeoutMs=${config.timeoutMs}ms over interleaveDepthMs=${config.interleaveDepthMs}ms)`,
988
+ )
989
+ }
990
+ return blocks
991
+ } catch (error) {
992
+ // Backstop only -- nothing above throws today. Deliberately unnarrowed:
993
+ // if the callee grows a new throw, this is what makes it visible rather
994
+ // than silently swallowed. The contract of this path
995
+ // (#orderedHorizonBlocks) is that it must not throw.
996
+ this.#noteLeftOrderedSettlement(packetId, String(error))
997
+ return undefined
998
+ }
999
+ }
1000
+
1001
+ /**
1002
+ * Warn once per stream that it dropped from ordered-relay settlement to the
1003
+ * datagram deadline. Swallowing is right for the packet-feed contract, but a
1004
+ * configured stream changing settlement mid-flight is a behavioural change,
1005
+ * and it is otherwise indistinguishable from a stream that never had
1006
+ * geometry.
1007
+ */
1008
+ #noteLeftOrderedSettlement(packetId: number, reason: string): void {
1009
+ if (this.#orderedHorizonWarnedFor.has(packetId)) return
1010
+ this.#orderedHorizonWarnedFor.add(packetId)
1011
+ console.warn(
1012
+ `[FECManager] packetId=${packetId} left ordered-relay settlement for the datagram deadline: ${reason}`,
1013
+ )
1014
+ }
1015
+
1016
+ /**
1017
+ * The stream's interleave horizon, or undefined when its geometry cannot
1018
+ * define one. Two different streams reach that case, with different causes:
1019
+ * a packetId that was never configured, and so resolves to the manager
1020
+ * defaults (explicitly configured tracks are refused an underivable geometry
1021
+ * at config time — #upsertTrackConfig, setMulticastSymbolDelivery); or a
1022
+ * configured track whose timeoutMs was later widened past the 255-block
1023
+ * ceiling without re-validating, which #derivableOrderedHorizon catches
1024
+ * rather than throwing from the packet-feed path. Either way the stream
1025
+ * keeps datagram semantics instead of throwing. The manager defaults can
1026
+ * also change under a stream that already holds blocks, so a caller cannot
1027
+ * assume a horizon that was defined when a block was held is still defined
1028
+ * (see #advanceOrderedFrontier).
1029
+ */
1030
+ #orderedHorizonBlocks(packetId: number): number | undefined {
1031
+ return this.#derivableOrderedHorizon(this.#resolveTrackConfig(packetId), packetId)
1032
+ }
1033
+
1034
+ /** True while a block of an ordered-relay stream can still receive symbols. */
1035
+ #withinOrderedHorizon(key: string): boolean {
1036
+ if (this.#multicastSymbolDelivery !== 'ordered-relay') return false
1037
+ const block = this.#parseBlockKey(key)
1038
+ const frontier = this.#orderedFrontiers.get(
1039
+ this.#streamKey(block.packetId, block.catalogTrackName),
1040
+ )
1041
+ if (frontier === undefined) return false
1042
+ const horizon = this.#orderedHorizonBlocks(block.packetId)
1043
+ if (horizon === undefined) return false
1044
+ return this.#blocksBehind(block.sbn, frontier, block.packetId) <= horizon
1045
+ }
1046
+
1047
+ /** Whether a block's fate is decided by stream order rather than a timer. */
1048
+ #settlesByOrder(key: string): boolean {
1049
+ return this.#multicastSymbolDelivery === 'ordered-relay' &&
1050
+ (this.#blockPathCounts.get(key)?.multicastSymbols ?? 0) > 0 &&
1051
+ this.#orderedHorizonBlocks(this.#parseBlockKey(key).packetId) !== undefined
1052
+ }
1053
+
1054
+ #forgetOrderedPending(key: string): void {
1055
+ if (this.#orderedPendingBlocks.size === 0) return
1056
+ const block = this.#parseBlockKey(key)
1057
+ const streamKey = this.#streamKey(block.packetId, block.catalogTrackName)
1058
+ const pending = this.#orderedPendingBlocks.get(streamKey)
1059
+ if (pending?.get(block.sbn) !== key) return
1060
+ pending.delete(block.sbn)
1061
+ if (pending.size === 0) this.#orderedPendingBlocks.delete(streamKey)
1062
+ }
1063
+
1064
+ /**
1065
+ * Record the stream position of a multicast symbol and settle every pending
1066
+ * block it proves final: a block more than the interleave horizon behind the
1067
+ * frontier has already been handed every symbol the relay will deliver.
1068
+ */
1069
+ #advanceOrderedFrontier(
1070
+ streamKey: string,
1071
+ sbn: number,
1072
+ packetId: number,
1073
+ catalogTrackName?: string,
1074
+ ): void {
1075
+ const previous = this.#orderedFrontiers.get(streamKey)
1076
+ const frontier = previous === undefined || this.#blockPrecedes(previous, sbn, packetId)
1077
+ ? sbn
1078
+ : previous
1079
+ if (frontier !== previous) this.#orderedFrontiers.set(streamKey, frontier)
1080
+ const pending = this.#orderedPendingBlocks.get(streamKey)
1081
+ if (!pending) return
1082
+ const blocks = this.#orderedHorizonBlocks(packetId)
1083
+ if (blocks === undefined) {
1084
+ // Either an unconfigured stream (it resolves to the manager defaults,
1085
+ // and configure() accepts a sub-floor interleaveDepthMs) or a configured
1086
+ // one whose timeoutMs was widened past the 255-block ceiling after
1087
+ // validation (#derivableOrderedHorizon). Stream
1088
+ // order can no longer settle its held blocks, so hand them to the
1089
+ // datagram deadline -- as setMulticastSymbolDelivery('datagram') does --
1090
+ // rather than settle them a horizon early or hold them with no timer.
1091
+ this.#orderedPendingBlocks.delete(streamKey)
1092
+ for (const pendingSbn of pending.keys()) {
1093
+ this.noteBlockActivity(pendingSbn, packetId, catalogTrackName)
1094
+ }
1095
+ return
1096
+ }
1097
+ for (const [pendingSbn, key] of pending) {
1098
+ if (this.#blocksBehind(pendingSbn, frontier, packetId) <= blocks) continue
1099
+ pending.delete(pendingSbn)
1100
+ this.#settleOrderedBlock(key, pendingSbn, packetId, catalogTrackName)
1101
+ }
1102
+ if (pending.size === 0 && this.#orderedPendingBlocks.get(streamKey) === pending) {
1103
+ this.#orderedPendingBlocks.delete(streamKey)
1104
+ }
1105
+ }
1106
+
1107
+ /**
1108
+ * Decide a block the ordered relay has finished delivering. Its accounting
1109
+ * is final: SSB_length (from any repair symbol) against the source and
1110
+ * repair symbols it holds says whether the decoder can still recover it.
1111
+ * A block short of that asks for repair when D-28 is wired, under the same
1112
+ * post-request fetch budget the datagram path grants; otherwise, and for a
1113
+ * block the decoder holds enough symbols for, the decoder finalizes it
1114
+ * exactly as a datagram deadline would (a Worker acknowledges the cleanup
1115
+ * only after every earlier symbol, so an in-flight recovery still wins).
1116
+ */
1117
+ #settleOrderedBlock(
1118
+ key: string,
1119
+ sbn: number,
1120
+ packetId: number,
1121
+ catalogTrackName?: string,
1122
+ ): void {
1123
+ if (
1124
+ this.#completedBlocks.has(key) ||
1125
+ this.#pendingWorkerCleanups.has(key) ||
1126
+ this.#repairRequestedBlocks.has(key)
1127
+ ) return
1128
+ const authoredLength = this.#blockAuthoredLengths.get(key)
1129
+ const received =
1130
+ (this.#blockSourceCounts.get(key) ?? 0) +
1131
+ (this.#blockRepairRsIds.get(key)?.size ?? 0)
1132
+ if (
1133
+ this.onRepairNeeded &&
1134
+ (authoredLength === undefined || received < authoredLength)
1135
+ ) {
1136
+ this.#requestRepair(sbn, packetId, catalogTrackName, true)
1137
+ return
1138
+ }
1139
+ this.#traceLifecycle('horizon-settled', sbn, packetId, catalogTrackName)
1140
+ this.#finalizeExpiredBlock(sbn, packetId, catalogTrackName)
1141
+ }
1142
+
1143
+ /** Register a block whose multicast symbols arrive over an ordered relay. */
1144
+ #holdForOrderedSettlement(
1145
+ key: string,
1146
+ sbn: number,
1147
+ packetId: number,
1148
+ catalogTrackName?: string,
1149
+ ): void {
1150
+ // A block that started on the other transport may already hold a timer.
1151
+ // Its multicast symbols now decide it by order; a wall clock cannot.
1152
+ this.#clearRepairDeadline(key)
1153
+ if (this.#activeBlocks.has(key)) {
1154
+ this.#clearBlockTimer(key)
1155
+ this.#unindexBlockKey(key)
1156
+ }
1157
+ const streamKey = this.#streamKey(packetId, catalogTrackName)
1158
+ let pending = this.#orderedPendingBlocks.get(streamKey)
1159
+ if (!pending) {
1160
+ pending = new Map()
1161
+ this.#orderedPendingBlocks.set(streamKey, pending)
1162
+ }
1163
+ pending.set(sbn, key)
1164
+ }
1165
+
1166
+ /** Cancel and forget a block's inactivity timer and deadline. */
1167
+ #clearBlockTimer(key: string): void {
1168
+ const timer = this.#activeBlocks.get(key)
1169
+ if (timer?.handle != null) clearTimeout(timer.handle)
1170
+ this.#activeBlocks.delete(key)
1171
+ this.#activeBlockDeadlinesMs.delete(key)
1172
+ this.#blockWaitDeadlinesMs.delete(key)
1173
+ this.#repairHoldUntilMs.delete(key)
1174
+ }
1175
+
1176
+ #parseBlockKey(key: string): { packetId: number; sbn: number; catalogTrackName?: string } {
1177
+ const [packetIdText, sbnText, ...catalogParts] = key.split(':')
1178
+ const catalogTrackName = catalogParts.length > 0
1179
+ ? decodeURIComponent(catalogParts.join(':'))
1180
+ : undefined
1181
+ return {
1182
+ packetId: Number(packetIdText) || 0,
1183
+ sbn: Number(sbnText) || 0,
1184
+ ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
1185
+ }
1186
+ }
1187
+
1188
+ #decoderForPacket(packetId: number, catalogTrackName?: string) {
1189
+ return this.#fecDecoders.get(this.#streamKey(packetId, catalogTrackName))
1190
+ ?? this.#fecDecoders.get(this.#streamKey(packetId))
1191
+ }
1192
+
1193
+ #workerForPacket(packetId: number, catalogTrackName?: string) {
1194
+ return this.#workerClients.get(this.#streamKey(packetId, catalogTrackName))
1195
+ ?? this.#workerClients.get(this.#streamKey(packetId))
1196
+ }
1197
+
1198
+ /**
1199
+ * The stream's Worker client for routing, or undefined after evicting a
1200
+ * registered client that was already disposed. A disposed client throws on
1201
+ * every command, so it leaves routing here before a symbol reaches it and
1202
+ * the stream reports its decoder unavailable instead.
1203
+ */
1204
+ #routableWorkerForPacket(packetId: number, catalogTrackName?: string): FecWorkerClient | undefined {
1205
+ const client = this.#workerForPacket(packetId, catalogTrackName)
1206
+ if (client === undefined || client.disposed !== true) return client
1207
+ this.#evictDisposedWorker(client)
1208
+ return undefined
1209
+ }
1210
+
1211
+ #claimDecoderBlock(
1212
+ client: object,
1213
+ sbn: number,
1214
+ catalogTrackName?: string,
1215
+ ): boolean {
1216
+ let blocks = this.#decoderBlockOwners.get(client)
1217
+ if (!blocks) {
1218
+ blocks = new Map()
1219
+ this.#decoderBlockOwners.set(client, blocks)
1220
+ }
1221
+ const owner = blocks.get(sbn)
1222
+ if (!owner) {
1223
+ blocks.set(sbn, { catalogTrackName, conflicted: false })
1224
+ if (blocks.size > 512) {
1225
+ const stale = [...blocks.keys()].slice(0, 256)
1226
+ for (const key of stale) blocks.delete(key)
1227
+ }
1228
+ return true
1229
+ }
1230
+ if (owner.conflicted) return false
1231
+ if (owner.catalogTrackName === catalogTrackName) return true
1232
+ owner.conflicted = true
1233
+ return false
1234
+ }
1235
+
1236
+ #decoderBlockCatalogTrackName(client: object, sbn: number): string | undefined {
1237
+ const owner = this.#decoderBlockOwners.get(client)?.get(sbn)
1238
+ return owner?.conflicted ? undefined : owner?.catalogTrackName
1239
+ }
1240
+
1241
+ #resetDecoderBlockOwners(client: object, catalogTrackName: string): void {
1242
+ const blocks = this.#decoderBlockOwners.get(client)
1243
+ if (!blocks) return
1244
+ for (const [sbn, owner] of blocks) {
1245
+ if (owner.catalogTrackName === catalogTrackName || owner.conflicted) {
1246
+ blocks.delete(sbn)
1247
+ }
1248
+ }
1249
+ if (blocks.size === 0) this.#decoderBlockOwners.delete(client)
1250
+ }
1251
+
1252
+ #discardFecDecoderClient(client: WasmFecDecoder): void {
1253
+ for (const [key, entry] of this.#fecDecoders) {
1254
+ if (entry.client === client) this.#fecDecoders.delete(key)
1255
+ }
1256
+ this.#decoderBlockOwners.delete(client)
1257
+ }
1258
+
1259
+ #assertExclusiveDecoderClient(
1260
+ streamKey: string,
1261
+ client: object,
1262
+ registrations: Iterable<readonly [string, object]>,
1263
+ kind: 'decoder' | 'worker',
1264
+ ): void {
1265
+ for (const [registeredKey, registeredClient] of registrations) {
1266
+ if (registeredClient !== client || registeredKey === streamKey) continue
1267
+ throw new Error(
1268
+ `[FECManager] ${kind} client is already registered for stream ${registeredKey}; ` +
1269
+ `create one client per physical stream`,
1270
+ )
1271
+ }
1272
+ }
1273
+
1274
+ #statsForPacket(packetId: number): MutableFecCounters {
1275
+ let stats = this.#fecTrackStats.get(packetId)
1276
+ if (!stats) {
1277
+ stats = newFecCounters()
1278
+ this.#fecTrackStats.set(packetId, stats)
1279
+ }
1280
+ return stats
1281
+ }
1282
+
1283
+ #markPathBlockComplete(sbn: number, packetId: number, catalogTrackName?: string): void {
1284
+ const block = this.#blockPathCounts.get(this.#blockKey(sbn, packetId, catalogTrackName))
1285
+ if (block) block.complete = true
1286
+ }
1287
+
1288
+ /** Cancel and forget a block's D-28 repair-deadline timer. */
1289
+ #clearRepairDeadline(key: string): void {
1290
+ const timer = this.#repairDeadlines.get(key)
1291
+ if (timer?.handle != null) clearTimeout(timer.handle)
1292
+ this.#repairDeadlines.delete(key)
1293
+ this.#repairDeadlineAtMs.delete(key)
1294
+ }
1295
+
1296
+ /**
1297
+ * Move a block's D-28 repair deadline to `delayMs` from now. The timer
1298
+ * already armed for an earlier moment fires first and re-arms for the
1299
+ * remainder, so repeated source progress costs no timer operation.
1300
+ */
1301
+ #armRepairDeadline(
1302
+ key: string,
1303
+ sbn: number,
1304
+ packetId: number,
1305
+ catalogTrackName: string | undefined,
1306
+ delayMs: number,
1307
+ ): void {
1308
+ const deadlineMs = performance.now() + delayMs
1309
+ this.#repairDeadlineAtMs.set(key, deadlineMs)
1310
+ const armed = this.#repairDeadlines.get(key)
1311
+ if (armed?.handle != null) {
1312
+ if (armed.fireAtMs <= deadlineMs) return
1313
+ clearTimeout(armed.handle)
1314
+ }
1315
+ const timer: DeadlineTimer = { handle: null, fireAtMs: deadlineMs }
1316
+ const onFire = (): void => {
1317
+ if (this.#repairDeadlines.get(key) !== timer) return
1318
+ const currentDeadlineMs = this.#repairDeadlineAtMs.get(key)
1319
+ const nowMs = performance.now()
1320
+ if (currentDeadlineMs !== undefined && currentDeadlineMs > nowMs) {
1321
+ timer.fireAtMs = currentDeadlineMs
1322
+ timer.handle = setTimeout(onFire, Math.ceil(currentDeadlineMs - nowMs))
1323
+ return
1324
+ }
1325
+ timer.handle = null
1326
+ this.#requestRepair(sbn, packetId, catalogTrackName)
1327
+ }
1328
+ timer.handle = setTimeout(onFire, delayMs)
1329
+ this.#repairDeadlines.set(key, timer)
1330
+ }
1331
+
1332
+ #requestRepair(
1333
+ sbn: number,
1334
+ packetId: number,
1335
+ catalogTrackName?: string,
1336
+ /**
1337
+ * Ordered-relay settlement only: the stream has proved the block final,
1338
+ * so its multicast wait ends now. A datagram block keeps the wait its
1339
+ * accepted symbols armed.
1340
+ */
1341
+ settledByOrder = false,
1342
+ ): void {
1343
+ const key = this.#blockKey(sbn, packetId, catalogTrackName)
1344
+ if (
1345
+ this.#completedBlocks.has(key) ||
1346
+ !this.onRepairNeeded ||
1347
+ this.#repairRequestedBlocks.has(key)
1348
+ ) return
1349
+
1350
+ const deadline = this.#repairDeadlines.get(key)
1351
+ if (deadline?.handle != null) clearTimeout(deadline.handle)
1352
+ this.#repairDeadlines.set(key, null)
1353
+ this.#repairDeadlineAtMs.delete(key)
1354
+ this.#repairRequestedBlocks.add(key)
1355
+
1356
+ // The request itself grants no time (BLO-35597). The multicast wait keeps
1357
+ // running, extended by any late symbol; the repair scheduler extends the
1358
+ // block past it (holdBlockForRepair) only for a FETCH it admits, i.e. one
1359
+ // the measured reliable-path RTT says can land before the block's
1360
+ // presentation deadline. A request it declines leaves the block on the
1361
+ // multicast wait alone, so no doomed FETCH is sent and none is aborted
1362
+ // for want of a grace that could never have covered the round trip.
1363
+ if (settledByOrder) this.#armBlockTimeout(sbn, packetId, catalogTrackName, 0)
1364
+ this.#traceLifecycle('repair-requested', sbn, packetId, catalogTrackName)
1365
+ this.onRepairNeeded(
1366
+ sbn,
1367
+ packetId,
1368
+ catalogTrackName,
1369
+ this.blockClassFor(sbn, packetId, catalogTrackName),
1370
+ )
1371
+ }
1372
+
1373
+ // ── Keyframe-protected repair policy ──
1374
+
1375
+ /**
1376
+ * The classifier geometry of one stream. A stream-scoped configuration
1377
+ * wins. Once any stream of a packetId was configured with its own geometry,
1378
+ * a sibling stream without one has none that provably belongs to it: the
1379
+ * packet-level geometry may be another rung's (a different framerate or
1380
+ * GOP), so it runs all-protected (`stream-geometry-absent`).
1381
+ */
1382
+ #geometryFor(packetId: number, catalogTrackName?: string): BlockClassifierGeometry {
1383
+ const own = this.#streamGeometries.get(this.#streamKey(packetId, catalogTrackName))
1384
+ if (own) return own
1385
+ const config = this.#trackConfigs.get(packetId)
1386
+ if (config?.trackKind === 'audio') return { trackKind: 'audio' }
1387
+ for (const key of this.#streamGeometries.keys()) {
1388
+ if (this.#belongsToPacket(key, packetId)) {
1389
+ return { trackKind: 'video', mode: 'all-protected', reason: 'stream-geometry-absent' }
1390
+ }
1391
+ }
1392
+ if (config?.geometry) return config.geometry
1393
+ // No catalog cadence reached this track: every video block is protected.
1394
+ return { trackKind: 'video', mode: 'all-protected', reason: 'keyframe-cadence-absent' }
1395
+ }
1396
+
1397
+ #classifierFor(packetId: number, catalogTrackName?: string): BlockClassifier {
1398
+ const streamKey = this.#streamKey(packetId, catalogTrackName)
1399
+ let classifier = this.#classifiers.get(streamKey)
1400
+ if (!classifier) {
1401
+ classifier = new BlockClassifier(this.#geometryFor(packetId, catalogTrackName))
1402
+ this.#classifiers.set(streamKey, classifier)
1403
+ if (classifier.geometry.trackKind === 'video') {
1404
+ this.onClassifierMode?.(packetId, catalogTrackName, classifier.mode, classifier.reason)
1405
+ }
1406
+ }
1407
+ return classifier
1408
+ }
1409
+
1410
+ #applyClassifierChanges(
1411
+ packetId: number,
1412
+ catalogTrackName: string | undefined,
1413
+ changes: readonly ClassifierChange[],
1414
+ ): void {
1415
+ for (const change of changes) {
1416
+ if (change.kind === 'mode') {
1417
+ this.onClassifierMode?.(packetId, catalogTrackName, change.mode, change.reason)
1418
+ } else {
1419
+ this.onBlockReclassified?.(
1420
+ change.sbn,
1421
+ packetId,
1422
+ catalogTrackName,
1423
+ change.from,
1424
+ change.to,
1425
+ change.evidence,
1426
+ )
1427
+ }
1428
+ }
1429
+ }
1430
+
1431
+ /**
1432
+ * Freeze a block's class at resolution and drop its classifier state. The
1433
+ * frozen class answers every later query (a recovery counted after the
1434
+ * block was cancelled, a failure event) exactly.
1435
+ */
1436
+ #retireBlockClass(
1437
+ key: string,
1438
+ sbn: number,
1439
+ packetId: number,
1440
+ catalogTrackName?: string,
1441
+ ): BlockClass {
1442
+ const frozen = this.#resolvedBlockClasses.get(key)
1443
+ if (frozen !== undefined) return frozen
1444
+ const blockClass = this.#peekClass(sbn, packetId, catalogTrackName)
1445
+ this.#classifiers.get(this.#streamKey(packetId, catalogTrackName))?.forget(sbn)
1446
+ this.#resolvedBlockClasses.set(key, blockClass)
1447
+ this.#materializedBlocks.delete(key)
1448
+ return blockClass
1449
+ }
1450
+
1451
+ /** A block's live class without creating classifier state for the stream. */
1452
+ #peekClass(sbn: number, packetId: number, catalogTrackName?: string): BlockClass {
1453
+ const classifier = this.#classifiers.get(this.#streamKey(packetId, catalogTrackName))
1454
+ if (classifier) return classifier.classOf(sbn)
1455
+ return this.#geometryFor(packetId, catalogTrackName).trackKind === 'audio' ? 'audio' : 'unknown'
1456
+ }
1457
+
1458
+ #viaFor(key: string): RepairVia {
1459
+ return (this.#blockPathCounts.get(key)?.moqSymbols ?? 0) > 0 ? 'quic' : 'inband'
1460
+ }
1461
+
1462
+ #noteBlockRepaired(
1463
+ key: string,
1464
+ sbn: number,
1465
+ packetId: number,
1466
+ catalogTrackName?: string,
1467
+ ): void {
1468
+ if (this.#repairedBlocks.has(key)) return
1469
+ this.#repairedBlocks.add(key)
1470
+ this.onBlockRepaired?.(
1471
+ sbn,
1472
+ packetId,
1473
+ catalogTrackName,
1474
+ this.blockClassFor(sbn, packetId, catalogTrackName),
1475
+ this.#viaFor(key),
1476
+ )
1477
+ }
1478
+
1479
+ // Deliberately one block tighter than deriveFecRetentionBlocks over the same
1480
+ // inputs (that function's `+ 1`). Different question: the retention horizon
1481
+ // asks how long the decoder still ACCEPTS symbols for a block, this asks how
1482
+ // far back a block that vanished entirely may still be repairable (see
1483
+ // #noteSbnSeen).
1484
+ // The extra block of retention is slack for a block still being fed; a block
1485
+ // nothing has been heard from does not get it. Not the units defect — but it
1486
+ // is the next place the two can drift, so change both or neither.
1487
+ #liveWindowBlocks(config: ResolvedFECTrackConfig): number | null {
1488
+ if (config.interleaveDepthMs < MIN_INTERLEAVE_DEPTH_MS || config.timeoutMs <= 0) return null
1489
+ return Math.ceil(config.timeoutMs / config.interleaveDepthMs)
1490
+ }
1491
+
1492
+ /**
1493
+ * Record that a symbol for block `sbn` arrived and materialise any blocks
1494
+ * that vanished entirely between the stream's highest SBN and this one.
1495
+ *
1496
+ * The newest ceil(timeoutMs / interleaveDepthMs) vanished blocks may still
1497
+ * be repairable, so they get the normal repair and fail deadlines, armed
1498
+ * from now. Older ones are already past their fail bound and fail now, as
1499
+ * class `unknown`. A SOURCE symbol whose SBN goes back further than one
1500
+ * live window, or jumps further than the stream could have produced since
1501
+ * its last symbol, is a source discontinuity, not loss. A repair symbol can
1502
+ * only advance the frontier: a D-28 fetch legitimately answers for an old
1503
+ * block, so it never signals a discontinuity.
1504
+ *
1505
+ * The producer closes a block at its Dth media-unit boundary OR when K
1506
+ * source symbols filled it, so one interleave span can start several
1507
+ * blocks (every large keyframe does). What bounds a forward jump is
1508
+ * therefore packet evidence, not a block rate: with the MMTP
1509
+ * packet_sequence_number (contiguous per packet_id, ISO/IEC 23008-1) of
1510
+ * both source symbols, every block strictly between them holds at least one
1511
+ * of the packets sent strictly between them, and K-closes can add at most
1512
+ * one block per K of those packets on top of the D-closes elapsed time
1513
+ * allows. Without it, only the D-close rate is known.
1514
+ */
1515
+ #noteSbnSeen(
1516
+ sbn: number,
1517
+ packetId: number,
1518
+ catalogTrackName: string | undefined,
1519
+ symbol: 'source' | 'repair',
1520
+ packetSequenceNumber?: number,
1521
+ ): void {
1522
+ const streamKey = this.#streamKey(packetId, catalogTrackName)
1523
+ const now = performance.now()
1524
+ const previous = this.#highestSbn.get(streamKey)
1525
+ const anchor = this.#sourceAnchors.get(streamKey)
1526
+ const psn = symbol === 'source' &&
1527
+ packetSequenceNumber !== undefined &&
1528
+ Number.isSafeInteger(packetSequenceNumber) &&
1529
+ packetSequenceNumber >= 0 &&
1530
+ packetSequenceNumber <= 0xffff_ffff
1531
+ ? packetSequenceNumber
1532
+ : undefined
1533
+ const anchorHere = (): void => {
1534
+ if (psn !== undefined && (anchor === undefined || !this.#blockPrecedes(sbn, anchor.sbn, packetId))) {
1535
+ this.#sourceAnchors.set(streamKey, { sbn, psn, atMs: now })
1536
+ }
1537
+ }
1538
+ const discontinuity = (): void => {
1539
+ // The classifier compares authored order with SBN order only within
1540
+ // one continuous SBN sequence.
1541
+ this.#classifiers.get(streamKey)?.noteSourceDiscontinuity()
1542
+ this.#highestSbn.set(streamKey, { sbn, atMs: now })
1543
+ if (psn === undefined) this.#sourceAnchors.delete(streamKey)
1544
+ else this.#sourceAnchors.set(streamKey, { sbn, psn, atMs: now })
1545
+ this.onSourceDiscontinuity?.(packetId, catalogTrackName)
1546
+ }
1547
+ if (previous === undefined) {
1548
+ this.#highestSbn.set(streamKey, { sbn, atMs: now })
1549
+ anchorHere()
1550
+ return
1551
+ }
1552
+ const config = this.#resolveTrackConfig(packetId)
1553
+ const window = this.#liveWindowBlocks(config)
1554
+ if (!this.#blockPrecedes(previous.sbn, sbn, packetId)) {
1555
+ if (symbol === 'source' && window !== null &&
1556
+ this.#blocksBehind(sbn, previous.sbn, packetId) > window) {
1557
+ discontinuity()
1558
+ return
1559
+ }
1560
+ anchorHere()
1561
+ return
1562
+ }
1563
+ const missing = this.#blocksBehind(previous.sbn, sbn, packetId) - 1
1564
+ let plausible = true
1565
+ if (window !== null) {
1566
+ // A packet_sequence_number that did not advance says nothing (a
1567
+ // producer that does not maintain it); only then is the D-close rate
1568
+ // the whole bound.
1569
+ if (this.#isIso(packetId) && psn !== undefined && anchor !== undefined &&
1570
+ this.#blockPrecedes(anchor.sbn, sbn, packetId) && psn !== anchor.psn) {
1571
+ // iso-ssbg1-v1: SS_ID advances by one per FEC source packet (C.5.2)
1572
+ // and packet_sequence_number by one per packet of the packet_id, so a
1573
+ // block start can move at most the packets sent since the anchor,
1574
+ // plus the anchor's own offset into its block (< K).
1575
+ const sent = (psn - anchor.psn) >>> 0
1576
+ plausible =
1577
+ sent <= 0x7fff_ffff &&
1578
+ serial32Distance(anchor.sbn, sbn) <= sent + config.k
1579
+ } else if (psn !== undefined && anchor !== undefined &&
1580
+ this.#blockPrecedes(anchor.sbn, sbn, packetId) && psn !== anchor.psn) {
1581
+ // Packets sent from the anchor packet to this one, serial arithmetic
1582
+ // (RFC 1982): more than half the space means it went backwards.
1583
+ const sent = (psn - anchor.psn) >>> 0
1584
+ const between = this.#blocksBehind(anchor.sbn, sbn, packetId) - 1
1585
+ const dCloses = window + Math.ceil((now - anchor.atMs) / config.interleaveDepthMs)
1586
+ // Each K-close consumes K of the packets sent; with K unknown,
1587
+ // every packet may have closed one.
1588
+ const kCloses = config.k > 0 ? Math.ceil(sent / config.k) : sent
1589
+ plausible =
1590
+ sent <= 0x7fff_ffff &&
1591
+ between <= sent - 1 &&
1592
+ between <= dCloses + kCloses
1593
+ } else {
1594
+ plausible = missing <= window + Math.ceil((now - previous.atMs) / config.interleaveDepthMs)
1595
+ }
1596
+ }
1597
+ if (!plausible) {
1598
+ if (symbol === 'repair') return
1599
+ discontinuity()
1600
+ return
1601
+ }
1602
+ this.#highestSbn.set(streamKey, { sbn, atMs: now })
1603
+ anchorHere()
1604
+ // iso-ssbg1-v1 cannot enumerate vanished blocks by key: their starts are
1605
+ // unknown. The SS_IDs between two stated blocks become one repair-less
1606
+ // span when the later block's repair arrives (placeIsoRepair).
1607
+ if (this.#isIso(packetId)) return
1608
+ if (missing <= 0 || window === null) return
1609
+ let vanished = previous.sbn
1610
+ for (let i = 0; i < missing; i++) {
1611
+ vanished = this.#nextBlock(vanished, packetId)
1612
+ this.#materializeBlock(
1613
+ vanished,
1614
+ packetId,
1615
+ catalogTrackName,
1616
+ this.#blocksBehind(vanished, sbn, packetId) <= window,
1617
+ )
1618
+ }
1619
+ }
1620
+
1621
+ /*
1622
+ * Block order for one stream (BLO-35597 iso:5). SS_ID is a 32-bit serial
1623
+ * number (ISO/IEC 23008-1 C.5.2), so blocks are ordered by RFC 1982
1624
+ * comparison of their first SS_ID (sbn * K mod 2^32). That is identical to
1625
+ * comparing SBNs with < for every stream that has not wrapped SS_ID; only
1626
+ * the order across the wrap changes. A block outside K-stride geometry has
1627
+ * no SS_ID position, so it keeps the SBN order it always had.
1628
+ */
1629
+ #blockPrecedes(a: number, b: number, packetId: number): boolean {
1630
+ // iso-ssbg1-v1 keys a block by its SS_Start: plain RFC 1982 order.
1631
+ if (this.#isIso(packetId)) return serial32Precedes(a, b)
1632
+ const k = this.kForPacket(packetId)
1633
+ return isKStrideBlock(a, k) && isKStrideBlock(b, k) ? kStrideBlockPrecedes(a, b, k) : a < b
1634
+ }
1635
+
1636
+ /** Blocks `a` lies behind `b` (0 when it does not precede it). */
1637
+ #blocksBehind(a: number, b: number, packetId: number): number {
1638
+ // iso-ssbg1-v1 blocks vary in length, so only a lower bound is known: the
1639
+ // one metric the decoder and the worker retire by too.
1640
+ if (this.#isIso(packetId)) return isoBlocksBehindLowerBound(a, b, this.kForPacket(packetId))
1641
+ const k = this.kForPacket(packetId)
1642
+ return isKStrideBlock(a, k) && isKStrideBlock(b, k)
1643
+ ? kStrideBlocksBehind(a, b, k)
1644
+ : Math.max(0, b - a)
1645
+ }
1646
+
1647
+ /** The block after `sbn`, stepping to block 0 where SS_ID wraps. */
1648
+ #nextBlock(sbn: number, packetId: number): number {
1649
+ const k = this.kForPacket(packetId)
1650
+ return isKStrideBlock(sbn, k) ? kStrideNextBlock(sbn, k) : sbn + 1
1651
+ }
1652
+
1653
+ #materializeBlock(
1654
+ sbn: number,
1655
+ packetId: number,
1656
+ catalogTrackName: string | undefined,
1657
+ repairable: boolean,
1658
+ ): void {
1659
+ const key = this.#blockKey(sbn, packetId, catalogTrackName)
1660
+ if (this.#blockResolutionFor(key) !== 'untracked') return
1661
+ this.#materializedBlocks.add(key)
1662
+ this.#classifierFor(packetId, catalogTrackName).touch(sbn)
1663
+ this.onBlockVanished?.(sbn, packetId, catalogTrackName)
1664
+ if (repairable) {
1665
+ this.noteBlockActivity(sbn, packetId, catalogTrackName, true)
1666
+ } else {
1667
+ this.#failBlock(sbn, packetId, catalogTrackName)
1668
+ }
1669
+ }
1670
+
1671
+ #blockResolutionFor(key: string): FecBlockResolution {
1672
+ if (this.#failedBlocks.has(key)) return 'failed'
1673
+ if (this.#completedBlocks.has(key)) return 'complete'
1674
+ if (
1675
+ this.#activeBlocks.has(key) ||
1676
+ this.#pendingWorkerCleanups.has(key) ||
1677
+ this.#repairDeadlines.has(key) ||
1678
+ this.#blockSourceCounts.has(key) ||
1679
+ this.#blockRepairRsIds.has(key) ||
1680
+ this.#materializedBlocks.has(key)
1681
+ ) {
1682
+ return 'pending'
1683
+ }
1684
+ return 'untracked'
1685
+ }
1686
+
1687
+ #bumpEpoch(packetId: number): void {
1688
+ this.#packetEpochs.set(packetId, (this.#packetEpochs.get(packetId) ?? 0) + 1)
1689
+ }
1690
+
1691
+ #traceLifecycle(
1692
+ event: FecLifecycleTrace['event'],
1693
+ sbn: number,
1694
+ packetId: number,
1695
+ catalogTrackName?: string,
1696
+ rsId?: number,
1697
+ ): void {
1698
+ const key = this.#blockKey(sbn, packetId, catalogTrackName)
1699
+ const deadline = this.#activeBlockDeadlinesMs.get(key)
1700
+ const decoderPath = this.#syncDecoderBlockOwners.has(key)
1701
+ ? 'sync'
1702
+ : this.#workerForPacket(packetId, catalogTrackName)
1703
+ ? 'worker'
1704
+ : this.#decoderForPacket(packetId, catalogTrackName)
1705
+ ? 'sync'
1706
+ : 'none'
1707
+ this.#lifecycleTrace.push({
1708
+ atMs: Date.now(),
1709
+ event,
1710
+ packetId,
1711
+ sbn,
1712
+ ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
1713
+ sourceCount: this.#blockSourceCounts.get(key) ?? 0,
1714
+ sourceEsis: [...(this.#blockSourceEsis.get(key) ?? [])].sort((a, b) => a - b),
1715
+ repairRsIds: [...(this.#blockRepairRsIds.get(key) ?? [])].sort((a, b) => a - b),
1716
+ decoderPath,
1717
+ ...(deadline === undefined
1718
+ ? {}
1719
+ : { deadlineRemainingMs: Math.max(0, deadline - performance.now()) }),
1720
+ pendingCleanup: this.#pendingWorkerCleanups.has(key),
1721
+ ...(rsId === undefined ? {} : { rsId }),
1722
+ })
1723
+ if (this.#lifecycleTrace.length > 64) this.#lifecycleTrace.splice(0, 32)
1724
+ }
1725
+
1726
+ #defaultTrackConfig(): ResolvedFECTrackConfig {
1727
+ return {
1728
+ k: this.#fecK,
1729
+ symbolSize: this.#fecSymbolSize,
1730
+ interleaveDepthMs: this.#fecInterleaveDepthMs,
1731
+ fps: this.#fecFps,
1732
+ timeoutMs: this.#fecTimeoutMs,
1733
+ }
1734
+ }
1735
+
1736
+ #resolveTrackConfig(packetId: number): ResolvedFECTrackConfig {
1737
+ return this.#trackConfigs.get(packetId) ?? this.#defaultTrackConfig()
1738
+ }
1739
+
1740
+ #computeTimeout(config: {
1741
+ k: number
1742
+ interleaveDepthMs: number
1743
+ fps?: number
1744
+ timeoutMs?: number
1745
+ }): number {
1746
+ if (config.timeoutMs && config.timeoutMs > 0) return config.timeoutMs
1747
+ // Only a valid §5.1 ms interleave (>= MIN_INTERLEAVE_DEPTH_MS) drives the
1748
+ // timeout. A sub-floor value is a raw frame count (no fps to convert with);
1749
+ // fall back to the established #fecTimeoutMs rather than collapse to ~0.
1750
+ if (config.interleaveDepthMs >= MIN_INTERLEAVE_DEPTH_MS) {
1751
+ return Math.max(
1752
+ this.#fecTimeoutMs,
1753
+ computeFecDeliveryWindowMs({
1754
+ interleaveDepthMs: config.interleaveDepthMs,
1755
+ sourceSymbols: config.k,
1756
+ mediaUnitsPerSecond: config.fps,
1757
+ }),
1758
+ )
1759
+ }
1760
+ return this.#fecTimeoutMs
1761
+ }
1762
+
1763
+ /**
1764
+ * The delivery-gap window is the longest catalog keyframe interval among
1765
+ * the configured video streams. A failed video block costs every frame up
1766
+ * to the next keyframe, so a receiver must remember a stall for at least
1767
+ * that long: forgetting it sooner would let the next recurrence fail a
1768
+ * block whose damage outlives the memory of the stall that caused it. It
1769
+ * comes from the catalog's `keyframeIntervalTicks`/`keyframeIntervalMs`
1770
+ * (via the classifier geometry), never from a constant. It also caps the
1771
+ * gap the wait may absorb; with no catalog cadence no gap is reported and
1772
+ * the catalog tolerance alone applies (see DeliveryGapEstimator).
1773
+ */
1774
+ #refreshDeliveryGapWindow(): void {
1775
+ let windowMs: number | undefined
1776
+ const consider = (geometry: BlockClassifierGeometry | undefined): void => {
1777
+ if (geometry?.trackKind !== 'video' || geometry.mode !== 'classifying') return
1778
+ const { ticks, timescale } = geometry.keyframeInterval
1779
+ const ms = (ticks / timescale) * 1000
1780
+ if (Number.isFinite(ms) && ms > 0 && (windowMs === undefined || ms > windowMs)) windowMs = ms
1781
+ }
1782
+ for (const geometry of this.#streamGeometries.values()) consider(geometry)
1783
+ for (const config of this.#trackConfigs.values()) consider(config.geometry)
1784
+ this.#deliveryGaps.setWindowMs(windowMs)
1785
+ }
1786
+
1787
+ /**
1788
+ * The multicast late-symbol wait: the block span plus the larger of the
1789
+ * catalog's signaled tolerance and the receiver-measured delivery gap.
1790
+ *
1791
+ * `config.timeoutMs` already is span + `fec.reorderToleranceMs` (or the
1792
+ * wider envelope configure() applies), so it stays the floor; the measured
1793
+ * gap widens it only when the path stalls for longer than the catalog
1794
+ * allowed for. Datagram delivery only: an ordered relay settles blocks by
1795
+ * stream order and never feeds the estimator.
1796
+ */
1797
+ #multicastWaitMs(config: ResolvedFECTrackConfig): number {
1798
+ return this.#waitForGapMs(config, this.#deliveryGaps.peakGapMs(performance.now()))
1799
+ }
1800
+
1801
+ /**
1802
+ * The widest #multicastWaitMs can become: the estimator reports at most its
1803
+ * catalog window, so this is a catalog quantity that no measurement moves.
1804
+ */
1805
+ #maxMulticastWaitMs(config: ResolvedFECTrackConfig): number {
1806
+ return this.#waitForGapMs(config, this.#deliveryGaps.windowMs ?? 0)
1807
+ }
1808
+
1809
+ #waitForGapMs(config: ResolvedFECTrackConfig, gapMs: number): number {
1810
+ if (!(gapMs > 0) || !(config.interleaveDepthMs > 0)) return config.timeoutMs
1811
+ return Math.max(config.timeoutMs, Math.ceil(config.interleaveDepthMs + gapMs))
1812
+ }
1813
+
1814
+ #repairDeadlineMs(
1815
+ config: ResolvedFECTrackConfig,
1816
+ waitMs = this.#multicastWaitMs(config),
1817
+ ): number {
1818
+ const halfFailBoundMs = Math.ceil(waitMs / 2)
1819
+ if (
1820
+ config.k > 0 &&
1821
+ config.interleaveDepthMs >= MIN_INTERLEAVE_DEPTH_MS &&
1822
+ config.fps !== undefined &&
1823
+ Number.isFinite(config.fps) &&
1824
+ config.fps > 0
1825
+ ) {
1826
+ const mediaUnitMs = 1000 / config.fps
1827
+ const interleaveUnits = deriveFecInterleaveDepth(
1828
+ config.interleaveDepthMs,
1829
+ mediaUnitMs,
1830
+ )
1831
+ const sourceHorizonMs = Math.ceil(config.k * interleaveUnits * mediaUnitMs)
1832
+ if (sourceHorizonMs < waitMs) return sourceHorizonMs
1833
+ }
1834
+ return halfFailBoundMs
1835
+ }
1836
+
1837
+ /**
1838
+ * One block lifetime: the inactivity fail bound plus the D-28 repair grace,
1839
+ * at the widest wait the catalog allows (#maxMulticastWaitMs). Everything
1840
+ * derived from it (terminal retention, the release gate's hold ceiling, the
1841
+ * duplicate ledgers, the owed-cleanup bound) is compared across time, so it
1842
+ * must not follow the live measurement.
1843
+ */
1844
+ #blockLifetimeMs(config: ResolvedFECTrackConfig): number {
1845
+ const waitMs = this.#maxMulticastWaitMs(config)
1846
+ return waitMs + this.#repairGraceMs(config, waitMs)
1847
+ }
1848
+
1849
+ /**
1850
+ * The lifetime at the live wait, capped at #blockLifetimeMs: lifetime is not
1851
+ * monotone in the wait (the repair deadline switches from half the wait to
1852
+ * the source horizon), so the cap is what keeps it inside the retention.
1853
+ */
1854
+ #liveBlockLifetimeMs(config: ResolvedFECTrackConfig): number {
1855
+ const waitMs = this.#multicastWaitMs(config)
1856
+ return Math.min(waitMs + this.#repairGraceMs(config, waitMs), this.#blockLifetimeMs(config))
1857
+ }
1858
+
1859
+ #repairGraceMs(config: ResolvedFECTrackConfig, waitMs: number): number {
1860
+ return Math.max(1, waitMs - this.#repairDeadlineMs(config, waitMs))
1861
+ }
1862
+
1863
+ #armBlockTimeout(
1864
+ sbn: number,
1865
+ packetId: number,
1866
+ catalogTrackName: string | undefined,
1867
+ delayMs: number,
1868
+ ): void {
1869
+ const key = this.#blockKey(sbn, packetId, catalogTrackName)
1870
+ const waitAtMs = performance.now() + Math.max(1, Math.ceil(delayMs))
1871
+ // A held block's symbols (its own FETCH's repair symbols included) never
1872
+ // carry its wait past the hold: the hold is its presentation deadline.
1873
+ const holdAtMs = this.#repairHoldUntilMs.get(key)
1874
+ this.#blockWaitDeadlinesMs.set(key, holdAtMs === undefined ? waitAtMs : Math.min(waitAtMs, holdAtMs))
1875
+ this.#applyBlockDeadline(key, sbn, packetId, catalogTrackName)
1876
+ }
1877
+
1878
+ /**
1879
+ * Arm (or move) a block's one fail timer at the later of its multicast
1880
+ * wait and its repair hold. Moving it later costs no timer operation: the
1881
+ * armed timer wakes no later than the new deadline and re-arms for the
1882
+ * remainder.
1883
+ */
1884
+ #applyBlockDeadline(
1885
+ key: string,
1886
+ sbn: number,
1887
+ packetId: number,
1888
+ catalogTrackName: string | undefined,
1889
+ ): void {
1890
+ const nowMs = performance.now()
1891
+ const deadlineMs = Math.max(
1892
+ this.#blockWaitDeadlinesMs.get(key) ?? nowMs + 1,
1893
+ this.#repairHoldUntilMs.get(key) ?? -Infinity,
1894
+ )
1895
+ this.#activeBlockDeadlinesMs.set(key, deadlineMs)
1896
+ const armed = this.#activeBlocks.get(key)
1897
+ if (armed?.handle != null) {
1898
+ if (armed.fireAtMs <= deadlineMs) return
1899
+ clearTimeout(armed.handle)
1900
+ }
1901
+ const timer: DeadlineTimer = { handle: null, fireAtMs: deadlineMs }
1902
+ const onFire = (): void => {
1903
+ // clearTimeout() cannot retract a callback that the browser already
1904
+ // moved onto its task queue. Only the timer that still owns this
1905
+ // block's deadline may fail it or enqueue Worker cleanup.
1906
+ if (this.#activeBlocks.get(key) !== timer) return
1907
+ const currentDeadlineMs = this.#activeBlockDeadlinesMs.get(key)
1908
+ const firedAtMs = performance.now()
1909
+ if (currentDeadlineMs !== undefined && currentDeadlineMs > firedAtMs) {
1910
+ timer.fireAtMs = currentDeadlineMs
1911
+ timer.handle = setTimeout(onFire, Math.ceil(currentDeadlineMs - firedAtMs))
1912
+ return
1913
+ }
1914
+ this.#activeBlocks.delete(key)
1915
+ this.#activeBlockDeadlinesMs.delete(key)
1916
+ this.#blockWaitDeadlinesMs.delete(key)
1917
+ this.#repairHoldUntilMs.delete(key)
1918
+ this.#traceLifecycle('timeout-queued', sbn, packetId, catalogTrackName)
1919
+ this.#finalizeExpiredBlock(sbn, packetId, catalogTrackName)
1920
+ }
1921
+ timer.handle = setTimeout(onFire, Math.max(1, Math.ceil(deadlineMs - nowMs)))
1922
+ this.#activeBlocks.set(key, timer)
1923
+ this.#indexBlockKey(key, sbn, packetId)
1924
+ }
1925
+
1926
+ /**
1927
+ * Hand a block whose delivery is over to its decoder for finalization: the
1928
+ * datagram deadline fired, or the ordered relay moved past it.
1929
+ */
1930
+ #finalizeExpiredBlock(
1931
+ sbn: number,
1932
+ packetId: number,
1933
+ catalogTrackName: string | undefined,
1934
+ ): void {
1935
+ const key = this.#blockKey(sbn, packetId, catalogTrackName)
1936
+ // A vanished block that never received a symbol holds no decoder state
1937
+ // to clean up; waiting on a Worker acknowledgement would never settle.
1938
+ if (this.#materializedBlocks.has(key) && !this.#blockPathCounts.has(key)) {
1939
+ this.#failBlock(sbn, packetId, catalogTrackName)
1940
+ return
1941
+ }
1942
+
1943
+ // A block that began on the synchronous decoder remains owned by that
1944
+ // decoder even if Worker setup completed later. Cleanup must follow the
1945
+ // same ownership rule as source and repair symbol dispatch.
1946
+ const syncOwner = this.#syncDecoderBlockOwners.get(key)
1947
+ if (syncOwner) {
1948
+ this.#routeSyncCleanup(syncOwner, sbn, packetId, catalogTrackName)
1949
+ this.#failBlock(sbn, packetId, catalogTrackName)
1950
+ return
1951
+ }
1952
+
1953
+ const workerClient = this.#routableWorkerForPacket(packetId, catalogTrackName)
1954
+ if (workerClient) {
1955
+ this.#pendingWorkerCleanups.add(key)
1956
+ this.#pendingWorkerCleanupClients.set(key, workerClient)
1957
+ this.#boundOwedCleanup(key, packetId, workerClient)
1958
+ workerClient.cleanup(sbn)
1959
+ } else {
1960
+ const decoder = this.#decoderForPacket(packetId, catalogTrackName)?.client
1961
+ if (decoder) {
1962
+ this.#routeSyncCleanup(decoder, sbn, packetId, catalogTrackName)
1963
+ this.#failBlock(sbn, packetId, catalogTrackName)
1964
+ } else {
1965
+ this.#failBlock(sbn, packetId, catalogTrackName)
1966
+ }
1967
+ }
1968
+ }
1969
+
1970
+ /**
1971
+ * Upsert one packetId's track configuration. With `catalogTrackName`, the
1972
+ * classifier geometry in `config` is that stream's own and is kept per
1973
+ * stream; every other field stays per packetId.
1974
+ */
1975
+ #upsertTrackConfig(
1976
+ packetId: number,
1977
+ config: FECManagerConfig,
1978
+ catalogTrackName?: string,
1979
+ ): ResolvedFECTrackConfig {
1980
+ const previous = this.#trackConfigs.get(packetId) ?? this.#defaultTrackConfig()
1981
+ const streamScopedGeometry =
1982
+ catalogTrackName !== undefined && config.geometry !== undefined ? config.geometry : undefined
1983
+ if (streamScopedGeometry !== undefined) {
1984
+ const { geometry: _streamGeometry, ...packetLevel } = config
1985
+ config = packetLevel
1986
+ }
1987
+ const timingChanged =
1988
+ config.k !== undefined ||
1989
+ config.interleaveDepthMs !== undefined ||
1990
+ config.fps !== undefined
1991
+ if (config.blockProfile !== undefined) {
1992
+ parseFecBlockProfile(config.blockProfile, `packetId=${packetId} fec.blockProfile`)
1993
+ }
1994
+ const blockProfile = config.blockProfile ?? previous.blockProfile
1995
+ if (blockProfile === undefined) {
1996
+ throw new Error(
1997
+ `[FECManager] packetId=${packetId}: fec.blockProfile is required ` +
1998
+ '("k-padded-v1" or "iso-ssbg1-v1"); configure the track from its catalog',
1999
+ )
2000
+ }
2001
+ if (blockProfile === 'iso-ssbg1-v1') {
2002
+ // iso-ssbg1-v1 block location keeps what it holds for a horizon derived
2003
+ // from this packet's catalog block span (#isoRetentionBlocks): without
2004
+ // one the locator would grow for the life of the session. The span is
2005
+ // this packet's own; the manager-wide default is never borrowed.
2006
+ const ownInterleaveDepthMs =
2007
+ config.interleaveDepthMs ?? this.#trackConfigs.get(packetId)?.interleaveDepthMs
2008
+ if (
2009
+ ownInterleaveDepthMs === undefined ||
2010
+ !Number.isFinite(ownInterleaveDepthMs) ||
2011
+ ownInterleaveDepthMs < MIN_INTERLEAVE_DEPTH_MS
2012
+ ) {
2013
+ throw new Error(
2014
+ `[FECManager] packetId=${packetId}: iso-ssbg1-v1 requires the catalog fec.interleaveDepthMs ` +
2015
+ `(>= ${MIN_INTERLEAVE_DEPTH_MS} ms; got ${String(ownInterleaveDepthMs)}): it bounds how long ` +
2016
+ 'block location keeps stated blocks and held sources',
2017
+ )
2018
+ }
2019
+ }
2020
+ const next: ResolvedFECTrackConfig = {
2021
+ blockProfile,
2022
+ k: config.k ?? previous.k,
2023
+ symbolSize:
2024
+ config.symbolSize != null && config.symbolSize > 0
2025
+ ? config.symbolSize
2026
+ : previous.symbolSize,
2027
+ interleaveDepthMs: config.interleaveDepthMs ?? previous.interleaveDepthMs,
2028
+ fps: config.fps ?? previous.fps,
2029
+ timeoutMs: config.timeoutMs ?? (timingChanged ? 0 : previous.timeoutMs),
2030
+ ...((config.trackKind ?? previous.trackKind) === undefined
2031
+ ? {}
2032
+ : { trackKind: config.trackKind ?? previous.trackKind }),
2033
+ ...((config.codec ?? previous.codec) === undefined
2034
+ ? {}
2035
+ : { codec: config.codec ?? previous.codec }),
2036
+ ...((config.geometry ?? previous.geometry) === undefined
2037
+ ? {}
2038
+ : { geometry: config.geometry ?? previous.geometry }),
2039
+ }
2040
+ next.timeoutMs = this.#computeTimeout(next)
2041
+ if (this.#multicastSymbolDelivery === 'ordered-relay') {
2042
+ // Error on bad init: an ordered-relay stream settles its blocks by the
2043
+ // decoder's interleave horizon, which this geometry must define.
2044
+ this.#orderedHorizonBlocksFor(packetId, next)
2045
+ }
2046
+ if (
2047
+ previous.blockProfile !== undefined &&
2048
+ (previous.blockProfile !== blockProfile || (blockProfile === 'iso-ssbg1-v1' && previous.k !== next.k))
2049
+ ) {
2050
+ // A different profile, or a different maximum K under iso-ssbg1-v1, is
2051
+ // a different block geometry: the stream's blocks must not be read
2052
+ // under the old one's rules.
2053
+ this.#dropIsoLocators(packetId)
2054
+ }
2055
+ this.#trackConfigs.set(packetId, next)
2056
+ this.#refreshDeliveryGapWindow()
2057
+ let policyChanged =
2058
+ (config.geometry !== undefined &&
2059
+ JSON.stringify(config.geometry) !== JSON.stringify(previous.geometry)) ||
2060
+ (config.trackKind !== undefined && config.trackKind !== previous.trackKind)
2061
+ if (streamScopedGeometry !== undefined) {
2062
+ const streamKey = this.#streamKey(packetId, catalogTrackName)
2063
+ if (JSON.stringify(streamScopedGeometry) !== JSON.stringify(this.#streamGeometries.get(streamKey))) {
2064
+ this.#streamGeometries.set(streamKey, streamScopedGeometry)
2065
+ this.#refreshDeliveryGapWindow()
2066
+ policyChanged = true
2067
+ }
2068
+ }
2069
+ if (policyChanged) {
2070
+ // A new catalog epoch for this packet: every classifier whose geometry
2071
+ // changed rebuilds from the new one on its next symbol and announces
2072
+ // its mode again. A stream with its own geometry is untouched by a
2073
+ // sibling's change, but the first stream-scoped geometry on a packet
2074
+ // does change the siblings that have none (stream-geometry-absent).
2075
+ this.#bumpEpoch(packetId)
2076
+ for (const [key, classifier] of [...this.#classifiers]) {
2077
+ if (!this.#belongsToPacket(key, packetId)) continue
2078
+ const stream = this.#parseStreamKey(key)
2079
+ const geometry = this.#geometryFor(stream.packetId, stream.catalogTrackName)
2080
+ if (JSON.stringify(geometry) === JSON.stringify(classifier.geometry)) continue
2081
+ this.#classifiers.delete(key)
2082
+ // Blocks still unresolved lose the evidence their class rested on:
2083
+ // under the fresh classifier they read `unknown`. Say so, so a queued
2084
+ // or running P2 repair for a former delta block is promoted to P0
2085
+ // instead of silently keeping a priority its class no longer has.
2086
+ this.#applyClassifierChanges(stream.packetId, stream.catalogTrackName, classifier.retire())
2087
+ }
2088
+ }
2089
+ return next
2090
+ }
2091
+
2092
+ #normalizeSymbolData(packetId: number, data: Uint8Array): Uint8Array {
2093
+ const symbolSize = this.symbolSizeForPacket(packetId)
2094
+ if (symbolSize <= 0 || data.byteLength === symbolSize) return data
2095
+ const normalized = new Uint8Array(symbolSize)
2096
+ normalized.set(data.subarray(0, symbolSize))
2097
+ return normalized
2098
+ }
2099
+
2100
+ #blockKeysFor(sbn: number, packetId?: number, catalogTrackName?: string): string[] {
2101
+ if (packetId != null && catalogTrackName !== undefined) {
2102
+ return [this.#blockKey(sbn, packetId, catalogTrackName)]
2103
+ }
2104
+
2105
+ // Every key held by a block-keyed lookup set is indexed under its SBN
2106
+ // (#indexBlockKey), so this visits only the handful of streams sharing the
2107
+ // SBN instead of every retained block. #cleanlyCompletedBlocks is a subset
2108
+ // of #completedBlocks and needs no separate check.
2109
+ const keys: string[] = []
2110
+ const candidates = this.#blockKeysBySbn.get(sbn)
2111
+ if (candidates) {
2112
+ for (const [key, keyPacketId] of candidates) {
2113
+ if (packetId !== undefined && keyPacketId !== packetId) continue
2114
+ if (!this.#isBlockKeyHeld(key)) {
2115
+ candidates.delete(key)
2116
+ continue
2117
+ }
2118
+ keys.push(key)
2119
+ }
2120
+ if (candidates.size === 0) this.#blockKeysBySbn.delete(sbn)
2121
+ }
2122
+
2123
+ if (packetId !== undefined) {
2124
+ if (keys.length === 0) return [this.#blockKey(sbn, packetId)]
2125
+ return keys.length === 1 ? keys : []
2126
+ }
2127
+ return keys.length > 0 ? keys : [this.#blockKey(sbn, 0, catalogTrackName)]
2128
+ }
2129
+
2130
+ // -----------------------------------------------------------------------
2131
+ // Accessors
2132
+ // -----------------------------------------------------------------------
2133
+
2134
+ /** Internal state for diagnostics — not part of the public stats API. */
2135
+ get diagnostics() {
2136
+ return {
2137
+ timeoutMs: this.#fecTimeoutMs,
2138
+ activeBlocks: this.#activeBlocks.size,
2139
+ completedBlocks: this.#completedBlocks.size,
2140
+ cleanlyCompletedBlocks: this.#cleanlyCompletedBlocks.size,
2141
+ pendingCounts: Object.fromEntries(this.#blockSourceCounts),
2142
+ fpsDetected: this.#fpsDetected,
2143
+ interleaveDepthMs: this.#fecInterleaveDepthMs,
2144
+ startupAlignedPackets: this.#packetIdsForStreamKeys(this.#startupAlignedPackets),
2145
+ startupPartialBlocks: this.#startupPartialBlocks.size,
2146
+ startupRepairReadyPackets: this.#packetIdsForStreamKeys(this.#startupRepairReadyPackets),
2147
+ startupRepairFailOpenPackets: this.#packetIdsForStreamKeys(this.#startupRepairFailOpenPackets),
2148
+ pendingWorkerCleanups: this.#pendingWorkerCleanups.size,
2149
+ decoderUnavailableEvents: this.#decoderUnavailableEvents,
2150
+ repairTrackedBlocks: this.#blockRepairRsIds.size,
2151
+ multicastSymbolDelivery: this.#multicastSymbolDelivery,
2152
+ orderedFrontiers: Object.fromEntries(this.#orderedFrontiers),
2153
+ orderedPendingBlocks: [...this.#orderedPendingBlocks.values()]
2154
+ .reduce((total, pending) => total + pending.size, 0),
2155
+ lifecycleTrace: this.#lifecycleTrace.map((entry) => ({ ...entry })),
2156
+ }
2157
+ }
2158
+
2159
+ get fecStats(): FecStats {
2160
+ const s = this.#fecStats
2161
+ const affected = s.blocksRecovered + s.blocksFailed
2162
+ const total = s.blocksClean + affected
2163
+ const tracks = [...this.#fecTrackStats.entries()]
2164
+ .sort(([left], [right]) => left - right)
2165
+ .map(([packetId, stats]) => {
2166
+ const trackAffected = stats.blocksRecovered + stats.blocksFailed
2167
+ const trackTotal = stats.blocksClean + trackAffected
2168
+ return {
2169
+ ...stats,
2170
+ packetId,
2171
+ blocksComplete: trackTotal,
2172
+ k: this.#resolveTrackConfig(packetId).k,
2173
+ recoveryRate: trackAffected > 0
2174
+ ? (stats.blocksRecovered / trackAffected) * 100
2175
+ : 100,
2176
+ packetLossRate: trackTotal > 0 ? (trackAffected / trackTotal) * 100 : 0,
2177
+ }
2178
+ })
2179
+ return {
2180
+ ...s,
2181
+ blocksComplete: total,
2182
+ mixedPathBlocks: Array.from(this.#blockPathCounts.values()).filter(
2183
+ (block) => block.multicastSymbols > 0 && block.moqSymbols > 0,
2184
+ ).length,
2185
+ recentBlockPathCounts: Array.from(this.#blockPathCounts.values()).slice(-24),
2186
+ k: this.#fecK,
2187
+ // True recovery rate: of blocks that HAD loss, how many were recovered?
2188
+ recoveryRate: affected > 0 ? (s.blocksRecovered / affected) * 100 : 100,
2189
+ packetLossRate: total > 0 ? (affected / total) * 100 : 0,
2190
+ tracks,
2191
+ workers: [...this.#fecWorkerStats.entries()]
2192
+ .sort(([left], [right]) => left - right)
2193
+ .map(([packetId, stats]) => ({
2194
+ ...stats,
2195
+ config: {
2196
+ ...stats.config,
2197
+ resolution: stats.config.resolution ? { ...stats.config.resolution } : undefined,
2198
+ },
2199
+ blocks: stats.blocks.map((block) => ({ ...block })),
2200
+ decoder: stats.decoder ? { ...stats.decoder } : undefined,
2201
+ altaFailedDetails: stats.altaFailedDetails?.map((detail) => ({ ...detail })),
2202
+ packetId,
2203
+ })),
2204
+ repairSymbolSizeMismatches: this.#repairSymbolSizeMismatchCount,
2205
+ workerErrors: Object.fromEntries(this.#workerErrorCounts),
2206
+ }
2207
+ }
2208
+
2209
+ /**
2210
+ * Observe Worker-backed per-track snapshots without taking ownership of the
2211
+ * legacy single-callback slot. Infrastructure diagnostics and a player may
2212
+ * therefore consume the same source event without wrapping or replacing one
2213
+ * another's callback.
2214
+ */
2215
+ addFecStatsObserver(
2216
+ observer: (packetId: number, stats: FecTrackStats) => void,
2217
+ ): () => void {
2218
+ this.#fecStatsObservers.add(observer)
2219
+ return () => this.#fecStatsObservers.delete(observer)
2220
+ }
2221
+
2222
+ /** Start a new multicast transport join without discarding track/decoder state. */
2223
+ beginMulticastJoin(): void {
2224
+ // A new subscription is a new relay: stream order is only defined within
2225
+ // one. Blocks still pending from the last join settle once the new
2226
+ // stream's own frontier passes them.
2227
+ this.#orderedFrontiers.clear()
2228
+ this.#startupAlignedPackets.clear()
2229
+ this.#startupPartialBlocks.clear()
2230
+ this.#startupRequireRepairReady = true
2231
+ this.#startupRepairReadyPackets.clear()
2232
+ this.#startupRepairBoundaryCounts.clear()
2233
+ this.#startupRepairFailOpenPackets.clear()
2234
+ }
2235
+
2236
+ /** How the multicast leg currently delivers symbols. */
2237
+ get multicastSymbolDelivery(): MulticastSymbolDelivery {
2238
+ return this.#multicastSymbolDelivery
2239
+ }
2240
+
2241
+ /**
2242
+ * Declare how the multicast leg delivers symbols (see
2243
+ * MulticastSymbolDelivery). The multicast provider owns this fact:
2244
+ * MulticastManager applies what the window.multicast subscription declares
2245
+ * on every join, and a consumer feeding packets itself declares its own leg.
2246
+ *
2247
+ * Switching to 'ordered-relay' requires every configured track to define
2248
+ * its interleave horizon and throws otherwise. Switching back to 'datagram'
2249
+ * gives every block awaiting ordered settlement the datagram inactivity
2250
+ * deadline from now.
2251
+ */
2252
+ setMulticastSymbolDelivery(delivery: MulticastSymbolDelivery): void {
2253
+ if (!isMulticastSymbolDelivery(delivery)) {
2254
+ throw new Error(
2255
+ `[FECManager] multicast symbol delivery must be 'datagram' or ` +
2256
+ `'ordered-relay', got ${String(delivery)}`,
2257
+ )
2258
+ }
2259
+ if (delivery === this.#multicastSymbolDelivery) return
2260
+ // A different delivery path: gaps measured on the old one say nothing
2261
+ // about it (and ordered-relay never feeds the estimator).
2262
+ this.#deliveryGaps.reset()
2263
+ if (delivery === 'ordered-relay') {
2264
+ for (const [packetId, config] of this.#trackConfigs) {
2265
+ this.#orderedHorizonBlocksFor(packetId, config)
2266
+ }
2267
+ this.#multicastSymbolDelivery = delivery
2268
+ return
2269
+ }
2270
+ this.#multicastSymbolDelivery = delivery
2271
+ const pending = [...this.#orderedPendingBlocks.values()]
2272
+ .flatMap((blocks) => [...blocks.values()])
2273
+ this.#orderedPendingBlocks.clear()
2274
+ this.#orderedFrontiers.clear()
2275
+ for (const key of pending) {
2276
+ const block = this.#parseBlockKey(key)
2277
+ this.noteBlockActivity(block.sbn, block.packetId, block.catalogTrackName)
2278
+ }
2279
+ }
2280
+
2281
+ /** Begin a fresh decode epoch for one physical stream. */
2282
+ resetTrack(packetId: number, catalogTrackName: string): void {
2283
+ if (!Number.isInteger(packetId) || packetId < 0) {
2284
+ throw new Error(`[FECManager] invalid packetId for reset: ${packetId}`)
2285
+ }
2286
+ if (!catalogTrackName) {
2287
+ throw new Error('[FECManager] catalogTrackName is required for track reset')
2288
+ }
2289
+ const resetMap = <T>(map: Map<string, T>): void => {
2290
+ for (const key of map.keys()) {
2291
+ if (!this.#belongsToStream(key, packetId, catalogTrackName)) continue
2292
+ map.delete(key)
2293
+ }
2294
+ }
2295
+ const resetTimerMap = (map: Map<string, DeadlineTimer | null>): void => {
2296
+ for (const [key, timer] of map) {
2297
+ if (!this.#belongsToStream(key, packetId, catalogTrackName)) continue
2298
+ if (timer?.handle != null) clearTimeout(timer.handle)
2299
+ map.delete(key)
2300
+ }
2301
+ }
2302
+ const resetSet = (set: Set<string>): void => {
2303
+ for (const key of set) {
2304
+ if (this.#belongsToStream(key, packetId, catalogTrackName)) set.delete(key)
2305
+ }
2306
+ }
2307
+
2308
+ resetTimerMap(this.#activeBlocks)
2309
+ resetTimerMap(this.#repairDeadlines)
2310
+ resetMap(this.#activeBlockDeadlinesMs)
2311
+ resetMap(this.#blockWaitDeadlinesMs)
2312
+ resetMap(this.#repairHoldUntilMs)
2313
+ resetMap(this.#repairDeadlineAtMs)
2314
+ // Queued terminal records of this stream no longer match an expiry entry,
2315
+ // so the retirement sweep skips them and cannot retire a reused key early.
2316
+ resetMap(this.#terminalExpiryMs)
2317
+ resetMap(this.#pendingWorkerCleanupClients)
2318
+ for (const key of [...this.#pendingWorkerCleanupBounds.keys()]) {
2319
+ if (this.#belongsToStream(key, packetId, catalogTrackName)) this.#clearOwedCleanupBound(key)
2320
+ }
2321
+ resetMap(this.#blockSourceCounts)
2322
+ resetMap(this.#blockSourceEsis)
2323
+ resetMap(this.#blockAuthoredLengths)
2324
+ resetMap(this.#blockRepairRsIds)
2325
+ resetMap(this.#blockPathCounts)
2326
+ resetMap(this.#blockAnchorsMs)
2327
+ resetMap(this.#blockCatalogTrackNames)
2328
+ resetMap(this.#syncDecoderBlockOwners)
2329
+ resetMap(this.#startupRepairBoundaryCounts)
2330
+ resetMap(this.#orderedFrontiers)
2331
+ resetMap(this.#orderedPendingBlocks)
2332
+ resetSet(this.#pendingWorkerCleanups)
2333
+ resetSet(this.#completedBlocks)
2334
+ resetSet(this.#cleanlyCompletedBlocks)
2335
+ resetSet(this.#repairRequestedBlocks)
2336
+ resetSet(this.#workerRecoveredBlocks)
2337
+ resetSet(this.#startupAlignedPackets)
2338
+ resetSet(this.#startupPartialBlocks)
2339
+ resetSet(this.#startupRepairReadyPackets)
2340
+ resetSet(this.#startupRepairFailOpenPackets)
2341
+ resetSet(this.#seenSymbols)
2342
+ resetSet(this.#failedBlocks)
2343
+ resetSet(this.#repairedBlocks)
2344
+ resetSet(this.#materializedBlocks)
2345
+ resetMap(this.#resolvedBlockClasses)
2346
+ resetMap(this.#highestSbn)
2347
+ resetMap(this.#isoLocators)
2348
+ resetMap(this.#sourceAnchors)
2349
+ this.#classifiers.delete(this.#streamKey(packetId, catalogTrackName))
2350
+ this.#bumpEpoch(packetId)
2351
+ for (const [sbn, keys] of this.#blockKeysBySbn) {
2352
+ for (const key of keys.keys()) {
2353
+ if (this.#belongsToStream(key, packetId, catalogTrackName)) keys.delete(key)
2354
+ }
2355
+ if (keys.size === 0) this.#blockKeysBySbn.delete(sbn)
2356
+ }
2357
+
2358
+ const decoderClients = new Set<WasmFecDecoder>()
2359
+ for (const [key, entry] of this.#fecDecoders) {
2360
+ if (this.#belongsToStream(key, packetId, catalogTrackName)) {
2361
+ decoderClients.add(entry.client)
2362
+ }
2363
+ }
2364
+ const clients = new Set<object>()
2365
+ for (const client of decoderClients) {
2366
+ if (
2367
+ typeof client.flush === 'function' &&
2368
+ typeof client.reset_stats === 'function'
2369
+ ) {
2370
+ try {
2371
+ client.flush()
2372
+ client.reset_stats()
2373
+ clients.add(client)
2374
+ } catch {
2375
+ this.#discardFecDecoderClient(client)
2376
+ }
2377
+ } else {
2378
+ this.#discardFecDecoderClient(client)
2379
+ }
2380
+ }
2381
+ for (const [key, client] of [...this.#workerClients]) {
2382
+ if (!this.#belongsToStream(key, packetId, catalogTrackName)) continue
2383
+ if (client.disposed === true) {
2384
+ // reset() throws on a disposed client; evict it (reporting the
2385
+ // decoder unavailable) instead of resetting a Worker that is gone.
2386
+ this.#evictDisposedWorker(client)
2387
+ continue
2388
+ }
2389
+ client.reset()
2390
+ clients.add(client)
2391
+ }
2392
+ for (const client of clients) this.#resetDecoderBlockOwners(client, catalogTrackName)
2393
+ }
2394
+
2395
+ /**
2396
+ * Retire one packet ID, drop-only: forget every stream on it (block
2397
+ * ledgers, deadlines and their timers, repair holds, terminal records,
2398
+ * classifiers, stream geometry), its per-packet configuration and
2399
+ * statistics, and take its decoders out of routing.
2400
+ *
2401
+ * This is the end of the packet ID's streams, not a new epoch of them (that
2402
+ * is resetTrack): afterwards isTrackConfigured() is false and nothing about
2403
+ * it remains until it is configured again. Nothing is reported, because
2404
+ * nothing happened to the media: no block fails, recovers or resolves, no
2405
+ * repair resolves, and no decoder becomes "unavailable" (a retired stream
2406
+ * has no symbols left to decode, so a decoder-unavailable event would
2407
+ * describe lost protection that no stream suffers). The epoch still
2408
+ * advances, so a gap decision made before the retirement never matches a
2409
+ * stream later configured on the same packet ID.
2410
+ *
2411
+ * Decoder and Worker clients stay caller-owned: each is flushed or reset so
2412
+ * it holds no block of the retired streams, then released from routing;
2413
+ * dispose it after this returns. Cumulative totals in fecStats keep what
2414
+ * the retired streams contributed.
2415
+ */
2416
+ releasePacket(packetId: number): void {
2417
+ if (!Number.isInteger(packetId) || packetId < 0) {
2418
+ throw new Error(`[FECManager] invalid packetId for release: ${packetId}`)
2419
+ }
2420
+ const onPacket = (key: string): boolean => this.#belongsToPacket(key, packetId)
2421
+ const dropMap = <T>(map: Map<string, T>): void => {
2422
+ for (const key of [...map.keys()]) if (onPacket(key)) map.delete(key)
2423
+ }
2424
+ const dropTimerMap = (map: Map<string, DeadlineTimer | null>): void => {
2425
+ for (const [key, timer] of [...map]) {
2426
+ if (!onPacket(key)) continue
2427
+ if (timer?.handle != null) clearTimeout(timer.handle)
2428
+ map.delete(key)
2429
+ }
2430
+ }
2431
+ const dropSet = (set: Set<string>): void => {
2432
+ for (const key of [...set]) if (onPacket(key)) set.delete(key)
2433
+ }
2434
+
2435
+ dropTimerMap(this.#activeBlocks)
2436
+ dropTimerMap(this.#repairDeadlines)
2437
+ for (const key of [...this.#pendingWorkerCleanupBounds.keys()]) {
2438
+ if (onPacket(key)) this.#clearOwedCleanupBound(key)
2439
+ }
2440
+ dropMap(this.#activeBlockDeadlinesMs)
2441
+ dropMap(this.#blockWaitDeadlinesMs)
2442
+ dropMap(this.#repairHoldUntilMs)
2443
+ dropMap(this.#repairDeadlineAtMs)
2444
+ dropMap(this.#terminalExpiryMs)
2445
+ dropMap(this.#pendingWorkerCleanupClients)
2446
+ dropMap(this.#blockSourceCounts)
2447
+ dropMap(this.#blockSourceEsis)
2448
+ dropMap(this.#blockAuthoredLengths)
2449
+ dropMap(this.#blockRepairRsIds)
2450
+ dropMap(this.#blockPathCounts)
2451
+ dropMap(this.#blockAnchorsMs)
2452
+ dropMap(this.#blockCatalogTrackNames)
2453
+ dropMap(this.#syncDecoderBlockOwners)
2454
+ dropMap(this.#startupRepairBoundaryCounts)
2455
+ dropMap(this.#orderedFrontiers)
2456
+ dropMap(this.#orderedPendingBlocks)
2457
+ dropMap(this.#resolvedBlockClasses)
2458
+ dropMap(this.#highestSbn)
2459
+ dropMap(this.#isoLocators)
2460
+ dropMap(this.#sourceAnchors)
2461
+ dropMap(this.#classifiers)
2462
+ dropMap(this.#streamGeometries)
2463
+ dropSet(this.#pendingWorkerCleanups)
2464
+ dropSet(this.#completedBlocks)
2465
+ dropSet(this.#cleanlyCompletedBlocks)
2466
+ dropSet(this.#repairRequestedBlocks)
2467
+ dropSet(this.#workerRecoveredBlocks)
2468
+ dropSet(this.#startupAlignedPackets)
2469
+ dropSet(this.#startupPartialBlocks)
2470
+ dropSet(this.#startupRepairReadyPackets)
2471
+ dropSet(this.#startupRepairFailOpenPackets)
2472
+ dropSet(this.#seenSymbols)
2473
+ dropSet(this.#failedBlocks)
2474
+ dropSet(this.#repairedBlocks)
2475
+ dropSet(this.#materializedBlocks)
2476
+ for (const [sbn, keys] of [...this.#blockKeysBySbn]) {
2477
+ for (const [key, keyPacketId] of [...keys]) {
2478
+ if (keyPacketId === packetId) keys.delete(key)
2479
+ }
2480
+ if (keys.size === 0) this.#blockKeysBySbn.delete(sbn)
2481
+ }
2482
+ this.#terminalQueues.delete(packetId)
2483
+
2484
+ // Bookkeeping first: a decoder or Worker client whose teardown throws must
2485
+ // not leave the packet ID configured or its epoch unbumped, or a gap
2486
+ // decision made before the release could match a stream later configured
2487
+ // on the same packet ID.
2488
+ this.#trackConfigs.delete(packetId)
2489
+ this.#fecTrackStats.delete(packetId)
2490
+ this.#fecWorkerStats.delete(packetId)
2491
+ this.#bumpEpoch(packetId)
2492
+ // The retired stream's keyframe cadence no longer widens the gap window.
2493
+ // The delivery-gap estimator's per-packet sequence is kept: it measures
2494
+ // the link, which the retired packet ID's datagrams still describe while
2495
+ // a rendition-stable allow-list keeps delivering them.
2496
+ this.#refreshDeliveryGapWindow()
2497
+
2498
+ for (const [key, entry] of [...this.#fecDecoders]) {
2499
+ if (!onPacket(key)) continue
2500
+ this.#fecDecoders.delete(key)
2501
+ this.#decoderBlockOwners.delete(entry.client)
2502
+ try {
2503
+ entry.client.flush?.()
2504
+ } catch {
2505
+ // The client is leaving routing either way; a decoder that cannot
2506
+ // flush holds nothing this manager will ask it for again.
2507
+ }
2508
+ }
2509
+ for (const [key, client] of [...this.#workerClients]) {
2510
+ if (!onPacket(key)) continue
2511
+ this.#workerClients.delete(key)
2512
+ this.#decoderBlockOwners.delete(client)
2513
+ this.#releasedWorkerClients.add(client)
2514
+ try {
2515
+ if (client.disposed !== true) client.reset()
2516
+ } catch {
2517
+ // Already out of routing, and its late messages are ignored
2518
+ // (#releasedWorkerClients); a Worker that cannot reset holds nothing
2519
+ // this manager will ask it for again.
2520
+ }
2521
+ }
2522
+ }
2523
+
2524
+ /** Record a transport symbol and reject later duplicates of the same packetId/SBN/ESI. */
2525
+ feedSymbolFromSource(
2526
+ packetId: number,
2527
+ sbn: number,
2528
+ esi: number,
2529
+ _data: Uint8Array,
2530
+ source: FecSymbolSource,
2531
+ catalogTrackName?: string,
2532
+ ): boolean {
2533
+ const blockKey = this.#blockKey(sbn, packetId, catalogTrackName)
2534
+ const streamKey = this.#streamKey(packetId, catalogTrackName)
2535
+ if (source === 'multicast' && this.#multicastSymbolDelivery === 'ordered-relay') {
2536
+ // Every multicast symbol is a stream position on an ordered relay, even
2537
+ // one refused below as a join partial or a duplicate.
2538
+ this.#advanceOrderedFrontier(streamKey, sbn, packetId, catalogTrackName)
2539
+ }
2540
+ const k = this.kForPacket(packetId)
2541
+ // iso-ssbg1-v1 repair ESIs start at the block's own SSB_length (K'), which
2542
+ // its locator states; k-padded-v1 repair ESIs start at K.
2543
+ const sourceSymbols = this.#isIso(packetId)
2544
+ ? this.#isoLocatorFor(packetId, catalogTrackName).blockAt(sbn)?.length ?? k
2545
+ : k
2546
+ const isSourceSymbol = esi >= 0 && esi < sourceSymbols
2547
+ const isStartupPartial = this.#startupPartialBlocks.has(blockKey)
2548
+ const isReadyMulticastRepair =
2549
+ this.#startupRequireRepairReady &&
2550
+ source === 'multicast' &&
2551
+ !isSourceSymbol &&
2552
+ this.#startupRepairReadyPackets.has(streamKey)
2553
+ const awaitingMulticastRepair =
2554
+ this.#startupRequireRepairReady &&
2555
+ source === 'multicast' &&
2556
+ !this.#startupRepairReadyPackets.has(streamKey) &&
2557
+ !this.#startupRepairFailOpenPackets.has(streamKey)
2558
+ if (awaitingMulticastRepair) {
2559
+ if (!isSourceSymbol) {
2560
+ this.#startupRepairReadyPackets.add(streamKey)
2561
+ this.#startupRepairBoundaryCounts.delete(streamKey)
2562
+ this.#markStartupPartial(blockKey)
2563
+ return false
2564
+ }
2565
+
2566
+ if (esi === 0) {
2567
+ const boundaries = (this.#startupRepairBoundaryCounts.get(streamKey) ?? 0) + 1
2568
+ this.#startupRepairBoundaryCounts.set(streamKey, boundaries)
2569
+ if (boundaries >= STARTUP_REPAIR_WAIT_SOURCE_BOUNDARIES) {
2570
+ this.#startupRepairBoundaryCounts.delete(streamKey)
2571
+ this.#startupRepairFailOpenPackets.add(streamKey)
2572
+ } else {
2573
+ this.#markStartupPartial(blockKey)
2574
+ return false
2575
+ }
2576
+ } else {
2577
+ this.#markStartupPartial(blockKey)
2578
+ return false
2579
+ }
2580
+ }
2581
+ if (isSourceSymbol && esi === 0) {
2582
+ // ESI=0 establishes a complete source boundary for this SBN. Remove
2583
+ // only this block: other interleaved SBNs may still be join partials.
2584
+ this.#startupPartialBlocks.delete(blockKey)
2585
+ this.#startupAlignedPackets.add(streamKey)
2586
+ } else if (
2587
+ (isStartupPartial && !isReadyMulticastRepair) ||
2588
+ (!this.#startupAlignedPackets.has(streamKey) && !isReadyMulticastRepair)
2589
+ ) {
2590
+ this.#markStartupPartial(blockKey)
2591
+ return false
2592
+ }
2593
+
2594
+ // A completed block remains authoritative after its individual symbol
2595
+ // identities age out of the bounded #seenSymbols cache. Without this
2596
+ // guard, a delayed copy from the other transport can recreate the block
2597
+ // inside a Worker decoder; that orphan never receives the full source and
2598
+ // repair set, so cleanup later reports a false failure/green fill.
2599
+ if (this.#completedBlocks.has(blockKey)) {
2600
+ this.#fecStats.duplicateSymbols++
2601
+ this.#statsForPacket(packetId).duplicateSymbols++
2602
+ return false
2603
+ }
2604
+
2605
+ const symbolKey = `${blockKey}:${esi}`
2606
+ if (this.#seenSymbols.has(symbolKey)) {
2607
+ this.#fecStats.duplicateSymbols++
2608
+ this.#statsForPacket(packetId).duplicateSymbols++
2609
+ return false
2610
+ }
2611
+ this.#seenSymbols.add(symbolKey)
2612
+
2613
+ const counts = this.#blockPathCounts.get(blockKey) ?? {
2614
+ packetId,
2615
+ sbn,
2616
+ ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
2617
+ multicastSymbols: 0,
2618
+ moqSymbols: 0,
2619
+ complete: false,
2620
+ }
2621
+ // The block's absolute anchor (fecBlockDeadlineMs): the receiver-local
2622
+ // time of its first accepted symbol, from which its presentation deadline
2623
+ // is measured.
2624
+ if (!this.#blockAnchorsMs.has(blockKey)) this.#blockAnchorsMs.set(blockKey, performance.now())
2625
+ if (source === 'multicast') counts.multicastSymbols++
2626
+ else counts.moqSymbols++
2627
+ this.#blockPathCounts.set(blockKey, counts)
2628
+ this.#observeBlockCatalogTrackName(blockKey, sbn, packetId, catalogTrackName)
2629
+
2630
+ // Keep the newest entries of each bounded cache. Deleting the oldest in
2631
+ // place keeps exactly the entries a slice(-n) rebuild would, without
2632
+ // copying the whole collection into an array on the page thread.
2633
+ if (this.#seenSymbols.size > 4096) {
2634
+ dropOldest(this.#seenSymbols, this.#seenSymbols.size - 2048)
2635
+ }
2636
+ if (this.#blockPathCounts.size > 512) {
2637
+ dropOldest(this.#blockPathCounts, this.#blockPathCounts.size - 256)
2638
+ }
2639
+ if (this.#blockAnchorsMs.size > 512) {
2640
+ dropOldest(this.#blockAnchorsMs, this.#blockAnchorsMs.size - 256)
2641
+ }
2642
+ if (this.#blockCatalogTrackNames.size > 512) {
2643
+ for (const key of dropOldest(
2644
+ this.#blockCatalogTrackNames,
2645
+ this.#blockCatalogTrackNames.size - 256,
2646
+ )) {
2647
+ this.#unindexBlockKey(key)
2648
+ }
2649
+ }
2650
+
2651
+ return true
2652
+ }
2653
+
2654
+ #observeBlockCatalogTrackName(
2655
+ blockKey: string,
2656
+ sbn: number,
2657
+ packetId: number,
2658
+ catalogTrackName?: string,
2659
+ ): void {
2660
+ if (catalogTrackName === undefined) return
2661
+ if (!this.#blockCatalogTrackNames.has(blockKey)) {
2662
+ this.#blockCatalogTrackNames.set(blockKey, catalogTrackName)
2663
+ this.#indexBlockKey(blockKey, sbn, packetId)
2664
+ return
2665
+ }
2666
+ if (this.#blockCatalogTrackNames.get(blockKey) !== catalogTrackName) {
2667
+ this.#blockCatalogTrackNames.set(blockKey, null)
2668
+ }
2669
+ }
2670
+
2671
+ /** Exact catalog identity retained for async recovery, if unambiguous. */
2672
+ catalogTrackNameForBlock(
2673
+ sbn: number,
2674
+ packetId: number,
2675
+ catalogTrackName?: string,
2676
+ ): string | undefined {
2677
+ if (catalogTrackName !== undefined) {
2678
+ return this.#blockCatalogTrackNames.get(
2679
+ this.#blockKey(sbn, packetId, catalogTrackName),
2680
+ ) ?? undefined
2681
+ }
2682
+ const names = new Set<string>()
2683
+ for (const [key, name] of this.#blockCatalogTrackNames) {
2684
+ const block = this.#parseBlockKey(key)
2685
+ if (block.packetId === packetId && block.sbn === sbn && name !== null) names.add(name)
2686
+ }
2687
+ return names.size === 1 ? [...names][0] : undefined
2688
+ }
2689
+
2690
+ #markStartupPartial(blockKey: string): void {
2691
+ this.#startupPartialBlocks.add(blockKey)
2692
+ if (this.#startupPartialBlocks.size > 128) {
2693
+ this.#startupPartialBlocks = new Set(
2694
+ Array.from(this.#startupPartialBlocks).slice(-64),
2695
+ )
2696
+ }
2697
+ }
2698
+
2699
+ /** Per-block state snapshot for FecBlockGrid visualization.
2700
+ * Returns the most recent blocks (active + recently completed). */
2701
+ getBlockStates(): Array<{
2702
+ sbn: number;
2703
+ packetId?: number;
2704
+ state: 'collecting' | 'complete' | 'failed';
2705
+ sourceCount: number;
2706
+ k: number;
2707
+ progress: number;
2708
+ }> {
2709
+ const blocks: Array<{ sbn: number; packetId?: number; state: 'collecting' | 'complete' | 'failed'; sourceCount: number; k: number; progress: number }> = [];
2710
+ // Active blocks (still collecting symbols)
2711
+ for (const [key] of this.#activeBlocks) {
2712
+ const { packetId, sbn } = this.#parseBlockKey(key);
2713
+ const count = this.#blockSourceCounts.get(key) ?? 0;
2714
+ const k = this.#blockAuthoredLengths.get(key) ?? this.kForPacket(packetId);
2715
+ blocks.push({ sbn, packetId, state: 'collecting', sourceCount: count, k, progress: k > 0 ? Math.min(count / k, 1) : 0 });
2716
+ }
2717
+ // Recently completed blocks (last 16)
2718
+ const completed = [...this.#completedBlocks].slice(-16);
2719
+ for (const key of completed) {
2720
+ const { packetId, sbn } = this.#parseBlockKey(key);
2721
+ if (!this.#activeBlocks.has(key)) {
2722
+ const k = this.kForPacket(packetId);
2723
+ blocks.push({ sbn, packetId, state: 'complete', sourceCount: k, k, progress: 1 });
2724
+ }
2725
+ }
2726
+ blocks.sort((a, b) => a.sbn - b.sbn || (a.packetId ?? 0) - (b.packetId ?? 0));
2727
+ return blocks.slice(-24); // cap at 24 most recent
2728
+ }
2729
+
2730
+ /** The raw WASM decoder for stats access (first registered decoder) */
2731
+ get wasmDecoder(): WasmFecDecoder | null {
2732
+ return this.#fecDecoders.values().next().value?.client ?? null
2733
+ }
2734
+
2735
+ get k(): number { return this.#fecK }
2736
+ kForPacket(packetId: number): number { return this.#resolveTrackConfig(packetId).k }
2737
+ /** FEC symbol size in bytes. */
2738
+ get symbolSize(): number { return this.#fecSymbolSize }
2739
+ symbolSizeForPacket(packetId: number): number { return this.#resolveTrackConfig(packetId).symbolSize }
2740
+ /** FEC block span in milliseconds (0 = no interleaving). */
2741
+ get interleaveDepthMs(): number { return this.#fecInterleaveDepthMs }
2742
+ interleaveDepthMsForPacket(packetId: number): number { return this.#resolveTrackConfig(packetId).interleaveDepthMs }
2743
+ /**
2744
+ * Record one multicast MMTP packet arrival (every packet of the leg, FEC
2745
+ * symbol or not) for the delivery-gap measurement. `packetSequenceNumber`
2746
+ * is the MMTP packet_sequence_number, which increments by one per packet of
2747
+ * a packet_id: it is what separates a stall from a loss (see
2748
+ * DeliveryGapEstimator). Datagram delivery only.
2749
+ */
2750
+ noteMulticastArrival(packetId: number, packetSequenceNumber: number): void {
2751
+ if (this.#multicastSymbolDelivery !== 'datagram') return
2752
+ this.#deliveryGaps.noteArrival(performance.now(), packetId, packetSequenceNumber)
2753
+ }
2754
+ /**
2755
+ * Resolved per-track multicast late-symbol wait in milliseconds: the span
2756
+ * plus the larger of the catalog tolerance and the measured delivery gap.
2757
+ */
2758
+ timeoutMsForPacket(packetId: number): number {
2759
+ return this.#multicastWaitMs(this.#resolveTrackConfig(packetId))
2760
+ }
2761
+ /**
2762
+ * Largest multicast delivery gap measured over the current window (ms), and
2763
+ * that window (undefined: held since the last reset). Diagnostics.
2764
+ */
2765
+ multicastDeliveryGap(): { peakGapMs: number; windowMs: number | undefined } {
2766
+ return {
2767
+ peakGapMs: this.#deliveryGaps.peakGapMs(performance.now()),
2768
+ windowMs: this.#deliveryGaps.windowMs,
2769
+ }
2770
+ }
2771
+ /**
2772
+ * Receiver-local time of a block's first accepted symbol, or undefined for
2773
+ * a block no symbol reached (one materialized because a later SBN arrived).
2774
+ */
2775
+ blockAnchorAtMs(sbn: number, packetId: number, catalogTrackName?: string): number | undefined {
2776
+ return this.#blockAnchorsMs.get(this.#blockKey(sbn, packetId, catalogTrackName))
2777
+ }
2778
+ /** Absolute fail deadline of a still-open block, or undefined. */
2779
+ blockDeadlineAtMs(sbn: number, packetId: number, catalogTrackName?: string): number | undefined {
2780
+ return this.#activeBlockDeadlinesMs.get(this.#blockKey(sbn, packetId, catalogTrackName))
2781
+ }
2782
+ /**
2783
+ * Keep a block open until `untilAtMs` while a repair FETCH the scheduler
2784
+ * admitted is in flight. Never shortens the multicast wait already armed;
2785
+ * while set, the hold is the block's whole deadline, so no later symbol
2786
+ * extends it. Returns false when the block is no longer open (resolved, or
2787
+ * not yet tracked).
2788
+ */
2789
+ holdBlockForRepair(
2790
+ sbn: number,
2791
+ packetId: number,
2792
+ catalogTrackName: string | undefined,
2793
+ untilAtMs: number,
2794
+ ): boolean {
2795
+ if (!Number.isFinite(untilAtMs)) {
2796
+ throw new Error(`[FECManager] repair hold must be a finite time (got ${untilAtMs})`)
2797
+ }
2798
+ const key = this.#blockKey(sbn, packetId, catalogTrackName)
2799
+ if (!this.#activeBlocks.has(key)) return false
2800
+ const previous = this.#repairHoldUntilMs.get(key)
2801
+ if (previous !== undefined && previous >= untilAtMs) return true
2802
+ // The hold absorbs the wait already armed, so it never shortens it.
2803
+ this.#repairHoldUntilMs.set(
2804
+ key,
2805
+ Math.max(untilAtMs, this.#blockWaitDeadlinesMs.get(key) ?? -Infinity),
2806
+ )
2807
+ this.#applyBlockDeadline(key, sbn, packetId, catalogTrackName)
2808
+ return true
2809
+ }
2810
+ /**
2811
+ * Drop a block's repair hold: its FETCH ended without delivering. The block
2812
+ * falls back to its multicast wait, which may already have passed.
2813
+ */
2814
+ releaseRepairHold(sbn: number, packetId: number, catalogTrackName?: string): void {
2815
+ const key = this.#blockKey(sbn, packetId, catalogTrackName)
2816
+ if (!this.#repairHoldUntilMs.delete(key) || !this.#activeBlocks.has(key)) return
2817
+ this.#applyBlockDeadline(key, sbn, packetId, catalogTrackName)
2818
+ }
2819
+ /** Resolved per-track D-28 repair request deadline in milliseconds. */
2820
+ repairDeadlineMsForPacket(packetId: number): number {
2821
+ return this.#repairDeadlineMs(this.#resolveTrackConfig(packetId))
2822
+ }
2823
+ /**
2824
+ * One block lifetime: the longest a block stays open after its last symbol
2825
+ * (inactivity fail bound plus D-28 repair grace).
2826
+ */
2827
+ blockLifetimeMsForPacket(packetId: number): number {
2828
+ return this.#blockLifetimeMs(this.#resolveTrackConfig(packetId))
2829
+ }
2830
+ /**
2831
+ * How long a terminal (clean, recovered or failed) block stays authoritative,
2832
+ * for late symbols and for the in-order release gate. It is also the gate's
2833
+ * hold ceiling (MmtpRouter's holdLimitMs): a retired block reads back as
2834
+ * untracked, which the gate counts as lost, so no frame may still be waiting
2835
+ * on a block that can retire. The window is one interleave depth for a gap's
2836
+ * last block to finish arriving, one block lifetime to fail it, and one more
2837
+ * for an owed Worker cleanup that can keep a failing block pending. Every
2838
+ * term comes from the track's catalog geometry; there is no separate cap.
2839
+ */
2840
+ terminalRetentionMsForPacket(packetId: number): number {
2841
+ const config = this.#resolveTrackConfig(packetId)
2842
+ return config.interleaveDepthMs + 2 * this.#blockLifetimeMs(config)
2843
+ }
2844
+ /** Remaining fetch budget for a specific repair-requested block. */
2845
+ repairFetchTimeoutMsForBlock(
2846
+ sbn: number,
2847
+ packetId: number,
2848
+ catalogTrackName?: string,
2849
+ ): number {
2850
+ const deadlineMs = this.#activeBlockDeadlinesMs.get(
2851
+ this.#blockKey(sbn, packetId, catalogTrackName),
2852
+ )
2853
+ if (deadlineMs !== undefined) {
2854
+ return Math.max(1, Math.ceil(deadlineMs - performance.now()))
2855
+ }
2856
+ const trackConfig = this.#resolveTrackConfig(packetId)
2857
+ return Math.max(1, trackConfig.timeoutMs - this.#repairDeadlineMs(trackConfig))
2858
+ }
2859
+
2860
+ /** True once configureTrack/setFecDecoder/setFecWorker configured this packetId. */
2861
+ // -----------------------------------------------------------------------
2862
+ // iso-ssbg1-v1 block location (BLO-35597 iso:2/iso:3)
2863
+ // -----------------------------------------------------------------------
2864
+
2865
+ /** The configured `fec.blockProfile` of a packetId (undefined until configured). */
2866
+ blockProfileForPacket(packetId: number): FecBlockProfile | undefined {
2867
+ return this.#trackConfigs.get(packetId)?.blockProfile
2868
+ }
2869
+
2870
+ #isIso(packetId: number): boolean {
2871
+ return this.#trackConfigs.get(packetId)?.blockProfile === 'iso-ssbg1-v1'
2872
+ }
2873
+
2874
+ #isoLocatorFor(packetId: number, catalogTrackName?: string): IsoBlockLocator<IsoHeldSource> {
2875
+ const streamKey = this.#streamKey(packetId, catalogTrackName)
2876
+ let locator = this.#isoLocators.get(streamKey)
2877
+ if (!locator) {
2878
+ locator = new IsoBlockLocator<IsoHeldSource>(this.kForPacket(packetId), this.#isoRetentionBlocks(packetId))
2879
+ this.#isoLocators.set(streamKey, locator)
2880
+ }
2881
+ return locator
2882
+ }
2883
+
2884
+ /**
2885
+ * The locator a call is about to grow, with its retention brought up to
2886
+ * date: the horizon follows catalog timing, which a later configuration
2887
+ * (this track's or another video track's keyframe cadence) can change.
2888
+ */
2889
+ #growingIsoLocatorFor(packetId: number, catalogTrackName?: string): IsoBlockLocator<IsoHeldSource> {
2890
+ const locator = this.#isoLocatorFor(packetId, catalogTrackName)
2891
+ locator.retentionBlocks = this.#isoRetentionBlocks(packetId)
2892
+ return locator
2893
+ }
2894
+
2895
+ #dropIsoLocators(packetId: number): void {
2896
+ for (const key of [...this.#isoLocators.keys()]) {
2897
+ if (this.#belongsToPacket(key, packetId)) this.#isoLocators.delete(key)
2898
+ }
2899
+ }
2900
+
2901
+ /**
2902
+ * The iso-ssbg1-v1 locator horizon in blocks: every block the manager can
2903
+ * still hold terminal state for (`terminalRetentionMsForPacket`) over the
2904
+ * block span, plus the block being filled, measured with the shared
2905
+ * lower-bound metric so a retained block is never forgotten early. Every
2906
+ * term is the packet's catalog geometry, which configuration validated
2907
+ * (#upsertTrackConfig), so this never falls back: an underivable horizon
2908
+ * throws rather than leave the locator without retention.
2909
+ */
2910
+ #isoRetentionBlocks(packetId: number): number {
2911
+ const config = this.#trackConfigs.get(packetId)
2912
+ if (config?.blockProfile !== 'iso-ssbg1-v1' || !(config.interleaveDepthMs >= MIN_INTERLEAVE_DEPTH_MS)) {
2913
+ throw new Error(
2914
+ `[FECManager] packetId=${packetId} has no iso-ssbg1-v1 block span to derive block-location retention from`,
2915
+ )
2916
+ }
2917
+ const blocks = Math.ceil(this.terminalRetentionMsForPacket(packetId) / config.interleaveDepthMs) + 1
2918
+ if (!Number.isSafeInteger(blocks) || blocks <= 0) {
2919
+ throw new Error(`[FECManager] packetId=${packetId} iso-ssbg1-v1 retention of ${blocks} blocks is not derivable`)
2920
+ }
2921
+ return blocks
2922
+ }
2923
+
2924
+ /** Whether an iso-ssbg1-v1 protected packet fits one ssbg_mode1 symbol (len + 2 <= T). */
2925
+ isoSourceFits(packetId: number, packetLength: number): boolean {
2926
+ return ssbg1FitsSymbol(packetLength, this.symbolSizeForPacket(packetId))
2927
+ }
2928
+
2929
+ /**
2930
+ * Locate an iso-ssbg1-v1 source by range membership in a block a repair
2931
+ * already stated. Null while its block is unknown: hold it (holdIsoSource).
2932
+ */
2933
+ locateIsoSource(
2934
+ packetId: number,
2935
+ catalogTrackName: string | undefined,
2936
+ ssId: number,
2937
+ ): { sbn: number; esi: number } | null {
2938
+ const located = this.#isoLocatorFor(packetId, catalogTrackName).locate(ssId)
2939
+ return located === null ? null : { sbn: located.block.start, esi: located.esi }
2940
+ }
2941
+
2942
+ /**
2943
+ * Hold a first-arrival iso-ssbg1-v1 source whose block no repair has stated
2944
+ * yet. Returns false for a later copy of an SS_ID already held.
2945
+ */
2946
+ holdIsoSource(
2947
+ packetId: number,
2948
+ catalogTrackName: string | undefined,
2949
+ ssId: number,
2950
+ source: IsoHeldSource,
2951
+ ): boolean {
2952
+ const locator = this.#growingIsoLocatorFor(packetId, catalogTrackName)
2953
+ const evictedBefore = locator.heldEvicted
2954
+ const held = locator.hold(ssId, source)
2955
+ const evicted = locator.heldEvicted - evictedBefore
2956
+ if (evicted > 0) {
2957
+ const before = this.#isoHeldEvictedCount
2958
+ this.#isoHeldEvictedCount += evicted
2959
+ if (before < 5 || Math.floor(before / 100) !== Math.floor(this.#isoHeldEvictedCount / 100)) {
2960
+ console.warn(
2961
+ `[FECManager] packetId=${packetId} iso-ssbg1-v1 held-source cap (${locator.heldCapacity}) ` +
2962
+ `evicted ${evicted} unplaced source(s); SS_IDs are not consecutive (C.5.2)`,
2963
+ )
2964
+ }
2965
+ }
2966
+ return held
2967
+ }
2968
+
2969
+ /**
2970
+ * Apply an iso-ssbg1-v1 repair symbol's `(SS_Start, SSB_length)`: states the
2971
+ * block, and hands back the held sources it (and any repair-less span in
2972
+ * front of it) now places. The caller feeds them as first-arrival sources,
2973
+ * then the repair symbol, the order they had on the wire. A repair that
2974
+ * contradicts a known block is refused (counted) and must be dropped.
2975
+ */
2976
+ placeIsoRepair(
2977
+ packetId: number,
2978
+ catalogTrackName: string | undefined,
2979
+ ssStart: number,
2980
+ ssbLength: number,
2981
+ ): IsoRepairPlacement<IsoHeldSource> {
2982
+ const placement = this.#growingIsoLocatorFor(packetId, catalogTrackName).placeRepair(ssStart, ssbLength)
2983
+ if (placement.status !== 'placed') {
2984
+ this.#isoRepairRefusedCount++
2985
+ if (this.#isoRepairRefusedCount <= 5 || this.#isoRepairRefusedCount % 100 === 0) {
2986
+ console.warn(`[FECManager] packetId=${packetId} iso-ssbg1-v1 repair refused (${placement.status}): ${placement.reason}`)
2987
+ }
2988
+ return placement
2989
+ }
2990
+ // A repair-less span has no repair to state its length, so it is known
2991
+ // here: its sources make it clean exactly when all of them arrived. A
2992
+ // stated block learns its SSB_length from its own repair symbol, fed
2993
+ // after its placed sources exactly as on the wire (feedRepairSymbol).
2994
+ if (placement.span !== undefined) {
2995
+ const key = this.#blockKey(placement.span.block.start, packetId, catalogTrackName)
2996
+ if (!this.#blockAuthoredLengths.has(key)) this.#blockAuthoredLengths.set(key, placement.span.block.length)
2997
+ }
2998
+
2999
+ if (placement.span !== undefined && placement.span.placed.length === 0) {
3000
+ // Every SS_ID of the span vanished, repair included: it resolves
3001
+ // through its deadline like any vanished block.
3002
+ this.#materializeBlock(placement.span.block.start, packetId, catalogTrackName, true)
3003
+ }
3004
+ return placement
3005
+ }
3006
+
3007
+ /**
3008
+ * Resolution of the SS_IDs strictly between two released frames' SS_IDs on
3009
+ * an iso-ssbg1-v1 stream, for in-order frame release: null while a block
3010
+ * that could hold them is pending or not yet stated by a repair, else the
3011
+ * starts of the blocks that failed or vanished.
3012
+ *
3013
+ * `untracked` reads exactly as in the k-padded-v1 release gate
3014
+ * (MmtpRouter #createFecReleaseGate): a block holding either released
3015
+ * frame's SS_ID (`fromSsId` or `toSsId`) delivered that frame, so untracked
3016
+ * there means it resolved and retired; a block starting after the stream's frontier (`highestSbn`,
3017
+ * an SS_Start here) may still be heard from; a block at or behind the
3018
+ * frontier that no symbol of this manager ever reached vanished, and its
3019
+ * media is lost.
3020
+ */
3021
+ isoSpanResolution(
3022
+ packetId: number,
3023
+ catalogTrackName: string | undefined,
3024
+ fromSsId: number,
3025
+ toSsId: number,
3026
+ ): readonly number[] | null {
3027
+ if (!serial32Precedes(fromSsId, toSsId) || serial32Distance(fromSsId, toSsId) <= 1) return []
3028
+ const locator = this.#isoLocators.get(this.#streamKey(packetId, catalogTrackName))
3029
+ if (!locator || !locator.coversBetween(fromSsId, toSsId)) return null
3030
+ const highest = this.highestSbn(packetId, catalogTrackName)
3031
+ const lost: number[] = []
3032
+ for (const block of locator.blocksBetween(fromSsId, toSsId)) {
3033
+ const resolution = this.blockResolution(block.start, packetId, catalogTrackName)
3034
+ if (resolution === 'pending') return null
3035
+ if (resolution === 'failed') {
3036
+ lost.push(block.start)
3037
+ } else if (resolution === 'untracked') {
3038
+ if (
3039
+ isoBlockContains(block.start, block.length, fromSsId) ||
3040
+ isoBlockContains(block.start, block.length, toSsId)
3041
+ ) {
3042
+ continue
3043
+ }
3044
+ if (highest === undefined || serial32Precedes(highest, block.start)) return null
3045
+ lost.push(block.start)
3046
+ }
3047
+ }
3048
+ return lost
3049
+ }
3050
+
3051
+ /**
3052
+ * iso-ssbg1-v1 block location: repair symbols it refused, oversize sources,
3053
+ * the sources held now for a block no repair has stated yet, and the held
3054
+ * sources the capacity cap evicted (cumulative), and the sources a
3055
+ * synchronous decoder held unplaced and retired at its horizon (cumulative).
3056
+ */
3057
+ get isoDiagnostics(): {
3058
+ repairRefused: number
3059
+ sourceOversize: number
3060
+ heldSources: number
3061
+ heldEvicted: number
3062
+ decoderUnplacedRetired: number
3063
+ decoderUnplacedRetireFailed: number
3064
+ } {
3065
+ let heldSources = 0
3066
+ for (const locator of this.#isoLocators.values()) heldSources += locator.heldCount
3067
+ return {
3068
+ repairRefused: this.#isoRepairRefusedCount,
3069
+ sourceOversize: this.#isoSourceOversizeCount,
3070
+ heldSources,
3071
+ heldEvicted: this.#isoHeldEvictedCount,
3072
+ decoderUnplacedRetired: this.#isoDecoderUnplacedRetired,
3073
+ decoderUnplacedRetireFailed: this.#isoDecoderUnplacedRetireFailed,
3074
+ }
3075
+ }
3076
+
3077
+ isTrackConfigured(packetId: number): boolean {
3078
+ return this.#trackConfigs.has(packetId)
3079
+ }
3080
+
3081
+ /** Catalog/reset epoch of a packet's streams; gap decisions carry it. */
3082
+ epochFor(packetId: number): number {
3083
+ return this.#packetEpochs.get(packetId) ?? 0
3084
+ }
3085
+
3086
+ /** Configured media kind of a packetId, when known. */
3087
+ trackKindFor(packetId: number): TrackKind | undefined {
3088
+ return this.#trackConfigs.get(packetId)?.trackKind
3089
+ }
3090
+
3091
+ /** Configured codec of a packetId, when known. */
3092
+ codecFor(packetId: number): string | undefined {
3093
+ return this.#trackConfigs.get(packetId)?.codec
3094
+ }
3095
+
3096
+ /**
3097
+ * Block classifier geometry of one stream: its own stream-scoped geometry,
3098
+ * else the packet's (fail-closed when absent, or when a sibling stream of
3099
+ * the packet has its own geometry and this one has none).
3100
+ */
3101
+ geometryFor(packetId: number, catalogTrackName?: string): BlockClassifierGeometry {
3102
+ return this.#geometryFor(packetId, catalogTrackName)
3103
+ }
3104
+
3105
+ /** Current classifier mode of a stream. */
3106
+ classifierModeFor(
3107
+ packetId: number,
3108
+ catalogTrackName?: string,
3109
+ ): { mode: ClassifierMode; reason: ClassifierReason | null } {
3110
+ const classifier = this.#classifierFor(packetId, catalogTrackName)
3111
+ return { mode: classifier.mode, reason: classifier.reason }
3112
+ }
3113
+
3114
+ /**
3115
+ * Whether an MPU boundary still bounds this stream's RAPs (see
3116
+ * `BlockClassifier.mpuBoundsRaps`). Gap decisions drop MPU evidence once
3117
+ * it does not. A stream with no classifier yet has shown nothing against it.
3118
+ */
3119
+ mpuBoundsRapsFor(packetId: number, catalogTrackName?: string): boolean {
3120
+ return this.#classifiers.get(this.#streamKey(packetId, catalogTrackName))?.mpuBoundsRaps ?? true
3121
+ }
3122
+
3123
+ /** A block's class now, or frozen at its resolution. */
3124
+ blockClassFor(sbn: number, packetId: number, catalogTrackName?: string): BlockClass {
3125
+ const frozen = this.#resolvedBlockClasses.get(this.#blockKey(sbn, packetId, catalogTrackName))
3126
+ if (frozen !== undefined) return frozen
3127
+ return this.#peekClass(sbn, packetId, catalogTrackName)
3128
+ }
3129
+
3130
+ /**
3131
+ * Lifecycle resolution of one block for in-order frame release: `pending`
3132
+ * while it can still complete, `complete`/`failed` once terminal, and
3133
+ * `untracked` when no symbol of it was ever accepted.
3134
+ */
3135
+ blockResolution(sbn: number, packetId: number, catalogTrackName?: string): FecBlockResolution {
3136
+ return this.#blockResolutionFor(this.#blockKey(sbn, packetId, catalogTrackName))
3137
+ }
3138
+
3139
+ /** Highest SBN any symbol of this stream carried in the current epoch. */
3140
+ highestSbn(packetId: number, catalogTrackName?: string): number | undefined {
3141
+ return this.#highestSbn.get(this.#streamKey(packetId, catalogTrackName))?.sbn
3142
+ }
3143
+
3144
+ /**
3145
+ * Record a RAP frame released in order, for the classifier's RAP-spacing
3146
+ * invariant (consecutive MPUs exactly one keyframe interval apart).
3147
+ */
3148
+ noteReleasedRap(
3149
+ packetId: number,
3150
+ catalogTrackName: string | undefined,
3151
+ mpu: number,
3152
+ presentationTime: bigint,
3153
+ mediaTimescale: number,
3154
+ ): void {
3155
+ if (!this.#trackConfigs.has(packetId)) return
3156
+ this.#applyClassifierChanges(
3157
+ packetId,
3158
+ catalogTrackName,
3159
+ this.#classifierFor(packetId, catalogTrackName).noteReleasedRap(
3160
+ mpu,
3161
+ presentationTime,
3162
+ mediaTimescale,
3163
+ ),
3164
+ )
3165
+ }
3166
+
3167
+ // -----------------------------------------------------------------------
3168
+ // Configuration
3169
+ // -----------------------------------------------------------------------
3170
+
3171
+ /** Configure FEC parameters. */
3172
+ configure(config: FECManagerConfig): void {
3173
+ if (config.k != null) this.#fecK = config.k
3174
+ if (config.symbolSize != null && config.symbolSize > 0) this.#fecSymbolSize = config.symbolSize
3175
+ if (config.interleaveDepthMs != null) this.#fecInterleaveDepthMs = config.interleaveDepthMs
3176
+ if (config.fps != null && Number.isFinite(config.fps) && config.fps > 0) {
3177
+ this.#fecFps = config.fps
3178
+ }
3179
+ // interleaveDepthMs is in milliseconds per spec §5.1.
3180
+ // Valid range: 33ms (interactive/gaming) to 10000ms (broadcast ATSC 3.0).
3181
+ // Warn if value looks like frame count instead of ms (common misconfiguration).
3182
+ if (config.interleaveDepthMs != null && config.interleaveDepthMs > 0 && config.interleaveDepthMs < MIN_INTERLEAVE_DEPTH_MS) {
3183
+ console.warn(`[FECManager] interleaveDepthMs=${config.interleaveDepthMs} looks like frame count, not milliseconds (§5.1 requires ms). Expected range: ${MIN_INTERLEAVE_DEPTH_MS}-10000ms.`)
3184
+ }
3185
+ if (config.timeoutMs != null && config.timeoutMs > 0) {
3186
+ this.#fecTimeoutMs = config.timeoutMs
3187
+ } else if (
3188
+ config.interleaveDepthMs != null &&
3189
+ config.interleaveDepthMs >= MIN_INTERLEAVE_DEPTH_MS
3190
+ ) {
3191
+ this.#fecTimeoutMs = computeFecDeliveryWindowMs({
3192
+ interleaveDepthMs: config.interleaveDepthMs,
3193
+ sourceSymbols: this.#fecK,
3194
+ symbolSize: this.#fecSymbolSize,
3195
+ mediaUnitsPerSecond: config.fps,
3196
+ })
3197
+ }
3198
+ // A shared multicast parser receives every packetId. Keep existing track
3199
+ // deadlines at least as wide as the selected video delivery envelope so a
3200
+ // short audio interleave cannot expire during browser/AMT scheduling gaps.
3201
+ for (const [packetId, track] of this.#trackConfigs) {
3202
+ if (track.timeoutMs < this.#fecTimeoutMs) {
3203
+ this.#trackConfigs.set(packetId, { ...track, timeoutMs: this.#fecTimeoutMs })
3204
+ }
3205
+ }
3206
+ }
3207
+
3208
+ /**
3209
+ * Configure FEC parameters for one packetId without changing legacy
3210
+ * defaults. With `catalogTrackName`, `config.geometry` is that stream's
3211
+ * own classifier geometry (ABR rungs that share a packetId each get
3212
+ * theirs); K, T, timing, kind and codec stay per packetId.
3213
+ */
3214
+ configureTrack(packetId: number, config: FECManagerConfig, catalogTrackName?: string): void {
3215
+ this.#upsertTrackConfig(packetId, config, catalogTrackName)
3216
+ }
3217
+
3218
+ /** Register a FEC decoder for a specific packetId (track). */
3219
+ setFecDecoder(
3220
+ packetId: number,
3221
+ client: WasmFecDecoder,
3222
+ k: number,
3223
+ config?: FECManagerConfig,
3224
+ catalogTrackName?: string,
3225
+ ): void {
3226
+ const streamKey = this.#streamKey(packetId, catalogTrackName)
3227
+ this.#assertExclusiveDecoderClient(
3228
+ streamKey,
3229
+ client,
3230
+ [...this.#fecDecoders].map(([key, entry]) => [key, entry.client] as const),
3231
+ 'decoder',
3232
+ )
3233
+ const trackConfig = this.#upsertTrackConfig(packetId, { ...config, k }, catalogTrackName)
3234
+ this.#fecDecoders.set(streamKey, { client, k: trackConfig.k })
3235
+ }
3236
+
3237
+ /**
3238
+ * Register a Worker-backed FEC decoder for a specific packetId (track).
3239
+ *
3240
+ * Worker-backed tracks decode asynchronously — feedSourceSymbol/feedRepairSymbol
3241
+ * return null immediately, and recovered frames arrive via the onBlockRecovered callback.
3242
+ *
3243
+ * Takes precedence over sync WASM decoders for the same packetId.
3244
+ */
3245
+ setFecWorker(
3246
+ packetId: number,
3247
+ client: FecWorkerClient,
3248
+ config: FECManagerConfig,
3249
+ catalogTrackName?: string,
3250
+ ): void {
3251
+ const k = config?.k
3252
+ if (k === undefined || !Number.isFinite(k) || k <= 0) {
3253
+ throw new Error(`[FECManager] packetId=${packetId} Worker requires an explicit positive k`)
3254
+ }
3255
+ const symbolSize = config.symbolSize
3256
+ if (symbolSize === undefined || !Number.isFinite(symbolSize) || symbolSize <= 0) {
3257
+ throw new Error(
3258
+ `[FECManager] packetId=${packetId} Worker requires an explicit positive symbolSize`,
3259
+ )
3260
+ }
3261
+ const streamKey = this.#streamKey(packetId, catalogTrackName)
3262
+ this.#assertExclusiveDecoderClient(
3263
+ streamKey,
3264
+ client,
3265
+ this.#workerClients,
3266
+ 'worker',
3267
+ )
3268
+ // Every Worker leaves routing through #retireWorker, which fails the
3269
+ // blocks it still owed and reports the stream's decoder unavailable.
3270
+ // Overwriting a registration would skip that path and strand those
3271
+ // blocks, so a second Worker under a routed stream is refused.
3272
+ const incumbent = this.#workerClients.get(streamKey)
3273
+ if (incumbent !== undefined && incumbent !== client) {
3274
+ throw new Error(
3275
+ `[FECManager] stream ${streamKey} already routes to a Worker; ` +
3276
+ `call clearFecWorker before registering another`,
3277
+ )
3278
+ }
3279
+ if (config) this.#upsertTrackConfig(packetId, config, catalogTrackName)
3280
+ const workerK = this.#resolveTrackConfig(packetId).k
3281
+ this.#workerClients.set(streamKey, client)
3282
+ this.#releasedWorkerClients.delete(client)
3283
+
3284
+ client.onFrame((data: ArrayBuffer, meta: FrameMeta) => {
3285
+ if (this.#releasedWorkerClients.has(client)) return
3286
+ if (meta.recovered) {
3287
+ // Recovered Worker events represent one exact source-symbol position.
3288
+ // Block-completion accounting needs that exact ESI, and counting an
3289
+ // ESI-less event as recovered would report healthy FEC while dropping
3290
+ // the media. Enforce the Worker contract before any recovery metric.
3291
+ // The Worker names the recovered block's authored SSB_length; ESIs
3292
+ // SSB_length..K-1 are encoder padding and are never emitted.
3293
+ if (
3294
+ !Number.isInteger(meta.ssbLength) ||
3295
+ meta.ssbLength! <= 0 ||
3296
+ meta.ssbLength! > workerK
3297
+ ) {
3298
+ const kind: FecWorkerErrorCode = 'recovered-symbol-ssb-length-invalid'
3299
+ const seen = this.#workerErrorCounts.get(kind) ?? 0
3300
+ this.#workerErrorCounts.set(kind, seen + 1)
3301
+ if (seen < WORKER_ERROR_LOG_CAP) {
3302
+ console.warn(
3303
+ `[FECManager] Dropping recovered symbol with invalid SSB_length: pid=${packetId} sbn=${meta.sbn} ssbLength=${String(meta.ssbLength)}`,
3304
+ )
3305
+ }
3306
+ return
3307
+ }
3308
+ if (
3309
+ !Number.isInteger(meta.esi) ||
3310
+ meta.esi! < 0 ||
3311
+ meta.esi! >= meta.ssbLength!
3312
+ ) {
3313
+ const kind = 'recovered-symbol-missing-esi'
3314
+ const seen = this.#workerErrorCounts.get(kind) ?? 0
3315
+ this.#workerErrorCounts.set(kind, seen + 1)
3316
+ if (seen < WORKER_ERROR_LOG_CAP) {
3317
+ console.warn(
3318
+ `[FECManager] Dropping recovered symbol without valid ESI: pid=${packetId} sbn=${meta.sbn} esi=${String(meta.esi)} ssbLength=${String(meta.ssbLength)}`,
3319
+ )
3320
+ }
3321
+ return
3322
+ }
3323
+ const esi = meta.esi!
3324
+
3325
+ // Suppress duplicate routing when the worker fires recovered=true
3326
+ // for a block ALREADY CLEANLY COMPLETED by K source symbols.
3327
+ //
3328
+ // BLO-3323 L15: predicate was previously `#completedBlocks.has(sbn)`,
3329
+ // but `#completedBlocks` is populated by 5 paths (clean K-arrival,
3330
+ // failed timeout, every worker frame emit via markBlockCompleted below,
3331
+ // external markBlockCompleted, and cancelBlockTimeout). The first
3332
+ // worker frame for a block adds the SBN, so subsequent recovered=true
3333
+ // emits for the same block were always suppressed → blocksRecovered
3334
+ // stayed at 0 even when recovery actually fired. Switching to the
3335
+ // narrower #cleanlyCompletedBlocks set (populated only by trackBlock
3336
+ // when count >= K) preserves the original dedup intent (BLO-3035
3337
+ // layer 4 — don't double-route a block already received via source)
3338
+ // while letting genuinely-recovered blocks bump the counter.
3339
+ const recoveredCatalogTrackName = catalogTrackName
3340
+ ?? this.#decoderBlockCatalogTrackName(client, meta.sbn)
3341
+ if (!this.#claimDecoderBlock(client, meta.sbn, recoveredCatalogTrackName)) return
3342
+ const blockKey = this.#blockKey(meta.sbn, packetId, recoveredCatalogTrackName)
3343
+ const cleanlyCompleted = this.#cleanlyCompletedBlocks.has(blockKey)
3344
+ if (!cleanlyCompleted) {
3345
+ if (!this.#workerRecoveredBlocks.has(blockKey)) {
3346
+ this.#traceLifecycle(
3347
+ 'recovered',
3348
+ meta.sbn,
3349
+ packetId,
3350
+ recoveredCatalogTrackName,
3351
+ )
3352
+ this.#workerRecoveredBlocks.add(blockKey)
3353
+ this.#fecStats.blocksRecovered++
3354
+ this.#statsForPacket(packetId).blocksRecovered++
3355
+ if (this.#workerRecoveredBlocks.size > 1000) {
3356
+ dropOldest(this.#workerRecoveredBlocks, 500)
3357
+ }
3358
+ this.#noteBlockRepaired(blockKey, meta.sbn, packetId, recoveredCatalogTrackName)
3359
+ }
3360
+
3361
+ // RaptorQ returns the full K-symbol block, not only erased symbols.
3362
+ // The direct path already parsed every source ESI in #seenSymbols;
3363
+ // routing those symbols again injects duplicate MFU fragments and can
3364
+ // leave a real FI=3 tail followed by duplicate FI=2 fragments.
3365
+ const symbolKey = `${blockKey}:${esi}`
3366
+ if (!this.#seenSymbols.has(symbolKey)) {
3367
+ this.#seenSymbols.add(symbolKey)
3368
+ this.onBlockRecovered?.(
3369
+ meta.sbn,
3370
+ packetId,
3371
+ data,
3372
+ meta,
3373
+ recoveredCatalogTrackName,
3374
+ )
3375
+ }
3376
+ }
3377
+
3378
+ // The worker emits recovered source symbols in ESI order. Complete the
3379
+ // block only after its final authored slot so assemblers receive every
3380
+ // missing symbol before their completion callback flushes buffered media.
3381
+ if (
3382
+ esi >= meta.ssbLength! - 1 &&
3383
+ !this.#completedBlocks.has(blockKey)
3384
+ ) {
3385
+ this.cancelBlockTimeout(meta.sbn, packetId, recoveredCatalogTrackName)
3386
+ if (recoveredCatalogTrackName === undefined) {
3387
+ this.onBlockComplete?.(meta.sbn, packetId)
3388
+ } else {
3389
+ this.onBlockComplete?.(
3390
+ meta.sbn,
3391
+ packetId,
3392
+ recoveredCatalogTrackName,
3393
+ )
3394
+ }
3395
+ }
3396
+ } else {
3397
+ const completedCatalogTrackName = catalogTrackName
3398
+ ?? this.#decoderBlockCatalogTrackName(client, meta.sbn)
3399
+ this.markBlockCompleted(meta.sbn, packetId, completedCatalogTrackName)
3400
+ }
3401
+ })
3402
+
3403
+ client.onSnapshot((stats: FecTrackStats) => {
3404
+ if (this.#releasedWorkerClients.has(client)) return
3405
+ this.#fecWorkerStats.set(packetId, stats)
3406
+ try {
3407
+ this.onFecStatsUpdate?.(packetId, stats)
3408
+ } catch (error) {
3409
+ console.warn('[FECManager] FEC stats callback failed:', error)
3410
+ }
3411
+ for (const observer of this.#fecStatsObservers) {
3412
+ try {
3413
+ observer(packetId, stats)
3414
+ } catch (error) {
3415
+ console.warn('[FECManager] FEC stats observer failed:', error)
3416
+ }
3417
+ }
3418
+ })
3419
+
3420
+ client.onCleanupComplete((sbn: number) => {
3421
+ if (this.#releasedWorkerClients.has(client)) return
3422
+ const cleanupKeys = [...this.#pendingWorkerCleanups].filter((key) => {
3423
+ const block = this.#parseBlockKey(key)
3424
+ return block.packetId === packetId &&
3425
+ block.sbn === sbn &&
3426
+ this.#pendingWorkerCleanupClients.get(key) === client
3427
+ })
3428
+ for (const key of cleanupKeys) this.#settleOwedCleanup(key, 'cleanup-ack')
3429
+ })
3430
+
3431
+ client.onError((msg: string, code?: FecWorkerErrorCode) => {
3432
+ const requestedKind = code ?? msg
3433
+ const kind =
3434
+ this.#workerErrorCounts.has(requestedKind) ||
3435
+ this.#workerErrorCounts.size < WORKER_ERROR_KIND_CAP - 1
3436
+ ? requestedKind
3437
+ : WORKER_ERROR_OVERFLOW_KIND
3438
+ const seen = this.#workerErrorCounts.get(kind) ?? 0
3439
+ this.#workerErrorCounts.set(kind, seen + 1)
3440
+ if (seen < WORKER_ERROR_LOG_CAP) {
3441
+ console.warn('[FECManager] Worker error:', msg)
3442
+ } else if (seen === WORKER_ERROR_LOG_CAP) {
3443
+ console.warn(
3444
+ `[FECManager] Worker error (suppressing further copies of ${kind}): ${msg}`,
3445
+ )
3446
+ }
3447
+ })
3448
+ }
3449
+
3450
+ /**
3451
+ * Remove a stream's Worker decoder from routing.
3452
+ *
3453
+ * Returns false when no Worker is registered under that exact stream key.
3454
+ * On success every cleanup acknowledgement still owed by the client is
3455
+ * resolved by failing its block now (a Worker that is going away cannot
3456
+ * post it), the client's block ownership is released, and
3457
+ * onDecoderUnavailable fires once with reason 'cleared' and the decoder
3458
+ * that remains for the stream ('sync' or 'none'). Call this before
3459
+ * disposing the client; a client disposed while still registered is
3460
+ * evicted on its next symbol or reset with reason 'disposed'.
3461
+ */
3462
+ clearFecWorker(packetId: number, catalogTrackName?: string): boolean {
3463
+ const streamKey = this.#streamKey(packetId, catalogTrackName)
3464
+ const client = this.#workerClients.get(streamKey)
3465
+ if (!client) return false
3466
+ this.#retireWorker(streamKey, client, 'cleared')
3467
+ return true
3468
+ }
3469
+
3470
+ /**
3471
+ * Take a Worker client out of routing under one stream key, fail the blocks
3472
+ * whose completion it still owed, and report the stream's decoder
3473
+ * unavailable together with whatever still decodes the stream.
3474
+ */
3475
+ #retireWorker(streamKey: string, client: FecWorkerClient, reason: FecDecoderUnavailableReason): void {
3476
+ this.#workerClients.delete(streamKey)
3477
+ this.#releaseWorkerClient(client)
3478
+ this.#decoderUnavailableEvents++
3479
+ const stream = this.#parseStreamKey(streamKey)
3480
+ const fallbackDecoder: FecFallbackDecoder =
3481
+ this.#decoderForPacket(stream.packetId, stream.catalogTrackName) ? 'sync' : 'none'
3482
+ this.onDecoderUnavailable?.({
3483
+ packetId: stream.packetId,
3484
+ ...(stream.catalogTrackName === undefined ? {} : { catalogTrackName: stream.catalogTrackName }),
3485
+ reason,
3486
+ fallbackDecoder,
3487
+ })
3488
+ }
3489
+
3490
+ /**
3491
+ * Fail every block whose Worker cleanup acknowledgement is owed by `client`
3492
+ * and drop its block ownership, so no block waits on a message the Worker
3493
+ * will never post.
3494
+ */
3495
+ #releaseWorkerClient(client: FecWorkerClient): void {
3496
+ const owed = [...this.#pendingWorkerCleanupClients]
3497
+ .filter(([, owner]) => owner === client)
3498
+ .map(([key]) => key)
3499
+ for (const key of owed) this.#settleOwedCleanup(key, 'decoder-unavailable')
3500
+ this.#decoderBlockOwners.delete(client)
3501
+ }
3502
+
3503
+ /**
3504
+ * Bound the wait for one Worker cleanup acknowledgement by the block's
3505
+ * catalog-derived terminal lifetime. When it elapses with the cleanup still
3506
+ * owed to the same client, the block fails as if the ack had arrived: its
3507
+ * fail deadline already passed, so failing it is the decision that deadline
3508
+ * made. A late ack then finds nothing owed and is a no-op.
3509
+ */
3510
+ #boundOwedCleanup(key: string, packetId: number, client: FecWorkerClient): void {
3511
+ this.#clearOwedCleanupBound(key)
3512
+ const bound = {
3513
+ handle: setTimeout(() => {
3514
+ if (this.#pendingWorkerCleanupBounds.get(key) !== bound) return
3515
+ this.#pendingWorkerCleanupBounds.delete(key)
3516
+ if (
3517
+ !this.#pendingWorkerCleanups.has(key) ||
3518
+ this.#pendingWorkerCleanupClients.get(key) !== client
3519
+ ) {
3520
+ return
3521
+ }
3522
+ this.#settleOwedCleanup(key, 'cleanup-ack-timeout')
3523
+ }, Math.ceil(this.#liveBlockLifetimeMs(this.#resolveTrackConfig(packetId)))),
3524
+ }
3525
+ this.#pendingWorkerCleanupBounds.set(key, bound)
3526
+ }
3527
+
3528
+ #clearOwedCleanupBound(key: string): void {
3529
+ const bound = this.#pendingWorkerCleanupBounds.get(key)
3530
+ if (!bound) return
3531
+ clearTimeout(bound.handle)
3532
+ this.#pendingWorkerCleanupBounds.delete(key)
3533
+ }
3534
+
3535
+ /**
3536
+ * Resolve one owed Worker cleanup: drop the pending entry and fail the block
3537
+ * unless it already completed. Shared by the cleanup acknowledgement and by
3538
+ * Worker retirement, so the two paths cannot drift in their bookkeeping.
3539
+ */
3540
+ #settleOwedCleanup(
3541
+ key: string,
3542
+ event: 'cleanup-ack' | 'cleanup-ack-timeout' | 'decoder-unavailable',
3543
+ ): void {
3544
+ this.#clearOwedCleanupBound(key)
3545
+ this.#pendingWorkerCleanups.delete(key)
3546
+ this.#pendingWorkerCleanupClients.delete(key)
3547
+ if (this.#completedBlocks.has(key)) return
3548
+ const block = this.#parseBlockKey(key)
3549
+ this.#traceLifecycle(event, block.sbn, block.packetId, block.catalogTrackName)
3550
+ this.#failBlock(block.sbn, block.packetId, block.catalogTrackName)
3551
+ }
3552
+
3553
+ /** Evict a disposed client under every stream key that still maps to it. */
3554
+ #evictDisposedWorker(client: FecWorkerClient): void {
3555
+ for (const [key, registered] of [...this.#workerClients]) {
3556
+ if (registered !== client) continue
3557
+ const stream = this.#parseStreamKey(key)
3558
+ const track = stream.catalogTrackName === undefined ? '' : ` track=${stream.catalogTrackName}`
3559
+ console.warn(
3560
+ `[FECManager] packetId=${stream.packetId}${track} Worker decoder was disposed while still registered; evicting it`,
3561
+ )
3562
+ this.#retireWorker(key, client, 'disposed')
3563
+ }
3564
+ }
3565
+
3566
+ /** Called by player when framerate is known (from decoder output). Refines FEC block timeout. */
3567
+ setFramerate(fps: number): void {
3568
+ if (this.#fpsDetected || fps <= 0) return
3569
+ this.#fpsDetected = true
3570
+ this.#fecFps = fps
3571
+ // Only a valid §5.1 ms interleave refines the timeout; a sub-floor value is
3572
+ // a raw frame count and would collapse it (see MIN_INTERLEAVE_DEPTH_MS).
3573
+ if (this.#fecInterleaveDepthMs >= MIN_INTERLEAVE_DEPTH_MS) {
3574
+ this.#fecTimeoutMs = computeFecDeliveryWindowMs({
3575
+ interleaveDepthMs: this.#fecInterleaveDepthMs,
3576
+ sourceSymbols: this.#fecK,
3577
+ symbolSize: this.#fecSymbolSize,
3578
+ mediaUnitsPerSecond: fps,
3579
+ })
3580
+ }
3581
+ for (const [packetId, config] of this.#trackConfigs) {
3582
+ if (config.fps !== undefined || config.interleaveDepthMs < MIN_INTERLEAVE_DEPTH_MS) continue
3583
+ this.#trackConfigs.set(packetId, {
3584
+ ...config,
3585
+ fps,
3586
+ timeoutMs: Math.max(
3587
+ this.#fecTimeoutMs,
3588
+ computeFecDeliveryWindowMs({
3589
+ interleaveDepthMs: config.interleaveDepthMs,
3590
+ sourceSymbols: config.k,
3591
+ symbolSize: config.symbolSize,
3592
+ mediaUnitsPerSecond: fps,
3593
+ }),
3594
+ ),
3595
+ })
3596
+ }
3597
+ }
3598
+
3599
+ // -----------------------------------------------------------------------
3600
+ // Symbol feeding (codec-agnostic)
3601
+ // -----------------------------------------------------------------------
3602
+
3603
+ /**
3604
+ * Feed a source symbol to the WASM FEC decoder.
3605
+ *
3606
+ * Does NOT track block source counts or fire lifecycle events — call
3607
+ * `trackBlock()` separately BEFORE this method (MmtpRouter does this).
3608
+ *
3609
+ * @returns Recovered block data if WASM completed the block, else null.
3610
+ * Caller is responsible for routing recovered data and updating
3611
+ * block completion state via `markBlockCompleted()`.
3612
+ */
3613
+ feedSourceSymbol(
3614
+ packetId: number,
3615
+ ssId: number,
3616
+ symbolData: Uint8Array,
3617
+ alta?: { auth: Uint8Array; payloadEnd: number },
3618
+ catalogTrackName?: string,
3619
+ ): Uint8Array | null {
3620
+ this.#fecStats.sourcePackets++
3621
+ this.#statsForPacket(packetId).sourcePackets++
3622
+ let sbn: number
3623
+ let fecData: Uint8Array
3624
+ if (this.#isIso(packetId)) {
3625
+ // iso-ssbg1-v1: the decoder builds the ssbg_mode1 symbol from the exact
3626
+ // protected packet, so the packet travels unpadded. Its block must
3627
+ // already be stated (MmtpRouter holds a source until a repair places it).
3628
+ const locator = this.#isoLocatorFor(packetId, catalogTrackName)
3629
+ const located = locator.locate(ssId)
3630
+ if (located === null) {
3631
+ throw new Error(
3632
+ `[FECManager] iso-ssbg1-v1 source SS_ID=${ssId} (packetId=${packetId}) has no stated block; ` +
3633
+ 'hold it until a repair places it (placeIsoRepair)',
3634
+ )
3635
+ }
3636
+ locator.observe(ssId)
3637
+ if (!ssbg1FitsSymbol(symbolData.byteLength, this.symbolSizeForPacket(packetId))) {
3638
+ this.#isoSourceOversizeCount++
3639
+ return null
3640
+ }
3641
+ sbn = located.block.start
3642
+ fecData = symbolData
3643
+ } else {
3644
+ fecData = this.#normalizeSymbolData(packetId, symbolData)
3645
+ sbn = Math.floor(ssId / this.kForPacket(packetId))
3646
+ }
3647
+ const blockKey = this.#blockKey(sbn, packetId, catalogTrackName)
3648
+ const syncBlockOwner = this.#syncDecoderBlockOwners.get(blockKey)
3649
+
3650
+ // Worker-backed track: dispatch async, return null (recovery via onFrame callback)
3651
+ const workerClient = this.#routableWorkerForPacket(packetId, catalogTrackName)
3652
+ if (workerClient && !syncBlockOwner) {
3653
+ if (!this.#claimDecoderBlock(workerClient, sbn, catalogTrackName)) return null
3654
+ // Transfer an ArrayBuffer nothing else references. A symbol this manager
3655
+ // just zero-padded to T already is one; copying it again cost a second
3656
+ // T-byte allocation per short packet (BLO-36599). A caller's buffer may
3657
+ // be a view into a larger packet, so it is copied first (T-03-06).
3658
+ const buf = fecData !== symbolData
3659
+ ? fecData.buffer as ArrayBuffer
3660
+ : fecData.buffer.slice(
3661
+ fecData.byteOffset,
3662
+ fecData.byteOffset + fecData.byteLength,
3663
+ ) as ArrayBuffer
3664
+ // Pass the flat SS_ID through to the worker. The WASM decoder derives
3665
+ // SBN/ESI internally per ISO 23008-1 §C.5.2; the WASM add_source
3666
+ // signature is (ss_id, data, timestamp) — pre-computing sbn/esi on this
3667
+ // side produces wrong WASM argument alignment and silently breaks
3668
+ // block assembly.
3669
+ if (alta) {
3670
+ // Copy auth into a fresh ArrayBuffer to transfer zero-copy. The
3671
+ // underlying buffer of the parsed trailer view belongs to the wire
3672
+ // packet, which may be transferred separately or reused.
3673
+ const authBuf = alta.auth.buffer.slice(
3674
+ alta.auth.byteOffset,
3675
+ alta.auth.byteOffset + alta.auth.byteLength,
3676
+ ) as ArrayBuffer
3677
+ workerClient.feedSource(ssId, buf, performance.now(), {
3678
+ auth: authBuf,
3679
+ payloadEnd: alta.payloadEnd,
3680
+ })
3681
+ } else {
3682
+ workerClient.feedSource(ssId, buf, performance.now())
3683
+ }
3684
+ return null
3685
+ }
3686
+
3687
+ // Synchronous WASM path.
3688
+ const entry = this.#decoderForPacket(packetId, catalogTrackName)
3689
+ const client = syncBlockOwner ?? entry?.client
3690
+ if (!client) return null
3691
+ if (!this.#claimDecoderBlock(client, sbn, catalogTrackName)) return null
3692
+ this.#syncDecoderBlockOwners.set(blockKey, client)
3693
+
3694
+ let recovered: Uint8Array | null = null
3695
+ try {
3696
+ recovered = client.add_source(ssId, fecData, performance.now())
3697
+ } catch {
3698
+ // WASM RefCell panic — decoder instance shared with multicast repair path
3699
+ }
3700
+ this.#retireIsoUnplaced(client, packetId, catalogTrackName)
3701
+ if (recovered || this.#completedBlocks.has(blockKey)) {
3702
+ this.#syncDecoderBlockOwners.delete(blockKey)
3703
+ }
3704
+
3705
+ return recovered
3706
+ }
3707
+
3708
+ /**
3709
+ * Feed a repair symbol to the WASM FEC decoder.
3710
+ *
3711
+ * @returns Recovered block data if repair completed the block, else null.
3712
+ * Caller is responsible for routing recovered data and updating
3713
+ * block completion state via `markBlockCompleted()`.
3714
+ */
3715
+ feedRepairSymbol(
3716
+ packetId: number,
3717
+ ssStart: number,
3718
+ ssbLength: number,
3719
+ rsId: number,
3720
+ repairData: Uint8Array,
3721
+ catalogTrackName?: string,
3722
+ ): Uint8Array | null {
3723
+ this.#fecStats.repairPackets++
3724
+ this.#statsForPacket(packetId).repairPackets++
3725
+ const symbolSize = this.symbolSizeForPacket(packetId)
3726
+ if (symbolSize > 0 && repairData.byteLength !== symbolSize) {
3727
+ this.#repairSymbolSizeMismatchCount++
3728
+ if (this.#repairSymbolSizeMismatchCount <= 5 || this.#repairSymbolSizeMismatchCount % 100 === 0) {
3729
+ console.warn(
3730
+ `[FECManager] Dropping repair symbol with invalid size: got ${repairData.byteLength}B, expected ${symbolSize}B (packetId=${packetId}, ssStart=${ssStart}, ssbLength=${ssbLength}, rsId=${rsId})`,
3731
+ )
3732
+ }
3733
+ return null
3734
+ }
3735
+ const fecData = repairData
3736
+
3737
+ // SSB_length is the authored source-symbol count in this block, not the
3738
+ // decoder's configured block width. Short authored blocks are padded to
3739
+ // the configured K, so all internal keys and lifecycle coordinates use K.
3740
+ // Keep the wire tuple unchanged when calling the decoder below.
3741
+ const k = this.kForPacket(packetId)
3742
+ let sbn: number
3743
+ if (this.#isIso(packetId)) {
3744
+ // iso-ssbg1-v1 (C.5.3): the block is [SS_Start, SS_Start + SSB_length),
3745
+ // K' = SSB_length, and the catalog K is only its maximum. MmtpRouter
3746
+ // placed the block before feeding its repair (placeIsoRepair).
3747
+ const block = this.#isoLocatorFor(packetId, catalogTrackName).blockAt(ssStart)
3748
+ if (ssbLength <= 0 || ssbLength > k || block === undefined || block.span || block.length !== ssbLength) {
3749
+ return null
3750
+ }
3751
+ sbn = ssStart
3752
+ } else {
3753
+ if (ssbLength <= 0 || ssbLength > k || ssStart % k !== 0) return null
3754
+ sbn = Math.floor(ssStart / k)
3755
+ }
3756
+ this.#noteSbnSeen(sbn, packetId, catalogTrackName, 'repair')
3757
+
3758
+ // Repair symbols are block activity: refresh the inactivity fail bound so
3759
+ // a block being actively repaired over MoQ is not failed mid-recovery.
3760
+ // The multicast in-band path already does this at the router; this covers
3761
+ // the MoQ backfill path, which bypasses the router's packet accounting.
3762
+ this.noteBlockActivity(sbn, packetId, catalogTrackName)
3763
+ const blockKey = this.#blockKey(sbn, packetId, catalogTrackName)
3764
+ if (this.#completedBlocks.has(blockKey)) return null
3765
+ if (!this.#noteAuthoredLength(blockKey, sbn, packetId, ssbLength, catalogTrackName)) {
3766
+ return null
3767
+ }
3768
+ let repairIds = this.#blockRepairRsIds.get(blockKey)
3769
+ if (!repairIds) {
3770
+ repairIds = new Set()
3771
+ this.#blockRepairRsIds.set(blockKey, repairIds)
3772
+ }
3773
+ repairIds.add(rsId)
3774
+ if (this.#repairRequestedBlocks.has(blockKey)) {
3775
+ this.#traceLifecycle('repair-arrived', sbn, packetId, catalogTrackName, rsId)
3776
+ }
3777
+
3778
+ // Every authored source symbol already arrived: the block is clean now
3779
+ // that its SSB_length is known. The decoder still receives the repair so
3780
+ // it retires its own copy of the block, but whatever it returns is the
3781
+ // source the direct path already routed, not a recovery.
3782
+ const cleanOnArrival =
3783
+ (this.#blockSourceCounts.get(blockKey) ?? 0) >= ssbLength
3784
+ const recovered = this.#dispatchRepairSymbol(
3785
+ blockKey,
3786
+ sbn,
3787
+ packetId,
3788
+ ssStart,
3789
+ ssbLength,
3790
+ rsId,
3791
+ fecData,
3792
+ catalogTrackName,
3793
+ )
3794
+ if (cleanOnArrival) {
3795
+ this.#completeCleanBlock(blockKey, sbn, packetId, catalogTrackName)
3796
+ return null
3797
+ }
3798
+ return recovered
3799
+ }
3800
+
3801
+ /**
3802
+ * Record a block's authored SSB_length from a repair symbol. Returns false,
3803
+ * leaving the block unchanged, when the symbol contradicts the block: a
3804
+ * different SSB_length than an earlier repair symbol declared is dropped,
3805
+ * and an SSB_length that places an already-received source ESI inside the
3806
+ * zero padding fails the block, since its geometry can no longer be trusted.
3807
+ */
3808
+ #noteAuthoredLength(
3809
+ blockKey: string,
3810
+ sbn: number,
3811
+ packetId: number,
3812
+ ssbLength: number,
3813
+ catalogTrackName?: string,
3814
+ ): boolean {
3815
+ const known = this.#blockAuthoredLengths.get(blockKey)
3816
+ if (known === ssbLength) return true
3817
+ if (known !== undefined) {
3818
+ this.#ssbLengthConflictCount++
3819
+ if (this.#ssbLengthConflictCount <= 5 || this.#ssbLengthConflictCount % 100 === 0) {
3820
+ console.warn(
3821
+ `[FECManager] Dropping repair symbol whose SSB_length ${ssbLength} conflicts with ${known} for packetId=${packetId} sbn=${sbn}`,
3822
+ )
3823
+ }
3824
+ return false
3825
+ }
3826
+ const sourceEsis = this.#blockSourceEsis.get(blockKey)
3827
+ if (sourceEsis && [...sourceEsis].some((esi) => esi >= ssbLength)) {
3828
+ this.#failBlock(sbn, packetId, catalogTrackName)
3829
+ return false
3830
+ }
3831
+ this.#blockAuthoredLengths.set(blockKey, ssbLength)
3832
+ return true
3833
+ }
3834
+
3835
+ #dispatchRepairSymbol(
3836
+ blockKey: string,
3837
+ sbn: number,
3838
+ packetId: number,
3839
+ ssStart: number,
3840
+ ssbLength: number,
3841
+ rsId: number,
3842
+ fecData: Uint8Array,
3843
+ catalogTrackName?: string,
3844
+ ): Uint8Array | null {
3845
+ const syncBlockOwner = this.#syncDecoderBlockOwners.get(blockKey)
3846
+ // Worker-backed track: dispatch async, return null (recovery via onFrame callback)
3847
+ const workerClient = this.#routableWorkerForPacket(packetId, catalogTrackName)
3848
+ if (workerClient && !syncBlockOwner) {
3849
+ if (!this.#claimDecoderBlock(workerClient, sbn, catalogTrackName)) return null
3850
+ // Create clean ArrayBuffer copy (T-03-06)
3851
+ const buf = fecData.buffer.slice(
3852
+ fecData.byteOffset,
3853
+ fecData.byteOffset + fecData.byteLength,
3854
+ ) as ArrayBuffer
3855
+ // Forward the wire-level Repair FEC Payload ID (ISO 23008-1 §C.5.3)
3856
+ // directly. WASM add_repair signature is
3857
+ // (ss_start, ssb_length, rs_id, data, timestamp) — pre-computing
3858
+ // (sbn, esi) on this side misaligns WASM args and produces 0% recovery.
3859
+ workerClient.feedRepair(ssStart, ssbLength, rsId, buf, performance.now())
3860
+ return null
3861
+ }
3862
+
3863
+ // Synchronous WASM path.
3864
+ const entry = this.#decoderForPacket(packetId, catalogTrackName)
3865
+ const client = syncBlockOwner ?? entry?.client
3866
+ if (!client) return null
3867
+ if (!this.#claimDecoderBlock(client, sbn, catalogTrackName)) return null
3868
+ this.#syncDecoderBlockOwners.set(blockKey, client)
3869
+
3870
+ let recovered: Uint8Array | null = null
3871
+ try {
3872
+ recovered = client.add_repair(ssStart, ssbLength, rsId, fecData, performance.now())
3873
+ } catch {
3874
+ // WASM add_repair failure — block will timeout and be marked failed
3875
+ }
3876
+ this.#retireIsoUnplaced(client, packetId, catalogTrackName)
3877
+ if (recovered) this.#syncDecoderBlockOwners.delete(blockKey)
3878
+
3879
+ return recovered
3880
+ }
3881
+
3882
+ #routeSyncCleanup(
3883
+ decoder: WasmFecDecoder,
3884
+ sbn: number,
3885
+ packetId: number,
3886
+ catalogTrackName: string | undefined,
3887
+ ): void {
3888
+ // Retire the block from the decoder. Concealment of a lost block is the
3889
+ // sink's job (hold last good frame), so nothing is routed from here.
3890
+ try {
3891
+ decoder.cleanup_blocks_detailed(Uint32Array.of(sbn))
3892
+ } catch (error) {
3893
+ console.warn(`[FECManager] Scoped cleanup failed for SBN ${sbn}:`, error)
3894
+ }
3895
+ this.#retireIsoUnplaced(decoder, packetId, catalogTrackName)
3896
+ }
3897
+
3898
+ /**
3899
+ * iso-ssbg1-v1: a placed source the decoder took before its block's repair
3900
+ * (fed in wire order, sources first) waits unplaced in the decoder; one
3901
+ * whose repair never reaches it, such as a source of a repair-less span,
3902
+ * would stay there. Retire those by the decoder's horizon behind the newest
3903
+ * SS_ID the stream's locator has seen. Run after every feed that grows the
3904
+ * decoder and when a block is retired, as the fec-worker sweeps its own
3905
+ * pool: a stream that loses only repair symbols never expires a block, so
3906
+ * a sweep tied to expiry alone would never run there.
3907
+ */
3908
+ #retireIsoUnplaced(
3909
+ decoder: WasmFecDecoder,
3910
+ packetId: number,
3911
+ catalogTrackName: string | undefined,
3912
+ ): void {
3913
+ if (!this.#isIso(packetId)) return
3914
+ const newest = this.#isoLocators.get(this.#streamKey(packetId, catalogTrackName))?.newest
3915
+ if (newest === undefined) return
3916
+ try {
3917
+ this.#isoDecoderUnplacedRetired += decoder.retire_unplaced(newest)
3918
+ } catch (error) {
3919
+ this.#isoDecoderUnplacedRetireFailed++
3920
+ const failed = this.#isoDecoderUnplacedRetireFailed
3921
+ if (failed <= 5 || failed % 100 === 0) {
3922
+ console.warn(
3923
+ `[FECManager] iso-ssbg1-v1 unplaced retirement failed for packetId=${packetId} (${failed} total):`,
3924
+ error,
3925
+ )
3926
+ }
3927
+ }
3928
+ }
3929
+
3930
+ // -----------------------------------------------------------------------
3931
+ // Block lifecycle
3932
+ // -----------------------------------------------------------------------
3933
+
3934
+ /**
3935
+ * Track a first-arrival network source symbol for an FEC block. Refreshes
3936
+ * the inactivity deadline on each symbol, records the symbol's in-band
3937
+ * classification evidence, and marks the block clean when all of its
3938
+ * authored source symbols arrived: the SSB_length a repair symbol declared,
3939
+ * otherwise K.
3940
+ *
3941
+ * Throws for a packetId that was never configured, so a classified block
3942
+ * never reads the manager-global K or timeout.
3943
+ */
3944
+ trackBlock(
3945
+ sbn: number,
3946
+ packetId: number,
3947
+ catalogTrackName: string | undefined,
3948
+ esi: number,
3949
+ evidence: SourceSymbolEvidence,
3950
+ ): void {
3951
+ const trackConfig = this.#trackConfigs.get(packetId)
3952
+ if (trackConfig === undefined) {
3953
+ throw new Error(
3954
+ `[FECManager] trackBlock for packetId=${packetId}, which was never configured; ` +
3955
+ 'configure the track from its catalog (configureTrack/setFecWorker/setFecDecoder) first',
3956
+ )
3957
+ }
3958
+ const key = this.#blockKey(sbn, packetId, catalogTrackName)
3959
+ const k = trackConfig.k
3960
+
3961
+ // Skip blocks already recovered — late source symbols must not restart timeouts
3962
+ if (this.#completedBlocks.has(key)) return
3963
+
3964
+ this.#noteSbnSeen(sbn, packetId, catalogTrackName, 'source', evidence.packetSequenceNumber)
3965
+
3966
+ // A source ESI inside the zero padding contradicts the block's authored
3967
+ // SSB_length; its geometry can no longer be trusted, so fail it closed.
3968
+ const authoredLength = this.#blockAuthoredLengths.get(key) ?? k
3969
+ if (Number.isInteger(esi) && esi! >= authoredLength) {
3970
+ this.#failBlock(sbn, packetId, catalogTrackName)
3971
+ return
3972
+ }
3973
+
3974
+ this.#applyClassifierChanges(
3975
+ packetId,
3976
+ catalogTrackName,
3977
+ this.#classifierFor(packetId, catalogTrackName).observe(sbn, evidence),
3978
+ )
3979
+
3980
+ // Count source symbols per block
3981
+ const count = (this.#blockSourceCounts.get(key) ?? 0) + 1
3982
+ this.#blockSourceCounts.set(key, count)
3983
+ if (count === 1) this.#indexBlockKey(key, sbn, packetId)
3984
+ if (Number.isInteger(esi) && esi! >= 0 && esi! < k) {
3985
+ let sourceEsis = this.#blockSourceEsis.get(key)
3986
+ if (!sourceEsis) {
3987
+ sourceEsis = new Set()
3988
+ this.#blockSourceEsis.set(key, sourceEsis)
3989
+ }
3990
+ sourceEsis.add(esi!)
3991
+ }
3992
+ if (count === 1) {
3993
+ if (catalogTrackName === undefined) {
3994
+ this.onBlockStarted?.(sbn, packetId)
3995
+ } else {
3996
+ this.onBlockStarted?.(sbn, packetId, catalogTrackName)
3997
+ }
3998
+ }
3999
+
4000
+ // When every authored source symbol arrived (SSB_length once a repair
4001
+ // symbol declared it, otherwise K), the block is clean: no loss, no FEC.
4002
+ if (k > 0 && count >= authoredLength) {
4003
+ this.#completeCleanBlock(key, sbn, packetId, catalogTrackName)
4004
+ return
4005
+ }
4006
+
4007
+ this.noteBlockActivity(sbn, packetId, catalogTrackName, true)
4008
+
4009
+ // Source ESI is intentionally not a repair trigger. Multicast delivery is
4010
+ // unordered, so K-1 may precede an interior symbol in a clean block. The
4011
+ // progress-refreshed source horizon above is the earliest loss signal.
4012
+ }
4013
+
4014
+ #completeCleanBlock(
4015
+ key: string,
4016
+ sbn: number,
4017
+ packetId: number,
4018
+ catalogTrackName?: string,
4019
+ ): void {
4020
+ this.#fecStats.blocksClean++
4021
+ this.#statsForPacket(packetId).blocksClean++
4022
+ this.cancelBlockTimeout(sbn, packetId, catalogTrackName)
4023
+ this.#blockSourceCounts.delete(key)
4024
+ this.#blockSourceEsis.delete(key)
4025
+ this.#blockAuthoredLengths.delete(key)
4026
+ this.#blockRepairRsIds.delete(key)
4027
+ // BLO-3323 L15: track which SBNs reached K source-symbol arrivals so the
4028
+ // worker recovery suppression can distinguish "block completed by source"
4029
+ // (legitimately suppress duplicate recovery) from "block touched by any
4030
+ // prior emit / timeout / external mark" (do NOT suppress recovery).
4031
+ // #markTerminal retires both memberships together (BLO-36599).
4032
+ this.#markTerminal(key, sbn, packetId, true)
4033
+ this.#markPathBlockComplete(sbn, packetId, catalogTrackName)
4034
+ if (catalogTrackName === undefined) {
4035
+ this.onBlockComplete?.(sbn, packetId)
4036
+ } else {
4037
+ this.onBlockComplete?.(sbn, packetId, catalogTrackName)
4038
+ }
4039
+ // MmtpRouter feeds the K-th source immediately after this callback.
4040
+ // Defer cleanup so that synchronous feed selects the same decoder; this
4041
+ // also clears blocks whose K-th packet was too large for decoder input.
4042
+ queueMicrotask(() => this.#syncDecoderBlockOwners.delete(key))
4043
+ }
4044
+
4045
+ /** Refresh deadlines after accepted block activity. */
4046
+ noteBlockActivity(
4047
+ sbn: number,
4048
+ packetId: number,
4049
+ catalogTrackName?: string,
4050
+ sourceProgress = false,
4051
+ ): void {
4052
+ const key = this.#blockKey(sbn, packetId, catalogTrackName)
4053
+
4054
+ // Late symbols must not resurrect a block that already completed or failed.
4055
+ if (this.#completedBlocks.has(key)) return
4056
+ // The worker cleanup command is already queued after all symbols that made
4057
+ // the inactivity deadline. Symbols arriving after that boundary are late
4058
+ // and must not arm a second timer before the acknowledgement resolves it.
4059
+ if (this.#pendingWorkerCleanups.has(key)) return
4060
+
4061
+ const repairRequested = this.#repairRequestedBlocks.has(key)
4062
+ // An ordered relay already decided a requested block by stream order; its
4063
+ // fate is the scheduler's hold (or its absence), never a re-hold.
4064
+ if (repairRequested && this.#settlesByOrder(key)) return
4065
+
4066
+ // An ordered relay's pauses say nothing about loss; stream order settles
4067
+ // the block (see MulticastSymbolDelivery). No timer is armed for it.
4068
+ if (this.#settlesByOrder(key)) {
4069
+ this.#holdForOrderedSettlement(key, sbn, packetId, catalogTrackName)
4070
+ return
4071
+ }
4072
+
4073
+ const trackConfig = this.#resolveTrackConfig(packetId)
4074
+ const timeoutMs = this.#multicastWaitMs(trackConfig)
4075
+ // #computeTimeout never yields <= 0 (computeFecDeliveryWindowMs throws on
4076
+ // a non-positive interleave or K). Returning here would leave a block
4077
+ // with source counted but no fail deadline, 'pending' forever, so a
4078
+ // violated invariant must fail loudly instead.
4079
+ if (!(timeoutMs > 0)) {
4080
+ throw new Error(`[FECManager] packetId=${packetId} has no positive fail bound (timeoutMs=${timeoutMs})`)
4081
+ }
4082
+
4083
+ // D-28: accepted source progress refreshes the pre-request horizon. There
4084
+ // are at most K such arrivals; repair traffic never refreshes this timer.
4085
+ // A requested block's accepted symbols keep extending its multicast wait
4086
+ // (a late symbol can still complete it) but never re-arm the request: at
4087
+ // most K+P accepted symbols can move the wait, so the block stays bounded.
4088
+ const repairDeadline = this.#repairDeadlines.get(key)
4089
+ if (
4090
+ this.onRepairNeeded &&
4091
+ !repairRequested &&
4092
+ (repairDeadline === undefined || sourceProgress)
4093
+ ) {
4094
+ this.#armRepairDeadline(
4095
+ key,
4096
+ sbn,
4097
+ packetId,
4098
+ catalogTrackName,
4099
+ this.#repairDeadlineMs(trackConfig),
4100
+ )
4101
+ }
4102
+
4103
+ this.#armBlockTimeout(sbn, packetId, catalogTrackName, timeoutMs)
4104
+ }
4105
+
4106
+ #failBlock(
4107
+ sbn: number,
4108
+ packetId: number,
4109
+ catalogTrackName?: string,
4110
+ ): void {
4111
+ const key = this.#blockKey(sbn, packetId, catalogTrackName)
4112
+ this.#traceLifecycle('failed', sbn, packetId, catalogTrackName)
4113
+ this.#clearRepairDeadline(key)
4114
+ this.#clearBlockTimer(key)
4115
+ const blockClass = this.#retireBlockClass(key, sbn, packetId, catalogTrackName)
4116
+ if (this.#repairRequestedBlocks.delete(key)) {
4117
+ this.onRepairResolved?.(sbn, packetId, 'failed', catalogTrackName, null)
4118
+ }
4119
+ this.#blockSourceCounts.delete(key)
4120
+ this.#blockSourceEsis.delete(key)
4121
+ this.#blockAuthoredLengths.delete(key)
4122
+ this.#blockRepairRsIds.delete(key)
4123
+ this.#syncDecoderBlockOwners.delete(key)
4124
+ this.#markTerminal(key, sbn, packetId, false)
4125
+ this.#failedBlocks.add(key)
4126
+ this.#markPathBlockComplete(sbn, packetId, catalogTrackName)
4127
+ this.#fecStats.blocksFailed++
4128
+ this.#statsForPacket(packetId).blocksFailed++
4129
+ this.onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass)
4130
+ this.onBlockResolved?.(sbn, packetId, catalogTrackName)
4131
+ }
4132
+
4133
+ /**
4134
+ * Resolve a block as completed (clean, recovered, or externally marked).
4135
+ * A block that had a D-28 repair outstanding counts as repaired: it was
4136
+ * short of its source at the repair deadline and completed anyway.
4137
+ */
4138
+ #resolveCompleted(key: string): void {
4139
+ const block = this.#parseBlockKey(key)
4140
+ this.#markTerminal(key, block.sbn, block.packetId, false)
4141
+ this.#clearOwedCleanupBound(key)
4142
+ this.#pendingWorkerCleanups.delete(key)
4143
+ this.#pendingWorkerCleanupClients.delete(key)
4144
+ this.#markPathBlockComplete(block.sbn, block.packetId, block.catalogTrackName)
4145
+ this.#clearRepairDeadline(key)
4146
+ if (this.#repairRequestedBlocks.delete(key)) {
4147
+ this.#noteBlockRepaired(key, block.sbn, block.packetId, block.catalogTrackName)
4148
+ this.onRepairResolved?.(
4149
+ block.sbn,
4150
+ block.packetId,
4151
+ 'completed',
4152
+ block.catalogTrackName,
4153
+ this.#viaFor(key),
4154
+ )
4155
+ }
4156
+ this.#retireBlockClass(key, block.sbn, block.packetId, block.catalogTrackName)
4157
+ this.#blockSourceCounts.delete(key)
4158
+ this.#blockSourceEsis.delete(key)
4159
+ this.#blockAuthoredLengths.delete(key)
4160
+ this.#blockRepairRsIds.delete(key)
4161
+ this.#clearBlockTimer(key)
4162
+ this.onBlockResolved?.(block.sbn, block.packetId, block.catalogTrackName)
4163
+ }
4164
+
4165
+ /** Check whether a block has already been completed (clean or recovered). */
4166
+ isBlockCompleted(sbn: number, packetId?: number, catalogTrackName?: string): boolean {
4167
+ return this.#blockKeysFor(sbn, packetId, catalogTrackName).some((key) => this.#completedBlocks.has(key))
4168
+ }
4169
+
4170
+ /** Mark a block as completed externally (e.g., after routing recovered data). */
4171
+ markBlockCompleted(sbn: number, packetId?: number, catalogTrackName?: string): void {
4172
+ for (const key of this.#blockKeysFor(sbn, packetId, catalogTrackName)) {
4173
+ this.#resolveCompleted(key)
4174
+ }
4175
+ }
4176
+
4177
+ /**
4178
+ * Increment blocksRecovered (called by MmtpRouter after routing a
4179
+ * synchronous recovery). With the block's coordinates the recovery is also
4180
+ * reported through onBlockRepaired, once per block.
4181
+ */
4182
+ countRecovery(packetId?: number, sbn?: number, catalogTrackName?: string): void {
4183
+ this.#fecStats.blocksRecovered++
4184
+ if (packetId != null) this.#statsForPacket(packetId).blocksRecovered++
4185
+ if (packetId != null && sbn != null) {
4186
+ this.#noteBlockRepaired(
4187
+ this.#blockKey(sbn, packetId, catalogTrackName),
4188
+ sbn,
4189
+ packetId,
4190
+ catalogTrackName,
4191
+ )
4192
+ }
4193
+ }
4194
+
4195
+ /**
4196
+ * Strict delivery provenance for one FEC block.
4197
+ *
4198
+ * A recovered symbol is a function of every symbol that entered its block,
4199
+ * so it can only be attributed to multicast when NO symbol in that block
4200
+ * arrived over MoQ. Repair symbols routinely arrive over reliable QUIC
4201
+ * (`MmtpRouter.feedRepairPacket`), so a block that recovered with MoQ help
4202
+ * produced bytes that never crossed a multicast socket.
4203
+ *
4204
+ * An untracked block returns 'unknown' rather than a guess: absence of
4205
+ * evidence is not evidence of multicast.
4206
+ */
4207
+ blockDeliveryProvenance(
4208
+ sbn: number,
4209
+ packetId: number,
4210
+ catalogTrackName?: string,
4211
+ ): MmtpDeliveryProvenance {
4212
+ const counts = this.blockPathCount(sbn, packetId, catalogTrackName)
4213
+ if (!counts) return 'unknown'
4214
+ if (counts.moqSymbols > 0) return 'mixed'
4215
+ if (counts.multicastSymbols > 0) return 'multicast'
4216
+ return 'unknown'
4217
+ }
4218
+
4219
+ /** Per-path symbol counts for one block (D-28 gate logging), if tracked. */
4220
+ blockPathCount(
4221
+ sbn: number,
4222
+ packetId: number,
4223
+ catalogTrackName?: string,
4224
+ ): FecBlockPathCount | null {
4225
+ return this.#blockPathCounts.get(
4226
+ this.#blockKey(sbn, packetId, catalogTrackName),
4227
+ ) ?? null
4228
+ }
4229
+
4230
+ /** Cancel block timeout (block completed via clean reception or FEC recovery). */
4231
+ cancelBlockTimeout(sbn: number, packetId?: number, catalogTrackName?: string): void {
4232
+ for (const key of this.#blockKeysFor(sbn, packetId, catalogTrackName)) {
4233
+ this.#resolveCompleted(key)
4234
+ }
4235
+ }
4236
+
4237
+ // -----------------------------------------------------------------------
4238
+ // Cleanup
4239
+ // -----------------------------------------------------------------------
4240
+
4241
+ dispose(): void {
4242
+ for (const timer of this.#activeBlocks.values()) {
4243
+ if (timer.handle != null) clearTimeout(timer.handle)
4244
+ }
4245
+ this.#activeBlocks.clear()
4246
+ this.#activeBlockDeadlinesMs.clear()
4247
+ this.#blockWaitDeadlinesMs.clear()
4248
+ this.#repairHoldUntilMs.clear()
4249
+ this.#deliveryGaps.reset()
4250
+ this.#pendingWorkerCleanups.clear()
4251
+ this.#pendingWorkerCleanupClients.clear()
4252
+ for (const bound of this.#pendingWorkerCleanupBounds.values()) clearTimeout(bound.handle)
4253
+ this.#pendingWorkerCleanupBounds.clear()
4254
+ for (const timer of this.#repairDeadlines.values()) {
4255
+ if (timer?.handle != null) clearTimeout(timer.handle)
4256
+ }
4257
+ this.#repairDeadlines.clear()
4258
+ this.#repairDeadlineAtMs.clear()
4259
+ this.#terminalExpiryMs.clear()
4260
+ this.#terminalQueues.clear()
4261
+ this.#blockKeysBySbn.clear()
4262
+ this.#blockSourceCounts.clear()
4263
+ this.#blockSourceEsis.clear()
4264
+ this.#blockAuthoredLengths.clear()
4265
+ this.#blockRepairRsIds.clear()
4266
+ this.#completedBlocks.clear()
4267
+ this.#cleanlyCompletedBlocks.clear()
4268
+ this.#repairRequestedBlocks.clear()
4269
+ this.#workerRecoveredBlocks.clear()
4270
+ this.#syncDecoderBlockOwners.clear()
4271
+ this.#startupAlignedPackets.clear()
4272
+ this.#startupPartialBlocks.clear()
4273
+ this.#startupRequireRepairReady = false
4274
+ this.#startupRepairReadyPackets.clear()
4275
+ this.#startupRepairBoundaryCounts.clear()
4276
+ this.#startupRepairFailOpenPackets.clear()
4277
+ this.#orderedFrontiers.clear()
4278
+ this.#orderedPendingBlocks.clear()
4279
+ this.#seenSymbols.clear()
4280
+ this.#blockPathCounts.clear()
4281
+ this.#blockAnchorsMs.clear()
4282
+ this.#blockCatalogTrackNames.clear()
4283
+ this.#classifiers.clear()
4284
+ this.#streamGeometries.clear()
4285
+ this.#failedBlocks.clear()
4286
+ this.#resolvedBlockClasses.clear()
4287
+ this.#repairedBlocks.clear()
4288
+ this.#materializedBlocks.clear()
4289
+ this.#highestSbn.clear()
4290
+ this.#isoLocators.clear()
4291
+ this.#sourceAnchors.clear()
4292
+ this.#trackConfigs.clear()
4293
+ this.#fecDecoders.clear()
4294
+ // Dispose Worker clients (T-03-07: prevent Worker leak)
4295
+ for (const client of new Set(this.#workerClients.values())) client.dispose()
4296
+ this.#workerClients.clear()
4297
+ }
4298
+ }