@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.
- package/README.md +524 -0
- package/dist/.build-stamp +0 -0
- package/dist/__tests__/test-utils.d.ts +71 -0
- package/dist/__tests__/test-utils.d.ts.map +1 -0
- package/dist/align.d.ts +152 -0
- package/dist/align.d.ts.map +1 -0
- package/dist/align.js +88 -0
- package/dist/align.js.map +7 -0
- package/dist/audio-init-parser.d.ts +66 -0
- package/dist/audio-init-parser.d.ts.map +1 -0
- package/dist/audio-init-parser.js +118 -0
- package/dist/audio-init-parser.js.map +7 -0
- package/dist/audio-ring-buffer.d.ts +50 -0
- package/dist/audio-ring-buffer.d.ts.map +1 -0
- package/dist/audio-ring-buffer.js +80 -0
- package/dist/audio-ring-buffer.js.map +7 -0
- package/dist/authored-timing.d.ts +32 -0
- package/dist/authored-timing.d.ts.map +1 -0
- package/dist/authored-timing.js +32 -0
- package/dist/authored-timing.js.map +7 -0
- package/dist/block-policy.d.ts +314 -0
- package/dist/block-policy.d.ts.map +1 -0
- package/dist/block-policy.js +466 -0
- package/dist/block-policy.js.map +7 -0
- package/dist/browser-runtime.js +26549 -0
- package/dist/browser-runtime.js.map +7 -0
- package/dist/carp-catalog.d.ts +429 -0
- package/dist/carp-catalog.d.ts.map +1 -0
- package/dist/carp-catalog.js +985 -0
- package/dist/carp-catalog.js.map +7 -0
- package/dist/catalog-fec-params.d.ts +275 -0
- package/dist/catalog-fec-params.d.ts.map +1 -0
- package/dist/catalog-fec-params.js +702 -0
- package/dist/catalog-fec-params.js.map +7 -0
- package/dist/catalog-network-source.d.ts +42 -0
- package/dist/catalog-network-source.d.ts.map +1 -0
- package/dist/catalog-network-source.js +47 -0
- package/dist/catalog-network-source.js.map +7 -0
- package/dist/catalog-validator.d.ts +18 -0
- package/dist/catalog-validator.d.ts.map +1 -0
- package/dist/catalog-validator.js +7747 -0
- package/dist/catalog-validator.js.map +7 -0
- package/dist/cmaf-block-assembler.d.ts +285 -0
- package/dist/cmaf-block-assembler.d.ts.map +1 -0
- package/dist/cmaf-block-assembler.js +1966 -0
- package/dist/cmaf-block-assembler.js.map +7 -0
- package/dist/cmaf-group-batcher.d.ts +38 -0
- package/dist/cmaf-group-batcher.d.ts.map +1 -0
- package/dist/cmaf-group-batcher.js +176 -0
- package/dist/cmaf-group-batcher.js.map +7 -0
- package/dist/cmcd-producer.d.ts +104 -0
- package/dist/cmcd-producer.d.ts.map +1 -0
- package/dist/cmcd-producer.js +183 -0
- package/dist/cmcd-producer.js.map +7 -0
- package/dist/codec-utils.d.ts +384 -0
- package/dist/codec-utils.d.ts.map +1 -0
- package/dist/codec-utils.js +1546 -0
- package/dist/codec-utils.js.map +7 -0
- package/dist/conceal-timeline.d.ts +146 -0
- package/dist/conceal-timeline.d.ts.map +1 -0
- package/dist/conceal-timeline.js +137 -0
- package/dist/conceal-timeline.js.map +7 -0
- package/dist/delivery-gap.d.ts +75 -0
- package/dist/delivery-gap.d.ts.map +1 -0
- package/dist/delivery-gap.js +88 -0
- package/dist/delivery-gap.js.map +7 -0
- package/dist/duplicate-ledger.d.ts +197 -0
- package/dist/duplicate-ledger.d.ts.map +1 -0
- package/dist/duplicate-ledger.js +226 -0
- package/dist/duplicate-ledger.js.map +7 -0
- package/dist/fec-codec.d.ts +32 -0
- package/dist/fec-codec.d.ts.map +1 -0
- package/dist/fec-codec.js +47 -0
- package/dist/fec-codec.js.map +7 -0
- package/dist/fec-manager.d.ts +583 -0
- package/dist/fec-manager.d.ts.map +1 -0
- package/dist/fec-manager.js +3899 -0
- package/dist/fec-manager.js.map +7 -0
- package/dist/fec-starvation-detector.d.ts +59 -0
- package/dist/fec-starvation-detector.d.ts.map +1 -0
- package/dist/fec-starvation-detector.js +60 -0
- package/dist/fec-starvation-detector.js.map +7 -0
- package/dist/fec-state.d.ts +131 -0
- package/dist/fec-state.d.ts.map +1 -0
- package/dist/fec-state.js +87 -0
- package/dist/fec-state.js.map +7 -0
- package/dist/fec-worker-setup.d.ts +94 -0
- package/dist/fec-worker-setup.d.ts.map +1 -0
- package/dist/fec-worker-setup.js +673 -0
- package/dist/fec-worker-setup.js.map +7 -0
- package/dist/fmp4-completion.d.ts +30 -0
- package/dist/fmp4-completion.d.ts.map +1 -0
- package/dist/fmp4-completion.js +21 -0
- package/dist/fmp4-completion.js.map +7 -0
- package/dist/fmp4-reader.d.ts +37 -0
- package/dist/fmp4-reader.d.ts.map +1 -0
- package/dist/fmp4-reader.js +100 -0
- package/dist/fmp4-reader.js.map +7 -0
- package/dist/index.d.ts +44 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +460 -14
- package/dist/index.js.map +7 -1
- package/dist/iso-block-locator.d.ts +118 -0
- package/dist/iso-block-locator.d.ts.map +1 -0
- package/dist/iso-block-locator.js +289 -0
- package/dist/iso-block-locator.js.map +7 -0
- package/dist/iso-bmff-timing.d.ts +78 -0
- package/dist/iso-bmff-timing.d.ts.map +1 -0
- package/dist/iso-bmff-timing.js +593 -0
- package/dist/iso-bmff-timing.js.map +7 -0
- package/dist/loc.d.ts +70 -75
- package/dist/loc.d.ts.map +1 -1
- package/dist/loc.js +106 -192
- package/dist/loc.js.map +7 -1
- package/dist/manifest-auth-plan.d.ts +30 -0
- package/dist/manifest-auth-plan.d.ts.map +1 -0
- package/dist/manifest-auth-plan.js +1266 -0
- package/dist/manifest-auth-plan.js.map +7 -0
- package/dist/media-router.d.ts +262 -0
- package/dist/media-router.d.ts.map +1 -0
- package/dist/media-router.js +584 -0
- package/dist/media-router.js.map +7 -0
- package/dist/mfu-reassembler.d.ts +313 -9
- package/dist/mfu-reassembler.d.ts.map +1 -1
- package/dist/mfu-reassembler.js +3051 -285
- package/dist/mfu-reassembler.js.map +7 -1
- package/dist/mfu-reassembly-policy.d.ts +24 -0
- package/dist/mfu-reassembly-policy.d.ts.map +1 -0
- package/dist/mfu-reassembly-policy.js +99 -0
- package/dist/mfu-reassembly-policy.js.map +7 -0
- package/dist/mmtp-container-parser.d.ts +568 -0
- package/dist/mmtp-container-parser.d.ts.map +1 -0
- package/dist/mmtp-container-parser.js +6580 -0
- package/dist/mmtp-container-parser.js.map +7 -0
- package/dist/mmtp-container-parser.no-modules.js +9616 -0
- package/dist/mmtp-container-parser.no-modules.js.map +7 -0
- package/dist/mmtp-decode-pipeline.d.ts +138 -0
- package/dist/mmtp-decode-pipeline.d.ts.map +1 -0
- package/dist/mmtp-decode-pipeline.js +6814 -0
- package/dist/mmtp-decode-pipeline.js.map +7 -0
- package/dist/mmtp-router.d.ts +679 -0
- package/dist/mmtp-router.d.ts.map +1 -0
- package/dist/mmtp-router.js +11278 -0
- package/dist/mmtp-router.js.map +7 -0
- package/dist/mmtp-to-cmaf.d.ts +187 -0
- package/dist/mmtp-to-cmaf.d.ts.map +1 -0
- package/dist/mmtp-to-cmaf.js +716 -0
- package/dist/mmtp-to-cmaf.js.map +7 -0
- package/dist/multicast-auth-catalog.d.ts +115 -0
- package/dist/multicast-auth-catalog.d.ts.map +1 -0
- package/dist/multicast-auth-catalog.js +202 -0
- package/dist/multicast-auth-catalog.js.map +7 -0
- package/dist/multicast-input.d.ts +141 -0
- package/dist/multicast-input.d.ts.map +1 -0
- package/dist/multicast-input.js +390 -0
- package/dist/multicast-input.js.map +7 -0
- package/dist/multicast-manager.d.ts +669 -0
- package/dist/multicast-manager.d.ts.map +1 -0
- package/dist/multicast-manager.js +19101 -0
- package/dist/multicast-manager.js.map +7 -0
- package/dist/multicast-transport.d.ts +271 -0
- package/dist/multicast-transport.d.ts.map +1 -0
- package/dist/multicast-transport.js +2264 -0
- package/dist/multicast-transport.js.map +7 -0
- package/dist/multicast-unwrap.d.ts +57 -0
- package/dist/multicast-unwrap.d.ts.map +1 -0
- package/dist/multicast-unwrap.js +418 -0
- package/dist/multicast-unwrap.js.map +7 -0
- package/dist/no-modules.d.ts +10 -0
- package/dist/no-modules.d.ts.map +1 -0
- package/dist/no-modules.js +9491 -0
- package/dist/no-modules.js.map +7 -0
- package/dist/phase14-catalog-override.d.ts +126 -0
- package/dist/phase14-catalog-override.d.ts.map +1 -0
- package/dist/phase14-catalog-override.js +155 -0
- package/dist/phase14-catalog-override.js.map +7 -0
- package/dist/repair-fetch.d.ts +270 -0
- package/dist/repair-fetch.d.ts.map +1 -0
- package/dist/repair-fetch.js +381 -0
- package/dist/repair-fetch.js.map +7 -0
- package/dist/repair-layers.d.ts +158 -0
- package/dist/repair-layers.d.ts.map +1 -0
- package/dist/repair-layers.js +344 -0
- package/dist/repair-layers.js.map +7 -0
- package/dist/sample-combiner.d.ts +212 -0
- package/dist/sample-combiner.d.ts.map +1 -0
- package/dist/sample-combiner.js +595 -0
- package/dist/sample-combiner.js.map +7 -0
- package/dist/signaling.d.ts +29 -0
- package/dist/signaling.d.ts.map +1 -0
- package/dist/signaling.js +572 -0
- package/dist/signaling.js.map +7 -0
- package/dist/source-auth.d.ts +2 -0
- package/dist/source-auth.d.ts.map +1 -0
- package/dist/source-auth.js +1209 -0
- package/dist/source-auth.js.map +7 -0
- package/dist/switching-set-alignment.d.ts +61 -0
- package/dist/switching-set-alignment.d.ts.map +1 -0
- package/dist/switching-set-alignment.js +70 -0
- package/dist/switching-set-alignment.js.map +7 -0
- package/dist/timestamp-codec.d.ts +34 -0
- package/dist/timestamp-codec.d.ts.map +1 -0
- package/dist/timestamp-codec.js +473 -0
- package/dist/timestamp-codec.js.map +7 -0
- package/dist/track-pipeline-setup.d.ts +83 -0
- package/dist/track-pipeline-setup.d.ts.map +1 -0
- package/dist/track-pipeline-setup.js +291 -0
- package/dist/track-pipeline-setup.js.map +7 -0
- package/dist/track-pipeline.d.ts +121 -0
- package/dist/track-pipeline.d.ts.map +1 -0
- package/dist/track-pipeline.js +209 -0
- package/dist/track-pipeline.js.map +7 -0
- package/dist/track-session.d.ts +414 -0
- package/dist/track-session.d.ts.map +1 -0
- package/dist/track-session.js +754 -0
- package/dist/track-session.js.map +7 -0
- package/dist/transport-pool.d.ts +69 -0
- package/dist/transport-pool.d.ts.map +1 -0
- package/dist/transport-pool.js +74 -0
- package/dist/transport-pool.js.map +7 -0
- package/dist/wasm-mfu-reassembler.d.ts +97 -0
- package/dist/wasm-mfu-reassembler.d.ts.map +1 -0
- package/dist/wasm-mfu-reassembler.js +2133 -0
- package/dist/wasm-mfu-reassembler.js.map +7 -0
- package/dist/wasm-mmtp-container-parser.d.ts +10 -0
- package/dist/wasm-mmtp-container-parser.d.ts.map +1 -0
- package/dist/wasm-mmtp-container-parser.js +6834 -0
- package/dist/wasm-mmtp-container-parser.js.map +7 -0
- package/node_modules/@blockcast/fec-worker/README.md +223 -0
- package/node_modules/@blockcast/fec-worker/dist/.build-stamp +0 -0
- package/node_modules/@blockcast/fec-worker/dist/block-order.d.ts +38 -0
- package/node_modules/@blockcast/fec-worker/dist/block-order.d.ts.map +1 -0
- package/node_modules/@blockcast/fec-worker/dist/block-order.js +78 -0
- package/node_modules/@blockcast/fec-worker/dist/block-order.js.map +1 -0
- package/node_modules/@blockcast/fec-worker/dist/fec-worker-client.d.ts +93 -0
- package/node_modules/@blockcast/fec-worker/dist/fec-worker-client.d.ts.map +1 -0
- package/node_modules/@blockcast/fec-worker/dist/fec-worker-client.js +346 -0
- package/node_modules/@blockcast/fec-worker/dist/fec-worker-client.js.map +1 -0
- package/node_modules/@blockcast/fec-worker/dist/fec-worker-types.d.ts +380 -0
- package/node_modules/@blockcast/fec-worker/dist/fec-worker-types.d.ts.map +1 -0
- package/node_modules/@blockcast/fec-worker/dist/fec-worker-types.js +11 -0
- package/node_modules/@blockcast/fec-worker/dist/fec-worker-types.js.map +1 -0
- package/node_modules/@blockcast/fec-worker/dist/fec-worker.d.ts +14 -0
- package/node_modules/@blockcast/fec-worker/dist/fec-worker.d.ts.map +1 -0
- package/node_modules/@blockcast/fec-worker/dist/fec-worker.js +859 -0
- package/node_modules/@blockcast/fec-worker/dist/fec-worker.js.map +7 -0
- package/node_modules/@blockcast/fec-worker/dist/index.d.ts +10 -0
- package/node_modules/@blockcast/fec-worker/dist/index.d.ts.map +1 -0
- package/node_modules/@blockcast/fec-worker/dist/index.js +8 -0
- package/node_modules/@blockcast/fec-worker/dist/index.js.map +1 -0
- package/node_modules/@blockcast/fec-worker/dist/shred-fec-worker.d.ts +20 -0
- package/node_modules/@blockcast/fec-worker/dist/shred-fec-worker.d.ts.map +1 -0
- package/node_modules/@blockcast/fec-worker/dist/shred-fec-worker.js +370 -0
- package/node_modules/@blockcast/fec-worker/dist/shred-fec-worker.js.map +7 -0
- package/node_modules/@blockcast/fec-worker/dist/shred-worker-client.d.ts +104 -0
- package/node_modules/@blockcast/fec-worker/dist/shred-worker-client.d.ts.map +1 -0
- package/node_modules/@blockcast/fec-worker/dist/shred-worker-client.js +181 -0
- package/node_modules/@blockcast/fec-worker/dist/shred-worker-client.js.map +1 -0
- package/node_modules/@blockcast/fec-worker/dist/shred-worker-types.d.ts +194 -0
- package/node_modules/@blockcast/fec-worker/dist/shred-worker-types.d.ts.map +1 -0
- package/node_modules/@blockcast/fec-worker/dist/shred-worker-types.js +62 -0
- package/node_modules/@blockcast/fec-worker/dist/shred-worker-types.js.map +1 -0
- package/node_modules/@blockcast/fec-worker/dist/transfer.d.ts +9 -0
- package/node_modules/@blockcast/fec-worker/dist/transfer.d.ts.map +1 -0
- package/node_modules/@blockcast/fec-worker/dist/transfer.js +13 -0
- package/node_modules/@blockcast/fec-worker/dist/transfer.js.map +1 -0
- package/node_modules/@blockcast/fec-worker/package.json +59 -0
- package/node_modules/@blockcast/fec-worker/src/block-order.ts +93 -0
- package/node_modules/@blockcast/fec-worker/src/fec-worker-client.ts +392 -0
- package/node_modules/@blockcast/fec-worker/src/fec-worker-types.ts +399 -0
- package/node_modules/@blockcast/fec-worker/src/fec-worker.ts +1502 -0
- package/node_modules/@blockcast/fec-worker/src/index.ts +35 -0
- package/node_modules/@blockcast/fec-worker/src/shred-fec-worker.ts +589 -0
- package/node_modules/@blockcast/fec-worker/src/shred-worker-client.ts +222 -0
- package/node_modules/@blockcast/fec-worker/src/shred-worker-types.ts +209 -0
- package/node_modules/@blockcast/fec-worker/src/transfer.ts +12 -0
- package/node_modules/@blockcast/mmt-alta-parse/README.md +181 -0
- package/node_modules/@blockcast/mmt-alta-parse/dist/.build-stamp +0 -0
- package/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts +57 -0
- package/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-alta-parse/dist/index.js +115 -0
- package/node_modules/@blockcast/mmt-alta-parse/dist/index.js.map +1 -0
- package/node_modules/@blockcast/mmt-alta-parse/package.json +50 -0
- package/node_modules/@blockcast/mmt-alta-parse/src/index.ts +136 -0
- package/node_modules/@blockcast/mmt-base/README.md +134 -0
- package/node_modules/@blockcast/mmt-base/dist/.build-stamp +0 -0
- package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts +55 -0
- package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js +128 -0
- package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/index.d.ts +7 -0
- package/node_modules/@blockcast/mmt-base/dist/index.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/index.js +7 -0
- package/node_modules/@blockcast/mmt-base/dist/index.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts +292 -0
- package/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/mmtp.js +612 -0
- package/node_modules/@blockcast/mmt-base/dist/mmtp.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts +18 -0
- package/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/net-constants.js +18 -0
- package/node_modules/@blockcast/mmt-base/dist/net-constants.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts +47 -0
- package/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/ntp-short.js +54 -0
- package/node_modules/@blockcast/mmt-base/dist/ntp-short.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts +57 -0
- package/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/serial-number.js +98 -0
- package/node_modules/@blockcast/mmt-base/dist/serial-number.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/signalling.d.ts +312 -0
- package/node_modules/@blockcast/mmt-base/dist/signalling.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/signalling.js +637 -0
- package/node_modules/@blockcast/mmt-base/dist/signalling.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/package.json +49 -0
- package/node_modules/@blockcast/mmt-base/src/fec-block-profile.ts +145 -0
- package/node_modules/@blockcast/mmt-base/src/index.ts +6 -0
- package/node_modules/@blockcast/mmt-base/src/mmtp.ts +892 -0
- package/node_modules/@blockcast/mmt-base/src/net-constants.ts +17 -0
- package/node_modules/@blockcast/mmt-base/src/ntp-short.ts +67 -0
- package/node_modules/@blockcast/mmt-base/src/serial-number.ts +107 -0
- package/node_modules/@blockcast/mmt-base/src/signalling.ts +862 -0
- package/node_modules/@blockcast/mmt-fec/README.md +271 -0
- package/node_modules/@blockcast/mmt-fec/dist/.build-stamp +0 -0
- package/node_modules/@blockcast/mmt-fec/dist/block-manager.d.ts +82 -0
- package/node_modules/@blockcast/mmt-fec/dist/block-manager.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-fec/dist/block-manager.js +296 -0
- package/node_modules/@blockcast/mmt-fec/dist/block-manager.js.map +1 -0
- package/node_modules/@blockcast/mmt-fec/dist/block-processor.d.ts +106 -0
- package/node_modules/@blockcast/mmt-fec/dist/block-processor.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-fec/dist/block-processor.js +288 -0
- package/node_modules/@blockcast/mmt-fec/dist/block-processor.js.map +1 -0
- package/node_modules/@blockcast/mmt-fec/dist/cmaf-recovery.d.ts +46 -0
- package/node_modules/@blockcast/mmt-fec/dist/cmaf-recovery.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-fec/dist/cmaf-recovery.js +96 -0
- package/node_modules/@blockcast/mmt-fec/dist/cmaf-recovery.js.map +1 -0
- package/node_modules/@blockcast/mmt-fec/dist/cmaf-symbols.d.ts +33 -0
- package/node_modules/@blockcast/mmt-fec/dist/cmaf-symbols.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-fec/dist/cmaf-symbols.js +57 -0
- package/node_modules/@blockcast/mmt-fec/dist/cmaf-symbols.js.map +1 -0
- package/node_modules/@blockcast/mmt-fec/dist/fec-block-mapper.d.ts +205 -0
- package/node_modules/@blockcast/mmt-fec/dist/fec-block-mapper.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-fec/dist/fec-block-mapper.js +240 -0
- package/node_modules/@blockcast/mmt-fec/dist/fec-block-mapper.js.map +1 -0
- package/node_modules/@blockcast/mmt-fec/dist/index.d.ts +28 -0
- package/node_modules/@blockcast/mmt-fec/dist/index.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-fec/dist/index.js +28 -0
- package/node_modules/@blockcast/mmt-fec/dist/index.js.map +1 -0
- package/node_modules/@blockcast/mmt-fec/dist/loc-symbols.d.ts +20 -0
- package/node_modules/@blockcast/mmt-fec/dist/loc-symbols.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-fec/dist/loc-symbols.js +28 -0
- package/node_modules/@blockcast/mmt-fec/dist/loc-symbols.js.map +1 -0
- package/node_modules/@blockcast/mmt-fec/dist/oti.d.ts +258 -0
- package/node_modules/@blockcast/mmt-fec/dist/oti.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-fec/dist/oti.js +382 -0
- package/node_modules/@blockcast/mmt-fec/dist/oti.js.map +1 -0
- package/node_modules/@blockcast/mmt-fec/dist/reorder-buffer.d.ts +41 -0
- package/node_modules/@blockcast/mmt-fec/dist/reorder-buffer.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-fec/dist/reorder-buffer.js +81 -0
- package/node_modules/@blockcast/mmt-fec/dist/reorder-buffer.js.map +1 -0
- package/node_modules/@blockcast/mmt-fec/dist/repair-group.d.ts +82 -0
- package/node_modules/@blockcast/mmt-fec/dist/repair-group.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-fec/dist/repair-group.js +170 -0
- package/node_modules/@blockcast/mmt-fec/dist/repair-group.js.map +1 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/README.md +181 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/dist/.build-stamp +0 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts +57 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/dist/index.js +115 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/dist/index.js.map +1 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/package.json +50 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/src/index.ts +136 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/README.md +134 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/.build-stamp +0 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts +55 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js +128 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js.map +1 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/index.d.ts +7 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/index.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/index.js +7 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/index.js.map +1 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts +292 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/mmtp.js +612 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/mmtp.js.map +1 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts +18 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/net-constants.js +18 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/net-constants.js.map +1 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts +47 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/ntp-short.js +54 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/ntp-short.js.map +1 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts +57 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/serial-number.js +98 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/serial-number.js.map +1 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/signalling.d.ts +312 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/signalling.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/signalling.js +637 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/signalling.js.map +1 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/package.json +49 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/fec-block-profile.ts +145 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/index.ts +6 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/mmtp.ts +892 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/net-constants.ts +17 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/ntp-short.ts +67 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/serial-number.ts +107 -0
- package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/signalling.ts +862 -0
- package/node_modules/@blockcast/mmt-fec/package.json +79 -0
- package/node_modules/@blockcast/mmt-fec/src/block-manager.ts +371 -0
- package/node_modules/@blockcast/mmt-fec/src/block-processor.ts +439 -0
- package/node_modules/@blockcast/mmt-fec/src/cmaf-recovery.ts +127 -0
- package/node_modules/@blockcast/mmt-fec/src/cmaf-symbols.ts +68 -0
- package/node_modules/@blockcast/mmt-fec/src/fec-block-mapper.ts +310 -0
- package/node_modules/@blockcast/mmt-fec/src/index.ts +94 -0
- package/node_modules/@blockcast/mmt-fec/src/loc-symbols.ts +34 -0
- package/node_modules/@blockcast/mmt-fec/src/oti.ts +463 -0
- package/node_modules/@blockcast/mmt-fec/src/reorder-buffer.ts +101 -0
- package/node_modules/@blockcast/mmt-fec/src/repair-group.ts +195 -0
- package/node_modules/@blockcast/mmt-manifest-verify/dist/.build-stamp +0 -0
- package/node_modules/@blockcast/mmt-manifest-verify/dist/admission.d.ts +28 -0
- package/node_modules/@blockcast/mmt-manifest-verify/dist/admission.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-manifest-verify/dist/admission.js +128 -0
- package/node_modules/@blockcast/mmt-manifest-verify/dist/admission.js.map +1 -0
- package/node_modules/@blockcast/mmt-manifest-verify/dist/index.d.ts +4 -0
- package/node_modules/@blockcast/mmt-manifest-verify/dist/index.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-manifest-verify/dist/index.js +4 -0
- package/node_modules/@blockcast/mmt-manifest-verify/dist/index.js.map +1 -0
- package/node_modules/@blockcast/mmt-manifest-verify/dist/source-auth.d.ts +37 -0
- package/node_modules/@blockcast/mmt-manifest-verify/dist/source-auth.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-manifest-verify/dist/source-auth.js +179 -0
- package/node_modules/@blockcast/mmt-manifest-verify/dist/source-auth.js.map +1 -0
- package/node_modules/@blockcast/mmt-manifest-verify/dist/verifier.d.ts +99 -0
- package/node_modules/@blockcast/mmt-manifest-verify/dist/verifier.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-manifest-verify/dist/verifier.js +492 -0
- package/node_modules/@blockcast/mmt-manifest-verify/dist/verifier.js.map +1 -0
- package/node_modules/@blockcast/mmt-manifest-verify/package.json +45 -0
- package/node_modules/@blockcast/mmt-manifest-verify/src/admission.ts +171 -0
- package/node_modules/@blockcast/mmt-manifest-verify/src/index.ts +3 -0
- package/node_modules/@blockcast/mmt-manifest-verify/src/source-auth.ts +230 -0
- package/node_modules/@blockcast/mmt-manifest-verify/src/verifier.ts +669 -0
- package/node_modules/@noble/hashes/LICENSE +21 -0
- package/node_modules/@noble/hashes/README.md +521 -0
- package/node_modules/@noble/hashes/_assert.d.ts +17 -0
- package/node_modules/@noble/hashes/_assert.d.ts.map +1 -0
- package/node_modules/@noble/hashes/_assert.js +18 -0
- package/node_modules/@noble/hashes/_assert.js.map +1 -0
- package/node_modules/@noble/hashes/_blake.d.ts +14 -0
- package/node_modules/@noble/hashes/_blake.d.ts.map +1 -0
- package/node_modules/@noble/hashes/_blake.js +50 -0
- package/node_modules/@noble/hashes/_blake.js.map +1 -0
- package/node_modules/@noble/hashes/_md.d.ts +51 -0
- package/node_modules/@noble/hashes/_md.d.ts.map +1 -0
- package/node_modules/@noble/hashes/_md.js +162 -0
- package/node_modules/@noble/hashes/_md.js.map +1 -0
- package/node_modules/@noble/hashes/_u64.d.ts +55 -0
- package/node_modules/@noble/hashes/_u64.d.ts.map +1 -0
- package/node_modules/@noble/hashes/_u64.js +90 -0
- package/node_modules/@noble/hashes/_u64.js.map +1 -0
- package/node_modules/@noble/hashes/argon2.d.ts +32 -0
- package/node_modules/@noble/hashes/argon2.d.ts.map +1 -0
- package/node_modules/@noble/hashes/argon2.js +401 -0
- package/node_modules/@noble/hashes/argon2.js.map +1 -0
- package/node_modules/@noble/hashes/blake1.d.ts +106 -0
- package/node_modules/@noble/hashes/blake1.d.ts.map +1 -0
- package/node_modules/@noble/hashes/blake1.js +459 -0
- package/node_modules/@noble/hashes/blake1.js.map +1 -0
- package/node_modules/@noble/hashes/blake2.d.ts +116 -0
- package/node_modules/@noble/hashes/blake2.d.ts.map +1 -0
- package/node_modules/@noble/hashes/blake2.js +420 -0
- package/node_modules/@noble/hashes/blake2.js.map +1 -0
- package/node_modules/@noble/hashes/blake2b.d.ts +11 -0
- package/node_modules/@noble/hashes/blake2b.d.ts.map +1 -0
- package/node_modules/@noble/hashes/blake2b.js +14 -0
- package/node_modules/@noble/hashes/blake2b.js.map +1 -0
- package/node_modules/@noble/hashes/blake2s.d.ts +20 -0
- package/node_modules/@noble/hashes/blake2s.d.ts.map +1 -0
- package/node_modules/@noble/hashes/blake2s.js +24 -0
- package/node_modules/@noble/hashes/blake2s.js.map +1 -0
- package/node_modules/@noble/hashes/blake3.d.ts +54 -0
- package/node_modules/@noble/hashes/blake3.d.ts.map +1 -0
- package/node_modules/@noble/hashes/blake3.js +255 -0
- package/node_modules/@noble/hashes/blake3.js.map +1 -0
- package/node_modules/@noble/hashes/crypto.d.ts +2 -0
- package/node_modules/@noble/hashes/crypto.d.ts.map +1 -0
- package/node_modules/@noble/hashes/crypto.js +5 -0
- package/node_modules/@noble/hashes/crypto.js.map +1 -0
- package/node_modules/@noble/hashes/cryptoNode.d.ts +2 -0
- package/node_modules/@noble/hashes/cryptoNode.d.ts.map +1 -0
- package/node_modules/@noble/hashes/cryptoNode.js +18 -0
- package/node_modules/@noble/hashes/cryptoNode.js.map +1 -0
- package/node_modules/@noble/hashes/eskdf.d.ts +47 -0
- package/node_modules/@noble/hashes/eskdf.d.ts.map +1 -0
- package/node_modules/@noble/hashes/eskdf.js +166 -0
- package/node_modules/@noble/hashes/eskdf.js.map +1 -0
- package/node_modules/@noble/hashes/esm/_assert.d.ts +17 -0
- package/node_modules/@noble/hashes/esm/_assert.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/_assert.js +15 -0
- package/node_modules/@noble/hashes/esm/_assert.js.map +1 -0
- package/node_modules/@noble/hashes/esm/_blake.d.ts +14 -0
- package/node_modules/@noble/hashes/esm/_blake.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/_blake.js +45 -0
- package/node_modules/@noble/hashes/esm/_blake.js.map +1 -0
- package/node_modules/@noble/hashes/esm/_md.d.ts +51 -0
- package/node_modules/@noble/hashes/esm/_md.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/_md.js +155 -0
- package/node_modules/@noble/hashes/esm/_md.js.map +1 -0
- package/node_modules/@noble/hashes/esm/_u64.d.ts +55 -0
- package/node_modules/@noble/hashes/esm/_u64.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/_u64.js +67 -0
- package/node_modules/@noble/hashes/esm/_u64.js.map +1 -0
- package/node_modules/@noble/hashes/esm/argon2.d.ts +32 -0
- package/node_modules/@noble/hashes/esm/argon2.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/argon2.js +392 -0
- package/node_modules/@noble/hashes/esm/argon2.js.map +1 -0
- package/node_modules/@noble/hashes/esm/blake1.d.ts +106 -0
- package/node_modules/@noble/hashes/esm/blake1.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/blake1.js +452 -0
- package/node_modules/@noble/hashes/esm/blake1.js.map +1 -0
- package/node_modules/@noble/hashes/esm/blake2.d.ts +116 -0
- package/node_modules/@noble/hashes/esm/blake2.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/blake2.js +413 -0
- package/node_modules/@noble/hashes/esm/blake2.js.map +1 -0
- package/node_modules/@noble/hashes/esm/blake2b.d.ts +11 -0
- package/node_modules/@noble/hashes/esm/blake2b.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/blake2b.js +11 -0
- package/node_modules/@noble/hashes/esm/blake2b.js.map +1 -0
- package/node_modules/@noble/hashes/esm/blake2s.d.ts +20 -0
- package/node_modules/@noble/hashes/esm/blake2s.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/blake2s.js +21 -0
- package/node_modules/@noble/hashes/esm/blake2s.js.map +1 -0
- package/node_modules/@noble/hashes/esm/blake3.d.ts +54 -0
- package/node_modules/@noble/hashes/esm/blake3.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/blake3.js +251 -0
- package/node_modules/@noble/hashes/esm/blake3.js.map +1 -0
- package/node_modules/@noble/hashes/esm/crypto.d.ts +2 -0
- package/node_modules/@noble/hashes/esm/crypto.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/crypto.js +2 -0
- package/node_modules/@noble/hashes/esm/crypto.js.map +1 -0
- package/node_modules/@noble/hashes/esm/cryptoNode.d.ts +2 -0
- package/node_modules/@noble/hashes/esm/cryptoNode.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/cryptoNode.js +15 -0
- package/node_modules/@noble/hashes/esm/cryptoNode.js.map +1 -0
- package/node_modules/@noble/hashes/esm/eskdf.d.ts +47 -0
- package/node_modules/@noble/hashes/esm/eskdf.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/eskdf.js +160 -0
- package/node_modules/@noble/hashes/esm/eskdf.js.map +1 -0
- package/node_modules/@noble/hashes/esm/hkdf.d.ts +36 -0
- package/node_modules/@noble/hashes/esm/hkdf.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/hkdf.js +82 -0
- package/node_modules/@noble/hashes/esm/hkdf.js.map +1 -0
- package/node_modules/@noble/hashes/esm/hmac.d.ts +35 -0
- package/node_modules/@noble/hashes/esm/hmac.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/hmac.js +86 -0
- package/node_modules/@noble/hashes/esm/hmac.js.map +1 -0
- package/node_modules/@noble/hashes/esm/index.d.ts +2 -0
- package/node_modules/@noble/hashes/esm/index.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/index.js +33 -0
- package/node_modules/@noble/hashes/esm/index.js.map +1 -0
- package/node_modules/@noble/hashes/esm/legacy.d.ts +71 -0
- package/node_modules/@noble/hashes/esm/legacy.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/legacy.js +281 -0
- package/node_modules/@noble/hashes/esm/legacy.js.map +1 -0
- package/node_modules/@noble/hashes/esm/package.json +10 -0
- package/node_modules/@noble/hashes/esm/pbkdf2.d.ts +23 -0
- package/node_modules/@noble/hashes/esm/pbkdf2.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/pbkdf2.js +97 -0
- package/node_modules/@noble/hashes/esm/pbkdf2.js.map +1 -0
- package/node_modules/@noble/hashes/esm/ripemd160.d.ts +13 -0
- package/node_modules/@noble/hashes/esm/ripemd160.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/ripemd160.js +13 -0
- package/node_modules/@noble/hashes/esm/ripemd160.js.map +1 -0
- package/node_modules/@noble/hashes/esm/scrypt.d.ts +34 -0
- package/node_modules/@noble/hashes/esm/scrypt.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/scrypt.js +228 -0
- package/node_modules/@noble/hashes/esm/scrypt.js.map +1 -0
- package/node_modules/@noble/hashes/esm/sha1.d.ts +11 -0
- package/node_modules/@noble/hashes/esm/sha1.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/sha1.js +11 -0
- package/node_modules/@noble/hashes/esm/sha1.js.map +1 -0
- package/node_modules/@noble/hashes/esm/sha2.d.ts +159 -0
- package/node_modules/@noble/hashes/esm/sha2.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/sha2.js +375 -0
- package/node_modules/@noble/hashes/esm/sha2.js.map +1 -0
- package/node_modules/@noble/hashes/esm/sha256.d.ts +20 -0
- package/node_modules/@noble/hashes/esm/sha256.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/sha256.js +20 -0
- package/node_modules/@noble/hashes/esm/sha256.js.map +1 -0
- package/node_modules/@noble/hashes/esm/sha3-addons.d.ts +142 -0
- package/node_modules/@noble/hashes/esm/sha3-addons.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/sha3-addons.js +393 -0
- package/node_modules/@noble/hashes/esm/sha3-addons.js.map +1 -0
- package/node_modules/@noble/hashes/esm/sha3.d.ts +53 -0
- package/node_modules/@noble/hashes/esm/sha3.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/sha3.js +234 -0
- package/node_modules/@noble/hashes/esm/sha3.js.map +1 -0
- package/node_modules/@noble/hashes/esm/sha512.d.ts +26 -0
- package/node_modules/@noble/hashes/esm/sha512.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/sha512.js +26 -0
- package/node_modules/@noble/hashes/esm/sha512.js.map +1 -0
- package/node_modules/@noble/hashes/esm/utils.d.ts +161 -0
- package/node_modules/@noble/hashes/esm/utils.d.ts.map +1 -0
- package/node_modules/@noble/hashes/esm/utils.js +281 -0
- package/node_modules/@noble/hashes/esm/utils.js.map +1 -0
- package/node_modules/@noble/hashes/hkdf.d.ts +36 -0
- package/node_modules/@noble/hashes/hkdf.d.ts.map +1 -0
- package/node_modules/@noble/hashes/hkdf.js +88 -0
- package/node_modules/@noble/hashes/hkdf.js.map +1 -0
- package/node_modules/@noble/hashes/hmac.d.ts +35 -0
- package/node_modules/@noble/hashes/hmac.d.ts.map +1 -0
- package/node_modules/@noble/hashes/hmac.js +91 -0
- package/node_modules/@noble/hashes/hmac.js.map +1 -0
- package/node_modules/@noble/hashes/index.d.ts +1 -0
- package/node_modules/@noble/hashes/index.d.ts.map +1 -0
- package/node_modules/@noble/hashes/index.js +33 -0
- package/node_modules/@noble/hashes/index.js.map +1 -0
- package/node_modules/@noble/hashes/legacy.d.ts +71 -0
- package/node_modules/@noble/hashes/legacy.d.ts.map +1 -0
- package/node_modules/@noble/hashes/legacy.js +287 -0
- package/node_modules/@noble/hashes/legacy.js.map +1 -0
- package/node_modules/@noble/hashes/package.json +266 -0
- package/node_modules/@noble/hashes/pbkdf2.d.ts +23 -0
- package/node_modules/@noble/hashes/pbkdf2.d.ts.map +1 -0
- package/node_modules/@noble/hashes/pbkdf2.js +101 -0
- package/node_modules/@noble/hashes/pbkdf2.js.map +1 -0
- package/node_modules/@noble/hashes/ripemd160.d.ts +13 -0
- package/node_modules/@noble/hashes/ripemd160.d.ts.map +1 -0
- package/node_modules/@noble/hashes/ripemd160.js +16 -0
- package/node_modules/@noble/hashes/ripemd160.js.map +1 -0
- package/node_modules/@noble/hashes/scrypt.d.ts +34 -0
- package/node_modules/@noble/hashes/scrypt.d.ts.map +1 -0
- package/node_modules/@noble/hashes/scrypt.js +232 -0
- package/node_modules/@noble/hashes/scrypt.js.map +1 -0
- package/node_modules/@noble/hashes/sha1.d.ts +11 -0
- package/node_modules/@noble/hashes/sha1.d.ts.map +1 -0
- package/node_modules/@noble/hashes/sha1.js +14 -0
- package/node_modules/@noble/hashes/sha1.js.map +1 -0
- package/node_modules/@noble/hashes/sha2.d.ts +159 -0
- package/node_modules/@noble/hashes/sha2.d.ts.map +1 -0
- package/node_modules/@noble/hashes/sha2.js +384 -0
- package/node_modules/@noble/hashes/sha2.js.map +1 -0
- package/node_modules/@noble/hashes/sha256.d.ts +20 -0
- package/node_modules/@noble/hashes/sha256.d.ts.map +1 -0
- package/node_modules/@noble/hashes/sha256.js +23 -0
- package/node_modules/@noble/hashes/sha256.js.map +1 -0
- package/node_modules/@noble/hashes/sha3-addons.d.ts +142 -0
- package/node_modules/@noble/hashes/sha3-addons.d.ts.map +1 -0
- package/node_modules/@noble/hashes/sha3-addons.js +402 -0
- package/node_modules/@noble/hashes/sha3-addons.js.map +1 -0
- package/node_modules/@noble/hashes/sha3.d.ts +53 -0
- package/node_modules/@noble/hashes/sha3.d.ts.map +1 -0
- package/node_modules/@noble/hashes/sha3.js +239 -0
- package/node_modules/@noble/hashes/sha3.js.map +1 -0
- package/node_modules/@noble/hashes/sha512.d.ts +26 -0
- package/node_modules/@noble/hashes/sha512.d.ts.map +1 -0
- package/node_modules/@noble/hashes/sha512.js +29 -0
- package/node_modules/@noble/hashes/sha512.js.map +1 -0
- package/node_modules/@noble/hashes/src/_assert.ts +22 -0
- package/node_modules/@noble/hashes/src/_blake.ts +50 -0
- package/node_modules/@noble/hashes/src/_md.ts +176 -0
- package/node_modules/@noble/hashes/src/_u64.ts +91 -0
- package/node_modules/@noble/hashes/src/argon2.ts +497 -0
- package/node_modules/@noble/hashes/src/blake1.ts +534 -0
- package/node_modules/@noble/hashes/src/blake2.ts +486 -0
- package/node_modules/@noble/hashes/src/blake2b.ts +10 -0
- package/node_modules/@noble/hashes/src/blake2s.ts +20 -0
- package/node_modules/@noble/hashes/src/blake3.ts +272 -0
- package/node_modules/@noble/hashes/src/crypto.ts +9 -0
- package/node_modules/@noble/hashes/src/cryptoNode.ts +15 -0
- package/node_modules/@noble/hashes/src/eskdf.ts +187 -0
- package/node_modules/@noble/hashes/src/hkdf.ts +88 -0
- package/node_modules/@noble/hashes/src/hmac.ts +94 -0
- package/node_modules/@noble/hashes/src/index.ts +31 -0
- package/node_modules/@noble/hashes/src/legacy.ts +293 -0
- package/node_modules/@noble/hashes/src/pbkdf2.ts +122 -0
- package/node_modules/@noble/hashes/src/ripemd160.ts +12 -0
- package/node_modules/@noble/hashes/src/scrypt.ts +257 -0
- package/node_modules/@noble/hashes/src/sha1.ts +10 -0
- package/node_modules/@noble/hashes/src/sha2.ts +402 -0
- package/node_modules/@noble/hashes/src/sha256.ts +24 -0
- package/node_modules/@noble/hashes/src/sha3-addons.ts +499 -0
- package/node_modules/@noble/hashes/src/sha3.ts +258 -0
- package/node_modules/@noble/hashes/src/sha512.ts +34 -0
- package/node_modules/@noble/hashes/src/utils.ts +395 -0
- package/node_modules/@noble/hashes/utils.d.ts +161 -0
- package/node_modules/@noble/hashes/utils.d.ts.map +1 -0
- package/node_modules/@noble/hashes/utils.js +313 -0
- package/node_modules/@noble/hashes/utils.js.map +1 -0
- package/package.json +179 -8
- package/schemas/catalog.schema.json +357 -0
- package/src/align.ts +239 -0
- package/src/audio-init-parser.ts +189 -0
- package/src/audio-ring-buffer.ts +120 -0
- package/src/authored-timing.ts +63 -0
- package/src/block-policy.ts +903 -0
- package/src/carp-catalog.ts +1065 -0
- package/src/catalog-fec-params.ts +729 -0
- package/src/catalog-network-source.ts +74 -0
- package/src/catalog-validator.ts +34 -0
- package/src/cmaf-block-assembler.ts +1255 -0
- package/src/cmaf-group-batcher.ts +104 -0
- package/src/cmcd-producer.ts +329 -0
- package/src/codec-utils.ts +1984 -0
- package/src/conceal-timeline.ts +293 -0
- package/src/delivery-gap.ts +147 -0
- package/src/duplicate-ledger.ts +372 -0
- package/src/fec-codec.ts +61 -0
- package/src/fec-manager.ts +4298 -0
- package/src/fec-starvation-detector.ts +128 -0
- package/src/fec-state.ts +219 -0
- package/src/fec-worker-setup.ts +283 -0
- package/src/fmp4-completion.ts +47 -0
- package/src/fmp4-reader.ts +112 -0
- package/src/index.ts +590 -21
- package/src/iso-block-locator.ts +329 -0
- package/src/iso-bmff-timing.ts +813 -0
- package/src/loc.ts +128 -154
- package/src/manifest-auth-plan.ts +107 -0
- package/src/media-router.ts +841 -0
- package/src/mfu-reassembler.ts +3480 -155
- package/src/mfu-reassembly-policy.ts +134 -0
- package/src/mmtp-container-parser.ts +2380 -0
- package/src/mmtp-decode-pipeline.ts +406 -0
- package/src/mmtp-router.ts +4860 -0
- package/src/mmtp-to-cmaf.ts +977 -0
- package/src/multicast-auth-catalog.ts +340 -0
- package/src/multicast-input.ts +793 -0
- package/src/multicast-manager.ts +2783 -0
- package/src/multicast-transport.ts +1058 -0
- package/src/multicast-unwrap.ts +132 -0
- package/src/no-modules.ts +21 -0
- package/src/phase14-catalog-override.ts +336 -0
- package/src/repair-fetch.ts +772 -0
- package/src/repair-layers.ts +533 -0
- package/src/sample-combiner.ts +938 -0
- package/src/signaling.ts +78 -0
- package/src/source-auth.ts +1 -0
- package/src/switching-set-alignment.ts +109 -0
- package/src/timestamp-codec.ts +265 -0
- package/src/track-pipeline-setup.ts +205 -0
- package/src/track-pipeline.ts +332 -0
- package/src/track-session.ts +1010 -0
- package/src/transport-pool.ts +128 -0
- package/src/wasm-mfu-reassembler.ts +405 -0
- package/src/wasm-mmtp-container-parser.ts +23 -0
- package/test-vectors/align.json +199 -0
- package/test-vectors/catalog/README.md +106 -0
- package/test-vectors/catalog/negative/archive-origin-with-path.json +33 -0
- package/test-vectors/catalog/negative/archive-unknown-field.json +34 -0
- package/test-vectors/catalog/negative/fec-block-profile-missing.json +51 -0
- package/test-vectors/catalog/negative/fec-block-profile-unknown.json +52 -0
- package/test-vectors/catalog/negative/fec-enhancement-repair-removed.json +28 -0
- package/test-vectors/catalog/negative/fec-repair-layer-geometry.json +33 -0
- package/test-vectors/catalog/negative/fec-repair-layer-missing-symbols.json +31 -0
- package/test-vectors/catalog/negative/fec-repair-multiple-depends.json +32 -0
- package/test-vectors/catalog/negative/fec-repair-overlay-with-layer.json +34 -0
- package/test-vectors/catalog/negative/fec-repair-priority-out-of-band.json +24 -0
- package/test-vectors/catalog/negative/fec-repair-source-symbols-without-scope.json +33 -0
- package/test-vectors/catalog/negative/legacy-selection-params.json +15 -0
- package/test-vectors/catalog/negative/media-track-repair-layer.json +16 -0
- package/test-vectors/catalog/negative/missing-mmtp-fields.json +12 -0
- package/test-vectors/catalog/negative/multicast-auth-padded-key.json +37 -0
- package/test-vectors/catalog/negative/multicast-auth-reused-track-format.json +53 -0
- package/test-vectors/catalog/negative/multicast-auth-trailing-pad-bits.json +37 -0
- package/test-vectors/catalog/negative/multicast-endpoint-missing-protocol-and-source.json +24 -0
- package/test-vectors/catalog/negative/multicast-network-source-empty.json +44 -0
- package/test-vectors/catalog/negative/multicast-packet-id-signaling.json +26 -0
- package/test-vectors/catalog/negative/network-source-object.json +25 -0
- package/test-vectors/catalog/negative/raptorq-source-symbols-over-max.json +24 -0
- package/test-vectors/catalog/negative/raptorq-unaligned-symbol.json +27 -0
- package/test-vectors/catalog/negative/repair-track-legacy-shape.json +19 -0
- package/test-vectors/catalog/negative/track-role-empty.json +16 -0
- package/test-vectors/catalog/negative/track-role-non-string.json +16 -0
- package/test-vectors/catalog/positive/archive-origin.json +34 -0
- package/test-vectors/catalog/positive/fec-iso-ssbg1.json +52 -0
- package/test-vectors/catalog/positive/fec-layered-repair.json +106 -0
- package/test-vectors/catalog/positive/fec-multicast.json +52 -0
- package/test-vectors/catalog/positive/flat-av.json +30 -0
- package/test-vectors/catalog/positive/multicast-auth-rotation.json +55 -0
- package/test-vectors/catalog/positive/multicast-native-ssm-no-network-source.json +43 -0
- package/test-vectors/catalog/positive/ticks-and-mixed-timescales.json +39 -0
- package/test-vectors/catalog/positive/track-role.json +49 -0
- package/test-vectors/catalog/releases/moqcast-native-v16-v1/catalog.json +65 -0
- package/test-vectors/catalog/releases/moqcast-native-v16-v1.archive.sha256 +1 -0
- package/test-vectors/catalog-multicast-auth-v1.json +26 -0
- package/test-vectors/catalog-validation.json +732 -0
- package/test-vectors/catalog-validator-public-api.mjs +25 -0
- package/test-vectors/generate-source-auth-fixture.mjs +114 -0
- package/test-vectors/hevc-x265-repeat-headers-v1.json +40 -0
- package/test-vectors/source-auth-v1.json +61 -0
- package/test-vectors/source-contiguity-v1.json +131 -0
- package/test-vectors/subsample-placement-v1.json +230 -0
- package/dist/mmtp.d.ts +0 -73
- package/dist/mmtp.d.ts.map +0 -1
- package/dist/mmtp.js +0 -210
- package/dist/mmtp.js.map +0 -1
- 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
|
+
}
|