@blockcast/mmt-container 2.1.0 → 2.2.0-main.0f6aad7bb803

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/browser-runtime.js +458 -77
  2. package/dist/browser-runtime.js.map +4 -4
  3. package/dist/carp-catalog.js.map +1 -1
  4. package/dist/catalog-fec-params.js.map +1 -1
  5. package/dist/catalog-network-source.js.map +1 -1
  6. package/dist/cmcd-producer.d.ts +5 -0
  7. package/dist/cmcd-producer.d.ts.map +1 -1
  8. package/dist/cmcd-producer.js.map +2 -2
  9. package/dist/delivery-catalog.js.map +1 -1
  10. package/dist/delta-fallback-config.js.map +1 -1
  11. package/dist/fec-manager.js.map +2 -2
  12. package/dist/fec-state.js.map +1 -1
  13. package/dist/fec-worker-setup.js.map +1 -1
  14. package/dist/held-layer-admission.js.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js.map +2 -2
  18. package/dist/iso-block-locator.js.map +1 -1
  19. package/dist/mfu-reassembler.js +12 -19
  20. package/dist/mfu-reassembler.js.map +2 -2
  21. package/dist/mmtp-container-parser.js +12 -19
  22. package/dist/mmtp-container-parser.js.map +2 -2
  23. package/dist/mmtp-container-parser.no-modules.js +133 -34
  24. package/dist/mmtp-container-parser.no-modules.js.map +3 -3
  25. package/dist/mmtp-decode-pipeline.js +12 -19
  26. package/dist/mmtp-decode-pipeline.js.map +2 -2
  27. package/dist/mmtp-fec-session.d.ts +41 -3
  28. package/dist/mmtp-fec-session.d.ts.map +1 -1
  29. package/dist/mmtp-fec-session.js +118 -13
  30. package/dist/mmtp-fec-session.js.map +3 -3
  31. package/dist/mmtp-router.d.ts +9 -1
  32. package/dist/mmtp-router.d.ts.map +1 -1
  33. package/dist/mmtp-router.js +1468 -33
  34. package/dist/mmtp-router.js.map +4 -4
  35. package/dist/multicast-auth-catalog.js.map +1 -1
  36. package/dist/multicast-input.js.map +1 -1
  37. package/dist/multicast-manager.d.ts +15 -2
  38. package/dist/multicast-manager.d.ts.map +1 -1
  39. package/dist/multicast-manager.js +15246 -14969
  40. package/dist/multicast-manager.js.map +4 -4
  41. package/dist/multicast-transport.d.ts +11 -3
  42. package/dist/multicast-transport.d.ts.map +1 -1
  43. package/dist/multicast-transport.js +255 -19
  44. package/dist/multicast-transport.js.map +4 -4
  45. package/dist/multicast-unwrap.js +12 -19
  46. package/dist/multicast-unwrap.js.map +2 -2
  47. package/dist/no-modules.js +130 -33
  48. package/dist/no-modules.js.map +3 -3
  49. package/dist/repair-fetch.js.map +1 -1
  50. package/dist/signaling.js.map +2 -2
  51. package/dist/timestamp-codec.js +12 -19
  52. package/dist/timestamp-codec.js.map +2 -2
  53. package/dist/wasm-mfu-reassembler.js.map +1 -1
  54. package/dist/wasm-mmtp-container-parser.js +12 -19
  55. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  56. package/package.json +5 -5
  57. package/src/cmcd-producer.ts +5 -0
  58. package/src/index.ts +2 -0
  59. package/src/mmtp-fec-session.ts +115 -15
  60. package/src/mmtp-router.ts +81 -16
  61. package/src/multicast-manager.ts +26 -5
  62. package/src/multicast-transport.ts +44 -20
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../mmt-base/src/mmtp.ts", "../../mmt-base/src/ntp-short.ts", "../../mmt-base/src/fec-block-profile.ts", "../../mmt-base/src/fec-catalog.ts", "../../mmt-base/src/fec-bindings.ts", "../src/track-session.ts", "../src/media-router.ts", "../src/delta-fallback-config.ts"],
4
- "sourcesContent": ["/**\n * @mmt/container - MMTP Parser\n *\n * MMTP (MMT Protocol) packet parser based on ISO/IEC 23008-1:2023 Section 9.2\n *\n * MMTP header format (12 bytes):\n * - Byte 0: V(2) | C(1) | FEC_type(2) | r(1) | X(1) | R(1)\n * - Byte 1: reserved(2) | packet_type(6)\n * - Bytes 2-3: packet_id (big-endian)\n * - Bytes 4-7: timestamp (big-endian, 32-bit)\n * - Bytes 8-11: sequence_number (big-endian)\n *\n * FEC_type values:\n * - 0: No FEC / source packet\n * - 1: FEC mode 0 (source with FEC payload ID)\n * - 2: FEC repair packet mode 0\n * - 3: FEC repair packet mode 1\n *\n * @packageDocumentation\n */\n\nexport interface MMTPHeader {\n version: number\n /** Packet type: 0x00=MPU, 0x02=Signaling, 0x03=Repair */\n packetType: number\n packetId: number\n timestamp: bigint\n sequenceNumber: number\n payloadLength: number\n fecType: number\n /** C flag: header carries a 32-bit packet_counter. */\n packetCounterFlag: boolean\n packetCounter?: number\n /** X flag: header carries an extension header and value. */\n extensionFlag: boolean\n extension?: { type: number; data: Uint8Array }\n /** Byte offset at which the MMTP payload begins. */\n headerLength: number\n /** Random Access Point flag - indicates keyframe (ISO 23008-1, byte 0 bit 0) */\n rapFlag: boolean\n}\n\nexport interface MPUHeader {\n mpuSequenceNumber: number\n fragmentationIndicator: number // FI: 0=complete, 1=first, 2=middle, 3=last\n /** 8-bit MPU fragment_counter from byte 3. */\n fragmentCounter: number\n /** T flag: 1=timed media (14-byte MFU DU header), 0=non-timed (4-byte header) */\n timedFlag: number\n /** A flag: aggregated MPU payload. This strict profile requires A=0. */\n aggregationFlag: boolean\n /** MPU payload_length (bytes 0-1): bytes after the length field, including\n * the remaining six MPU-header bytes and all data-unit bytes. */\n payloadLength: number\n}\n\nexport interface MFUDuHeader {\n movieFragmentSequenceNumber: number\n sampleNumber: number\n offset: number\n priority: number\n dependencyCounter: number\n}\n\nexport interface MMTPPacket {\n header: MMTPHeader\n payload: Uint8Array\n}\n\nexport const MMTP_HEADER_SIZE = 12\nexport const MPU_HEADER_SIZE = 8\nexport const MFU_DU_HEADER_SIZE_TIMED = 14 // T=1: timed media (video/audio samples)\nexport const MFU_DU_HEADER_SIZE_NONTIMED = 4 // T=0: non-timed media (item_id only)\n\nconst MPU_LENGTH_FIELD_SIZE = 2\nconst MPU_HEADER_REMAINDER_SIZE = MPU_HEADER_SIZE - MPU_LENGTH_FIELD_SIZE\nexport const SOURCE_FEC_PAYLOAD_ID_SIZE = 4\n\n// MMTP Packet Types (ISO/IEC 23008-1:2023 Section 9.2.2)\nexport const PACKET_TYPE_MPU = 0x00\nexport const PACKET_TYPE_SIGNALING = 0x02\nexport const PACKET_TYPE_REPAIR = 0x03\n\n// AL-FEC Signaling Message ID (ISO/IEC 23008-1:2023 Amendment 1:2025)\nexport const ALFEC_MESSAGE_ID = 0x0203\n\n/**\n * MMTP `packet_id` of the signaling flow, which carries PACKET_TYPE_SIGNALING\n * packets such as the AL-FEC message (ALFEC_MESSAGE_ID). The FFmpeg `moq_mmt`\n * muxer sends that message on this packet_id into the same multicast group as\n * the media, and moqcast-draft (PR #44) makes advertising it for a track a\n * MUST NOT. A track's packet_id is therefore 1..MMTP_PACKET_ID_MAX.\n */\nexport const MMTP_SIGNALING_PACKET_ID = 0\n\n/** Largest value of the 16-bit MMTP `packet_id` header field. */\nexport const MMTP_PACKET_ID_MAX = 0xffff\n\n// fec_code_id_for_repair_flow code points (ISO/IEC 23008-10 Table 1;\n// draft-ramadan-moq-mmt-01 Section 8.3).\nexport const FEC_CODE_REED_SOLOMON = 1\nexport const FEC_CODE_RAPTORQ = 3\n\n/**\n * Parse MMTP header from buffer\n */\nexport function parseMMTPHeader(buffer: Uint8Array): MMTPHeader {\n if (buffer.length < MMTP_HEADER_SIZE) {\n throw new Error(`MMTP packet too small: ${buffer.length} bytes`)\n }\n\n const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)\n\n const version = (buffer[0]! >> 6) & 0x03\n if (version !== 0) {\n throw new Error(`Unsupported MMTP version: ${version}`)\n }\n\n // Extract FEC type from byte 0, bits 3-4\n const fecType = (buffer[0]! >> 3) & 0x03\n const packetCounterFlag = (buffer[0]! & 0x20) !== 0\n const extensionFlag = (buffer[0]! & 0x02) !== 0\n\n // Extract RAP flag from byte 0, bit 0 (R bit - Random Access Point)\n const rapFlag = (buffer[0]! & 0x01) === 1\n\n // ISO/IEC 23008-1:2023 Figure 8 (V=0) places reserved(2) before\n // packet_type(6), so packet_type occupies the low six bits of byte 1.\n const packetType = buffer[1]! & 0x3F\n\n let offset = 2\n\n // Packet ID (2 bytes, big-endian)\n const packetId = view.getUint16(offset, false)\n offset += 2\n\n // Timestamp (4 bytes, big-endian)\n const timestamp = BigInt(view.getUint32(offset, false))\n offset += 4\n\n // Sequence number (4 bytes, big-endian)\n const sequenceNumber = view.getUint32(offset, false)\n offset += 4\n\n let packetCounter: number | undefined\n if (packetCounterFlag) {\n if (offset + 4 > buffer.length) throw new Error('MMTP packet truncated in packet_counter')\n packetCounter = view.getUint32(offset, false)\n offset += 4\n }\n\n let extension: { type: number; data: Uint8Array } | undefined\n if (extensionFlag) {\n if (offset + 4 > buffer.length) throw new Error('MMTP packet truncated in header extension')\n const type = view.getUint16(offset, false)\n const length = view.getUint16(offset + 2, false)\n offset += 4\n if (offset + length > buffer.length) throw new Error('MMTP packet truncated in header extension value')\n extension = { type, data: buffer.subarray(offset, offset + length) }\n offset += length\n }\n\n return {\n version,\n packetType,\n packetId,\n timestamp,\n sequenceNumber,\n payloadLength: buffer.length - offset,\n fecType,\n packetCounterFlag,\n packetCounter,\n extensionFlag,\n extension,\n headerLength: offset,\n rapFlag,\n }\n}\n\n/**\n * The MMTP `packet_id` of `buffer`, or null exactly when parseMMTPHeader would\n * throw for it (short buffer, V != 0, truncated packet_counter or header\n * extension).\n *\n * This is the per-packet filter a receiver runs before any other work, so it\n * builds no header object, DataView or BigInt timestamp (BLO-36599). Use\n * parseMMTPHeader when any other header field is needed.\n */\nexport function readMMTPPacketId(buffer: Uint8Array): number | null {\n if (buffer.length < MMTP_HEADER_SIZE) return null\n const flags = buffer[0]!\n if (((flags >> 6) & 0x03) !== 0) return null\n let offset = MMTP_HEADER_SIZE\n if ((flags & 0x20) !== 0) {\n if (offset + 4 > buffer.length) return null\n offset += 4\n }\n if ((flags & 0x02) !== 0) {\n if (offset + 4 > buffer.length) return null\n const length = (buffer[offset + 2]! << 8) | buffer[offset + 3]!\n if (offset + 4 + length > buffer.length) return null\n }\n return (buffer[2]! << 8) | buffer[3]!\n}\n\n/**\n * Unwrap MMTP packet to extract payload\n */\nexport function unwrapMMTP(buffer: Uint8Array): MMTPPacket {\n const header = parseMMTPHeader(buffer)\n const payload = buffer.subarray(header.headerLength)\n\n return { header, payload }\n}\n\n/**\n * Parse MPU header from payload\n * ISO/IEC 23008-1:2023 Section 9.2.3.3\n *\n * MPU Header format (8 bytes) as written by FFmpeg/libmmt:\n * - Bytes 0-1: payload_length (16 bits, big-endian)\n * - Byte 2: FT(4) | T(1) | FI(2) | A(1)\n * - Byte 3: fragment_counter (8 bits)\n * - Bytes 4-7: MPU_sequence_number (32 bits, big-endian)\n */\nexport function parseMPUHeader(payload: Uint8Array): MPUHeader {\n if (payload.length < MPU_HEADER_SIZE) {\n throw new Error(`Payload too small for MPU header: ${payload.length} bytes`)\n }\n\n const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength)\n\n // Bytes 0-1: payload_length (16 bits, big-endian)\n const payloadLength = view.getUint16(0, false)\n\n // Byte 2: FT(4) | T(1) | FI(2) | A(1)\n const flagsByte = payload[2]!\n const timedFlag = (flagsByte >> 3) & 0x01\n const fragmentationIndicator = (flagsByte >> 1) & 0x03\n const aggregationFlag = (flagsByte & 0x01) !== 0\n\n // Byte 3: fragment_counter (8 bits). The high bit is part of the counter in\n // the current libmmt/FFmpeg writer ABI.\n const fragmentCounter = payload[3]!\n\n // Bytes 4-7: MPU_sequence_number (32 bits, big-endian)\n const mpuSequenceNumber = view.getUint32(4, false)\n\n return {\n mpuSequenceNumber,\n fragmentationIndicator,\n fragmentCounter,\n timedFlag,\n aggregationFlag,\n payloadLength,\n }\n}\n\nexport function parseTimedMfuDuHeader(payload: Uint8Array, offset: number): MFUDuHeader | null {\n if (offset < 0 || offset + MFU_DU_HEADER_SIZE_TIMED > payload.length) return null\n const view = new DataView(payload.buffer, payload.byteOffset + offset, MFU_DU_HEADER_SIZE_TIMED)\n return {\n movieFragmentSequenceNumber: view.getUint32(0, false),\n sampleNumber: view.getUint32(4, false),\n offset: view.getUint32(8, false),\n priority: payload[offset + 12]!,\n dependencyCounter: payload[offset + 13]!,\n }\n}\n\n/**\n * Why `extractMFUPayload` refused a packet.\n *\n * These identifiers are part of the public contract: callers surface them in\n * diagnostics, so renaming one is a breaking change.\n */\nexport type MFURejectionGate =\n | 'packet-shorter-than-mmtp-plus-mpu-header'\n | 'mmtp-header-unparseable'\n | 'not-an-mpu-packet'\n | 'payload-shorter-than-mpu-header'\n | 'aggregation-flag-set'\n | 'mpu-payload-length-below-header-remainder'\n | 'mpu-declared-end-exceeds-payload'\n | 'mfu-offset-at-or-past-declared-end'\n | 'source-fec-payload-id-width-mismatch'\n | 'declared-trailers-unresolved'\n\nexport interface MFURejection {\n gate: MFURejectionGate\n /** Human-readable detail, including the byte arithmetic that decided it. */\n detail: string\n}\n\ninterface MfuPayloadLayout {\n hasMfuDuHeader: boolean\n mfuOffset: number\n dataEnd: number\n}\n\nfunction resolveMfuPayloadLayout(\n mpu: MPUHeader,\n fragmentType: number,\n payload: Uint8Array,\n): { layout: MfuPayloadLayout } | { rejection: MFURejection } {\n if (mpu.payloadLength < MPU_HEADER_REMAINDER_SIZE) {\n return {\n rejection: {\n gate: 'mpu-payload-length-below-header-remainder',\n detail: `payload_length=${mpu.payloadLength} < ${MPU_HEADER_REMAINDER_SIZE}`,\n },\n }\n }\n\n // ISO/IEC 23008-1 defines payload_length from immediately after its own\n // two-byte field. It therefore includes the remaining six MPU-header bytes.\n const declaredEnd = MPU_LENGTH_FIELD_SIZE + mpu.payloadLength\n if (declaredEnd > payload.length) {\n return {\n rejection: {\n gate: 'mpu-declared-end-exceeds-payload',\n detail:\n `payload_length=${mpu.payloadLength} implies end=${declaredEnd}, ` +\n `payload is ${payload.length} bytes`,\n },\n }\n }\n\n const hasHeader = fragmentType === 2 && mpu.fragmentationIndicator <= 1\n const mfuHeaderSize = mpu.timedFlag ? MFU_DU_HEADER_SIZE_TIMED : MFU_DU_HEADER_SIZE_NONTIMED\n const mfuOffset = MPU_HEADER_SIZE + (hasHeader ? mfuHeaderSize : 0)\n if (mfuOffset >= declaredEnd) {\n return {\n rejection: {\n gate: 'mfu-offset-at-or-past-declared-end',\n detail: `mfuOffset=${mfuOffset} >= declaredEnd=${declaredEnd}`,\n },\n }\n }\n\n return {\n layout: {\n hasMfuDuHeader: hasHeader,\n mfuOffset,\n dataEnd: declaredEnd,\n },\n }\n}\n\n// Legacy detached per-packet authenticator suffix `[0xf0a1][len][auth]`. Its\n// producer was removed (BLO-16483) and nothing verifies it any more; provenance\n// is the signed per-group manifest. Receivers still skip an exact suffix so a\n// stale producer degrades to unauthenticated playback instead of dropped\n// packets. ponytail: hard-cut to reject once no deployed producer can emit it.\nconst LEGACY_TRAILER_EXT_TYPE = 0xf0a1\nconst LEGACY_TRAILER_HEADER_SIZE = 4\n\nfunction isEmptyOrExactLegacyTrailer(payload: Uint8Array, start: number, end: number): boolean {\n if (start === end) return true\n if (start < 0 || end > payload.length || start + LEGACY_TRAILER_HEADER_SIZE > end) {\n return false\n }\n\n const view = new DataView(\n payload.buffer,\n payload.byteOffset + start,\n end - start,\n )\n if (view.getUint16(0, false) !== LEGACY_TRAILER_EXT_TYPE) return false\n\n const authLength = view.getUint16(2, false)\n return start + LEGACY_TRAILER_HEADER_SIZE + authLength === end\n}\n\ninterface DeclaredTrailerLayout {\n hasFecId: boolean\n hasOverlayFecId: boolean\n}\n\n/** Options for extractMFUPayload and explainMFURejection. */\nexport interface MFUExtractOptions {\n /**\n * When true, do NOT subtract the Source FEC Payload ID from the payload\n * boundary, even if fecType >= 1. For FEC-recovered symbols whose FEC PID\n * was already stripped before WASM decoding.\n */\n skipFecId?: boolean\n /**\n * The catalog declares a keyframe overlay (`scope: \"keyframe\"` repair track)\n * for this packet's source. A RAP packet then carries two Source FEC Payload\n * IDs, base first and overlay last (BLO-32719 Clarification 7). This is a\n * catalog fact, never inferred: the header has no overlay bit, and a payload\n * four bytes shorter is byte-identical to a missing overlay. Mirrors\n * mmt-core `split_payload_and_source_fec_trailers(overlay_declared)`.\n */\n overlayDeclared?: boolean\n}\n\nfunction resolveDeclaredTrailers(\n payload: Uint8Array,\n declaredEnd: number,\n fecType: number,\n options?: MFUExtractOptions,\n rapFlag = false,\n): DeclaredTrailerLayout | null {\n if (declaredEnd > payload.length) return null\n\n if (options?.skipFecId) {\n return isEmptyOrExactLegacyTrailer(payload, declaredEnd, payload.length)\n ? { hasFecId: false, hasOverlayFecId: false }\n : null\n }\n\n const overlay = options?.overlayDeclared === true && rapFlag\n if (fecType === 1) {\n const fecStart = payload.length - sourceFecTrailerSize(overlay)\n if (fecStart < declaredEnd || !isEmptyOrExactLegacyTrailer(payload, declaredEnd, fecStart)) {\n return null\n }\n return { hasFecId: true, hasOverlayFecId: overlay }\n }\n // An overlay ID rides behind a base ID the header says is absent.\n if (fecType !== 0 || overlay) return null\n\n if (isEmptyOrExactLegacyTrailer(payload, declaredEnd, payload.length)) {\n return { hasFecId: false, hasOverlayFecId: false }\n }\n\n return null\n}\n\nfunction sourceFecTrailerSize(overlay: boolean): number {\n return overlay ? 2 * SOURCE_FEC_PAYLOAD_ID_SIZE : SOURCE_FEC_PAYLOAD_ID_SIZE\n}\n\n/**\n * Extract NAL unit payload from MMTP packet\n */\nexport function extractNALUnit(mmtpPacket: Uint8Array): Uint8Array {\n const extraction = extractMFUPayload(mmtpPacket)\n if (!extraction) throw new Error('Malformed MMTP MPU payload')\n return extraction.data\n}\n\n/** Result of extracting MFU payload from an MMTP packet */\nexport interface MFUExtraction {\n /** MMTP header fields */\n header: MMTPHeader\n /** MPU header fields */\n mpu: MPUHeader\n /** Fragment type (FT field, 4 bits): 0=metadata, 2=MFU data */\n fragmentType: number\n /** MFU payload data (video/audio bytes, headers stripped, FEC ID excluded) */\n data: Uint8Array\n /** Parsed timed MFU DU header when present, otherwise null */\n mfuDuHeader: MFUDuHeader | null\n /** Non-timed MFU Item_ID when the 4-byte DU header is present. */\n itemId: number | null\n /** Source FEC Payload ID if present (`FEC_type == 1`), or null */\n fecPayloadId: { ssId: number } | null\n /** Keyframe-overlay Source FEC Payload ID: RAP packets with `overlayDeclared` only. */\n overlayFecPayloadId: { ssId: number } | null\n}\n\n/**\n * Extract MFU payload from a raw MMTP packet.\n *\n * Handles all the byte arithmetic for:\n * - MMTP header (12 bytes)\n * - MPU sub-header (8 bytes, payloadLength field)\n * - MFU DU header (14 bytes timed / 4 bytes non-timed, only for FI \u2264 1)\n * - Source FEC Payload ID (4 bytes at end, only when `FEC_type == 1`)\n *\n * `payloadLength` is the ISO length-after-field value: six remaining MPU-header\n * bytes, then the MFU DU header when present, then media. The legacy\n * authenticator suffix and the Source FEC trailer are outside that declared span and must consume the exact suffix.\n *\n * @param mmtpPacket Raw MMTP packet (from multicast or MoQ)\n * @param options.skipFecId When true, do NOT subtract the 4-byte Source FEC Payload ID\n * from the payload boundary, even if fecType >= 1 in the MMTP header. Use this for\n * FEC-recovered symbols where the FEC PID was already stripped before WASM decoding\n * but the fecType bits in byte 0 still reflect the original packet (FEC Type=1 per\n * draft-ramadan-moq-mmt-01 \u00A73.1).\n * @param options.overlayDeclared The catalog declares a keyframe overlay for this\n * source, so a RAP packet carries an 8-byte trailer (see MFUExtractOptions).\n * @returns Extracted MFU data + metadata, or null if packet is too small\n */\ninterface MfuPlan {\n header: MMTPHeader\n payload: Uint8Array\n mpu: MPUHeader\n fragmentType: number\n layout: MfuPayloadLayout\n trailerLayout: DeclaredTrailerLayout\n}\n\n/**\n * Single source of truth for whether a packet yields an MFU payload.\n *\n * `extractMFUPayload` and `explainMFURejection` both delegate here, so the\n * reason reported to operators can never drift from the decision the parser\n * actually made.\n */\nfunction planMfuExtraction(\n mmtpPacket: Uint8Array,\n options?: MFUExtractOptions,\n parsedHeader?: MMTPHeader,\n): { plan: MfuPlan } | { rejection: MFURejection } {\n if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) {\n return {\n rejection: {\n gate: 'packet-shorter-than-mmtp-plus-mpu-header',\n detail: `${mmtpPacket.length} bytes < ${MMTP_HEADER_SIZE + MPU_HEADER_SIZE}`,\n },\n }\n }\n\n let header: MMTPHeader\n if (parsedHeader) {\n header = parsedHeader\n } else {\n try {\n header = parseMMTPHeader(mmtpPacket)\n } catch (error) {\n return {\n rejection: {\n gate: 'mmtp-header-unparseable',\n detail: error instanceof Error ? error.message : String(error),\n },\n }\n }\n }\n if (header.packetType !== PACKET_TYPE_MPU) {\n return {\n rejection: {\n gate: 'not-an-mpu-packet',\n detail: `packetType=0x${header.packetType.toString(16).padStart(2, '0')}`,\n },\n }\n }\n\n const payload = mmtpPacket.subarray(header.headerLength)\n if (payload.length < MPU_HEADER_SIZE) {\n return {\n rejection: {\n gate: 'payload-shorter-than-mpu-header',\n detail: `${payload.length} bytes < ${MPU_HEADER_SIZE}`,\n },\n }\n }\n\n const mpu = parseMPUHeader(payload)\n if (mpu.aggregationFlag) {\n return {\n rejection: { gate: 'aggregation-flag-set', detail: 'strict profile requires A=0' },\n }\n }\n\n // Fragment type from byte 2 upper nibble (not returned by parseMPUHeader)\n const fragmentType = (payload[2]! >> 4) & 0x0f\n\n const layoutResult = resolveMfuPayloadLayout(mpu, fragmentType, payload)\n if ('rejection' in layoutResult) return layoutResult\n const { layout } = layoutResult\n\n const trailerLayout = resolveDeclaredTrailers(\n payload,\n layout.dataEnd,\n header.fecType,\n options,\n header.rapFlag,\n )\n if (!trailerLayout) {\n // The most common cause is a producer whose Source FEC Payload ID width\n // disagrees with ISO/IEC 23008-1 \u00A7C.5.2. Name that explicitly rather than\n // leaving operators to infer it from a generic trailer error.\n if (!options?.skipFecId && header.fecType === 1) {\n const observed = payload.length - layout.dataEnd\n const expected = sourceFecTrailerSize(options?.overlayDeclared === true && header.rapFlag)\n if (observed !== expected) {\n return {\n rejection: {\n gate: 'source-fec-payload-id-width-mismatch',\n detail:\n `producer wrote a ${observed}-byte Source FEC Payload ID, ` +\n `ISO/IEC 23008-1 \u00A7C.5.2 requires ${expected} ` +\n `(payload=${payload.length}, declaredEnd=${layout.dataEnd})`,\n },\n }\n }\n }\n return {\n rejection: {\n gate: 'declared-trailers-unresolved',\n detail:\n `trailer bytes [${layout.dataEnd}, ${payload.length}) are neither empty ` +\n `nor an exact legacy authenticator trailer (fecType=${header.fecType})`,\n },\n }\n }\n\n return { plan: { header, payload, mpu, fragmentType, layout, trailerLayout } }\n}\n\n/**\n * Report why `extractMFUPayload` would refuse this packet, or null if it would\n * accept it.\n *\n * `extractMFUPayload` returns bare `null` on ten distinct conditions, which\n * makes a producer/consumer framing disagreement indistinguishable from a\n * publisher that never started \u2014 both render as silence. Use this wherever\n * that distinction matters: diagnostics, contract tests, and operator-facing\n * logs.\n */\nexport function explainMFURejection(\n mmtpPacket: Uint8Array,\n options?: MFUExtractOptions,\n): MFURejection | null {\n const result = planMfuExtraction(mmtpPacket, options)\n return 'rejection' in result ? result.rejection : null\n}\n\nexport function extractMFUPayload(\n mmtpPacket: Uint8Array,\n options?: MFUExtractOptions,\n): MFUExtraction | null {\n return extractPlannedMfu(mmtpPacket, planMfuExtraction(mmtpPacket, options))\n}\n\n/**\n * extractMFUPayload for a caller that already holds this packet's parsed MMTP\n * header, so the header is not parsed a second time (BLO-36599).\n *\n * `header` must be `parseMMTPHeader(mmtpPacket)` of this same packet; the\n * result is then identical to `extractMFUPayload(mmtpPacket, options)`.\n */\nexport function extractMFUPayloadWithHeader(\n mmtpPacket: Uint8Array,\n header: MMTPHeader,\n options?: MFUExtractOptions,\n): MFUExtraction | null {\n return extractPlannedMfu(mmtpPacket, planMfuExtraction(mmtpPacket, options, header))\n}\n\nfunction extractPlannedMfu(\n mmtpPacket: Uint8Array,\n result: { plan: MfuPlan } | { rejection: MFURejection },\n): MFUExtraction | null {\n if ('rejection' in result) return null\n const { header, payload, mpu, fragmentType, layout, trailerLayout } = result.plan\n\n const mfuDuHeaderPresent = layout.hasMfuDuHeader\n const mfuOffset = layout.mfuOffset\n const dataEnd = layout.dataEnd\n\n const mfuDuHeader =\n mfuDuHeaderPresent && mpu.timedFlag === 1\n ? parseTimedMfuDuHeader(payload, MPU_HEADER_SIZE)\n : null\n const itemId =\n mfuDuHeaderPresent && mpu.timedFlag === 0\n ? new DataView(payload.buffer, payload.byteOffset + MPU_HEADER_SIZE, 4).getUint32(0, false)\n : null\n\n const data = payload.subarray(mfuOffset, dataEnd)\n\n const trailerStart =\n mmtpPacket.length - sourceFecTrailerSize(trailerLayout.hasOverlayFecId)\n const ssIdAt = (offset: number) => ({\n ssId: new DataView(mmtpPacket.buffer, mmtpPacket.byteOffset + offset, 4).getUint32(0, false),\n })\n const fecPayloadId = trailerLayout.hasFecId ? ssIdAt(trailerStart) : null\n const overlayFecPayloadId = trailerLayout.hasOverlayFecId\n ? ssIdAt(trailerStart + SOURCE_FEC_PAYLOAD_ID_SIZE)\n : null\n\n return { header, mpu, fragmentType, data, mfuDuHeader, itemId, fecPayloadId, overlayFecPayloadId }\n}\n\n/**\n * Extract timestamp from MMTP packet\n */\nexport function extractTimestamp(mmtpPacket: Uint8Array): bigint {\n const { header } = unwrapMMTP(mmtpPacket)\n return header.timestamp\n}\n\n/**\n * Check if MMTP packet is an FEC repair packet\n * FEC_type 2 and 3 are repair packets\n */\nexport function isFecRepairPacket(mmtpPacket: Uint8Array): boolean {\n if (mmtpPacket.length < 1) return false\n const fecType = (mmtpPacket[0]! >> 3) & 0x03\n return fecType >= 2\n}\n\n/**\n * Extract Source FEC Payload ID (SS_ID) from the last 4 bytes of an MMTP\n * source packet per ISO/IEC 23008-1:2023 Section C.5.2 Figure C.10.\n *\n * SS_ID is a flat 32-bit monotonic counter (incremented by 1 per packet\n * for ssbg_mode0 and ssbg_mode1). Block membership and ESI use the\n * configured K; repair SS_Start identifies the first SS_ID of the block and\n * SSB_length records the authored source-symbol count:\n * SBN = floor(SS_ID / K), ESI = SS_ID % K\n * SS_Start = SBN * K (first SS_ID of the block)\n *\n * This matches FFmpeg moqenc_mmt.c, the drafts (draft-ramadan-moq-fec \u00A76.2),\n * and ATSC A/331 \u00A78.1.2.5 which defers to ISO 23008-1 Annex C.\n */\nexport function extractFecPayloadId(mmtpPacket: Uint8Array): { ssId: number } | null {\n const header = parseHeaderForSourceFecId(mmtpPacket)\n return header ? sourceFecPayloadIdOf(mmtpPacket, header) : null\n}\n\n/**\n * Return true when FEC_type=1 and the exact suffix after the ISO-declared MPU\n * payload contains a four-byte Source FEC Payload ID.\n */\nexport function hasSourceFecPayloadId(mmtpPacket: Uint8Array): boolean {\n const header = parseHeaderForSourceFecId(mmtpPacket)\n return header !== null && sourceFecPayloadIdOf(mmtpPacket, header) !== null\n}\n\nfunction parseHeaderForSourceFecId(mmtpPacket: Uint8Array): MMTPHeader | null {\n if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) return null\n try {\n return parseMMTPHeader(mmtpPacket)\n } catch {\n return null\n }\n}\n\n/**\n * The Source FEC Payload ID of a packet whose MMTP header the caller already\n * parsed, or null when the packet carries none: present only when FEC_type=1\n * and the exact suffix after the ISO-declared MPU payload holds the four-byte\n * ID (ISO/IEC 23008-1 \u00A7C.5.2). Same answer as extractFecPayloadId, without a\n * second header parse (BLO-36599).\n *\n * `header` must be `parseMMTPHeader(mmtpPacket)` of this same packet.\n */\nexport function sourceFecPayloadIdOf(\n mmtpPacket: Uint8Array,\n header: MMTPHeader,\n): { ssId: number } | null {\n if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) return null\n if (header.packetType !== PACKET_TYPE_MPU) return null\n\n const payload = mmtpPacket.subarray(header.headerLength)\n if (payload.length < MPU_HEADER_SIZE) return null\n const payloadLength = (payload[0]! << 8) | payload[1]!\n if (payloadLength < MPU_HEADER_REMAINDER_SIZE) return null\n const declaredEnd = MPU_LENGTH_FIELD_SIZE + payloadLength\n if (!resolveDeclaredTrailers(payload, declaredEnd, header.fecType)?.hasFecId) return null\n\n // Flat 4-byte SS_ID: encoder writes block_id * K + symbol_id\n const off = mmtpPacket.length - SOURCE_FEC_PAYLOAD_ID_SIZE\n return {\n ssId:\n ((mmtpPacket[off]! << 24) |\n (mmtpPacket[off + 1]! << 16) |\n (mmtpPacket[off + 2]! << 8) |\n mmtpPacket[off + 3]!) >>>\n 0,\n }\n}\n\n/** Size of Repair FEC Payload ID per ISO/IEC 23008-1 Section C.5.3 */\nexport const REPAIR_FEC_PID_SIZE = 13\n\n/**\n * Parse the 13-byte Repair FEC Payload ID after the complete dynamic MMTP header.\n * `parsedHeader`, when given, must be `parseMMTPHeader(mmtpPacket)` of this\n * same packet; it spares the second header parse (BLO-36599).\n */\nfunction readRepairFecPayloadIdFields(mmtpPacket: Uint8Array, parsedHeader?: MMTPHeader): {\n packetId: number; ssStart: number; rsbLength: number; rsId: number; ssbLength: number; dataOffset: number\n} | null {\n let header: MMTPHeader\n if (parsedHeader) {\n header = parsedHeader\n } else {\n try {\n header = parseMMTPHeader(mmtpPacket)\n } catch {\n return null\n }\n }\n if (header.packetType !== PACKET_TYPE_REPAIR || header.fecType !== 2) return null\n\n const off = header.headerLength\n if (mmtpPacket.length < off + REPAIR_FEC_PID_SIZE) return null\n const ssStart =\n ((mmtpPacket[off]! << 24) |\n (mmtpPacket[off + 1]! << 16) |\n (mmtpPacket[off + 2]! << 8) |\n mmtpPacket[off + 3]!) >>>\n 0 // 32 bits\n const rsbLength = (mmtpPacket[off + 4]! << 16) | (mmtpPacket[off + 5]! << 8) | mmtpPacket[off + 6]! // 24 bits\n const rsId = (mmtpPacket[off + 7]! << 16) | (mmtpPacket[off + 8]! << 8) | mmtpPacket[off + 9]! // 24 bits\n const ssbLength = (mmtpPacket[off + 10]! << 16) | (mmtpPacket[off + 11]! << 8) | mmtpPacket[off + 12]! // 24 bits\n return {\n packetId: header.packetId,\n ssStart,\n rsbLength,\n rsId,\n ssbLength,\n dataOffset: off + REPAIR_FEC_PID_SIZE,\n }\n}\n\n/**\n * Extract Repair FEC Payload ID from a raw-multicast MMTP repair packet.\n * Per ISO/IEC 23008-1:2023 Section C.5.3 (ssbg_mode0, one-stage, no FFSRP_TS):\n * [MMTP header][SS_Start:4][RSB_length:3][RS_ID:3][SSB_length:3][repair data]\n * OTI is delivered via AL-FEC signaling message (Table C.3), not per-packet.\n *\n * Byte 1 is reserved(2)|packet_type(6) for V=0, so repair type 0x03 is 0x03.\n * Pre-migration high-bits bytes (0x0c) are rejected \u2014 no compatibility decode.\n */\nexport function extractRepairFecPayloadId(mmtpPacket: Uint8Array): {\n ssStart: number; rsbLength: number; rsId: number; ssbLength: number\n} | null {\n const fields = readRepairFecPayloadIdFields(mmtpPacket)\n if (!fields) return null\n return {\n ssStart: fields.ssStart,\n rsbLength: fields.rsbLength,\n rsId: fields.rsId,\n ssbLength: fields.ssbLength,\n }\n}\n\n/** Parsed MoQ repair frame per ISO/IEC 23008-1 Section C.4.3 + C.5.3 */\nexport interface MoqRepairFrame {\n /** MMTP packetId (1=video, 2=audio) \u2014 for routing to correct FEC decoder */\n packetId: number\n /** SS_Start (32 bits): SS_ID of first source symbol in block */\n ssStart: number\n /** RSB_length (24 bits): number of repair symbols (P) */\n rsbLength: number\n /** RS_ID (24 bits): repair symbol index (0-based) */\n rsId: number\n /** SSB_length (24 bits): authored source-symbol count (1..=configured K) */\n ssbLength: number\n /** Repair symbol data after the dynamic MMTP header and 13-byte FEC payload ID. */\n symbolData: Uint8Array\n}\n\n/**\n * Parse a MoQ repair frame (ISO/IEC 23008-1 Annex C format).\n *\n * Wire format per ISO 23008-1 \u00A7C.4.3 + \u00A7C.5.3:\n * MMTP Header: fixed 12 bytes plus C/X optional fields\n * then SS_Start (4 bytes) \u2192 block identifier\n * then RSB_length (3 bytes) \u2192 P\n * then RS_ID (3 bytes) \u2192 ESI = K + RS_ID\n * then SSB_length (3 bytes) \u2192 authored source-symbol count (1..=K)\n * then Repair Symbol Data (T bytes)\n *\n * @param frame - Raw MoQ repair frame bytes\n * @returns Parsed frame or null if invalid\n */\nexport function parseMoqRepairFrame(frame: Uint8Array): MoqRepairFrame | null {\n return moqRepairFrameFrom(frame, readRepairFecPayloadIdFields(frame))\n}\n\n/**\n * parseMoqRepairFrame for a caller that already holds this frame's parsed MMTP\n * header, so the header is not parsed a second time (BLO-36599). The router's\n * multicast repair branch uses it: one repair packet in every K + P otherwise\n * paid two header parses.\n *\n * `header` must be `parseMMTPHeader(frame)` of this same frame; the result is\n * then identical to `parseMoqRepairFrame(frame)`.\n */\nexport function parseMoqRepairFrameWithHeader(\n frame: Uint8Array,\n header: MMTPHeader,\n): MoqRepairFrame | null {\n return moqRepairFrameFrom(frame, readRepairFecPayloadIdFields(frame, header))\n}\n\nfunction moqRepairFrameFrom(\n frame: Uint8Array,\n repairId: ReturnType<typeof readRepairFecPayloadIdFields>,\n checkRsId = true,\n): MoqRepairFrame | null {\n if (!repairId) return null\n\n const symbolData = frame.subarray(repairId.dataOffset)\n if (\n repairId.ssbLength === 0 ||\n (checkRsId && repairId.rsId >= repairId.rsbLength) ||\n symbolData.length === 0\n ) return null\n\n return {\n packetId: repairId.packetId,\n ssStart: repairId.ssStart,\n rsbLength: repairId.rsbLength,\n rsId: repairId.rsId,\n ssbLength: repairId.ssbLength,\n symbolData,\n }\n}\n\n/**\n * Why a repair object of an mmtp track is discarded on admission:\n * `repairLength` -- the object is not exactly 12 + 13 + T bytes with a\n * 12-byte MMTP header (C = 0, X = 0; draft-ramadan-moq-mmt-02 7.3, judged\n * before any Repair FEC Payload ID field is read); `repairRsId` -- RS_ID is not\n * below RSB_length, under every scheme; `repairSchemeBound` -- the object\n * breaks its scheme's bound: reed-solomon SSB_length + RSB_length > 255, which\n * also caps RS_ID at 253, or raptorq SSB_length + RSB_length - 1 > 2^24 - 1\n * (draft-ramadan-moq-fec-core-00 7.5 and 10.3, -block-codes-00 7).\n */\nexport type RepairObjectReject = 'repairLength' | 'repairRsId' | 'repairSchemeBound'\n\n/**\n * Admission of one mmtp repair object under its instance's T and scheme. The\n * caller names the instance (by the repair track it subscribed); nothing here\n * infers it from the fields. `repairRsId` and `repairSchemeBound` carry the\n * frame so the caller can first route an RSB_length below a held layer's\n * O_i + P_i to the 7.3.2 stop, which takes precedence over the discard.\n * `null`: not an iso repair packet, or SSB_length 0.\n */\nexport function admitMmtpRepairObject(\n packet: Uint8Array,\n header: MMTPHeader,\n symbolSize: number,\n algorithm: string | undefined,\n): { frame: MoqRepairFrame; reject?: Exclude<RepairObjectReject, 'repairLength'> } | { reject: 'repairLength' } | null {\n if (header.packetType !== PACKET_TYPE_REPAIR || header.fecType !== 2) return null\n if (header.headerLength !== MMTP_HEADER_SIZE || packet.length !== MMTP_HEADER_SIZE + REPAIR_FEC_PID_SIZE + symbolSize) {\n return { reject: 'repairLength' }\n }\n const frame = moqRepairFrameFrom(packet, readRepairFecPayloadIdFields(packet, header), false)\n if (!frame) return null\n if (frame.rsId >= frame.rsbLength) return { frame, reject: 'repairRsId' }\n const blockSymbols = frame.ssbLength + frame.rsbLength\n if (algorithm === 'reed-solomon' ? blockSymbols > 255 : algorithm === 'raptorq' && blockSymbols - 1 > 0xffffff) {\n return { frame, reject: 'repairSchemeBound' }\n }\n return { frame }\n}\n\n// The signalling-message parsers (9.3.4.2 payload header, AL-FEC message,\n// PA/MPT) live in ./signalling.ts.\n", "/**\n * NTP short format (RFC 5905 \u00A76): an unsigned 32-bit, 16.16 fixed-point count\n * of seconds. The MMTP header `timestamp` field (ISO/IEC 23008-1 \u00A79.2.2)\n * carries each packet's UTC sending instant in this format, so the value wraps\n * to 0 every 2^16 s (~18.2 h).\n *\n * Plain numeric order on these values breaks at every wrap: a retained\n * `Math.max` stays at the pre-wrap value and every later instant compares as\n * older. Order send times only with {@link compareNtpShort}, which applies\n * RFC 1982 serial-number arithmetic over the field's width.\n */\n\n/** Width in bits of the MMTP `timestamp` field (NTP short). */\nexport const NTP_SHORT_BITS = 32\n\n/** Number of distinct NTP-short values; the field wraps to 0 here. */\nexport const NTP_SHORT_MODULUS = 2 ** NTP_SHORT_BITS\n\n/**\n * RFC 1982 \u00A73.2's `2^(SERIAL_BITS - 1)`. Two values whose forward distance is\n * below this are ordered; exactly this far apart they are not ordered at all.\n * Two instants must be less than this far apart (2^15 s, ~9.1 h of send time)\n * for the comparison to give their true order.\n */\nexport const NTP_SHORT_HALF_RANGE = 2 ** (NTP_SHORT_BITS - 1)\n\n/**\n * Serial order of one NTP-short value relative to another.\n *\n * `'unordered'` is RFC 1982's undefined case: the values are exactly\n * {@link NTP_SHORT_HALF_RANGE} apart, so neither precedes the other. It is\n * never folded into `'before'` or `'after'`; each caller decides what an\n * unorderable pair means for it.\n */\nexport type NtpShortOrder = 'before' | 'equal' | 'after' | 'unordered'\n\n/** True when `value` is an NTP-short value: an integer in [0, NTP_SHORT_MODULUS). */\nexport function isNtpShort(value: number): boolean {\n return Number.isInteger(value) && value >= 0 && value < NTP_SHORT_MODULUS\n}\n\n/**\n * RFC 1982 serial comparison of NTP-short `a` against `b`: `'after'` when `a`\n * is the later instant, `'before'` when it is the earlier one.\n *\n * The answer is the true order only while the two instants are less than\n * {@link NTP_SHORT_HALF_RANGE} apart. Past that a 32-bit field cannot tell\n * later from earlier, and the result names whichever direction is nearer.\n * This is not a total order (it is not transitive across the whole space), so\n * it must never be used to sort.\n *\n * @throws {RangeError} if either operand is not an NTP-short value\n */\nexport function compareNtpShort(a: number, b: number): NtpShortOrder {\n if (!isNtpShort(a) || !isNtpShort(b)) {\n throw new RangeError(\n `NTP-short values must be integers in [0, 2^${NTP_SHORT_BITS}), got ${String(a)} and ${String(b)}`,\n )\n }\n if (a === b) return 'equal'\n // Forward distance from b to a, reduced into [1, NTP_SHORT_MODULUS). Both\n // operands are below 2^32, so every step is exact in a double.\n const forward = (a - b + NTP_SHORT_MODULUS) % NTP_SHORT_MODULUS\n if (forward < NTP_SHORT_HALF_RANGE) return 'after'\n if (forward > NTP_SHORT_HALF_RANGE) return 'before'\n return 'unordered'\n}\n", "/**\n * FEC block profile (BLO-35597, ISO/IEC 23008-1 Annex C).\n *\n * Every catalog `fec` object that declares an FEC instance names its block\n * profile in `fec.blockProfile`. A receiver applies all of a profile's rules\n * or none of them, so the token is the one explicit per-stream discriminator:\n * it is never defaulted and never inferred from the wire.\n *\n * draft-ramadan-moq-fec \u00A75.1 defines exactly one value, and it is the only\n * profile this library implements:\n *\n * - `iso-ssbg1-v1`: ssbg_mode1 (C.3.3): a source symbol is\n * `u16 length || protected packet || zero padding` to T; a block is a\n * K' = SSB_length RaptorQ block (C.5.3) and repair ESI = SSB_length + RS_ID;\n * SS_ID increments by one per FEC source packet and wraps at 2^32 (C.5.2),\n * so a block is located by membership in `[SS_Start, SS_Start + SSB_length)`\n * under RFC 1982 serial arithmetic; the MoQ repair group id is the 62-bit\n * wrap-epoch-extended SS_Start. The AL-FEC coding byte for this profile is\n * 0x14 (one-stage, ssbg_mode1, fec_payload_id_mode 0, Amd 1:2025 Table C.3).\n *\n * A track whose `blockProfile` is absent or names any other token (the\n * retired Blockcast-private `k-padded-v1` included) has no FEC: a receiver\n * consumes its source without FEC and joins none of its repair tracks\n * (draft-ramadan-moq-fec \u00A75.1). `parseFecBlockProfile` throws for such a\n * token; the per-track catalog resolver turns the same rule into a typed\n * per-track \"FEC unavailable\" outcome instead of failing the session.\n */\nimport { serial32Add, serial32Distance, serial32Precedes } from './serial-number.js'\n\n/** The block profile a catalog `fec.blockProfile` may name: the one this library implements. */\nexport type FecBlockProfile = 'iso-ssbg1-v1'\n\n/** Every accepted `fec.blockProfile` token. */\nexport const FEC_BLOCK_PROFILES: readonly FecBlockProfile[] = Object.freeze([\n 'iso-ssbg1-v1',\n])\n\n/** True for a block-profile token this library implements. */\nexport function isFecBlockProfile(value: unknown): value is FecBlockProfile {\n return value === 'iso-ssbg1-v1'\n}\n\n/**\n * Read a required `fec.blockProfile`. Throws on an absent token and on any\n * token other than `iso-ssbg1-v1`: there is no default and nothing is\n * inferred from the wire (draft-ramadan-moq-fec \u00A75.1).\n *\n * @param where the catalog path of the field, for the error message.\n */\nexport function parseFecBlockProfile(value: unknown, where: string): FecBlockProfile {\n if (isFecBlockProfile(value)) return value\n if (value === undefined) {\n throw new Error(`${where} is required: name the FEC block profile (\"iso-ssbg1-v1\")`)\n }\n throw new Error(`${where} must be \"iso-ssbg1-v1\" (got ${JSON.stringify(value)})`)\n}\n\n/** ISO/IEC 23008-1 C.3.3 ssbg_mode1: the two-octet packet length field. */\nexport const SSBG1_LENGTH_FIELD_BYTES = 2\n\n/** The largest protected packet an ssbg_mode1 symbol of size T can carry. */\nexport function ssbg1MaxPacketLength(symbolSize: number): number {\n return symbolSize - SSBG1_LENGTH_FIELD_BYTES\n}\n\n/** Whether a protected packet of `packetLength` bytes fits one T-byte ssbg_mode1 symbol. */\nexport function ssbg1FitsSymbol(packetLength: number, symbolSize: number): boolean {\n return (\n Number.isSafeInteger(packetLength) &&\n packetLength >= 0 &&\n packetLength <= ssbg1MaxPacketLength(symbolSize) &&\n packetLength <= 0xffff\n )\n}\n\n/**\n * Build one ssbg_mode1 source symbol (C.3.3): the protected packet's length\n * in two octets, network byte order, then the packet, then zero octets up to\n * T. The protected packet is the MMTP packet before its Source FEC Payload ID\n * is appended (C.4.2).\n *\n * @throws when `length + 2 > T`: the packet cannot be protected under this\n * profile, and silently truncating it would corrupt every repair symbol.\n */\nexport function buildSsbg1SourceSymbol(packet: Uint8Array, symbolSize: number): Uint8Array {\n if (!Number.isSafeInteger(symbolSize) || symbolSize <= SSBG1_LENGTH_FIELD_BYTES) {\n throw new Error(`ssbg_mode1 symbol size must exceed ${SSBG1_LENGTH_FIELD_BYTES} (got ${symbolSize})`)\n }\n if (!ssbg1FitsSymbol(packet.byteLength, symbolSize)) {\n throw new Error(\n `ssbg_mode1 source symbol needs ${packet.byteLength} + ${SSBG1_LENGTH_FIELD_BYTES} bytes but T is ${symbolSize}`,\n )\n }\n const symbol = new Uint8Array(symbolSize)\n symbol[0] = (packet.byteLength >>> 8) & 0xff\n symbol[1] = packet.byteLength & 0xff\n symbol.set(packet, SSBG1_LENGTH_FIELD_BYTES)\n return symbol\n}\n\n/**\n * Recover the protected packet from one ssbg_mode1 source symbol: the bytes\n * the length field names. Returns null, never a guess, when the length field\n * points past the symbol or any padding octet is non-zero (C.3.3 padding is\n * all 00h), since either means the recovered symbol is not what was sent.\n */\nexport function trimSsbg1SourceSymbol(symbol: Uint8Array): Uint8Array | null {\n if (symbol.byteLength < SSBG1_LENGTH_FIELD_BYTES) return null\n const length = (symbol[0]! << 8) | symbol[1]!\n const end = SSBG1_LENGTH_FIELD_BYTES + length\n if (end > symbol.byteLength) return null\n for (let i = end; i < symbol.byteLength; i++) {\n if (symbol[i] !== 0) return null\n }\n return symbol.subarray(SSBG1_LENGTH_FIELD_BYTES, end)\n}\n\n/** The SS_ID after the last source symbol of `[ssStart, ssStart + ssbLength)`. */\nexport function isoBlockEnd(ssStart: number, ssbLength: number): number {\n return serial32Add(ssStart, ssbLength)\n}\n\n/**\n * Range membership (C.5.2, C.5.3): whether `ssId` is one of the\n * `ssbLength` consecutive SS_IDs starting at `ssStart`, modulo 2^32.\n */\nexport function isoBlockContains(ssStart: number, ssbLength: number, ssId: number): boolean {\n return serial32Distance(ssStart, ssId) < ssbLength\n}\n\n/**\n * A lower bound on how many blocks the block starting at `a` lies behind the\n * one starting at `b` (0 when `a` does not precede `b`). An iso-ssbg1-v1\n * block holds at most `kMax` source symbols (the catalog `sourceSymbols`, the\n * AL-FEC maximum_k_for_repair_flow), so `n * kMax` SS_IDs always span at least\n * `n` blocks. Every interleave horizon in the receiver (decoder retention,\n * worker retirement, ordered-relay settlement) uses this one bound, so the\n * manager's horizon can never fall inside the decoder's.\n */\nexport function isoBlocksBehindLowerBound(a: number, b: number, kMax: number): number {\n if (!Number.isSafeInteger(kMax) || kMax <= 0) {\n throw new Error(`iso-ssbg1-v1 block horizon needs a positive maximum K (got ${kMax})`)\n }\n return serial32Precedes(a, b) ? Math.floor(serial32Distance(a, b) / kMax) : 0\n}\n", "// FEC catalog binding validator (draft-ramadan-moq-fec-catalog-00, as posted\n// 2026-11-02, moqcast-draft 5f49172).\n//\n// The catalog-00 shape only: a track's `fec` is an array of instance objects,\n// and a repair or symbol track binds to its instance by `source` + `instanceId`\n// (or by being named in `repairTrack` / `symbolTrack`). The draft-01 object\n// shape (`fec: {...}`, `depends`) is not accepted: an object `fec` is a type\n// violation, and a repair track that only `depends` on its source is an orphan.\n//\n// Validation fails closed per instance (fec-core-00 10.3): a violation stops\n// FEC for that instance and no other. Three outcomes, each reported with the\n// catalog path that caused it:\n// ok - FEC may start for the instance.\n// unavailable - the receiver forgoes FEC for it (fec-core-00 10.3 rule 1,\n// 4.5); not a validation failure.\n// violation - the configuration is invalid for this receiver.\n// An orphan repair or symbol track (fec-catalog-00 5 rule 6) is reported and\n// stops nothing. `enhancementRepair` anywhere rejects the catalog (rule 4).\n//\n// The Rust twin is `mmt_fec::fec_catalog`; both run vectors/fec-catalog-00/.\n\nexport type FecCatalogOutcome = \"ok\" | \"unavailable\" | \"violation\";\n\nexport type FecCatalogCode =\n\t// Unavailable (fec-core-00 10.3 rule 1; fec-catalog-00 3.1).\n\t| \"fec_scheme_absent\"\n\t| \"fec_scheme_unimplemented\"\n\t| \"fec_block_profile_absent\"\n\t| \"fec_block_profile_unimplemented\"\n\t| \"fec_block_profile_packaging\"\n\t// Violations.\n\t| \"fec_member\" // fec-catalog-00 5 rule 1; fec-core-00 10.3 rule 11\n\t| \"fec_naming\" // fec-catalog-00 5 rule 2\n\t| \"fec_resolution\" // fec-catalog-00 5 rule 3\n\t| \"fec_enhancement_repair\" // fec-catalog-00 5 rule 4, 3.4\n\t| \"fec_orphan\" // fec-catalog-00 5 rule 6 (report only)\n\t| \"fec_layers\" // fec-core-00 10.3 rule 3\n\t| \"fec_layer0\" // fec-core-00 10.3 rule 4\n\t| \"fec_bounds\" // fec-core-00 10.3 rule 5; block-codes-00 7; mmt-02 7.1\n\t| \"fec_instance_id\" // fec-core-00 10.3 rule 7\n\t| \"fec_symbol_tracks\" // fec-core-00 10.3 rule 7\n\t| \"fec_track_geometry\" // fec-core-00 10.3 rule 8\n\t| \"fec_priority\" // fec-core-00 10.3 rule 10\n\t| \"fec_overlay\" // mmt-02 7.4.1\n\t| \"fec_overlay_unsupported\"; // mmt-02 7.4.4\n\nexport interface FecCatalogIssue {\n\treadonly code: FecCatalogCode;\n\t/** JSON Pointer into the catalog. */\n\treadonly path: string;\n}\n\nexport interface FecCatalogInstance {\n\t/** JSON Pointer of the instance object, `/tracks/<i>/fec/<j>`. */\n\treadonly path: string;\n\treadonly track: string | null;\n\t/** Resolved Instance ID; null when `instanceId` is malformed. */\n\treadonly instanceId: number | null;\n\treadonly outcome: FecCatalogOutcome;\n\treadonly issues: readonly FecCatalogIssue[];\n\t/** Index into `tracks` of the source track declaring the instance. */\n\treadonly trackIndex: number;\n\t/** Track index of the repair track `repairTrack` names, or null. */\n\treadonly repairTrack: number | null;\n\t/** Track indices of the other repair tracks bound by `source` + `instanceId`. */\n\treadonly boundRepairTracks: readonly number[];\n\t/** Track index of the instance's symbol track (named or bound), or null. */\n\treadonly symbolTrack: number | null;\n}\n\nexport interface FecCatalogReport {\n\treadonly instances: readonly FecCatalogInstance[];\n\t/** Every report, instance-attributed or not, in discovery order. */\n\treadonly issues: readonly FecCatalogIssue[];\n}\n\nexport interface FecCatalogReceiver {\n\t/** Schemes the receiver implements. Default: reed-solomon, raptorq. */\n\treadonly schemes?: readonly string[];\n\t/** Block profiles the receiver implements. Default: moq-object-v1, iso-ssbg1-v1. */\n\treadonly blockProfiles?: readonly string[];\n\t/** Whether the receiver implements the mmt-02 7.4 keyframe overlay. Default true. */\n\treadonly overlays?: boolean;\n}\n\nconst DEFAULT_SCHEMES = [\"reed-solomon\", \"raptorq\"];\nconst DEFAULT_PROFILES = [\"moq-object-v1\", \"iso-ssbg1-v1\"];\nconst RS_MAX_BLOCK = 255; // block-codes-00 7\nconst RAPTORQ_MAX_K = 56403; // block-codes-00 7\nconst MAX_24 = 0xffffff; // fec-core-00 10.3 rule 5\nconst ISO_MIN_T = 14; // mmt-02 7.1\nconst GEOMETRY_KEYS = [\n\t\"scheme\",\n\t\"blockProfile\",\n\t\"mmtpMode\",\n\t\"timescale\",\n\t\"symbolSize\",\n\t\"interleaveDepthMs\",\n\t\"reorderToleranceMs\",\n\t\"sourceSymbols\",\n\t\"fec\",\n];\n\ntype Json = unknown;\ntype Known =\n\t| \"tracks\" | \"fec\" | \"name\" | \"namespace\" | \"packaging\" | \"source\" | \"instanceId\"\n\t| \"repairLayer\" | \"repairSymbols\" | \"priority\" | \"scheme\" | \"blockProfile\"\n\t| \"sourceSymbols\" | \"symbolSize\" | \"repairTrack\" | \"symbolTrack\"\n\t| \"interleaveDepthMs\" | \"reorderToleranceMs\" | \"label\" | \"scope\";\ntype Obj = Record<string, Json> & Partial<Record<Known, Json>>;\n\nconst isObj = (v: Json): v is Obj => typeof v === \"object\" && v !== null && !Array.isArray(v);\nconst has = (o: Obj, k: string) => Object.prototype.hasOwnProperty.call(o, k);\nconst isInt = (v: Json): v is number => typeof v === \"number\" && Number.isInteger(v);\nconst intIn = (v: Json, lo: number, hi = Number.MAX_SAFE_INTEGER): v is number =>\n\tisInt(v) && v >= lo && v <= hi;\n\n// moq-object-v1 admits any packaging whose objects carry media as whole\n// objects (fec-core-00 15.3); iso-ssbg1-v1 admits mmtp (mmt-02 7.1).\nconst profileAdmits = (profile: string, packaging: Json) => profile !== \"iso-ssbg1-v1\" || packaging === \"mmtp\";\n\ninterface Inst {\n\tpath: string;\n\tti: number;\n\ttrack: Obj;\n\tobj: Obj | null;\n\tid: number | null;\n\tunavailable: boolean;\n\toverlay: boolean;\n\tissues: FecCatalogIssue[];\n\tlayer0: number | null; // track index of the resolved repairTrack\n\tsymbolNamed: number | null;\n\tattached: number[]; // unnamed fec-repair/fec-source tracks bound by source\n}\n\nexport function validateFecCatalog(catalog: Json, receiver: FecCatalogReceiver = {}): FecCatalogReport {\n\tconst schemes = receiver.schemes ?? DEFAULT_SCHEMES;\n\tconst profiles = receiver.blockProfiles ?? DEFAULT_PROFILES;\n\tconst overlays = receiver.overlays ?? true;\n\tconst all: FecCatalogIssue[] = [];\n\tconst report = (code: FecCatalogCode, path: string, inst?: Inst) => {\n\t\tconst issue = { code, path };\n\t\tall.push(issue);\n\t\tinst?.issues.push(issue);\n\t};\n\n\tconst tracks: Json[] = isObj(catalog) && Array.isArray(catalog.tracks) ? catalog.tracks : [];\n\tif (!isObj(catalog) || !Array.isArray(catalog.tracks)) report(\"fec_member\", \"/tracks\");\n\n\t// Rule 4: enhancementRepair at track level or inside an instance object.\n\tlet rejectAll = false;\n\ttracks.forEach((t, ti) => {\n\t\tif (!isObj(t)) return;\n\t\tif (has(t, \"enhancementRepair\")) {\n\t\t\trejectAll = true;\n\t\t\treport(\"fec_enhancement_repair\", `/tracks/${ti}/enhancementRepair`);\n\t\t}\n\t\tif (Array.isArray(t.fec))\n\t\t\tt.fec.forEach((o, ii) => {\n\t\t\t\tif (isObj(o) && has(o, \"enhancementRepair\")) {\n\t\t\t\t\trejectAll = true;\n\t\t\t\t\treport(\"fec_enhancement_repair\", `/tracks/${ti}/fec/${ii}/enhancementRepair`);\n\t\t\t\t}\n\t\t\t});\n\t});\n\n\t// Track lookup by (namespace, name): a name resolves in the source track's namespace.\n\tconst key = (ns: Json, name: string) => JSON.stringify([ns ?? null, name]);\n\tconst byName = new Map<string, number>();\n\ttracks.forEach((t, ti) => {\n\t\tif (isObj(t) && typeof t.name === \"string\") {\n\t\t\tconst k = key(t.namespace, t.name);\n\t\t\tif (!byName.has(k)) byName.set(k, ti);\n\t\t}\n\t});\n\tconst trackAt = (ti: number) => tracks[ti] as Obj;\n\n\t// Collect instances.\n\tconst insts: Inst[] = [];\n\ttracks.forEach((t, ti) => {\n\t\t// A repair or symbol track's own `fec` is geometry (core rule 8), not instances.\n\t\tif (!isObj(t) || !has(t, \"fec\") || t.packaging === \"fec-repair\" || t.packaging === \"fec-source\") return;\n\t\tif (!Array.isArray(t.fec)) {\n\t\t\treport(\"fec_member\", `/tracks/${ti}/fec`);\n\t\t\treturn;\n\t\t}\n\t\tt.fec.forEach((o, ii) => {\n\t\t\tconst path = `/tracks/${ti}/fec/${ii}`;\n\t\t\tconst inst: Inst = {\n\t\t\t\tpath,\n\t\t\t\tti,\n\t\t\t\ttrack: t,\n\t\t\t\tobj: isObj(o) ? o : null,\n\t\t\t\tid: null,\n\t\t\t\tunavailable: false,\n\t\t\t\toverlay: false,\n\t\t\t\tissues: [],\n\t\t\t\tlayer0: null,\n\t\t\t\tsymbolNamed: null,\n\t\t\t\tattached: [],\n\t\t\t};\n\t\t\tinsts.push(inst);\n\t\t\tif (!inst.obj) return report(\"fec_member\", path, inst);\n\t\t\tconst o2 = inst.obj;\n\t\t\tif (!has(o2, \"instanceId\")) inst.id = 0;\n\t\t\telse if (intIn(o2.instanceId, 0, 255)) inst.id = o2.instanceId;\n\t\t\telse report(\"fec_member\", `${path}/instanceId`, inst);\n\t\t\tinst.overlay = has(o2, \"scope\");\n\t\t});\n\t});\n\tconst declaresFec = insts.length > 0;\n\n\t// Availability (fec-core-00 10.3 rule 1). Scheme first, then profile.\n\tfor (const inst of insts) {\n\t\tconst o = inst.obj;\n\t\tif (!o) continue;\n\t\tconst avail = (field: \"scheme\" | \"blockProfile\", impl: readonly string[]) => {\n\t\t\tconst p = `${inst.path}/${field}`;\n\t\t\tconst absent = field === \"scheme\" ? \"fec_scheme_absent\" : \"fec_block_profile_absent\";\n\t\t\tconst unimpl = field === \"scheme\" ? \"fec_scheme_unimplemented\" : \"fec_block_profile_unimplemented\";\n\t\t\tif (!has(o, field)) return report(absent, p, inst), false;\n\t\t\tif (typeof o[field] !== \"string\") return report(\"fec_member\", p, inst), true;\n\t\t\tif (!impl.includes(o[field] as string)) return report(unimpl, p, inst), false;\n\t\t\treturn true;\n\t\t};\n\t\tif (!avail(\"scheme\", schemes) || !avail(\"blockProfile\", profiles)) inst.unavailable = true;\n\t\telse if (typeof o.blockProfile === \"string\" && !profileAdmits(o.blockProfile, inst.track.packaging)) {\n\t\t\treport(\"fec_block_profile_packaging\", `${inst.path}/blockProfile`, inst);\n\t\t\tinst.unavailable = true;\n\t\t}\n\t}\n\tconst live = (i: Inst) => !i.unavailable;\n\n\t// Rule 1 on instance members, and core rule 11.\n\tfor (const inst of insts.filter(live)) {\n\t\tconst o = inst.obj;\n\t\tif (!o) continue;\n\t\tconst p = inst.path;\n\t\tfor (const m of [\"sourceSymbols\", \"repairSymbols\", \"symbolSize\"])\n\t\t\tif (!intIn(o[m], 1)) report(\"fec_member\", `${p}/${m}`, inst);\n\t\tif (typeof o.repairTrack !== \"string\") report(\"fec_member\", `${p}/repairTrack`, inst);\n\t\tif (has(o, \"symbolTrack\") && typeof o.symbolTrack !== \"string\") report(\"fec_member\", `${p}/symbolTrack`, inst);\n\t\tif (has(o, \"interleaveDepthMs\")) {\n\t\t\tconst d = o.interleaveDepthMs;\n\t\t\tif (!intIn(d, 0) || (d === 0 && o.blockProfile !== \"iso-ssbg1-v1\"))\n\t\t\t\treport(\"fec_member\", `${p}/interleaveDepthMs`, inst);\n\t\t}\n\t\tif (has(o, \"reorderToleranceMs\")) {\n\t\t\tconst r = o.reorderToleranceMs;\n\t\t\tif (typeof r !== \"number\" || !Number.isFinite(r) || r < 0) report(\"fec_member\", `${p}/reorderToleranceMs`, inst);\n\t\t}\n\t\tif (has(o, \"label\") && typeof o.label !== \"string\") report(\"fec_member\", `${p}/label`, inst);\n\t\tif (inst.overlay && o.scope !== \"keyframe\") report(\"fec_member\", `${p}/scope`, inst);\n\t}\n\n\t// Core rule 7: unique Instance IDs within a track.\n\tconst idGroups = new Map<string, Inst[]>();\n\tfor (const inst of insts)\n\t\tif (inst.id !== null) {\n\t\t\tconst k = `${inst.ti}:${inst.id}`;\n\t\t\tidGroups.set(k, [...(idGroups.get(k) ?? []), inst]);\n\t\t}\n\tfor (const group of idGroups.values())\n\t\tif (group.length > 1)\n\t\t\tfor (const inst of group.filter(live))\n\t\t\t\treport(\"fec_instance_id\", inst.obj && has(inst.obj, \"instanceId\") ? `${inst.path}/instanceId` : inst.path, inst);\n\n\t// Rule 2: repairTrack / symbolTrack name a track of the right packaging, once\n\t// per catalog. A name used twice binds no layer 0 or symbol track for either\n\t// namer; the track is still claimed by the first, so it is not re-attached.\n\tconst nameUses = new Map<string, { inst: Inst; field: \"repairTrack\" | \"symbolTrack\" }[]>();\n\tfor (const inst of insts)\n\t\tfor (const field of [\"repairTrack\", \"symbolTrack\"] as const)\n\t\t\tif (inst.obj && typeof inst.obj[field] === \"string\") {\n\t\t\t\tconst k = key(inst.track.namespace, inst.obj[field] as string);\n\t\t\t\tnameUses.set(k, [...(nameUses.get(k) ?? []), { inst, field }]);\n\t\t\t}\n\tconst namedBy = new Map<number, Inst>();\n\tfor (const [k, uses] of nameUses) {\n\t\tconst tj = byName.get(k);\n\t\tfor (const { inst, field } of uses) {\n\t\t\tconst path = `${inst.path}/${field}`;\n\t\t\tif (tj === undefined || trackAt(tj).packaging !== (field === \"repairTrack\" ? \"fec-repair\" : \"fec-source\")) {\n\t\t\t\tif (live(inst)) report(\"fec_naming\", path, inst);\n\t\t\t} else if (uses.length > 1) {\n\t\t\t\tif (live(inst)) report(\"fec_naming\", path, inst);\n\t\t\t\tif (!namedBy.has(tj)) namedBy.set(tj, inst);\n\t\t\t} else {\n\t\t\t\tnamedBy.set(tj, inst);\n\t\t\t\tif (field === \"repairTrack\") inst.layer0 = tj;\n\t\t\t\telse inst.symbolNamed = tj;\n\t\t\t}\n\t\t}\n\t}\n\n\t// Repair and symbol tracks: resolve each to its instance.\n\tconst owner = new Map<number, Inst>();\n\tif (declaresFec)\n\t\ttracks.forEach((t, tj) => {\n\t\t\tif (!isObj(t) || (t.packaging !== \"fec-repair\" && t.packaging !== \"fec-source\")) return;\n\t\t\tconst tp = `/tracks/${tj}`;\n\t\t\tconst named = namedBy.get(tj);\n\t\t\tif (named) {\n\t\t\t\towner.set(tj, named);\n\t\t\t\tif (!live(named)) return;\n\t\t\t\t// Rule 3: a named track's source / instanceId agree with its namer.\n\t\t\t\tif (has(t, \"source\") && t.source !== named.track.name) report(\"fec_resolution\", `${tp}/source`, named);\n\t\t\t\tif (has(t, \"instanceId\") && t.instanceId !== named.id) report(\"fec_resolution\", `${tp}/instanceId`, named);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (!has(t, \"source\")) return report(\"fec_orphan\", tp); // rule 6, stops nothing\n\t\t\tconst id = has(t, \"instanceId\") ? t.instanceId : 0;\n\t\t\tconst src = typeof t.source === \"string\" ? byName.get(key(t.namespace, t.source)) : undefined;\n\t\t\tconst target = src === undefined ? undefined : insts.find((i) => i.ti === src && i.id === id);\n\t\t\tif (!target) return report(\"fec_naming\", has(t, \"instanceId\") && src !== undefined ? `${tp}/instanceId` : `${tp}/source`);\n\t\t\towner.set(tj, target);\n\t\t\ttarget.attached.push(tj);\n\t\t});\n\n\t// Track fields (rule 1) and no geometry (core rule 8), charged to the owner.\n\tfor (const [tj, inst] of owner) {\n\t\tif (!live(inst)) continue;\n\t\tconst t = trackAt(tj);\n\t\tconst tp = `/tracks/${tj}`;\n\t\tconst symbol = t.packaging === \"fec-source\";\n\t\tif (has(t, \"source\") && typeof t.source !== \"string\") report(\"fec_member\", `${tp}/source`, inst);\n\t\tif (has(t, \"instanceId\") && !intIn(t.instanceId, 0, 255)) report(\"fec_member\", `${tp}/instanceId`, inst);\n\t\tfor (const m of [\"repairLayer\", \"repairSymbols\"]) {\n\t\t\tif (!has(t, m)) continue;\n\t\t\tif (symbol || !intIn(t[m], m === \"repairLayer\" ? 0 : 1)) report(\"fec_member\", `${tp}/${m}`, inst);\n\t\t}\n\t\tif (has(t, \"priority\") && !intIn(t.priority, symbol ? 0 : 192, 255)) report(\"fec_member\", `${tp}/priority`, inst);\n\t\tfor (const k of Object.keys(t))\n\t\t\tif (GEOMETRY_KEYS.includes(k) || k.startsWith(\"groupDuration\"))\n\t\t\t\treport(\"fec_track_geometry\", `${tp}/${k.replace(/~/g, \"~0\").replace(/\\//g, \"~1\")}`, inst); // RFC 6901\n\t}\n\n\t// Per instance: layers, symbol tracks, priorities, scheme bounds.\n\tfor (const inst of insts.filter(live)) {\n\t\tconst o = inst.obj;\n\t\tif (!o) continue;\n\t\tconst repairs = inst.attached.filter((tj) => trackAt(tj).packaging === \"fec-repair\");\n\t\tconst symbols = inst.attached.filter((tj) => trackAt(tj).packaging === \"fec-source\");\n\t\tif (symbols.length + (inst.symbolNamed !== null ? 1 : 0) > 1)\n\t\t\tfor (const tj of symbols) report(\"fec_symbol_tracks\", `/tracks/${tj}`, inst);\n\n\t\tconst l0 = inst.layer0 === null ? null : trackAt(inst.layer0);\n\t\tconst l0p = `/tracks/${inst.layer0}`;\n\t\tif (inst.overlay) {\n\t\t\t// mmt-02 7.4.1: an overlay has one repair track and no repairLayer.\n\t\t\tif (l0 && has(l0, \"repairLayer\")) report(\"fec_overlay\", `${l0p}/repairLayer`, inst);\n\t\t\tfor (const tj of repairs)\n\t\t\t\treport(\"fec_overlay\", has(trackAt(tj), \"repairLayer\") ? `/tracks/${tj}/repairLayer` : `/tracks/${tj}`, inst);\n\t\t} else {\n\t\t\tif (l0 && has(l0, \"repairLayer\") && l0.repairLayer !== 0) report(\"fec_layers\", `${l0p}/repairLayer`, inst);\n\t\t\tif (l0 && has(l0, \"repairSymbols\") && intIn(o.repairSymbols, 1) && l0.repairSymbols !== o.repairSymbols)\n\t\t\t\treport(\"fec_layer0\", `${l0p}/repairSymbols`, inst);\n\t\t\tconst layers: { tj: number; layer: number }[] = [];\n\t\t\tfor (const tj of repairs) {\n\t\t\t\tconst t = trackAt(tj);\n\t\t\t\tif (!has(t, \"repairLayer\")) report(\"fec_member\", `/tracks/${tj}/repairLayer`, inst);\n\t\t\t\telse if (t.repairLayer === 0) report(\"fec_layers\", `/tracks/${tj}/repairLayer`, inst);\n\t\t\t\telse if (intIn(t.repairLayer, 1)) layers.push({ tj, layer: t.repairLayer });\n\t\t\t\tif (!has(t, \"repairSymbols\")) report(\"fec_member\", `/tracks/${tj}/repairSymbols`, inst);\n\t\t\t}\n\t\t\t// Core rule 3: layers 1..n contiguous, no duplicates.\n\t\t\tconst counts = new Map<number, number>();\n\t\t\tfor (const { layer } of layers) counts.set(layer, (counts.get(layer) ?? 0) + 1);\n\t\t\tlet missing = 1;\n\t\t\twhile (counts.has(missing)) missing++;\n\t\t\tfor (const { tj, layer } of layers)\n\t\t\t\tif (counts.get(layer)! > 1 || layer > missing) report(\"fec_layers\", `/tracks/${tj}/repairLayer`, inst);\n\t\t\t// Core rule 10: priority non-decreasing with layer; layer 0 defaults to 240.\n\t\t\tconst chain = [\n\t\t\t\t{ layer: 0, tj: inst.layer0, pri: l0 && intIn(l0.priority, 192, 255) ? l0.priority : l0 && has(l0, \"priority\") ? null : 240 },\n\t\t\t\t...layers.map(({ tj, layer }) => {\n\t\t\t\t\tconst pr = trackAt(tj).priority;\n\t\t\t\t\treturn { layer, tj, pri: intIn(pr, 192, 255) ? pr : null };\n\t\t\t\t}),\n\t\t\t].sort((a, b) => a.layer - b.layer);\n\t\t\tlet prev: number | null = null;\n\t\t\tfor (const c of chain) {\n\t\t\t\tif (c.pri === null) continue;\n\t\t\t\tif (prev !== null && c.pri < prev) report(\"fec_priority\", `/tracks/${c.tj}/priority`, inst);\n\t\t\t\tprev = Math.max(prev ?? c.pri, c.pri);\n\t\t\t}\n\t\t}\n\n\t\t// Core rule 5 and the scheme / profile bounds.\n\t\tconst K = o.sourceSymbols;\n\t\tconst T = o.symbolSize;\n\t\tconst P0 = o.repairSymbols;\n\t\tif (intIn(K, 1) && intIn(P0, 1)) {\n\t\t\tlet P = P0;\n\t\t\tif (!inst.overlay)\n\t\t\t\tfor (const tj of repairs) {\n\t\t\t\t\tconst t = trackAt(tj);\n\t\t\t\t\tif (intIn(t.repairLayer, 1) && intIn(t.repairSymbols, 1)) P += t.repairSymbols;\n\t\t\t\t}\n\t\t\tif (P > MAX_24 || K + P - 1 > MAX_24) report(\"fec_bounds\", inst.path, inst);\n\t\t\telse if (o.scheme === \"reed-solomon\" && K + P > RS_MAX_BLOCK) report(\"fec_bounds\", inst.path, inst);\n\t\t}\n\t\tif (o.scheme === \"raptorq\" && intIn(K, 1) && K > RAPTORQ_MAX_K) report(\"fec_bounds\", `${inst.path}/sourceSymbols`, inst);\n\t\tif (intIn(T, 1)) {\n\t\t\tif (o.scheme === \"raptorq\" && T % 8 !== 0) report(\"fec_bounds\", `${inst.path}/symbolSize`, inst);\n\t\t\telse if (o.blockProfile === \"iso-ssbg1-v1\" && T < ISO_MIN_T) report(\"fec_bounds\", `${inst.path}/symbolSize`, inst);\n\t\t}\n\t}\n\n\t// mmt-02 7.4.1 / 7.4.4: overlays, per source track.\n\tconst overlayTracks = new Set(insts.filter((i) => live(i) && i.overlay).map((i) => i.ti));\n\tfor (const ti of overlayTracks) {\n\t\tconst ofTrack = insts.filter((i) => i.ti === ti && live(i));\n\t\tconst ovs = ofTrack.filter((i) => i.overlay);\n\t\tif (!overlays) {\n\t\t\tfor (const ov of ovs) report(\"fec_overlay_unsupported\", `${ov.path}/scope`, ov);\n\t\t\tfor (const i of ofTrack) i.issues.length === 0 && i.issues.push({ code: \"fec_overlay_unsupported\", path: `${ovs[0].path}/scope` });\n\t\t\tcontinue;\n\t\t}\n\t\tif (ovs.length > 1) for (const ov of ovs) report(\"fec_overlay\", `${ov.path}/scope`, ov);\n\t\tfor (const ov of ovs)\n\t\t\tif (ov.obj?.blockProfile !== \"iso-ssbg1-v1\") report(\"fec_overlay\", `${ov.path}/blockProfile`, ov);\n\t\tconst cause = ovs.flatMap((ov) => ov.issues).find((x) => x.code === \"fec_overlay\");\n\t\tif (cause) for (const ov of ovs) if (ov.issues.length === 0) ov.issues.push(cause);\n\t}\n\n\t// Rule 4 rejects every instance; each carries the cause so a consumer reading\n\t// `instances[].issues` sees why.\n\tconst rule4 = all.find((x) => x.code === \"fec_enhancement_repair\");\n\tif (rejectAll && rule4) for (const i of insts) if (i.issues.length === 0) i.issues.push(rule4);\n\n\treturn {\n\t\tinstances: insts.map((i) => ({\n\t\t\tpath: i.path,\n\t\t\ttrack: typeof i.track.name === \"string\" ? i.track.name : null,\n\t\t\tinstanceId: i.id,\n\t\t\toutcome: rejectAll || (!i.unavailable && i.issues.length > 0) ? \"violation\" : i.unavailable ? \"unavailable\" : \"ok\",\n\t\t\tissues: i.issues,\n\t\t\ttrackIndex: i.ti,\n\t\t\trepairTrack: i.layer0,\n\t\t\tboundRepairTracks: i.attached.filter((tj) => trackAt(tj).packaging === \"fec-repair\"),\n\t\t\tsymbolTrack: i.symbolNamed ?? i.attached.find((tj) => trackAt(tj).packaging === \"fec-source\") ?? null,\n\t\t})),\n\t\tissues: all,\n\t};\n}\n", "/**\n * Runtime FEC bindings (BLO-50171): what each source track's FEC resolves to\n * for this library's receiver, read from `validateFecCatalog`'s per-instance\n * result and never re-derived.\n *\n * Every runtime reader (the container's `resolveCatalogTrackFec`,\n * `resolveRepairLayers`, `MulticastManager.configureMultiTrack`, transport's\n * `catalogRepairTracks`) goes through `resolveFecBindings`, so they cannot\n * disagree with the validator or with each other about which tracks have FEC\n * and which repair tracks belong to them.\n *\n * The draft-ramadan-moq-fec-catalog-00 array is the only shape. Both catalog\n * dialects bind through it: an MSF `tracks[]` entry directly, and a hang\n * `video`/`audio` rendition (`fec` the same array) with its `repair.renditions`\n * entries (`source` + `instanceId`), mapped onto a `tracks[]` view so the same\n * validator runs. A hang rendition is authoritative over a `tracks[]` entry of\n * the same name.\n *\n * Fail closed per instance: a source whose base instance is not `ok` resolves\n * to `unavailable` with the validator's code and path, and costs no other\n * track anything. A draft-01 object `fec` is `fec_member` at that track's\n * `fec`: the track plays without FEC.\n */\nimport { validateFecCatalog, type FecCatalogCode, type FecCatalogReceiver } from './fec-catalog.js'\n\n/** The receiver this runtime is: it decodes `iso-ssbg1-v1` only. */\nexport const RUNTIME_FEC_RECEIVER: FecCatalogReceiver = Object.freeze({\n schemes: Object.freeze(['raptorq', 'reed-solomon']),\n blockProfiles: Object.freeze(['iso-ssbg1-v1']),\n overlays: true,\n})\n\n/** Repair track priority when the track states none (MoQT scale, 192..255). */\nexport const DEFAULT_FEC_REPAIR_PRIORITY = 240\n\n/** One `ok` instance's geometry. */\nexport interface FecInstanceGeometry {\n readonly instanceId: number\n readonly scheme: 'raptorq' | 'reed-solomon'\n /** `iso-ssbg1-v1` under `RUNTIME_FEC_RECEIVER`. */\n readonly blockProfile: string\n /** K. */\n readonly sourceSymbols: number\n /** P_0 for a base instance; the overlay's own P for an overlay. */\n readonly repairSymbols: number\n /** T. */\n readonly symbolSize: number\n readonly interleaveDepthMs?: number\n readonly reorderToleranceMs?: number\n readonly repairTrack: string\n readonly symbolTrack?: string\n}\n\nexport interface FecLayerBinding {\n readonly layer: number\n readonly repairTrackName: string\n readonly repairSymbols: number\n /** O_i = sum(P_j, j < i). */\n readonly rsIdOffset: number\n readonly priority: number\n}\n\nexport interface FecOverlayBinding {\n readonly repairTrackName: string\n readonly scope: 'keyframe'\n readonly sourceSymbols: number\n readonly repairSymbols: number\n readonly priority: number\n readonly geometry: FecInstanceGeometry\n}\n\nexport interface FecBindingUnavailable {\n /** The validator's code, or `fec_base_instances` for a track with no base\n * instance or more than one `ok` one (the MMTP wire carries one base Source FEC Payload\n * ID, mmt-02 7.4.1). */\n readonly code: FecCatalogCode | 'fec_base_instances'\n /** JSON Pointer into the catalog (a hang path starts `/video` or `/audio`). */\n readonly path: string\n readonly outcome: 'unavailable' | 'violation'\n}\n\nexport type FecSourceBinding =\n | {\n readonly status: 'ok'\n readonly track: string\n /** JSON Pointer of the source track (`/tracks/<i>`, `/video/renditions/<name>`). */\n readonly pointer: string\n readonly base: FecInstanceGeometry\n /** Base layers in order, layer 0 first. */\n readonly layers: readonly FecLayerBinding[]\n /** Sum of P over the base layers: the wire RSB_length. */\n readonly totalBaseRepairSymbols: number\n readonly overlay?: FecOverlayBinding\n /** Why a declared overlay is not used, when one is not. */\n readonly overlayUnavailable?: FecBindingUnavailable\n }\n | {\n readonly status: 'unavailable'\n readonly track: string\n readonly pointer: string\n readonly reason: FecBindingUnavailable\n }\n\ntype Obj = Record<string, unknown>\nconst recordOf = (v: unknown): Obj | undefined =>\n typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Obj) : undefined\nconst escape = (s: string) => s.replace(/~/g, '~0').replace(/\\//g, '~1')\n\n/**\n * The `tracks[]` view of a catalog in either dialect, with each view track's\n * JSON Pointer in the original catalog.\n */\nexport function fecCatalogTracks(catalog: unknown): { tracks: unknown[]; pointers: string[] } {\n const root = recordOf(catalog) ?? {}\n const tracks: unknown[] = []\n const pointers: string[] = []\n const hangNames = new Set<string>()\n for (const role of ['video', 'audio'] as const) {\n for (const [name, r] of Object.entries(recordOf(recordOf(root[role])?.['renditions']) ?? {})) {\n const entry = recordOf(r)\n if (!entry) continue\n hangNames.add(name)\n const kind = recordOf(entry['container'])?.['kind']\n // A hang rendition carries no `packaging`; its container kind is it.\n // `mmtp-fec` is hang's kind for MMTP that carries FEC (the FFmpeg\n // publisher's `moq_publish_set_video_packaging`): MMTP packaging.\n const view: Obj = { name, packaging: kind === undefined || kind === 'mmtp-fec' ? 'mmtp' : kind }\n if (entry['fec'] !== undefined) view['fec'] = entry['fec']\n tracks.push(view)\n pointers.push(`/${role}/renditions/${escape(name)}`)\n }\n }\n const hangRenditions = tracks.length\n for (const [name, r] of Object.entries(recordOf(recordOf(root['repair'])?.['renditions']) ?? {})) {\n hangNames.add(name)\n tracks.push({ packaging: 'fec-repair', ...recordOf(r), name })\n pointers.push(`/repair/renditions/${escape(name)}`)\n }\n const raw = Array.isArray(root['tracks']) ? root['tracks'] : []\n const named = new Set(hangNames)\n raw.forEach((t, i) => {\n const name = recordOf(t)?.['name']\n if (typeof name === 'string') named.add(name)\n if (typeof name === 'string' && hangNames.has(name)) return\n tracks.push(t)\n pointers.push(`/tracks/${i}`)\n })\n // hang layer 0 needs no `repair.renditions` entry: the rendition's instance\n // names it, and a hang Root (hang's toHang) carries no repair block. Such a\n // name stands for a repair track of that instance.\n tracks.slice(0, hangRenditions).forEach((t, ti) => {\n const view = t as Obj\n if (!Array.isArray(view['fec'])) return\n view['fec'].forEach((o, ii) => {\n const repairTrack = recordOf(o)?.['repairTrack']\n if (typeof repairTrack !== 'string' || named.has(repairTrack)) return\n named.add(repairTrack)\n const instanceId = recordOf(o)?.['instanceId']\n tracks.push({ name: repairTrack, packaging: 'fec-repair', source: view['name'], ...(instanceId === undefined ? {} : { instanceId }) })\n pointers.push(`${pointers[ti]}/fec/${ii}/repairTrack`)\n })\n })\n return { tracks, pointers }\n}\n\n/**\n * Every source track's runtime FEC binding, by track name. A track with no\n * `fec`, or an empty `fec` array, declares no FEC and has no entry.\n */\nexport function resolveFecBindings(\n catalog: unknown,\n receiver: FecCatalogReceiver = RUNTIME_FEC_RECEIVER,\n): ReadonlyMap<string, FecSourceBinding> {\n const { tracks, pointers } = fecCatalogTracks(catalog)\n const report = validateFecCatalog({ tracks }, receiver)\n const at = (path: string) => {\n const m = /^\\/tracks\\/(\\d+)(.*)$/.exec(path)\n return m ? pointers[Number(m[1])] + m[2] : path\n }\n const trackAt = (i: number) => recordOf(tracks[i]) ?? {}\n const nameAt = (i: number | null) => (i === null ? undefined : (trackAt(i)['name'] as string))\n const why = (i: { outcome: string; issues: readonly { code: FecCatalogCode; path: string }[]; path: string }) =>\n Object.freeze({\n code: i.issues[0]?.code ?? 'fec_member',\n path: at(i.issues[0]?.path ?? i.path),\n outcome: i.outcome === 'unavailable' ? ('unavailable' as const) : ('violation' as const),\n })\n\n const out = new Map<string, FecSourceBinding>()\n tracks.forEach((t, ti) => {\n const track = recordOf(t)\n const name = track?.['name']\n if (!track || typeof name !== 'string' || out.has(name)) return\n if (track['packaging'] === 'fec-repair' || track['packaging'] === 'fec-source') return\n const fec = track['fec']\n if (fec === undefined) return\n if (!Array.isArray(fec)) {\n const reason = { code: 'fec_member' as const, path: at(`/tracks/${ti}/fec`), outcome: 'violation' as const }\n out.set(name, Object.freeze({ status: 'unavailable', track: name, pointer: pointers[ti]!, reason: Object.freeze(reason) }))\n return\n }\n if (fec.length === 0) return\n const insts = report.instances.filter((i) => i.trackIndex === ti)\n const isOverlay = (i: (typeof insts)[number]) => Object.hasOwn(recordOf(fec[Number(i.path.split('/').pop())]) ?? {}, 'scope')\n const bases = insts.filter((i) => !isOverlay(i))\n const okBases = bases.filter((i) => i.outcome === 'ok')\n if (okBases.length !== 1) {\n const reason =\n // No base instance at all (overlay-only `fec`) is a count of 0, not\n // the overlay's own issue.\n okBases.length > 1 || bases.length === 0\n ? Object.freeze({ code: 'fec_base_instances' as const, path: at(`/tracks/${ti}/fec`), outcome: 'violation' as const })\n : why(bases[0] ?? insts[0]!)\n out.set(name, Object.freeze({ status: 'unavailable', track: name, pointer: pointers[ti]!, reason }))\n return\n }\n const b = okBases[0]!\n const base = geometry(recordOf(fec[Number(b.path.split('/').pop())])!, b.instanceId!, nameAt(b.symbolTrack))\n const priorityOf = (i: number | null) => {\n const p = i === null ? undefined : trackAt(i)['priority']\n return typeof p === 'number' ? p : DEFAULT_FEC_REPAIR_PRIORITY\n }\n const layers: FecLayerBinding[] = [\n { layer: 0, repairTrackName: base.repairTrack, repairSymbols: base.repairSymbols, rsIdOffset: 0, priority: priorityOf(b.repairTrack) },\n ]\n const more = b.boundRepairTracks\n .map((tj) => ({ tj, layer: trackAt(tj)['repairLayer'] as number }))\n .sort((x, y) => x.layer - y.layer)\n let offset = base.repairSymbols\n for (const { tj, layer } of more) {\n const repairSymbols = trackAt(tj)['repairSymbols'] as number\n layers.push({ layer, repairTrackName: nameAt(tj)!, repairSymbols, rsIdOffset: offset, priority: priorityOf(tj) })\n offset += repairSymbols\n }\n let overlay: FecOverlayBinding | undefined\n let overlayUnavailable: FecBindingUnavailable | undefined\n const ov = insts.find(isOverlay)\n if (ov?.outcome === 'ok') {\n const g = geometry(recordOf(fec[Number(ov.path.split('/').pop())])!, ov.instanceId!, nameAt(ov.symbolTrack))\n overlay = Object.freeze({\n repairTrackName: g.repairTrack,\n scope: 'keyframe' as const,\n sourceSymbols: g.sourceSymbols,\n repairSymbols: g.repairSymbols,\n priority: priorityOf(ov.repairTrack),\n geometry: g,\n })\n } else if (ov) overlayUnavailable = why(ov)\n out.set(\n name,\n Object.freeze({\n status: 'ok' as const,\n track: name,\n pointer: pointers[ti]!,\n base,\n layers: Object.freeze(layers.map((l) => Object.freeze(l))),\n totalBaseRepairSymbols: offset,\n ...(overlay ? { overlay } : {}),\n ...(overlayUnavailable ? { overlayUnavailable } : {}),\n }),\n )\n })\n return out\n}\n\n// The validator passed this instance for this receiver, so every member read\n// here is present and in range.\nfunction geometry(o: Obj, instanceId: number, symbolTrack: string | undefined): FecInstanceGeometry {\n return Object.freeze({\n instanceId,\n scheme: o['scheme'] as 'raptorq' | 'reed-solomon',\n blockProfile: o['blockProfile'] as string,\n sourceSymbols: o['sourceSymbols'] as number,\n repairSymbols: o['repairSymbols'] as number,\n symbolSize: o['symbolSize'] as number,\n ...(typeof o['interleaveDepthMs'] === 'number' ? { interleaveDepthMs: o['interleaveDepthMs'] } : {}),\n ...(typeof o['reorderToleranceMs'] === 'number' ? { reorderToleranceMs: o['reorderToleranceMs'] } : {}),\n repairTrack: o['repairTrack'] as string,\n ...(symbolTrack !== undefined ? { symbolTrack } : {}),\n })\n}\n", "/**\n * TrackSession \u2014 per-track transport state machine.\n * SessionManager \u2014 orchestrates all sessions + TransportPool.\n *\n * Implements Patent Claim 9 (draft-ramadan-moq-fec APPLICATION-1):\n * (a) subscribe unicast\n * (b) simultaneously join multicast\n * (c) render from unicast during join\n * (d) activate multicast on RAP, suppress unicast\n * (e) cross-transport FEC repair during transition\n * (f) hysteresis before switching away from multicast\n * (g) re-subscribe unicast on sustained failure\n *\n * PIM: manages transport state without inspecting packet contents.\n */\n\nimport {\n TransportPool,\n type Subscription,\n type SubscriptionMode,\n type AcquireOptions,\n type DirectSocketHealth,\n type TransportPoolOptions,\n} from './transport-pool.js'\nimport { fecBlockBudgetMs } from '@blockcast/mmt-fec'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport type TrackState =\n | 'idle'\n | 'unicast'\n | 'unicast-fallback'\n | 'joining'\n | 'multicast'\n | 'amt'\n | 'failing'\n\nexport interface TrackSessionConfig {\n trackName: string\n trackType: 'video' | 'audio'\n packetId: number\n /** FEC interleave depth from catalog (ms). Used for hysteresis timer. */\n fecInterleaveMs: number\n /** Explicit catalog jitter/reorder allowance in ms. */\n fecReorderToleranceMs?: number\n /** FEC K from catalog. Used for audio worklet latency calculation. */\n fecK: number\n /** FEC repair symbols per block (P). NOT symbol size. */\n fecRepairCount: number\n /**\n * Repair symbols per block this receiver actually joined: the sum of\n * `repairSymbols` over the joined BASE layers, not a count of layers\n * (draft-ramadan-moq-fec 6.3). A keyframe overlay is a separate FEC instance\n * and is not included.\n *\n * This is runtime join state, not catalog geometry: a receiver on a clean\n * path joins layer 0 only while a lossy one adds layers, so the catalog's\n * total P is not what this receiver is receiving. Diagnostics that report P\n * (the worker snapshot, CMCD `x-bc-fec-p`) must read this.\n *\n * Absent when the caller joins no layers explicitly, in which case P_0 is\n * what was joined. That is an identity on a single-layer track, not a\n * catalog default: with one layer, P_0 is the total.\n */\n joinedRepairCount?: number\n /** FEC symbol size in bytes (T). Required for WASM decoder OTI. */\n fecSymbolSize: number\n /** MoQ repair track name from catalog FEC config. */\n repairTrackName?: string\n /** Frame samples from initData parsing (audio-init-parser.ts). */\n frameSamples?: number\n /** Audio sample rate from catalog. */\n sampleRate?: number\n /** Ordered transport preference: best first. */\n preferredTransports: SubscriptionMode[]\n /** Subscription key for multicast/AMT: \"(S,G,port)\". */\n multicastKey?: string\n /** MoQ track name for unicast subscription. */\n moqTrackName?: string\n /** AMT relay address (if AMT is in preference chain). */\n amtRelay?: string\n /** MoQ relay URL (for WebTransport subscription). */\n moqRelayUrl?: string\n}\n\n// ---------------------------------------------------------------------------\n// PIM callbacks (protocol-independent \u2014 transport state only)\n// ---------------------------------------------------------------------------\n\nexport interface SessionManagerCallbacks {\n /** Fired on every transport state transition. */\n onStateChange?(trackName: string, from: TrackState, to: TrackState): void\n /** Fired to suppress/unsuppress unicast for this track (transport arbitration). */\n onSuppress?(trackName: string, suppressed: boolean): void\n /**\n * Fired when a track sat in `joining` for SessionManager.takeoverDeadlineMs\n * without a RAP (BLO-9413/D9). The session has already returned to `unicast`\n * (nothing was suppressed, so nothing to restore) and it keeps its FEC\n * decoder armed, because the multicast socket is still joined and the router\n * still admits the stream's symbols. The host should abandon the router-side\n * attempt (e.g. MediaRouter.failbackToMoq()) so the router stops waiting for\n * a keyframe that is never coming; it must not dispose the decoder here. The\n * decoder is released on the next transport edge (unsubscribe, failbackAll,\n * or transportFailed falling back to unicast) through fec-teardown.\n */\n onTakeoverTimeout?(trackName: string): void\n}\n\n// ---------------------------------------------------------------------------\n// MMTP application-layer extension (FEC lifecycle, media resets)\n// ---------------------------------------------------------------------------\n\n/**\n * MMTP track events (discriminated union).\n * Each protocol layer defines its own event union \u2014 MMTP events cover\n * media resets, latency, and FEC lifecycle.\n */\nexport type MmtpTrackEvent =\n | { type: 'reset' }\n | { type: 'latency'; latencyMs: number }\n | { type: 'fec-setup' }\n | { type: 'fec-teardown' }\n\n/**\n * MMTP application-layer extension of SessionManagerCallbacks.\n * Single onTrackEvent dispatches all MMTP-specific events.\n * PIM consumers use the base SessionManagerCallbacks (no onTrackEvent).\n *\n * IWA can define its own subinterface with its own TrackEvent union\n * (e.g., IwaTrackEvent for socket health, AMT state) using the same pattern.\n */\nexport interface MmtpSessionCallbacks extends SessionManagerCallbacks {\n onTrackEvent?(trackName: string, event: MmtpTrackEvent, config: TrackSessionConfig): void\n}\n\n/**\n * Loss-hysteresis window covering the signaled FEC block span plus jitter.\n *\n * `fecK` no longer feeds the math; it is still validated as a catalog\n * well-formedness guard and kept in the signature for positional API compat\n * (removal is a follow-up with the next cross-repo pin bump).\n */\nexport function computeFecLossWindowMs(fecInterleaveMs: number, fecK: number, jitterBufferMs?: number): number {\n return computeFecDeliveryWindowMs({\n interleaveDepthMs: fecInterleaveMs,\n sourceSymbols: fecK,\n jitterBufferMs,\n })\n}\n\nexport interface FecDeliveryWindowConfig {\n interleaveDepthMs: number\n sourceSymbols: number\n repairSymbols?: number\n symbolSize?: number\n mediaUnitsPerSecond?: number\n bandwidthBps?: number\n /**\n * Explicit jitter/reorder allowance in ms, added once to the complete span.\n * Callers pass their signaled catalog allowance, never a local default.\n */\n jitterBufferMs?: number\n}\n\n/**\n * The catalog interleaveDepthMs is the complete FEC block span, including the\n * repair tail. K, P, cadence, and bandwidth must not expand it again. Geometry\n * fields remain accepted for existing callers, but do not feed deadline math.\n */\nexport function computeFecDeliveryWindowMs(\n config: FecDeliveryWindowConfig,\n): number {\n const fecK = config.sourceSymbols\n if (!Number.isFinite(fecK) || fecK <= 0) {\n throw new Error(\n `[TrackSession] fecK=${fecK}. ` +\n `Derive a positive FEC source-symbol count from the stream catalog.`\n )\n }\n return fecSpanDeliveryWindowMs(config.interleaveDepthMs, config.jitterBufferMs)\n}\n\n/**\n * The delivery window of a \u00A75.1 span alone. Every window is this; K only\n * validates a track's geometry in computeFecDeliveryWindowMs, so a caller\n * with a span and no K (FECManager's stream-global envelope) uses this\n * rather than invent one.\n */\nexport function fecSpanDeliveryWindowMs(interleaveDepthMs: number, jitterBufferMs?: number): number {\n if (!Number.isFinite(interleaveDepthMs) || interleaveDepthMs <= 0) {\n throw new Error(\n `[TrackSession] fecInterleaveMs=${interleaveDepthMs}. ` +\n `Derive a positive FEC interleave window from the stream catalog.`\n )\n }\n // Absence stays absent; it does not authorize a synthesized reorder budget.\n if (jitterBufferMs === undefined) return Math.ceil(interleaveDepthMs)\n return fecBlockBudgetMs(interleaveDepthMs, jitterBufferMs)\n}\n\n/** Convert the shared FEC delivery deadline into an integer media timescale. */\nexport function computeFecDeliveryWindowMediaUnits(\n config: FecDeliveryWindowConfig,\n timestampTimescale: number,\n): number {\n if (!Number.isFinite(timestampTimescale) || timestampTimescale <= 0) {\n throw new Error(\n `[TrackSession] timestampTimescale=${timestampTimescale}. ` +\n 'Derive a positive media timescale from the container.',\n )\n }\n const deliveryWindow = Math.ceil(\n computeFecDeliveryWindowMs(config) * timestampTimescale / 1000,\n )\n if (!Number.isSafeInteger(deliveryWindow)) {\n throw new Error('[TrackSession] FEC delivery window exceeds the media timestamp range.')\n }\n return deliveryWindow\n}\n\n// ---------------------------------------------------------------------------\n// TrackSession\n// ---------------------------------------------------------------------------\n\nexport class TrackSession {\n readonly config: TrackSessionConfig\n #state: TrackState = 'idle'\n #subscription: Subscription | null = null\n #hysteresisTimer: ReturnType<typeof setTimeout> | null = null\n #joinDeadlineTimer: ReturnType<typeof setTimeout> | null = null\n #previousPrimary: SubscriptionMode | null = null\n #manager: SessionManager\n // Single suppression authority flag (BLO-9413 failback coherence): every\n // suppress this session fires is recorded here, and every path that leaves\n // a suppressed state unwinds through #unsuppress(). Before this flag,\n // unsubscribe() only un-suppressed from multicast/amt \u2014 a session torn down\n // from `failing` (exactly where a starving track sits) was re-subscribed to\n // unicast with the host's media still muted: MoQ playing into a dead sink.\n #suppressFired = false\n // Single FEC authority flag: set by the fec-setup edges (joinStarted,\n // enterUnicastFallback), cleared by the fec-teardown edge in #transition.\n // Setup and teardown are paired by construction, so a takeover timeout that\n // parks the session in `unicast` keeps the decoder the still-joined\n // transport is feeding, and the later unsubscribe still tears it down.\n #fecArmed = false\n\n constructor(config: TrackSessionConfig, manager: SessionManager) {\n this.config = config\n this.#manager = manager\n }\n\n // -----------------------------------------------------------------------\n // State\n // -----------------------------------------------------------------------\n\n get state(): TrackState { return this.#state }\n get subscription(): Subscription | null { return this.#subscription }\n\n /** True while this session's onSuppress(true) is outstanding (no matching\n * onSuppress(false) yet). Test/assertion surface for failback coherence. */\n get isSuppressed(): boolean { return this.#suppressFired }\n\n /** Current active transport mode, or null if idle. */\n get transport(): SubscriptionMode | null {\n switch (this.#state) {\n case 'multicast': return 'multicast'\n case 'amt': return 'amt'\n case 'unicast': return 'moq'\n case 'unicast-fallback': return 'moq' // SSM bridge absent \u2014 MoQ unicast is the only source\n case 'joining': return 'moq' // MoQ still primary during join\n case 'failing': return this.#previousPrimary\n default: return null\n }\n }\n\n // -----------------------------------------------------------------------\n // Computed (no magic numbers)\n // -----------------------------------------------------------------------\n\n /** Audio worklet latency: max(fecInterleaveMs, frameDurationMs * fecK).\n * Returns undefined if frame duration unknown (no initData). */\n get audioLatencyMs(): number | undefined {\n const fd = this.frameDurationMs\n if (fd === undefined) {\n return this.config.fecInterleaveMs > 0 ? this.config.fecInterleaveMs : undefined\n }\n return Math.max(this.config.fecInterleaveMs, fd * this.config.fecK)\n }\n\n /** Frame duration in ms, derived from frameSamples + sampleRate. */\n get frameDurationMs(): number | undefined {\n const { frameSamples, sampleRate } = this.config\n if (!frameSamples || !sampleRate || sampleRate <= 0) return undefined\n return (frameSamples / sampleRate) * 1000\n }\n\n // -----------------------------------------------------------------------\n // Transitions (Patent Claim 9)\n // -----------------------------------------------------------------------\n\n /** idle \u2192 unicast: player subscribes to this track. */\n subscribe(): void {\n if (this.#state !== 'idle') return\n this.#transition('unicast')\n\n // Emit audio latency on first subscribe\n if (this.config.trackType === 'audio') {\n const latency = this.audioLatencyMs\n if (latency !== undefined) {\n this.#manager.mmtp?.onTrackEvent?.(this.config.trackName, { type: 'latency', latencyMs: latency }, this.config)\n }\n }\n }\n\n /** unicast \u2192 joining: multicast/AMT capability detected, join in progress. */\n joinStarted(): void {\n if (this.#state !== 'unicast') return\n this.#transition('joining')\n // FEC decoders must be ready before multicast data arrives (Claim 9e:\n // cross-transport repair). A session re-joining after a takeover timeout\n // still owns its decoder, so fec-setup fires once per FEC epoch.\n this.#armFec()\n }\n\n /**\n * any non-fallback state \u2192 unicast-fallback. Used when the SSM bridge is\n * absent (vanilla Chromium / no extension) and the host falls back to a\n * MoQ unicast subscription per track. Terminal-ish: activateMulticast,\n * failbackAll, and transportFailed are no-ops while in this state.\n *\n * Fires fec-setup so the per-track repair worker spins up \u2014 repair packets\n * still flow over the MoQ repair-track subscription, so FEC source+repair\n * can converge as in the multicast path.\n *\n * Audio latency is NOT re-emitted here: subscribe() already fires latency\n * once on the idle \u2192 unicast edge, and enterUnicastFallback is only ever\n * reached from a non-idle state (SessionManager skips idle sessions).\n */\n enterUnicastFallback(): void {\n if (this.#state === 'unicast-fallback') return\n this.#clearHysteresis()\n this.#transition('unicast-fallback')\n // Suppress the redundant unicast media decoder. On the no-bridge path the\n // host feeds MoQ-unicast source packets into MulticastManager (the render\n // consumer); a second base-media subscribe (the @moq/watch decoder) would\n // split the single connection's QUIC flow-control budget and stall delivery\n // (BLO-8969). Fire suppress here, on the fallback edge, so the decoder is\n // gated off before it subscribes rather than after the relay forward task\n // has already wedged.\n this.#suppress()\n this.#armFec()\n }\n\n /**\n * unicast-fallback \u2192 unicast. Called when the SSM bridge appears mid-session\n * (extension installed after fallback engaged). Host first aborts the fallback\n * drainers, then transitions sessions back to unicast so the regular\n * multicast/AMT preference chain can take over via joinStarted().\n */\n exitUnicastFallback(): void {\n if (this.#state !== 'unicast-fallback') return\n this.#transition('unicast')\n // Un-suppress the unicast decoder: the bridge appeared mid-session, so the\n // host tears down the fallback drainers and the regular preference chain\n // (joinStarted -> joining -> rapReceived) will re-suppress on multicast RAP.\n this.#unsuppress()\n }\n\n /**\n * joining \u2192 multicast or amt: RAP received on the joined transport (Claim 9d).\n * Resets audio worklet, then suppresses MoQ.\n */\n rapReceived(mode: 'multicast' | 'amt'): void {\n if (this.#state !== 'joining') return\n this.#previousPrimary = mode\n this.#transition(mode)\n if (this.config.trackType === 'audio') {\n this.#manager.mmtp?.onTrackEvent?.(this.config.trackName, { type: 'reset' }, this.config)\n }\n this.#suppress()\n }\n\n /** multicast/amt \u2192 failing: loss detected, start hysteresis timer (Claim 9f). */\n lossDetected(): void {\n if (this.#state !== 'multicast' && this.#state !== 'amt') return\n\n // A zero/absent catalog span carries no elapsed FEC deadline. The stall\n // detector that calls this method already supplied the generic loss\n // evidence, so there is no FEC hysteresis window to derive here. Follow\n // the existing transport-failure edge immediately instead of inventing a\n // local timeout for a catalog value that explicitly supplied none.\n if (this.config.fecInterleaveMs === 0 || this.config.fecInterleaveMs === undefined) {\n this.transportFailed()\n return\n }\n\n this.#previousPrimary = this.#state as SubscriptionMode\n this.#transition('failing')\n\n // Hysteresis timer: sustained loss before failback (Claim 9f). Use the\n // same complete span plus explicit allowance as FECManager and workers.\n const hysteresisMs = computeFecLossWindowMs(\n this.config.fecInterleaveMs,\n this.config.fecK,\n this.config.fecReorderToleranceMs,\n )\n this.#hysteresisTimer = setTimeout(() => {\n this.hysteresisExpired()\n }, hysteresisMs)\n }\n\n /** failing \u2192 previous primary: loss recovered within hysteresis window. */\n lossRecovered(): void {\n if (this.#state !== 'failing') return\n this.#clearHysteresis()\n if (this.#previousPrimary) {\n this.#transition(this.#previousPrimary as TrackState)\n }\n }\n\n /**\n * Transport failed (AMT relay down, multicast interface gone).\n * Try next transport in preference chain.\n */\n transportFailed(): void {\n this.#clearHysteresis()\n // Unicast-fallback is terminal: the host owns the subscription and\n // SessionManager transport machinery does not arbitrate it.\n if (this.#state === 'unicast-fallback') return\n const current = this.#previousPrimary ?? this.transport\n const prefs = this.config.preferredTransports\n const currentIdx = current ? prefs.indexOf(current) : -1\n const nextMode = prefs[currentIdx + 1]\n\n if (nextMode !== undefined && nextMode !== 'moq') {\n // Next is multicast or AMT: start joining. `joining` means MoQ is\n // primary again (see transport getter), so if the failed transport had\n // suppressed unicast, restore it for the join window or the viewer\n // watches black video until the next RAP (which may never come).\n // The chain position moves with the attempt: a join that times out\n // parks the session in `unicast` with this transport recorded, so the\n // next failure continues past it rather than re-trying the transport\n // that already failed and re-firing onTakeoverTimeout on every lap.\n this.#previousPrimary = nextMode\n this.#transition('joining')\n this.#unsuppress()\n return\n }\n // MoQ is next, or the chain is exhausted: unicast either way (Claim 9g).\n // For a session parked in `unicast` with its decoder retained after a\n // takeover timeout, this transition is a self-edge and fires no teardown,\n // so the release is explicit. It is the \"transportFailed falling back to\n // unicast\" release path the onTakeoverTimeout doc names, and #disarmFec is\n // idempotent, so it is a no-op when the transition already fired the\n // teardown. The audio reset is not flag-guarded, so it is tied to the edge\n // itself: a session already resting in unicast changed no transport and\n // owes the host no reset, however many pool subscriptions fail for it.\n const from = this.#state\n this.#previousPrimary = null\n this.#transition('unicast')\n this.#disarmFec()\n this.#unsuppress()\n if (from !== 'unicast' && this.config.trackType === 'audio') {\n this.#manager.mmtp?.onTrackEvent?.(this.config.trackName, { type: 'reset' }, this.config)\n }\n }\n\n /** Hysteresis expired \u2192 failback to next transport (Claim 9g). */\n hysteresisExpired(): void {\n if (this.#state !== 'failing') return\n this.#clearHysteresis()\n this.transportFailed()\n }\n\n /** \u2192 idle: player unsubscribes / tab close / cleanup. */\n unsubscribe(): void {\n this.#clearHysteresis()\n if (this.#state !== 'idle') {\n this.#transition('idle')\n // Unwind any outstanding suppression regardless of which state we left.\n // The old `wasActive` check (multicast/amt only) leaked suppression when\n // tearing down from `failing` or `unicast-fallback` \u2014 failbackAll()'s\n // unsubscribe\u2192subscribe cycle then re-entered unicast with the host's\n // media still muted (BLO-9413 freeze: \"failing back\" logged, video dead).\n this.#unsuppress()\n }\n }\n\n /** True when SessionManager.enterUnicastFallback put this track into the\n * host-driven unicast subscription mode. Host can iterate sessions to\n * decide whether to keep its fallback drainer alive across MediaRouter\n * arbitration events. */\n get isUnicastFallback(): boolean {\n return this.#state === 'unicast-fallback'\n }\n\n /**\n * True while this track owns a FEC decoder (fec-setup fired, fec-teardown\n * not yet). Stays true across a takeover timeout that parked the session in\n * `unicast`; SessionManager.failbackAll uses it to release such sessions.\n */\n get isFecArmed(): boolean {\n return this.#fecArmed\n }\n\n /**\n * Start the deadline a `joining` entry deferred while the join was inactive\n * (SessionManager.joinActive). The track gets the FULL deadline from here,\n * because that is when media can first arrive. No-op outside `joining` and\n * when a deadline is already running, so it never shortens or restarts one.\n *\n * The `joinActive` guard is what makes this safe to call from anywhere: a\n * track parked in `joining` with a deferred (null) timer satisfies the other\n * two, so without it a host calling this while the join is still inactive\n * would arm the exact deadline the gate exists to defer -- silently\n * reinstating BLO-34289. It is a no-op for the internal caller, which writes\n * `#joinActive = true` before iterating the sessions.\n */\n armDeferredJoinDeadline(): void {\n if (this.#state !== 'joining' || this.#joinDeadlineTimer !== null) return\n if (!this.#manager.joinActive) return\n this.#armJoinDeadline(this.#manager.takeoverDeadlineMs)\n }\n\n dispose(): void {\n this.#clearHysteresis()\n this.#clearJoinDeadline()\n this.#state = 'idle'\n this.#subscription = null\n // Release through the teardown edge, not a bare assignment: the\n // retained-FEC park makes `unicast` + isFecArmed a resting state, and\n // SessionManager.dispose() calls this directly (removeTrack() reaches it\n // via unsubscribe(), which has already disarmed). A bare assignment there\n // drops the host's decoder without a fec-teardown -- a leaked Worker.\n this.#disarmFec()\n }\n\n // -----------------------------------------------------------------------\n // Internal\n // -----------------------------------------------------------------------\n\n #transition(to: TrackState, opts?: { retainFec?: boolean }): void {\n const from = this.#state\n if (from === to) return\n // Takeover deadline (BLO-9413/D9): every entry into `joining` arms it\n // (joinStarted AND transportFailed-failover both route through here);\n // every exit cancels it. Without this, a transport that accepts the\n // subscribe but never delivers a RAP parks the session in `joining`\n // forever (observed live in the Phase-0 headless repro). The value is\n // resolved before any state write, so the one residual failure -- a\n // manager whose deadline was never set at all, which addTrack() refuses --\n // leaves the session in its previous state rather than half-transitioned.\n // Invalid values never reach here: SessionManager's setter refuses them.\n // The read is unconditional even when `joinActive` defers the arm, so a\n // manager without a deadline still fails closed on every joining edge.\n const joinDeadlineMs = to === 'joining' ? this.#manager.takeoverDeadlineMs : undefined\n this.#state = to\n if (from === 'joining') this.#clearJoinDeadline()\n if (joinDeadlineMs !== undefined && this.#manager.joinActive) {\n this.#armJoinDeadline(joinDeadlineMs)\n }\n // FEC teardown: entering a state without a FEC decoder releases the one\n // this track armed, unless the caller keeps it (takeover timeout: the\n // transport is still joined and still feeding the decoder).\n const fecActive = (s: TrackState) =>\n s === 'joining' ||\n s === 'multicast' ||\n s === 'amt' ||\n s === 'failing' ||\n s === 'unicast-fallback'\n if (!fecActive(to) && !opts?.retainFec) this.#disarmFec()\n this.#manager.callbacks?.onStateChange?.(this.config.trackName, from, to)\n }\n\n #clearHysteresis(): void {\n if (this.#hysteresisTimer !== null) {\n clearTimeout(this.#hysteresisTimer)\n this.#hysteresisTimer = null\n }\n }\n\n #armJoinDeadline(deadlineMs: number): void {\n this.#clearJoinDeadline()\n if (deadlineMs === 0) return // a declared 0 disables the deadline\n this.#joinDeadlineTimer = setTimeout(() => {\n this.#joinDeadlineTimer = null\n if (this.#state !== 'joining') return\n // No RAP within the deadline: return to unicast for arbitration only.\n // MoQ was never suppressed in `joining`, so there is nothing to restore\n // and this edge stays silent on onSuppress (asserted in tests). The\n // multicast socket is still joined and the router still admits this\n // stream's symbols, so the FEC decoder stays armed; the next transport\n // edge releases it.\n this.#transition('unicast', { retainFec: true })\n this.#manager.callbacks?.onTakeoverTimeout?.(this.config.trackName)\n }, deadlineMs)\n }\n\n #clearJoinDeadline(): void {\n if (this.#joinDeadlineTimer !== null) {\n clearTimeout(this.#joinDeadlineTimer)\n this.#joinDeadlineTimer = null\n }\n }\n\n /** Fire fec-setup exactly once per FEC epoch. */\n #armFec(): void {\n if (this.#fecArmed) return\n this.#fecArmed = true\n this.#manager.mmtp?.onTrackEvent?.(this.config.trackName, { type: 'fec-setup' }, this.config)\n }\n\n /** Fire fec-teardown for an armed decoder; no-op when none is armed. */\n #disarmFec(): void {\n if (!this.#fecArmed) return\n this.#fecArmed = false\n this.#manager.mmtp?.onTrackEvent?.(this.config.trackName, { type: 'fec-teardown' }, this.config)\n }\n\n /** Fire onSuppress(true) exactly once per suppression epoch. */\n #suppress(): void {\n if (this.#suppressFired) return\n this.#suppressFired = true\n this.#manager.callbacks?.onSuppress?.(this.config.trackName, true)\n }\n\n /** Unwind an outstanding suppression; no-op when none is outstanding. */\n #unsuppress(): void {\n if (!this.#suppressFired) return\n this.#suppressFired = false\n this.#manager.callbacks?.onSuppress?.(this.config.trackName, false)\n }\n}\n\n// ---------------------------------------------------------------------------\n// SessionManager\n// ---------------------------------------------------------------------------\n\n/**\n * Largest takeover deadline setTimeout represents faithfully. setTimeout\n * coerces its delay to a signed 32-bit int, so a larger value fires on the\n * next tick (Node also warns TimeoutOverflowWarning) instead of after the\n * window the host asked for -- silently inverting the feature. Rejecting it\n * keeps the classic units bug (seconds-as-ms, a wrong timescale) loud.\n */\nexport const MAX_TAKEOVER_DEADLINE_MS = 2_147_483_647\n\n/**\n * How long `joinActive` may stay false before the library says so. The deferral\n * is unbounded by construction -- that is the feature -- so a host that never\n * flips it back parks every track in `joining` with no timer at all: exactly the\n * forever-join the takeover deadline exists to prevent, only silent. MoQ stays\n * unsuppressed so the viewer sees video, but onTakeoverTimeout never fires, the\n * FEC decoder stays armed, and the preference chain never advances past the\n * transport that failed. It looks like a slow join. A host is one un-awaited\n * rejection between `false` and `true` away from it, so the library complains\n * rather than trusting every future host to get all of its exit paths right.\n *\n * Generous against a genuinely slow subscribe -- a bridge reconnect or AMT relay\n * handshake is seconds -- so anything still unsettled here is broken, not slow.\n */\nexport const JOIN_DEFER_WARN_MS = 30_000\n\nexport class SessionManager {\n readonly pool: TransportPool\n readonly sessions = new Map<string, TrackSession>()\n callbacks: SessionManagerCallbacks | null = null\n #takeoverDeadlineMs: number | undefined = undefined\n #joinActive = true\n #joinDeferWarnTimer: ReturnType<typeof setTimeout> | null = null\n\n /**\n * Takeover deadline (BLO-9413/D9): the time a session may sit in `joining`\n * without a RAP before returning to `unicast` and firing onTakeoverTimeout.\n * MoQ is never suppressed during `joining`, so expiry abandons the takeover\n * without a playback glitch. The host derives it from the catalog (RAP\n * cadence from groupDurationTicks/timescale plus the signaled FEC delivery\n * window or reorder tolerance) and sets it before addTrack(). There is no\n * default: an unset value fails addTrack() closed, and reading the getter\n * before assignment throws the same way, so no host ever holds a\n * `number | undefined` it could coalesce back into a default.\n * `hasTakeoverDeadline` answers whether one was admitted. A declared 0\n * disables the deadline. The value is an integer number of milliseconds:\n * setTimeout truncates fractions, so a host that divides ticks by a\n * timescale must round before assigning.\n *\n * Assignment validates, so the diagnostic lands at the host's own call site.\n * That is what bounds the blast radius: `joining` is entered from\n * joinStarted(), from the transportFailed() failover branch -- itself\n * reachable from a setTimeout via hysteresisExpired(), where no caller could\n * catch a throw -- and from the activateMulticast()/activateAmt()/pool-failure\n * loops, where a throw on one session would abandon its siblings mid-loop\n * (the split-brain those loops exist to prevent). Refusing a bad value at\n * assignment means no host mutation can make an already-admitted session\n * throw out of a transport edge.\n */\n get takeoverDeadlineMs(): number {\n return this.#requireTakeoverDeadlineMs()\n }\n\n set takeoverDeadlineMs(value: number) {\n if (!Number.isInteger(value) || value < 0 || value > MAX_TAKEOVER_DEADLINE_MS) {\n throw new Error(\n `[SessionManager] takeoverDeadlineMs must be an integer from 0 to ${MAX_TAKEOVER_DEADLINE_MS} (0 disables it, larger values overflow setTimeout); got ${String(value)}`,\n )\n }\n this.#takeoverDeadlineMs = value\n }\n\n /** True once the host has admitted a deadline; a declared 0 counts. */\n get hasTakeoverDeadline(): boolean {\n return this.#takeoverDeadlineMs !== undefined\n }\n\n /**\n * Whether the transport the joining tracks are waiting on is actually\n * subscribed. The takeover deadline is a media-arrival budget and media\n * cannot arrive before the join exists, so while this is false, entering\n * `joining` arms no deadline; flipping it true starts the FULL deadline for\n * every track already parked there.\n *\n * Default true, which is the arm-on-`joining` behaviour every host had\n * before: a host that only calls joinStarted() after its own join resolves\n * (moqtail-private's player, post-BLO-33339) is already at the right edge and\n * needs no gate. A host that arms joinStarted() up front \u2014 to have FEC\n * decoders ready before the first datagram \u2014 sets this false before addTrack()\n * and true when its subscribe settles. Charging a slow subscribe (bridge\n * reconnect, AMT relay handshake) against the media budget expired the\n * deadline before the subscription existed and abandoned the join on the\n * happy path. BLO-34289.\n *\n * The deadline is a media budget (group cadence plus FEC delivery window) and\n * budgets nothing for reaching the stream, so ideally it starts at\n * MulticastTransport.onDataPlaneLive, the join's first accepted packet. Over\n * AMT that is measurably later than a settled subscribe: the subscribe\n * settles once the gateway has sent its Membership Update, and the relay's\n * upstream join plus a path RTT (0.4-0.8 s measured to the prod relay) still\n * precede the first packet. But RFC 7450 gives a gateway no signal that the\n * relay has traffic for its (S,G), so a join whose source never sends never\n * reaches that edge. Starting the deadline there is therefore sound only when\n * the provider bounds the join phase itself, with a typed subscription error\n * for a join that delivers nothing (MulticastTransport forwards it to\n * onFailback). A host whose provider makes no such guarantee flips this true\n * when the subscribe settles and accepts that join latency is charged to the\n * media budget; holding it until the first packet would park a silent join\n * in `joining` forever.\n *\n * The flag tracks the host's primary join, not per-track transport state. A\n * track that fails over to the next transport in its own chain while this is\n * false (transportFailed's failover branch also enters `joining`) has that\n * deadline gated on the primary join activating, not on its own transport.\n * Under the documented usage -- false once at setup, true once the subscribe\n * settles -- the two coincide.\n *\n * The gate is on the ENTRY into `joining`, and false cannot take back a\n * deadline already running: the `true -> false` edge arms the warn below and\n * clears nothing. So set it false BEFORE the `joining` entry it is meant to\n * gate. A host that instead flipped it false after a `transportFailed()`\n * failover had already entered `joining` would leave the full deadline\n * counting against a subscribe that had not begun -- BLO-34289 again, on the\n * failover edge. Clearing running deadlines on that edge is deliberately NOT\n * done: this flag is manager-wide, so one transport's brief false would\n * cancel an unrelated track's live deadline and then re-arm it at FULL length\n * on the next activation (armDeferredJoinDeadline sees `joining` + a null\n * timer and cannot tell a cleared deadline from a deferred one). Every\n * unrelated transport blip would restart the whole budget, so under repeated\n * failover a track that should have timed out is extended indefinitely and\n * never advances. Only a false that never returns true loses the timer\n * outright, and JOIN_DEFER_WARN_MS already covers that.\n *\n * Leaving this false is not free: see JOIN_DEFER_WARN_MS.\n */\n get joinActive(): boolean {\n return this.#joinActive\n }\n\n set joinActive(active: boolean) {\n const wasActive = this.#joinActive\n this.#joinActive = active\n if (!active) {\n // Edge-triggered: a host re-declaring false has not restarted its\n // subscribe, and total deferral is what the warning is about.\n if (wasActive) this.#armJoinDeferWarn()\n return\n }\n this.#clearJoinDeferWarn()\n if (wasActive) return\n for (const [, session] of this.sessions) session.armDeferredJoinDeadline()\n }\n\n #armJoinDeferWarn(): void {\n this.#joinDeferWarnTimer = setTimeout(() => {\n this.#joinDeferWarnTimer = null\n const deferred = [...this.sessions.values()].filter(s => s.state === 'joining').length\n console.warn(\n `[SessionManager] joinActive has been false for ${JOIN_DEFER_WARN_MS} ms and ${deferred} track(s) sit in 'joining' with no takeover deadline. Set joinActive = true when the subscribe settles, on every exit path including failures, or these tracks never time out, never release their FEC decoders, and never advance to the next transport.`,\n )\n }, JOIN_DEFER_WARN_MS)\n }\n\n #clearJoinDeferWarn(): void {\n if (this.#joinDeferWarnTimer === null) return\n clearTimeout(this.#joinDeferWarnTimer)\n this.#joinDeferWarnTimer = null\n }\n\n /** Narrow to MMTP callbacks (onTrackEvent). Safe \u2014 optional chaining handles PIM-only consumers. */\n get mmtp(): MmtpSessionCallbacks | null {\n return this.callbacks as MmtpSessionCallbacks | null\n }\n\n constructor(poolOptions?: TransportPoolOptions) {\n this.pool = new TransportPool(poolOptions)\n\n // Wire pool failure \u2192 per-track failover\n this.pool.onSubscriptionFailed(sub => {\n for (const trackName of sub.consumers) {\n const session = this.sessions.get(trackName)\n if (session) session.transportFailed()\n }\n })\n }\n\n // -----------------------------------------------------------------------\n // Track management\n // -----------------------------------------------------------------------\n\n /**\n * The admitted takeover deadline. Throws only when the host never set one,\n * every other invalid value being refused by the setter, and returns a\n * declared 0 as 0. The getter and addTrack() both route through it, so a\n * session cannot exist without an admitted deadline, which is what makes\n * the #transition() read unable to throw on an already-admitted session.\n */\n #requireTakeoverDeadlineMs(): number {\n const value = this.#takeoverDeadlineMs\n if (value === undefined) {\n throw new Error(\n '[SessionManager] takeoverDeadlineMs is unset; set a catalog-derived takeover deadline (0 disables it) before adding tracks',\n )\n }\n return value\n }\n\n /** Add a track session from catalog config. */\n addTrack(config: TrackSessionConfig): TrackSession {\n if (this.sessions.has(config.trackName)) {\n throw new Error(`[SessionManager] Track \"${config.trackName}\" already exists`)\n }\n this.#requireTakeoverDeadlineMs()\n const session = new TrackSession(config, this)\n this.sessions.set(config.trackName, session)\n return session\n }\n\n /** Remove a track session. Releases subscription with grace period. */\n removeTrack(trackName: string): void {\n const session = this.sessions.get(trackName)\n if (!session) return\n session.unsubscribe()\n\n // Release pool subscription\n const subKey = session.config.multicastKey ?? session.config.moqTrackName\n if (subKey) this.pool.releaseSubscription(subKey, trackName)\n\n session.dispose()\n this.sessions.delete(trackName)\n }\n\n // -----------------------------------------------------------------------\n // Atomic multi-track operations\n // -----------------------------------------------------------------------\n\n /**\n * All tracks with multicast RAP \u2192 multicast state (atomic).\n * Called when MediaRouter fires onSourceSwitch('multicast').\n * Prevents split-brain between video and audio.\n *\n * A takeover deadline can return a track from `joining` to `unicast` while\n * the multicast socket keeps delivering packets and the track keeps its FEC\n * decoder armed. If a valid RAP arrives later, that late-RAP edge suppresses\n * unicast and promotes multicast without a second fec-setup.\n */\n activateMulticast(): void {\n for (const [, session] of this.sessions) {\n if (session.state === 'unicast') {\n session.joinStarted()\n }\n if (session.state === 'joining') {\n session.rapReceived('multicast')\n }\n }\n }\n\n /**\n * All tracks with AMT RAP \u2192 amt state (atomic).\n */\n activateAmt(): void {\n for (const [, session] of this.sessions) {\n if (session.state === 'unicast') {\n session.joinStarted()\n }\n if (session.state === 'joining') {\n session.rapReceived('amt')\n }\n }\n }\n\n /**\n * All tracks \u2192 unicast (failback, atomic).\n * Called when MulticastManager fires onFailback().\n * unicast-fallback sessions are skipped \u2014 the host owns those subscriptions\n * and they must drain uninterrupted across transport-state events.\n *\n * Coherence invariant (BLO-9413): after this returns, every non-fallback\n * session is `unicast` (or `idle`), unsuppressed, and without a FEC decoder.\n * unsubscribe() unwinds any outstanding suppression regardless of the state\n * it tears down from (`failing` included; see TrackSession.#suppressFired),\n * and a session that a takeover timeout parked in `unicast` with its decoder\n * still armed is cycled too, because this transport edge is what releases\n * that decoder (unsubscribe fires fec-teardown).\n */\n failbackAll(): void {\n for (const [, session] of this.sessions) {\n const parkedWithFec = session.state === 'unicast' && session.isFecArmed\n if (\n parkedWithFec ||\n (session.state !== 'idle' &&\n session.state !== 'unicast' &&\n session.state !== 'unicast-fallback')\n ) {\n session.unsubscribe()\n session.subscribe() // re-enter unicast\n }\n }\n }\n\n /**\n * All tracks \u2192 unicast-fallback (atomic).\n * Called by the host when the multicast gateway is absent and\n * the player will subscribe to source tracks over MoQ unicast and feed\n * packets via MulticastManager.feedUnicastSource() (or manager.router\n * directly). Sessions in this state stay terminal until the host calls\n * unsubscribe() / disposes the SessionManager.\n */\n enterUnicastFallback(): void {\n for (const [, session] of this.sessions) {\n if (session.state === 'idle') continue\n session.enterUnicastFallback()\n }\n }\n\n /**\n * True if any session is currently in unicast-fallback. Hosts use this to\n * gate `multicastTrack.close()` on failback events \u2014 closing the shared\n * broadcast would kill the host's fallback drainers.\n */\n hasActiveFallback(): boolean {\n for (const [, session] of this.sessions) {\n if (session.isUnicastFallback) return true\n }\n return false\n }\n\n /**\n * All fallback tracks \u2192 unicast (atomic).\n * Called when the SSM bridge appears mid-session and the host has aborted\n * the fallback drainers. The session re-enters the regular preference chain\n * via joinStarted() (issued by the host immediately after).\n */\n exitUnicastFallback(): void {\n for (const [, session] of this.sessions) {\n if (session.isUnicastFallback) session.exitUnicastFallback()\n }\n }\n\n // -----------------------------------------------------------------------\n // Cleanup lifecycle\n // -----------------------------------------------------------------------\n\n /** Tab closed \u2014 release all subscriptions for this tab (grace period). */\n tabClosed(tabId: string): void {\n // All tracks owned by this tab \u2192 idle\n for (const [trackName, session] of this.sessions) {\n session.unsubscribe()\n this.pool.releaseAllForConsumer(trackName)\n }\n }\n\n /** Bridge WebTransport connection dropped \u2014 same as tab close. */\n bridgeDisconnected(): void {\n this.tabClosed('bridge')\n }\n\n /**\n * Track selection changed (user or ABR).\n * Old track released (grace period), new track subscribed.\n */\n trackSelectionChanged(oldTrackName: string, newConfig: TrackSessionConfig): void {\n this.removeTrack(oldTrackName)\n const session = this.addTrack(newConfig)\n session.subscribe()\n }\n\n // -----------------------------------------------------------------------\n // Query\n // -----------------------------------------------------------------------\n\n /** Get a track session by name. */\n getTrack(trackName: string): TrackSession | undefined {\n return this.sessions.get(trackName)\n }\n\n // -----------------------------------------------------------------------\n // Cleanup\n // -----------------------------------------------------------------------\n\n dispose(): void {\n this.#clearJoinDeferWarn()\n for (const [, session] of this.sessions) {\n session.dispose()\n }\n this.sessions.clear()\n this.pool.dispose()\n this.callbacks = null\n }\n}\n", "/**\n * MediaRouter \u2014 source arbitration for MoQ \u2194 multicast switching.\n *\n * Both moq-lib and moqtail wire their MoQ and multicast frame outputs\n * through this router. The router decides which source reaches the decoder:\n *\n * - MoQ frames pass through while multicast is inactive.\n * - Multicast video frames are suppressed until the first keyframe.\n * - Once multicast has both a video keyframe and audio, takeover is requested.\n * - MoQ is suppressed only after the source-switch callback completes.\n * - failbackToMoq() reverses the switch immediately.\n *\n * Per-track setTrackSource() allows individual tracks to be wired to a\n * specific transport source (video=multicast, audio=moq, overlay=amt) without\n * changing the binary MoQ\u2194multicast switching for other tracks.\n */\n\nimport { computeFecDeliveryWindowMs } from './track-session.js'\n\nexport type FrameMeta = {\n timestamp: number\n keyframe: boolean\n packetId?: number\n packetSequenceNumber?: number\n source?: 'moq' | 'multicast' | 'repair'\n recovered?: boolean\n sourceDiscontinuity?: boolean\n /**\n * Raw MMTP header timestamp (RFC 5905 short NTP, 32 bits): the publisher's\n * send-wall clock for the packet that carried this frame. On timed media it\n * is distinct from `timestamp`, the authored media presentation time. On a\n * non-timed MFU there is no authored presentation time, so `MmtpRouter`\n * derives `timestamp` from this same clock and the two carry no latency\n * between them. Populated from `RoutedFrameMeta.mmtpTimestamp` and\n * preserved by every re-emit on this router (multicast, MoQ, and repair) so\n * players can measure network ingress on the multicast legs; absent when\n * the packet header could not be read.\n */\n mmtpTimestamp?: bigint\n}\nexport type FrameKind = 'video' | 'audio'\nexport type SourceKind = 'moq' | 'multicast'\n\ntype FrameCb = (data: Uint8Array, meta: FrameMeta) => void\ntype SwitchCb = (source: SourceKind) => void | Promise<void>\n\nexport interface MediaRouterOptions {\n /** Set false for video-only streams. Default: true. */\n requireAudio?: boolean\n /**\n * Audio fallback timeout (ms): force multicast activation if video keyframe\n * arrived but audio hasn't within this window. Catalog-derived via\n * computeFecDeliveryWindowMs (the single FEC recovery-window source of truth).\n * Omit to disable the independent audio-fallback timer (no magic default).\n */\n audioFallbackMs?: number\n /**\n * Number of binary-path frames to allow through during a crossfade transition.\n * Derive from FEC block size (e.g., ceil(K / groupsPerBlock)). Default: 10.\n */\n crossfadeFrames?: number\n /**\n * Max pre-boundary overlay frames to buffer before dropping.\n * Derive from FEC config (e.g., ceil(K * 1.5)). Default: 128.\n */\n maxOverlayBuffer?: number\n /**\n * Max concurrent in-flight Phase-13 keyframe-group repairs (relay SUBSCRIBE\n * flood mitigation, T-13-01). Required once repair is wired: registering\n * `onRepairRequest` without it throws \u2014 there is no default. A number is\n * validated at construction. A bound derived from live measurements that\n * arrive after construction (e.g. ceil(reliableRtt / keyframeCadence)) is\n * passed as a reader, evaluated at each repair decision: a throw or a value\n * that is not a positive safe integer skips that group's repair (fail closed\n * per decision, not per session) and the next decision reads it again.\n * Unused for FEC tracks, which never arm the keyframe-lost path; their QUIC\n * repair is the D-28 scheduler (`wireRepairFetch`), prioritised by block\n * class.\n */\n maxRepairInFlight?: number | (() => number)\n /**\n * Allow multicast takeover from a delta frame after the player has confirmed\n * that MoQ already seeded the same decoder timeline with a video keyframe.\n * Default false: decoder-reset paths still need multicast to provide a RAP.\n */\n allowDeltaMulticastTakeoverAfterMoqKeyframe?: boolean\n}\n\n/**\n * A per-track source that emits frames independently of the binary MoQ/multicast path.\n * Used by setTrackSource() to wire specific tracks to specific transport sources.\n */\nexport interface TrackSource {\n name: string\n /**\n * Register a frame callback. Returns an unsubscribe function.\n * MediaRouter calls unsubscribe() when clearTrackSource() is called.\n */\n onFrame(cb: (data: Uint8Array, meta: FrameMeta) => void): () => void\n /**\n * Optional error callback for when the source fails.\n * MediaRouter uses this to trigger failback to binary switching for this track.\n */\n onError?(cb: () => void): () => void\n /** Optional dispose hook called when clearTrackSource() is called. */\n dispose?(): void\n}\n\n/**\n * Thrown when setTrackSource() is called for a track that already has a source\n * without an intervening clearTrackSource(). Prevents silent concurrent-assignment\n * races (T-02-16 mitigation).\n */\nexport class TrackSourceConflictError extends Error {\n constructor(public readonly trackName: string) {\n super(`Track source already assigned for '${trackName}'; call clearTrackSource first`)\n this.name = 'TrackSourceConflictError'\n }\n}\n\n/**\n * Migration policy for setTrackSource() transport switching.\n *\n * - 'crossfade': Overlap old (binary) and new source briefly (CROSSFADE_WINDOW_FRAMES),\n * then suppress the binary path. Default for audio.\n * - 'drop': Cut to new source immediately. Binary path suppressed at once.\n * Suitable for latency-critical tracks.\n * - 'wait': Defer migration until first FEC block boundary (same as overlay=true).\n * Default for video \u2014 avoids decoder reset mid-block.\n */\nexport type MigrationPolicy = 'crossfade' | 'drop' | 'wait'\n\n/** Default crossfade window \u2014 override via MediaRouterOptions.crossfadeFrames. */\nconst DEFAULT_CROSSFADE_FRAMES = 10\n\n/** Options passed to setTrackSource(). */\nexport interface SetTrackSourceOptions {\n /**\n * If true, this source is an overlay track that must wait for the first FEC\n * block boundary signal (via notifyFecBlockBoundary()) before emitting frames.\n * Frames arriving before the first boundary are buffered up to MAX_OVERLAY_BUFFER.\n *\n * Backwards compat: equivalent to policy: 'wait'.\n */\n overlay?: boolean\n /**\n * Migration policy for the transport switch. Overrides `overlay` when provided.\n * See MigrationPolicy for semantics.\n */\n policy?: MigrationPolicy\n}\n\n/** Default overlay buffer cap \u2014 override via MediaRouterOptions.maxOverlayBuffer. */\nconst DEFAULT_MAX_OVERLAY_BUFFER = 128\n\n/** Internal per-track source record. */\ninterface TrackSourceRecord {\n source: TrackSource\n unsubscribeFrame: () => void\n unsubscribeError?: () => void\n emitCount: number\n}\n\n/** Catalog-derived inputs to {@link MediaRouter.configureDelta}. */\nexport interface DeltaFallbackConfig {\n framerate: number\n sourceSymbols: number\n repairSymbols: number\n deltaFailWindowBlocks?: number\n deltaProbeIntervalMs?: number\n interleaveDepthMs?: number\n}\n\n/**\n * The one admission check for {@link DeltaFallbackConfig}, shared by\n * {@link MediaRouter.configureDelta} and `resolveDeltaFallback` so a config the\n * resolver accepts is exactly one configureDelta accepts. Every present number\n * must be finite and positive (`typeof NaN === 'number'`, and `NaN <= 0` is\n * false, so a bare comparison admits it); the fail window is a block count and\n * must be an integer; and a probe interval must be derivable.\n */\nexport function assertDeltaFallbackConfig(cfg: DeltaFallbackConfig, prefix: string): void {\n if (!isPositiveFinite(cfg.framerate)) throw new Error(`${prefix}: framerate required and must be > 0`)\n if (!isPositiveFinite(cfg.sourceSymbols))\n throw new Error(`${prefix}: sourceSymbols (K_delta) required and must be > 0`)\n if (!isPositiveFinite(cfg.repairSymbols))\n throw new Error(`${prefix}: repairSymbols (P_delta) required and must be > 0`)\n if (\n cfg.deltaFailWindowBlocks !== undefined &&\n !(Number.isInteger(cfg.deltaFailWindowBlocks) && cfg.deltaFailWindowBlocks > 0)\n ) {\n throw new Error(`${prefix}: deltaFailWindowBlocks must be a positive integer when present`)\n }\n if (cfg.deltaProbeIntervalMs !== undefined && !isPositiveFinite(cfg.deltaProbeIntervalMs))\n throw new Error(`${prefix}: deltaProbeIntervalMs must be > 0 when present`)\n if (cfg.interleaveDepthMs !== undefined && !isPositiveFinite(cfg.interleaveDepthMs))\n throw new Error(`${prefix}: interleaveDepthMs must be > 0 when present`)\n if (cfg.deltaProbeIntervalMs === undefined && cfg.interleaveDepthMs === undefined) {\n throw new Error(\n `${prefix}: either deltaProbeIntervalMs or interleaveDepthMs required for probe interval derivation`\n )\n }\n}\n\nfunction isPositiveFinite(value: unknown): value is number {\n return typeof value === 'number' && Number.isFinite(value) && value > 0\n}\n\nexport class MediaRouter {\n #active: SourceKind = 'moq'\n #multicastVideoKeyed = false\n #multicastAudioReceived = false\n #moqVideoKeyframeEmitted = false\n #requireAudio: boolean\n #suppressed = false // true after multicast confirmed \u2014 suppresses MoQ\n #audioFallbackTimer: ReturnType<typeof setTimeout> | null = null\n\n // Configurable constants (from catalog/FEC config, not magic numbers)\n readonly #audioFallbackMs: number\n readonly #crossfadeFrames: number\n readonly #maxOverlayBuffer: number\n readonly #allowDeltaMulticastTakeoverAfterMoqKeyframe: boolean\n\n // \u2500\u2500 Keyframe repair state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n /** Groups currently being repaired \u2014 idempotent guard per Pitfall 2 (T-13-01 mitigation) */\n #repairInFlight = new Set<number>()\n /** Max concurrent repairs \u2014 prevents relay SUBSCRIBE flood (T-13-01 mitigation). A reader is evaluated per decision. */\n readonly #maxRepairInFlight: number | (() => number) | null\n /** Callback to request MoQ subscribe for a specific group (wired by player) */\n #onRepairRequest: ((expectedGroupId: number, trackName: string) => void) | null = null\n\n #onVideoFrame: FrameCb | null = null\n #onAudioFrame: FrameCb | null = null\n #onOverlayFrame: FrameCb | null = null\n #onSourceSwitch: SwitchCb | null = null\n #sourceSwitchReady: Promise<void> | null = null\n #sourceSwitchToken = 0\n #pendingMulticastToken: number | null = null\n #pendingMulticastVideo: { data: Uint8Array; meta: FrameMeta } | null = null\n #pendingMulticastAudio: { data: Uint8Array; meta: FrameMeta } | null = null\n\n // Per-track source state\n #trackSources = new Map<string, TrackSourceRecord>()\n\n // Track type registry: trackName \u2192 { type, source }.\n // Init data / inline signaling is authoritative until catalog updates.\n // Priority: 'init' > 'catalog' > 'name' (backward compat fallback).\n #trackTypes = new Map<string, { type: FrameKind; source: 'catalog' | 'init' }>()\n\n // Crossfade state: maps trackName \u2192 remaining binary-path frames allowed during window\n #crossfadeCounters = new Map<string, number>()\n\n // Overlay alignment state\n #overlayBoundaryReached = false\n #overlayBuffer: Array<{ data: Uint8Array; meta: FrameMeta }> = []\n\n // \u2500\u2500 Phase 14: deltaActive state machine (D-11..D-14) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // No timers \u2014 state transitions driven entirely by notifyDeltaBlock() callbacks.\n // All thresholds derived from catalog fields or throw at configureDelta() time.\n #deltaConfigured = false\n #deltaActive: boolean = true\n #deltaWindow: boolean[] = [] // ring buffer: true = block failed\n #deltaWindowSize: number = 0\n #deltaFailThresholdRatio: number = 0 // P/K\n #deltaRecoverThresholdRatio: number = 0 // (P/K)/2 \u2014 hysteresis (D-13)\n #deltaProbeIntervalMs: number = 0\n #lastProbeAt: number = 0\n #onDeltaFallbackCb: (() => void) | null = null\n #onDeltaRecoverCb: (() => void) | null = null\n\n constructor(options?: MediaRouterOptions) {\n this.#requireAudio = options?.requireAudio ?? true\n // Catalog-derived \u2014 the caller passes an audioFallbackMs computed from the\n // FEC recovery window (computeFecDeliveryWindowMs). No magic default: when\n // absent, the independent audio-fallback timer is disabled (0) and\n // activation waits for real audio arrival.\n this.#audioFallbackMs = options?.audioFallbackMs ?? 0\n this.#crossfadeFrames = options?.crossfadeFrames ?? DEFAULT_CROSSFADE_FRAMES\n this.#maxOverlayBuffer = options?.maxOverlayBuffer ?? DEFAULT_MAX_OVERLAY_BUFFER\n const maxRepairInFlight = options?.maxRepairInFlight\n if (\n maxRepairInFlight !== undefined &&\n typeof maxRepairInFlight !== 'function' &&\n (!Number.isSafeInteger(maxRepairInFlight) || maxRepairInFlight < 1)\n ) {\n throw new Error(\n `[MediaRouter] maxRepairInFlight=${maxRepairInFlight} must be a positive integer`,\n )\n }\n this.#maxRepairInFlight = maxRepairInFlight ?? null\n this.#allowDeltaMulticastTakeoverAfterMoqKeyframe =\n options?.allowDeltaMulticastTakeoverAfterMoqKeyframe ?? false\n }\n\n onVideoFrame(cb: FrameCb) { this.#onVideoFrame = cb }\n onAudioFrame(cb: FrameCb) { this.#onAudioFrame = cb }\n onOverlayFrame(cb: FrameCb) { this.#onOverlayFrame = cb }\n onSourceSwitch(cb: SwitchCb) { this.#onSourceSwitch = cb }\n\n get activeSource(): SourceKind { return this.#active }\n\n // \u2500\u2500 Keyframe repair API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n /** Register callback for keyframe repair requests. Player wires this to issue MoQ SUBSCRIBE. */\n onRepairRequest(cb: (expectedGroupId: number, trackName: string) => void): void {\n if (this.#maxRepairInFlight === null) {\n throw new Error(\n '[MediaRouter] onRepairRequest requires an explicit maxRepairInFlight option',\n )\n }\n this.#onRepairRequest = cb\n }\n\n /**\n * Request repair for a lost keyframe group. Only fires when activeSource is 'multicast'.\n * Idempotent per groupId \u2014 duplicate calls for the same group are suppressed.\n * A reader `maxRepairInFlight` is evaluated here; without a valid value the\n * group is skipped and a later call reads it again.\n * @param expectedGroupId - The group ID expected but not received\n * @param trackName - Track name for the SUBSCRIBE (e.g. 'video'). Default: 'video'.\n */\n repairLostGroup(expectedGroupId: number, trackName = 'video'): void {\n if (this.#active !== 'multicast') return // only repair on multicast path\n if (this.#maxRepairInFlight === null) return // repair is not wired\n if (this.#repairInFlight.has(expectedGroupId)) return // idempotent\n let maxRepairInFlight: number\n try {\n maxRepairInFlight =\n typeof this.#maxRepairInFlight === 'function'\n ? this.#maxRepairInFlight()\n : this.#maxRepairInFlight\n } catch (error) {\n console.warn(\n `[MediaRouter] maxRepairInFlight unmeasured, skipping repair for group ${expectedGroupId}:`,\n error,\n )\n return\n }\n if (!Number.isSafeInteger(maxRepairInFlight) || maxRepairInFlight < 1) {\n console.warn(\n `[MediaRouter] maxRepairInFlight=${maxRepairInFlight} is not a positive integer, skipping repair for group ${expectedGroupId}`,\n )\n return\n }\n if (this.#repairInFlight.size >= maxRepairInFlight) {\n console.warn(\n `[MediaRouter] max repair in flight (${maxRepairInFlight}), dropping repair for group ${expectedGroupId}`,\n )\n return\n }\n this.#repairInFlight.add(expectedGroupId)\n this.#onRepairRequest?.(expectedGroupId, trackName)\n }\n\n /**\n * Feed a recovered keyframe frame from the repair path.\n * Bypasses source arbitration entirely \u2014 goes directly to decoder callbacks.\n * Does NOT change activeSource. Does NOT trigger failbackToMoq.\n */\n feedRepair(data: Uint8Array, meta: FrameMeta, kind: FrameKind): void {\n // Repair only makes sense when multicast is active.\n // Silently drop if MoQ became active (race with failbackToMoq) to avoid\n // injecting stale-timestamp frames into the MoQ decoder path (WR-02).\n if (this.#active !== 'multicast') return\n const routedMeta = { ...meta, source: 'repair' as const, recovered: true }\n if (kind === 'video') this.#onVideoFrame?.(data, routedMeta)\n else this.#onAudioFrame?.(data, routedMeta)\n }\n\n /**\n * Clear repair-in-flight state for a group (called after repair completes or times out).\n */\n clearRepairInFlight(groupId: number): void {\n this.#repairInFlight.delete(groupId)\n }\n\n // \u2500\u2500 Phase 14: deltaActive state machine API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n /**\n * Configure the delta-track fallback state machine from catalog parameters.\n *\n * D-11: Thresholds derive from fec.sourceSymbols, fec.repairSymbols, framerate.\n * D-13: Probe interval derives from deltaProbeIntervalMs or interleaveDepthMs*2.\n * CLAUDE.md: error on bad init \u2014 throws if required inputs are absent.\n *\n * Calling again resets deltaActive=true and clears the window buffer (stream restart).\n * Pass null on teardown or when a replacement catalog cannot configure this policy.\n */\n configureDelta(cfg: DeltaFallbackConfig | null): void {\n if (cfg === null) {\n this.#deltaConfigured = false\n this.#deltaActive = true\n this.#deltaWindow = []\n this.#deltaWindowSize = 0\n this.#deltaFailThresholdRatio = 0\n this.#deltaRecoverThresholdRatio = 0\n this.#deltaProbeIntervalMs = 0\n this.#lastProbeAt = 0\n return\n }\n assertDeltaFallbackConfig(cfg, '[MediaRouter] configureDelta')\n\n // Derivations \u2014 no magic floors, no hardcoded defaults (CLAUDE.md).\n this.#deltaWindowSize = cfg.deltaFailWindowBlocks ?? Math.ceil(cfg.framerate)\n this.#deltaFailThresholdRatio = cfg.repairSymbols / cfg.sourceSymbols\n this.#deltaRecoverThresholdRatio = this.#deltaFailThresholdRatio / 2\n // Without an explicit probe interval, use one complete signaled block span.\n this.#deltaProbeIntervalMs = cfg.deltaProbeIntervalMs ?? computeFecDeliveryWindowMs({\n interleaveDepthMs: cfg.interleaveDepthMs!,\n sourceSymbols: cfg.sourceSymbols,\n })\n\n // Reset state \u2014 useful for stream restart.\n this.#deltaActive = true\n this.#deltaWindow = []\n this.#lastProbeAt = 0\n this.#deltaConfigured = true\n }\n\n /**\n * Notify the state machine of a completed FEC block for the delta track.\n *\n * @param failed - true if the block was unrecoverable (FEC failure)\n * @param nowMs - caller-injected monotonic ms (no internal timer \u2014 D-13, CLAUDE.md)\n *\n * If configureDelta() has not been called, this is a silent no-op (pre-catalog state).\n */\n notifyDeltaBlock(failed: boolean, nowMs: number): void {\n if (!this.#deltaConfigured) return\n\n // Maintain sliding window \u2014 ring buffer semantics.\n this.#deltaWindow.push(failed)\n if (this.#deltaWindow.length > this.#deltaWindowSize) {\n this.#deltaWindow.shift()\n }\n\n // Window not yet full \u2014 insufficient data for decision.\n if (this.#deltaWindow.length < this.#deltaWindowSize) return\n\n const failedCount = this.#deltaWindow.filter(x => x).length\n const ratio = failedCount / this.#deltaWindowSize\n\n if (this.#deltaActive) {\n // Fallback transition: active \u2192 inactive (T-14-03: fires exactly once)\n if (ratio > this.#deltaFailThresholdRatio) {\n this.#deltaActive = false\n this.#lastProbeAt = nowMs\n this.#onDeltaFallbackCb?.()\n }\n } else {\n // Recovery transition: inactive \u2192 active (after probe interval + low ratio)\n if (\n (nowMs - this.#lastProbeAt) >= this.#deltaProbeIntervalMs &&\n ratio < this.#deltaRecoverThresholdRatio\n ) {\n this.#deltaActive = true\n this.#lastProbeAt = nowMs\n this.#onDeltaRecoverCb?.()\n }\n }\n }\n\n /** Register callback fired when delta track degrades past threshold (D-12). */\n onDeltaFallback(cb: () => void): void {\n this.#onDeltaFallbackCb = cb\n }\n\n /** Register callback fired when delta track recovers below recovery threshold (D-13). */\n onDeltaRecover(cb: () => void): void {\n this.#onDeltaRecoverCb = cb\n }\n\n /** Whether the delta track is currently considered healthy (true = active). */\n get deltaActive(): boolean {\n return this.#deltaActive\n }\n\n // \u2500\u2500 Test-visible accessors for threshold verification \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // These expose internal derived values for test assertions (Tests 3,4,5,6,7).\n // Prefixed with 'delta' to namespace clearly within the public API surface.\n\n /** Derived window size \u2014 exposed for test assertions (Test 3, 4). */\n get deltaWindowSize(): number { return this.#deltaWindowSize }\n /** Derived fail threshold ratio (P/K) \u2014 exposed for test assertions (Test 6). */\n get deltaFailThresholdRatio(): number { return this.#deltaFailThresholdRatio }\n /** Derived recovery threshold ratio ((P/K)/2) \u2014 exposed for test assertions (Test 7). */\n get deltaRecoverThresholdRatio(): number { return this.#deltaRecoverThresholdRatio }\n /** Derived probe interval ms \u2014 exposed for test assertions (Test 5a). */\n get deltaProbeIntervalMs(): number { return this.#deltaProbeIntervalMs }\n\n /**\n * Register a track name \u2192 type mapping from catalog.\n * Catalog is the default authority. Init data / inline signaling\n * takes precedence until the next catalog update.\n */\n registerTrackType(trackName: string, type: FrameKind): void {\n // Catalog overwrites any previous registration (including init).\n this.#trackTypes.set(trackName, { type, source: 'catalog' })\n }\n\n /**\n * Register a track name \u2192 type from init data / inline signaling (MMTP FT=0).\n * Authoritative until the next catalog update per \u00A75.3.\n * Does NOT overwrite an existing catalog registration.\n */\n registerTrackTypeFromInit(trackName: string, type: FrameKind): void {\n const existing = this.#trackTypes.get(trackName)\n if (existing?.source === 'catalog') return // catalog already set \u2014 don't downgrade\n this.#trackTypes.set(trackName, { type, source: 'init' })\n }\n\n /**\n * Assign a per-track source. Frames from this source bypass the binary\n * MoQ\u2194multicast switching for this track only.\n *\n * Throws TrackSourceConflictError if a source is already assigned for this\n * track without a clearTrackSource() in between.\n */\n setTrackSource(trackName: string, source: TrackSource, opts?: SetTrackSourceOptions): void {\n if (this.#trackSources.has(trackName)) {\n throw new TrackSourceConflictError(trackName)\n }\n\n // Resolve effective policy. opts.policy takes precedence; overlay=true is legacy 'wait'.\n const effectivePolicy: MigrationPolicy | undefined =\n opts?.policy ?? (opts?.overlay ? 'wait' : undefined)\n\n // For crossfade: open the binary-path window immediately so the first per-track frame\n // triggers the window (counter starts before the frame callback is wired).\n if (effectivePolicy === 'crossfade') {\n this.#crossfadeCounters.set(trackName, this.#crossfadeFrames)\n }\n\n const record: TrackSourceRecord = {\n source,\n unsubscribeFrame: () => {},\n emitCount: 0,\n }\n\n // Wire the frame callback\n record.unsubscribeFrame = source.onFrame((data, meta) => {\n record.emitCount++\n if (effectivePolicy === 'wait') {\n this.#handleOverlayFrame(data, meta)\n } else {\n // 'drop', 'crossfade', or undefined: emit immediately\n this.#emitForTrack(trackName, data, meta)\n }\n })\n\n // Wire the optional error callback \u2014 failback to binary switching\n if (source.onError) {\n record.unsubscribeError = source.onError(() => {\n this.clearTrackSource(trackName)\n })\n }\n\n this.#trackSources.set(trackName, record)\n }\n\n /**\n * Remove the per-track source for the given track.\n * Binary switching resumes for this track after this call.\n */\n clearTrackSource(trackName: string): void {\n const record = this.#trackSources.get(trackName)\n if (!record) return\n\n record.unsubscribeFrame()\n record.unsubscribeError?.()\n record.source.dispose?.()\n this.#trackSources.delete(trackName)\n\n // Clean up crossfade window if one was active for this track\n this.#crossfadeCounters.delete(trackName)\n\n // Reset overlay buffer if removing a non-media track (overlay/unknown)\n const reg = this.#trackTypes.get(trackName)\n const isMedia = reg?.type === 'video' || reg?.type === 'audio' ||\n (reg === undefined && (trackName === 'video' || trackName === 'audio'))\n if (!isMedia) {\n this.#overlayBoundaryReached = false\n this.#overlayBuffer = []\n }\n }\n\n /**\n * Signal that a FEC block boundary has been reached on the base track.\n * Overlay tracks waiting for alignment flush their buffered frames now.\n */\n notifyFecBlockBoundary(): void {\n if (this.#overlayBoundaryReached) return\n this.#overlayBoundaryReached = true\n\n // Flush buffered overlay frames\n const buffered = this.#overlayBuffer.splice(0)\n for (const { data, meta } of buffered) {\n this.#onOverlayFrame?.(data, meta)\n }\n }\n\n /**\n * Return the number of frames emitted from the per-track source for a given\n * track. Used in test-mode to verify per-track routing histogram.\n * Returns 0 if no per-track source is active for the track.\n */\n trackSourceEmitCount(trackName: string): number {\n return this.#trackSources.get(trackName)?.emitCount ?? 0\n }\n\n /** Feed a frame from the MoQ path. Suppressed when multicast is active OR a per-track source owns this track. */\n feedMoq(data: Uint8Array, meta: FrameMeta, kind: FrameKind) {\n // Per-track source takes precedence; allow through during crossfade window\n if (this.#trackSources.has(kind)) {\n const remaining = this.#crossfadeCounters.get(kind)\n if (remaining && remaining > 0) {\n // Decrement crossfade counter and allow this frame through\n const next = remaining - 1\n if (next === 0) {\n this.#crossfadeCounters.delete(kind)\n } else {\n this.#crossfadeCounters.set(kind, next)\n }\n // Fall through to emit below (no return)\n } else {\n return // suppressed \u2014 no crossfade window active\n }\n }\n if (this.#suppressed) return\n // Audio suppression is handled by SessionManager onSuppress callback \u2014\n // do NOT independently suppress based on multicastAudioReceived.\n // The state machine decides when to switch; the router just routes.\n const routedMeta = { ...meta, source: 'moq' as const }\n if (kind === 'video') this.#onVideoFrame?.(data, routedMeta)\n else this.#onAudioFrame?.(data, routedMeta)\n }\n\n /** Feed a frame from the multicast path. Video waits for keyframe before activating. */\n feedMulticast(data: Uint8Array, meta: FrameMeta, kind: FrameKind) {\n // Per-track source takes precedence; allow through during crossfade window\n if (this.#trackSources.has(kind)) {\n const remaining = this.#crossfadeCounters.get(kind)\n if (remaining && remaining > 0) {\n const next = remaining - 1\n if (next === 0) {\n this.#crossfadeCounters.delete(kind)\n } else {\n this.#crossfadeCounters.set(kind, next)\n }\n // Fall through to emit below (no return)\n } else {\n return // suppressed\n }\n }\n\n if (kind === 'video') {\n if (!this.#multicastVideoKeyed) {\n const moqSeededDelta =\n !meta.keyframe &&\n this.#allowDeltaMulticastTakeoverAfterMoqKeyframe &&\n this.#moqVideoKeyframeEmitted\n if (!meta.keyframe && !moqSeededDelta) return // drop until first safe decode point\n this.#multicastVideoKeyed = true\n const pendingMeta: FrameMeta = { ...meta, source: 'multicast' }\n if (!moqSeededDelta) pendingMeta.sourceDiscontinuity = true\n this.#pendingMulticastVideo = {\n data: data.slice(),\n meta: pendingMeta,\n }\n // If audio is required but hasn't arrived yet, start a fallback timer.\n // Audio FEC blocks may recover after video \u2014 don't block activation forever.\n // Only arms when a catalog-derived audioFallbackMs was supplied (>0).\n if (this.#requireAudio && this.#audioFallbackMs > 0 && !this.#multicastAudioReceived && !this.#audioFallbackTimer) {\n this.#audioFallbackTimer = setTimeout(() => {\n if (!this.#multicastAudioReceived && this.#active !== 'multicast') {\n this.#multicastAudioReceived = true // force\n // Also release the MoQ-audio-warmup gate so the fallback actually\n // activates \u2014 otherwise #tryActivateMulticast would still bail.\n this.#moqAudioWarmed = true\n this.#tryActivateMulticast()\n }\n }, this.#audioFallbackMs)\n }\n } else if (this.#active !== 'multicast') {\n // Do not feed video into the multicast decoder while the source switch\n // is still pending. Keep the newest keyframe as the post-activation\n // decoder opener and drop deltas until takeover commits.\n if (meta.keyframe) {\n this.#pendingMulticastVideo = {\n data: data.slice(),\n meta: { ...meta, source: 'multicast', sourceDiscontinuity: true },\n }\n }\n this.#tryActivateMulticast()\n return\n }\n if (this.#active === 'multicast') this.#onVideoFrame?.(data, { ...meta, source: 'multicast' })\n this.#tryActivateMulticast()\n } else {\n this.#multicastAudioReceived = true\n if (this.#audioFallbackTimer) {\n clearTimeout(this.#audioFallbackTimer)\n this.#audioFallbackTimer = null\n }\n // BLO-3035 layer 5: in a multicast-first scenario (no MoQ unicast audio\n // source) `notifyMoqAudioEmit()` is never called and the audio-fallback\n // timer's force-set is bypassed (the timer's inner `!multicastAudioReceived`\n // check returns early because audio already arrived). Without this gate\n // `#moqAudioWarmed` stays false forever and `#tryActivateMulticast` never\n // fires `onSourceSwitch('multicast')` \u2014 the player can't know to emit\n // `__MULTICAST_TAKEOVER_TIME`. Treat the arrival of multicast audio as\n // sufficient evidence to release the warmup gate. For the MoQ\u2192multicast\n // handoff case `notifyMoqAudioEmit()` will already have set this flag, so\n // the assignment is idempotent.\n if (!this.#moqAudioWarmed) this.#moqAudioWarmed = true\n // Activate BEFORE delivering the frame \u2014 the source switch callback\n // (e.g. ring buffer reset) must fire before the first multicast audio\n // frame reaches the backend. Otherwise the NTP timestamp hits the\n // MoQ-era ring buffer base \u2192 overflow \u2192 silence gap.\n this.#tryActivateMulticast()\n // Only emit multicast audio AFTER takeover succeeds. Before takeover,\n // MoQ audio is still flowing through feedMoq \u2192 #onAudioFrame; emitting\n // multicast audio too would double-feed the backend (2x audio frames,\n // pitch wobble and distortion). Post-takeover, feedMoq is suppressed\n // (#suppressed=true) so only multicast audio reaches the backend.\n if (this.#active === 'multicast') {\n this.#onAudioFrame?.(data, { ...meta, source: 'multicast' })\n } else if (this.#pendingMulticastToken !== null) {\n this.#pendingMulticastAudio = { data: data.slice(), meta: { ...meta, source: 'multicast' } }\n }\n }\n }\n\n /**\n * MoQ unicast audio warmup gate. When audio is required, multicast takeover\n * waits until the MoQ unicast audio decoder has emitted at least one frame\n * (so downstream samples-before-takeover counters observe nonzero) or until\n * the existing audio-fallback timer forces the flag true.\n */\n #moqAudioWarmed = false\n\n /** Called by the player when the MoQ unicast audio decoder emits first frame. */\n public notifyMoqAudioEmit(): void {\n if (this.#moqAudioWarmed) return\n this.#moqAudioWarmed = true\n this.#tryActivateMulticast()\n }\n\n /**\n * Called by the player after a MoQ video keyframe has reached the active\n * decoder path. Used only by MSE/MMTP continuity switching; decoder-reset\n * paths leave allowDeltaMulticastTakeoverAfterMoqKeyframe disabled.\n */\n public notifyMoqVideoKeyframeEmit(): void {\n this.#moqVideoKeyframeEmitted = true\n this.#tryActivateMulticast()\n }\n\n #tryActivateMulticast() {\n if (this.#active === 'multicast') return\n if (this.#pendingMulticastToken !== null) return\n if (!this.#multicastVideoKeyed) return\n if (this.#requireAudio && !this.#multicastAudioReceived) return\n if (this.#requireAudio && !this.#moqAudioWarmed) return\n const token = ++this.#sourceSwitchToken\n const maybeReady = this.#onSourceSwitch?.('multicast')\n if (!maybeReady || typeof (maybeReady as Promise<void>).then !== 'function') {\n this.#commitMulticastActivation(token)\n return\n }\n const ready = Promise.resolve(maybeReady)\n this.#pendingMulticastToken = token\n this.#sourceSwitchReady = ready\n void ready.then(\n () => this.#commitMulticastActivation(token),\n err => this.#rejectMulticastActivation(token, err),\n ).finally(() => {\n if (this.#sourceSwitchReady === ready) this.#sourceSwitchReady = null\n })\n }\n\n #commitMulticastActivation(token: number) {\n if (token !== this.#sourceSwitchToken) return\n this.#active = 'multicast'\n this.#suppressed = true\n this.#pendingMulticastToken = null\n const video = this.#pendingMulticastVideo\n this.#pendingMulticastVideo = null\n const audio = this.#pendingMulticastAudio\n this.#pendingMulticastAudio = null\n if (video) this.#onVideoFrame?.(video.data, video.meta)\n if (audio) this.#onAudioFrame?.(audio.data, audio.meta)\n }\n\n #rejectMulticastActivation(token: number, err: unknown) {\n if (token !== this.#sourceSwitchToken) return\n if (this.#pendingMulticastToken === token) this.#pendingMulticastToken = null\n this.#pendingMulticastVideo = null\n this.#pendingMulticastAudio = null\n console.warn('[MediaRouter] onSourceSwitch(multicast) failed', err)\n }\n\n /** Switch back to MoQ immediately (called on multicast failback). */\n failbackToMoq() {\n const hadPendingMulticast = this.#pendingMulticastToken !== null\n if (this.#active === 'moq' && !hadPendingMulticast) return // already on MoQ, no-op\n ++this.#sourceSwitchToken\n this.#active = 'moq'\n this.#suppressed = false\n this.#pendingMulticastToken = null\n this.#pendingMulticastVideo = null\n this.#pendingMulticastAudio = null\n this.#sourceSwitchReady = null\n this.#multicastVideoKeyed = false\n this.#multicastAudioReceived = false\n this.#moqVideoKeyframeEmitted = false\n // A pending audio-fallback timer belongs to the abandoned takeover attempt.\n // Left running it would force-set the audio flags against the next attempt's\n // fresh state (BLO-9413 failback coherence \u2014 every takeover-era flag/timer\n // unwinds on this edge).\n if (this.#audioFallbackTimer) {\n clearTimeout(this.#audioFallbackTimer)\n this.#audioFallbackTimer = null\n }\n this.#onSourceSwitch?.('moq')\n }\n\n /** Dispose timers and per-track sources. Call on teardown to prevent\n * stale timer callbacks firing into garbage-collected closures (WR-03). */\n dispose(): void {\n this.configureDelta(null)\n if (this.#audioFallbackTimer) {\n clearTimeout(this.#audioFallbackTimer)\n this.#audioFallbackTimer = null\n }\n for (const [name] of this.#trackSources) {\n this.clearTrackSource(name)\n }\n }\n\n // \u2500\u2500 Private helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n /** Emit a frame on the correct per-track callback using the type registry.\n * Falls back to name match if no type registered (backward compat). */\n #emitForTrack(trackName: string, data: Uint8Array, meta: FrameMeta): void {\n // Prefer explicit type registry (catalog or init data)\n const reg = this.#trackTypes.get(trackName)\n if (reg?.type === 'video') {\n this.#onVideoFrame?.(data, meta)\n } else if (reg?.type === 'audio') {\n this.#onAudioFrame?.(data, meta)\n } else if (reg !== undefined) {\n // Registered but unknown type \u2014 overlay\n this.#onOverlayFrame?.(data, meta)\n } else {\n // Not registered \u2014 fall back to name match (backward compat for tests/legacy)\n if (trackName === 'video') this.#onVideoFrame?.(data, meta)\n else if (trackName === 'audio') this.#onAudioFrame?.(data, meta)\n else this.#onOverlayFrame?.(data, meta)\n }\n }\n\n /** Handle an overlay frame: buffer until first FEC block boundary, then pass through. */\n #handleOverlayFrame(data: Uint8Array, meta: FrameMeta): void {\n if (this.#overlayBoundaryReached) {\n this.#onOverlayFrame?.(data, meta)\n return\n }\n\n // Buffer until boundary \u2014 enforcing cap for T-02-17 DoS mitigation\n if (this.#overlayBuffer.length >= this.#maxOverlayBuffer) {\n console.warn(\n `[MediaRouter] overlay buffer full (${this.#maxOverlayBuffer} frames); dropping frame ts=${meta.timestamp}`\n )\n return\n }\n this.#overlayBuffer.push({ data, meta })\n }\n}\n", "/**\n * Maps one base/delta switching group's catalog FEC metadata onto\n * {@link MediaRouter.configureDelta} (BLO-45475).\n *\n * Generic client policy selects WHICH group to admit (roles, groups, endpoint\n * availability); this module owns the MMTP/FEC half of that decision \u2014 each\n * track's repair track and the delta block geometry \u2014 so no player re-states\n * the catalog FEC schema. It validates and never defaults: a group whose\n * required metadata is absent or contradictory throws, and the caller decides\n * that such a group invalidates the catalog rather than being skipped.\n *\n * FEC comes only from `resolveFecBindings` (BLO-50171), the validator's\n * per-instance result: base and delta each bind their own instance and their\n * own repair track (draft-ramadan-moq-fec-catalog-00 5 rule 2 forbids one\n * Track Name in two `repairTrack` members, so a shared repair track is no FEC\n * on either). The Phase-14 extension keys `deltaFailWindowBlocks` and\n * `deltaProbeIntervalMs` are read from the bound instance objects; the\n * validator ignores them as unrecognized instance keys (catalog-00 3.4).\n */\n\nimport { resolveFecBindings, type FecSourceBinding } from '@blockcast/mmt-base'\nimport { assertDeltaFallbackConfig, type DeltaFallbackConfig } from './media-router.js'\n\nexport interface ResolvedDeltaFallback {\n /** The base track's layer-0 repair track. */\n baseRepairTrackName: string\n /** The delta track's layer-0 repair track. */\n deltaRepairTrackName: string\n config: DeltaFallbackConfig\n}\n\ntype Obj = Record<string, unknown>\nconst recordOf = (v: unknown): Obj | undefined =>\n typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Obj) : undefined\n\n/** RFC 6901 lookup; the binding pointers are the validator's own. */\nfunction at(root: unknown, pointer: string): Obj | undefined {\n let cur: unknown = root\n for (const raw of pointer.split('/').slice(1)) {\n const key = raw.replace(/~1/g, '/').replace(/~0/g, '~')\n cur = Array.isArray(cur) ? cur[Number(key)] : recordOf(cur)?.[key]\n }\n return recordOf(cur)\n}\n\n/**\n * Resolve one switching group of `catalog` (either dialect). Throws when\n * either track has no `ok` FEC binding (the message carries the validator's\n * code and path), when the base framerate is not positive, or when the\n * assembled config fails {@link assertDeltaFallbackConfig}, the same check\n * configureDelta runs. Fallback-window and probe keys are read from the base\n * instance first, then the delta's; the interleave depth likewise.\n */\nexport function resolveDeltaFallback(catalog: unknown, baseName: string, deltaName: string): ResolvedDeltaFallback {\n const bindings = resolveFecBindings(catalog)\n const bound = (name: string, role: string) => {\n const b = bindings.get(name)\n if (!b) throw new Error(`[delta-fallback] ${role} track \"${name}\" declares no fec`)\n if (b.status !== 'ok')\n throw new Error(`[delta-fallback] ${role} track \"${name}\" has no FEC: ${b.reason.code} at ${b.reason.path}`)\n const track = at(catalog, b.pointer) ?? {}\n const instance = (Array.isArray(track['fec']) ? track['fec'] : [])\n .map(recordOf)\n .find((o) => o && (o['instanceId'] ?? 0) === b.base.instanceId) ?? {}\n return { b, track, instance }\n }\n const base = bound(baseName, 'base')\n const delta = bound(deltaName, 'delta')\n const framerate = base.track['framerate']\n if (!(typeof framerate === 'number' && Number.isFinite(framerate) && framerate > 0))\n throw new Error(`[delta-fallback] base track \"${baseName}\" missing positive framerate`)\n const key = (k: string) => (base.instance[k] ?? delta.instance[k]) as number | undefined\n const config: DeltaFallbackConfig = {\n framerate,\n sourceSymbols: delta.b.base.sourceSymbols,\n repairSymbols: delta.b.base.repairSymbols,\n deltaFailWindowBlocks: key('deltaFailWindowBlocks'),\n deltaProbeIntervalMs: key('deltaProbeIntervalMs'),\n interleaveDepthMs: base.b.base.interleaveDepthMs ?? delta.b.base.interleaveDepthMs,\n }\n assertDeltaFallbackConfig(config, `[delta-fallback] group ${baseName}/${deltaName}`)\n return { baseRepairTrackName: repairOf(base.b), deltaRepairTrackName: repairOf(delta.b), config }\n}\n\nconst repairOf = (b: Extract<FecSourceBinding, { status: 'ok' }>) => b.layers[0]!.repairTrackName\n"],
4
+ "sourcesContent": ["/**\n * @mmt/container - MMTP Parser\n *\n * MMTP (MMT Protocol) packet parser based on ISO/IEC 23008-1:2023 Section 9.2\n *\n * MMTP header format (12 bytes):\n * - Byte 0: V(2) | C(1) | FEC_type(2) | r(1) | X(1) | R(1)\n * - Byte 1: reserved(2) | packet_type(6)\n * - Bytes 2-3: packet_id (big-endian)\n * - Bytes 4-7: timestamp (big-endian, 32-bit)\n * - Bytes 8-11: sequence_number (big-endian)\n *\n * FEC_type values:\n * - 0: No FEC / source packet\n * - 1: FEC mode 0 (source with FEC payload ID)\n * - 2: FEC repair packet mode 0\n * - 3: FEC repair packet mode 1\n *\n * @packageDocumentation\n */\n\nexport interface MMTPHeader {\n version: number\n /** Packet type: 0x00=MPU, 0x02=Signaling, 0x03=Repair */\n packetType: number\n packetId: number\n timestamp: bigint\n sequenceNumber: number\n payloadLength: number\n fecType: number\n /** C flag: header carries a 32-bit packet_counter. */\n packetCounterFlag: boolean\n packetCounter?: number\n /** X flag: header carries an extension header and value. */\n extensionFlag: boolean\n extension?: { type: number; data: Uint8Array }\n /** Byte offset at which the MMTP payload begins. */\n headerLength: number\n /** Random Access Point flag - indicates keyframe (ISO 23008-1, byte 0 bit 0) */\n rapFlag: boolean\n}\n\nexport interface MPUHeader {\n mpuSequenceNumber: number\n fragmentationIndicator: number // FI: 0=complete, 1=first, 2=middle, 3=last\n /** 8-bit MPU fragment_counter from byte 3. */\n fragmentCounter: number\n /** T flag: 1=timed media (14-byte MFU DU header), 0=non-timed (4-byte header) */\n timedFlag: number\n /** A flag: aggregated MPU payload. This strict profile requires A=0. */\n aggregationFlag: boolean\n /** MPU payload_length (bytes 0-1): bytes after the length field, including\n * the remaining six MPU-header bytes and all data-unit bytes. */\n payloadLength: number\n}\n\nexport interface MFUDuHeader {\n movieFragmentSequenceNumber: number\n sampleNumber: number\n offset: number\n priority: number\n dependencyCounter: number\n}\n\nexport interface MMTPPacket {\n header: MMTPHeader\n payload: Uint8Array\n}\n\nexport const MMTP_HEADER_SIZE = 12\nexport const MPU_HEADER_SIZE = 8\nexport const MFU_DU_HEADER_SIZE_TIMED = 14 // T=1: timed media (video/audio samples)\nexport const MFU_DU_HEADER_SIZE_NONTIMED = 4 // T=0: non-timed media (item_id only)\n\nconst MPU_LENGTH_FIELD_SIZE = 2\nconst MPU_HEADER_REMAINDER_SIZE = MPU_HEADER_SIZE - MPU_LENGTH_FIELD_SIZE\nexport const SOURCE_FEC_PAYLOAD_ID_SIZE = 4\n\n// MMTP Packet Types (ISO/IEC 23008-1:2023 Section 9.2.2)\nexport const PACKET_TYPE_MPU = 0x00\nexport const PACKET_TYPE_SIGNALING = 0x02\nexport const PACKET_TYPE_REPAIR = 0x03\n\n// AL-FEC Signaling Message ID (ISO/IEC 23008-1:2023 Amendment 1:2025)\nexport const ALFEC_MESSAGE_ID = 0x0203\n\n/**\n * MMTP `packet_id` of the signaling flow, which carries PACKET_TYPE_SIGNALING\n * packets such as the AL-FEC message (ALFEC_MESSAGE_ID). The FFmpeg `moq_mmt`\n * muxer sends that message on this packet_id into the same multicast group as\n * the media, and moqcast-draft (PR #44) makes advertising it for a track a\n * MUST NOT. A track's packet_id is therefore 1..MMTP_PACKET_ID_MAX.\n */\nexport const MMTP_SIGNALING_PACKET_ID = 0\n\n/** Largest value of the 16-bit MMTP `packet_id` header field. */\nexport const MMTP_PACKET_ID_MAX = 0xffff\n\n// fec_code_id_for_repair_flow code points (ISO/IEC 23008-10 Table 1;\n// draft-ramadan-moq-mmt-01 Section 8.3).\nexport const FEC_CODE_REED_SOLOMON = 1\nexport const FEC_CODE_RAPTORQ = 3\n\n/**\n * Parse MMTP header from buffer\n */\nexport function parseMMTPHeader(buffer: Uint8Array): MMTPHeader {\n if (buffer.length < MMTP_HEADER_SIZE) {\n throw new Error(`MMTP packet too small: ${buffer.length} bytes`)\n }\n\n const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)\n\n const version = (buffer[0]! >> 6) & 0x03\n if (version !== 0) {\n throw new Error(`Unsupported MMTP version: ${version}`)\n }\n\n // Extract FEC type from byte 0, bits 3-4\n const fecType = (buffer[0]! >> 3) & 0x03\n const packetCounterFlag = (buffer[0]! & 0x20) !== 0\n const extensionFlag = (buffer[0]! & 0x02) !== 0\n\n // Extract RAP flag from byte 0, bit 0 (R bit - Random Access Point)\n const rapFlag = (buffer[0]! & 0x01) === 1\n\n // ISO/IEC 23008-1:2023 Figure 8 (V=0) places reserved(2) before\n // packet_type(6), so packet_type occupies the low six bits of byte 1.\n const packetType = buffer[1]! & 0x3F\n\n let offset = 2\n\n // Packet ID (2 bytes, big-endian)\n const packetId = view.getUint16(offset, false)\n offset += 2\n\n // Timestamp (4 bytes, big-endian)\n const timestamp = BigInt(view.getUint32(offset, false))\n offset += 4\n\n // Sequence number (4 bytes, big-endian)\n const sequenceNumber = view.getUint32(offset, false)\n offset += 4\n\n let packetCounter: number | undefined\n if (packetCounterFlag) {\n if (offset + 4 > buffer.length) throw new Error('MMTP packet truncated in packet_counter')\n packetCounter = view.getUint32(offset, false)\n offset += 4\n }\n\n let extension: { type: number; data: Uint8Array } | undefined\n if (extensionFlag) {\n if (offset + 4 > buffer.length) throw new Error('MMTP packet truncated in header extension')\n const type = view.getUint16(offset, false)\n const length = view.getUint16(offset + 2, false)\n offset += 4\n if (offset + length > buffer.length) throw new Error('MMTP packet truncated in header extension value')\n extension = { type, data: buffer.subarray(offset, offset + length) }\n offset += length\n }\n\n return {\n version,\n packetType,\n packetId,\n timestamp,\n sequenceNumber,\n payloadLength: buffer.length - offset,\n fecType,\n packetCounterFlag,\n packetCounter,\n extensionFlag,\n extension,\n headerLength: offset,\n rapFlag,\n }\n}\n\n/**\n * The MMTP `packet_id` of `buffer`, or null exactly when parseMMTPHeader would\n * throw for it (short buffer, V != 0, truncated packet_counter or header\n * extension).\n *\n * This is the per-packet filter a receiver runs before any other work, so it\n * builds no header object, DataView or BigInt timestamp (BLO-36599). Use\n * parseMMTPHeader when any other header field is needed.\n */\nexport function readMMTPPacketId(buffer: Uint8Array): number | null {\n if (buffer.length < MMTP_HEADER_SIZE) return null\n const flags = buffer[0]!\n if (((flags >> 6) & 0x03) !== 0) return null\n let offset = MMTP_HEADER_SIZE\n if ((flags & 0x20) !== 0) {\n if (offset + 4 > buffer.length) return null\n offset += 4\n }\n if ((flags & 0x02) !== 0) {\n if (offset + 4 > buffer.length) return null\n const length = (buffer[offset + 2]! << 8) | buffer[offset + 3]!\n if (offset + 4 + length > buffer.length) return null\n }\n return (buffer[2]! << 8) | buffer[3]!\n}\n\n/**\n * Unwrap MMTP packet to extract payload\n */\nexport function unwrapMMTP(buffer: Uint8Array): MMTPPacket {\n const header = parseMMTPHeader(buffer)\n const payload = buffer.subarray(header.headerLength)\n\n return { header, payload }\n}\n\n/**\n * Parse MPU header from payload\n * ISO/IEC 23008-1:2023 Section 9.2.3.3\n *\n * MPU Header format (8 bytes) as written by FFmpeg/libmmt:\n * - Bytes 0-1: payload_length (16 bits, big-endian)\n * - Byte 2: FT(4) | T(1) | FI(2) | A(1)\n * - Byte 3: fragment_counter (8 bits)\n * - Bytes 4-7: MPU_sequence_number (32 bits, big-endian)\n */\nexport function parseMPUHeader(payload: Uint8Array): MPUHeader {\n if (payload.length < MPU_HEADER_SIZE) {\n throw new Error(`Payload too small for MPU header: ${payload.length} bytes`)\n }\n\n const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength)\n\n // Bytes 0-1: payload_length (16 bits, big-endian)\n const payloadLength = view.getUint16(0, false)\n\n // Byte 2: FT(4) | T(1) | FI(2) | A(1)\n const flagsByte = payload[2]!\n const timedFlag = (flagsByte >> 3) & 0x01\n const fragmentationIndicator = (flagsByte >> 1) & 0x03\n const aggregationFlag = (flagsByte & 0x01) !== 0\n\n // Byte 3: fragment_counter (8 bits). The high bit is part of the counter in\n // the current libmmt/FFmpeg writer ABI.\n const fragmentCounter = payload[3]!\n\n // Bytes 4-7: MPU_sequence_number (32 bits, big-endian)\n const mpuSequenceNumber = view.getUint32(4, false)\n\n return {\n mpuSequenceNumber,\n fragmentationIndicator,\n fragmentCounter,\n timedFlag,\n aggregationFlag,\n payloadLength,\n }\n}\n\nexport function parseTimedMfuDuHeader(payload: Uint8Array, offset: number): MFUDuHeader | null {\n if (offset < 0 || offset + MFU_DU_HEADER_SIZE_TIMED > payload.length) return null\n const view = new DataView(payload.buffer, payload.byteOffset + offset, MFU_DU_HEADER_SIZE_TIMED)\n return {\n movieFragmentSequenceNumber: view.getUint32(0, false),\n sampleNumber: view.getUint32(4, false),\n offset: view.getUint32(8, false),\n priority: payload[offset + 12]!,\n dependencyCounter: payload[offset + 13]!,\n }\n}\n\n/**\n * Why `extractMFUPayload` refused a packet.\n *\n * These identifiers are part of the public contract: callers surface them in\n * diagnostics, so renaming one is a breaking change.\n */\nexport type MFURejectionGate =\n | 'packet-shorter-than-mmtp-plus-mpu-header'\n | 'mmtp-header-unparseable'\n | 'not-an-mpu-packet'\n | 'payload-shorter-than-mpu-header'\n | 'aggregation-flag-set'\n | 'mpu-payload-length-below-header-remainder'\n | 'mpu-declared-end-exceeds-payload'\n | 'mfu-offset-at-or-past-declared-end'\n | 'source-fec-payload-id-width-mismatch'\n | 'declared-trailers-unresolved'\n\nexport interface MFURejection {\n gate: MFURejectionGate\n /** Human-readable detail, including the byte arithmetic that decided it. */\n detail: string\n}\n\ninterface MfuPayloadLayout {\n hasMfuDuHeader: boolean\n mfuOffset: number\n dataEnd: number\n}\n\nfunction resolveMfuPayloadLayout(\n mpu: MPUHeader,\n fragmentType: number,\n payload: Uint8Array,\n): { layout: MfuPayloadLayout } | { rejection: MFURejection } {\n if (mpu.payloadLength < MPU_HEADER_REMAINDER_SIZE) {\n return {\n rejection: {\n gate: 'mpu-payload-length-below-header-remainder',\n detail: `payload_length=${mpu.payloadLength} < ${MPU_HEADER_REMAINDER_SIZE}`,\n },\n }\n }\n\n // ISO/IEC 23008-1 defines payload_length from immediately after its own\n // two-byte field. It therefore includes the remaining six MPU-header bytes.\n const declaredEnd = MPU_LENGTH_FIELD_SIZE + mpu.payloadLength\n if (declaredEnd > payload.length) {\n return {\n rejection: {\n gate: 'mpu-declared-end-exceeds-payload',\n detail:\n `payload_length=${mpu.payloadLength} implies end=${declaredEnd}, ` +\n `payload is ${payload.length} bytes`,\n },\n }\n }\n\n const hasHeader = fragmentType === 2 && mpu.fragmentationIndicator <= 1\n const mfuHeaderSize = mpu.timedFlag ? MFU_DU_HEADER_SIZE_TIMED : MFU_DU_HEADER_SIZE_NONTIMED\n const mfuOffset = MPU_HEADER_SIZE + (hasHeader ? mfuHeaderSize : 0)\n if (mfuOffset >= declaredEnd) {\n return {\n rejection: {\n gate: 'mfu-offset-at-or-past-declared-end',\n detail: `mfuOffset=${mfuOffset} >= declaredEnd=${declaredEnd}`,\n },\n }\n }\n\n return {\n layout: {\n hasMfuDuHeader: hasHeader,\n mfuOffset,\n dataEnd: declaredEnd,\n },\n }\n}\n\n// Legacy detached per-packet authenticator suffix `[0xf0a1][len][auth]`. Its\n// producer was removed (BLO-16483) and nothing verifies it any more; provenance\n// is the signed per-group manifest. Receivers still skip an exact suffix so a\n// stale producer degrades to unauthenticated playback instead of dropped\n// packets. ponytail: hard-cut to reject once no deployed producer can emit it.\nconst LEGACY_TRAILER_EXT_TYPE = 0xf0a1\nconst LEGACY_TRAILER_HEADER_SIZE = 4\n\nfunction isEmptyOrExactLegacyTrailer(payload: Uint8Array, start: number, end: number): boolean {\n if (start === end) return true\n if (start < 0 || end > payload.length || start + LEGACY_TRAILER_HEADER_SIZE > end) {\n return false\n }\n\n const view = new DataView(\n payload.buffer,\n payload.byteOffset + start,\n end - start,\n )\n if (view.getUint16(0, false) !== LEGACY_TRAILER_EXT_TYPE) return false\n\n const authLength = view.getUint16(2, false)\n return start + LEGACY_TRAILER_HEADER_SIZE + authLength === end\n}\n\ninterface DeclaredTrailerLayout {\n hasFecId: boolean\n}\n\n/** Options for extractMFUPayload and explainMFURejection. */\nexport interface MFUExtractOptions {\n /**\n * When true, do NOT subtract the Source FEC Payload ID from the payload\n * boundary, even if fecType >= 1. For FEC-recovered symbols whose FEC PID\n * was already stripped before WASM decoding.\n */\n skipFecId?: boolean\n}\n\nfunction resolveDeclaredTrailers(\n payload: Uint8Array,\n declaredEnd: number,\n fecType: number,\n options?: MFUExtractOptions,\n): DeclaredTrailerLayout | null {\n if (declaredEnd > payload.length) return null\n\n if (options?.skipFecId) {\n return isEmptyOrExactLegacyTrailer(payload, declaredEnd, payload.length)\n ? { hasFecId: false }\n : null\n }\n\n if (fecType === 1) {\n const fecStart = payload.length - SOURCE_FEC_PAYLOAD_ID_SIZE\n if (fecStart < declaredEnd || !isEmptyOrExactLegacyTrailer(payload, declaredEnd, fecStart)) {\n return null\n }\n return { hasFecId: true }\n }\n if (fecType !== 0) return null\n\n if (isEmptyOrExactLegacyTrailer(payload, declaredEnd, payload.length)) {\n return { hasFecId: false }\n }\n\n return null\n}\n\n/**\n * Extract NAL unit payload from MMTP packet\n */\nexport function extractNALUnit(mmtpPacket: Uint8Array): Uint8Array {\n const extraction = extractMFUPayload(mmtpPacket)\n if (!extraction) throw new Error('Malformed MMTP MPU payload')\n return extraction.data\n}\n\n/** Result of extracting MFU payload from an MMTP packet */\nexport interface MFUExtraction {\n /** MMTP header fields */\n header: MMTPHeader\n /** MPU header fields */\n mpu: MPUHeader\n /** Fragment type (FT field, 4 bits): 0=metadata, 2=MFU data */\n fragmentType: number\n /** MFU payload data (video/audio bytes, headers stripped, FEC ID excluded) */\n data: Uint8Array\n /** Parsed timed MFU DU header when present, otherwise null */\n mfuDuHeader: MFUDuHeader | null\n /** Non-timed MFU Item_ID when the 4-byte DU header is present. */\n itemId: number | null\n /** Source FEC Payload ID if present (`FEC_type == 1`), or null */\n fecPayloadId: { ssId: number } | null\n}\n\n/**\n * Extract MFU payload from a raw MMTP packet.\n *\n * Handles all the byte arithmetic for:\n * - MMTP header (12 bytes)\n * - MPU sub-header (8 bytes, payloadLength field)\n * - MFU DU header (14 bytes timed / 4 bytes non-timed, only for FI \u2264 1)\n * - Source FEC Payload ID (4 bytes at end, only when `FEC_type == 1`)\n *\n * `payloadLength` is the ISO length-after-field value: six remaining MPU-header\n * bytes, then the MFU DU header when present, then media. The legacy\n * authenticator suffix and the Source FEC trailer are outside that declared span and must consume the exact suffix.\n *\n * @param mmtpPacket Raw MMTP packet (from multicast or MoQ)\n * @param options.skipFecId When true, do NOT subtract the 4-byte Source FEC Payload ID\n * from the payload boundary, even if fecType >= 1 in the MMTP header. Use this for\n * FEC-recovered symbols where the FEC PID was already stripped before WASM decoding\n * but the fecType bits in byte 0 still reflect the original packet (FEC Type=1 per\n * draft-ramadan-moq-mmt-01 \u00A73.1).\n * @returns Extracted MFU data + metadata, or null if packet is too small\n */\ninterface MfuPlan {\n header: MMTPHeader\n payload: Uint8Array\n mpu: MPUHeader\n fragmentType: number\n layout: MfuPayloadLayout\n trailerLayout: DeclaredTrailerLayout\n}\n\n/**\n * Single source of truth for whether a packet yields an MFU payload.\n *\n * `extractMFUPayload` and `explainMFURejection` both delegate here, so the\n * reason reported to operators can never drift from the decision the parser\n * actually made.\n */\nfunction planMfuExtraction(\n mmtpPacket: Uint8Array,\n options?: MFUExtractOptions,\n parsedHeader?: MMTPHeader,\n): { plan: MfuPlan } | { rejection: MFURejection } {\n if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) {\n return {\n rejection: {\n gate: 'packet-shorter-than-mmtp-plus-mpu-header',\n detail: `${mmtpPacket.length} bytes < ${MMTP_HEADER_SIZE + MPU_HEADER_SIZE}`,\n },\n }\n }\n\n let header: MMTPHeader\n if (parsedHeader) {\n header = parsedHeader\n } else {\n try {\n header = parseMMTPHeader(mmtpPacket)\n } catch (error) {\n return {\n rejection: {\n gate: 'mmtp-header-unparseable',\n detail: error instanceof Error ? error.message : String(error),\n },\n }\n }\n }\n if (header.packetType !== PACKET_TYPE_MPU) {\n return {\n rejection: {\n gate: 'not-an-mpu-packet',\n detail: `packetType=0x${header.packetType.toString(16).padStart(2, '0')}`,\n },\n }\n }\n\n const payload = mmtpPacket.subarray(header.headerLength)\n if (payload.length < MPU_HEADER_SIZE) {\n return {\n rejection: {\n gate: 'payload-shorter-than-mpu-header',\n detail: `${payload.length} bytes < ${MPU_HEADER_SIZE}`,\n },\n }\n }\n\n const mpu = parseMPUHeader(payload)\n if (mpu.aggregationFlag) {\n return {\n rejection: { gate: 'aggregation-flag-set', detail: 'strict profile requires A=0' },\n }\n }\n\n // Fragment type from byte 2 upper nibble (not returned by parseMPUHeader)\n const fragmentType = (payload[2]! >> 4) & 0x0f\n\n const layoutResult = resolveMfuPayloadLayout(mpu, fragmentType, payload)\n if ('rejection' in layoutResult) return layoutResult\n const { layout } = layoutResult\n\n const trailerLayout = resolveDeclaredTrailers(\n payload,\n layout.dataEnd,\n header.fecType,\n options,\n )\n if (!trailerLayout) {\n // The most common cause is a producer whose Source FEC Payload ID width\n // disagrees with ISO/IEC 23008-1 \u00A7C.5.2. Name that explicitly rather than\n // leaving operators to infer it from a generic trailer error.\n if (!options?.skipFecId && header.fecType === 1) {\n const observed = payload.length - layout.dataEnd\n const expected = SOURCE_FEC_PAYLOAD_ID_SIZE\n if (observed !== expected) {\n return {\n rejection: {\n gate: 'source-fec-payload-id-width-mismatch',\n detail:\n `producer wrote a ${observed}-byte Source FEC Payload ID, ` +\n `ISO/IEC 23008-1 \u00A7C.5.2 requires ${expected} ` +\n `(payload=${payload.length}, declaredEnd=${layout.dataEnd})`,\n },\n }\n }\n }\n return {\n rejection: {\n gate: 'declared-trailers-unresolved',\n detail:\n `trailer bytes [${layout.dataEnd}, ${payload.length}) are neither empty ` +\n `nor an exact legacy authenticator trailer (fecType=${header.fecType})`,\n },\n }\n }\n\n return { plan: { header, payload, mpu, fragmentType, layout, trailerLayout } }\n}\n\n/**\n * Report why `extractMFUPayload` would refuse this packet, or null if it would\n * accept it.\n *\n * `extractMFUPayload` returns bare `null` on ten distinct conditions, which\n * makes a producer/consumer framing disagreement indistinguishable from a\n * publisher that never started \u2014 both render as silence. Use this wherever\n * that distinction matters: diagnostics, contract tests, and operator-facing\n * logs.\n */\nexport function explainMFURejection(\n mmtpPacket: Uint8Array,\n options?: MFUExtractOptions,\n): MFURejection | null {\n const result = planMfuExtraction(mmtpPacket, options)\n return 'rejection' in result ? result.rejection : null\n}\n\nexport function extractMFUPayload(\n mmtpPacket: Uint8Array,\n options?: MFUExtractOptions,\n): MFUExtraction | null {\n return extractPlannedMfu(mmtpPacket, planMfuExtraction(mmtpPacket, options))\n}\n\n/**\n * extractMFUPayload for a caller that already holds this packet's parsed MMTP\n * header, so the header is not parsed a second time (BLO-36599).\n *\n * `header` must be `parseMMTPHeader(mmtpPacket)` of this same packet; the\n * result is then identical to `extractMFUPayload(mmtpPacket, options)`.\n */\nexport function extractMFUPayloadWithHeader(\n mmtpPacket: Uint8Array,\n header: MMTPHeader,\n options?: MFUExtractOptions,\n): MFUExtraction | null {\n return extractPlannedMfu(mmtpPacket, planMfuExtraction(mmtpPacket, options, header))\n}\n\nfunction extractPlannedMfu(\n mmtpPacket: Uint8Array,\n result: { plan: MfuPlan } | { rejection: MFURejection },\n): MFUExtraction | null {\n if ('rejection' in result) return null\n const { header, payload, mpu, fragmentType, layout, trailerLayout } = result.plan\n\n const mfuDuHeaderPresent = layout.hasMfuDuHeader\n const mfuOffset = layout.mfuOffset\n const dataEnd = layout.dataEnd\n\n const mfuDuHeader =\n mfuDuHeaderPresent && mpu.timedFlag === 1\n ? parseTimedMfuDuHeader(payload, MPU_HEADER_SIZE)\n : null\n const itemId =\n mfuDuHeaderPresent && mpu.timedFlag === 0\n ? new DataView(payload.buffer, payload.byteOffset + MPU_HEADER_SIZE, 4).getUint32(0, false)\n : null\n\n const data = payload.subarray(mfuOffset, dataEnd)\n\n const fecPayloadId = trailerLayout.hasFecId\n ? {\n ssId: new DataView(\n mmtpPacket.buffer,\n mmtpPacket.byteOffset + mmtpPacket.length - SOURCE_FEC_PAYLOAD_ID_SIZE,\n 4,\n ).getUint32(0, false),\n }\n : null\n\n return { header, mpu, fragmentType, data, mfuDuHeader, itemId, fecPayloadId }\n}\n\n/**\n * Extract timestamp from MMTP packet\n */\nexport function extractTimestamp(mmtpPacket: Uint8Array): bigint {\n const { header } = unwrapMMTP(mmtpPacket)\n return header.timestamp\n}\n\n/**\n * Check if MMTP packet is an FEC repair packet\n * FEC_type 2 and 3 are repair packets\n */\nexport function isFecRepairPacket(mmtpPacket: Uint8Array): boolean {\n if (mmtpPacket.length < 1) return false\n const fecType = (mmtpPacket[0]! >> 3) & 0x03\n return fecType >= 2\n}\n\n/**\n * Extract Source FEC Payload ID (SS_ID) from the last 4 bytes of an MMTP\n * source packet per ISO/IEC 23008-1:2023 Section C.5.2 Figure C.10.\n *\n * SS_ID is a flat 32-bit monotonic counter (incremented by 1 per packet\n * for ssbg_mode0 and ssbg_mode1). Block membership and ESI use the\n * configured K; repair SS_Start identifies the first SS_ID of the block and\n * SSB_length records the authored source-symbol count:\n * SBN = floor(SS_ID / K), ESI = SS_ID % K\n * SS_Start = SBN * K (first SS_ID of the block)\n *\n * This matches FFmpeg moqenc_mmt.c, the drafts (draft-ramadan-moq-fec \u00A76.2),\n * and ATSC A/331 \u00A78.1.2.5 which defers to ISO 23008-1 Annex C.\n */\nexport function extractFecPayloadId(mmtpPacket: Uint8Array): { ssId: number } | null {\n const header = parseHeaderForSourceFecId(mmtpPacket)\n return header ? sourceFecPayloadIdOf(mmtpPacket, header) : null\n}\n\n/**\n * Return true when FEC_type=1 and the exact suffix after the ISO-declared MPU\n * payload contains a four-byte Source FEC Payload ID.\n */\nexport function hasSourceFecPayloadId(mmtpPacket: Uint8Array): boolean {\n const header = parseHeaderForSourceFecId(mmtpPacket)\n return header !== null && sourceFecPayloadIdOf(mmtpPacket, header) !== null\n}\n\nfunction parseHeaderForSourceFecId(mmtpPacket: Uint8Array): MMTPHeader | null {\n if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) return null\n try {\n return parseMMTPHeader(mmtpPacket)\n } catch {\n return null\n }\n}\n\n/**\n * The Source FEC Payload ID of a packet whose MMTP header the caller already\n * parsed, or null when the packet carries none: present only when FEC_type=1\n * and the exact suffix after the ISO-declared MPU payload holds the four-byte\n * ID (ISO/IEC 23008-1 \u00A7C.5.2). Same answer as extractFecPayloadId, without a\n * second header parse (BLO-36599).\n *\n * `header` must be `parseMMTPHeader(mmtpPacket)` of this same packet.\n */\nexport function sourceFecPayloadIdOf(\n mmtpPacket: Uint8Array,\n header: MMTPHeader,\n): { ssId: number } | null {\n if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) return null\n if (header.packetType !== PACKET_TYPE_MPU) return null\n\n const payload = mmtpPacket.subarray(header.headerLength)\n if (payload.length < MPU_HEADER_SIZE) return null\n const payloadLength = (payload[0]! << 8) | payload[1]!\n if (payloadLength < MPU_HEADER_REMAINDER_SIZE) return null\n const declaredEnd = MPU_LENGTH_FIELD_SIZE + payloadLength\n if (!resolveDeclaredTrailers(payload, declaredEnd, header.fecType)?.hasFecId) return null\n\n // Flat 4-byte SS_ID: encoder writes block_id * K + symbol_id\n const off = mmtpPacket.length - SOURCE_FEC_PAYLOAD_ID_SIZE\n return {\n ssId:\n ((mmtpPacket[off]! << 24) |\n (mmtpPacket[off + 1]! << 16) |\n (mmtpPacket[off + 2]! << 8) |\n mmtpPacket[off + 3]!) >>>\n 0,\n }\n}\n\n/** Size of Repair FEC Payload ID per ISO/IEC 23008-1 Section C.5.3 */\nexport const REPAIR_FEC_PID_SIZE = 13\n\n/**\n * Parse the 13-byte Repair FEC Payload ID after the complete dynamic MMTP header.\n * `parsedHeader`, when given, must be `parseMMTPHeader(mmtpPacket)` of this\n * same packet; it spares the second header parse (BLO-36599).\n */\nfunction readRepairFecPayloadIdFields(mmtpPacket: Uint8Array, parsedHeader?: MMTPHeader): {\n packetId: number; ssStart: number; rsbLength: number; rsId: number; ssbLength: number; dataOffset: number\n} | null {\n let header: MMTPHeader\n if (parsedHeader) {\n header = parsedHeader\n } else {\n try {\n header = parseMMTPHeader(mmtpPacket)\n } catch {\n return null\n }\n }\n if (header.packetType !== PACKET_TYPE_REPAIR || header.fecType !== 2) return null\n\n const off = header.headerLength\n if (mmtpPacket.length < off + REPAIR_FEC_PID_SIZE) return null\n const ssStart =\n ((mmtpPacket[off]! << 24) |\n (mmtpPacket[off + 1]! << 16) |\n (mmtpPacket[off + 2]! << 8) |\n mmtpPacket[off + 3]!) >>>\n 0 // 32 bits\n const rsbLength = (mmtpPacket[off + 4]! << 16) | (mmtpPacket[off + 5]! << 8) | mmtpPacket[off + 6]! // 24 bits\n const rsId = (mmtpPacket[off + 7]! << 16) | (mmtpPacket[off + 8]! << 8) | mmtpPacket[off + 9]! // 24 bits\n const ssbLength = (mmtpPacket[off + 10]! << 16) | (mmtpPacket[off + 11]! << 8) | mmtpPacket[off + 12]! // 24 bits\n return {\n packetId: header.packetId,\n ssStart,\n rsbLength,\n rsId,\n ssbLength,\n dataOffset: off + REPAIR_FEC_PID_SIZE,\n }\n}\n\n/**\n * Extract Repair FEC Payload ID from a raw-multicast MMTP repair packet.\n * Per ISO/IEC 23008-1:2023 Section C.5.3 (ssbg_mode0, one-stage, no FFSRP_TS):\n * [MMTP header][SS_Start:4][RSB_length:3][RS_ID:3][SSB_length:3][repair data]\n * OTI is delivered via AL-FEC signaling message (Table C.3), not per-packet.\n *\n * Byte 1 is reserved(2)|packet_type(6) for V=0, so repair type 0x03 is 0x03.\n * Pre-migration high-bits bytes (0x0c) are rejected \u2014 no compatibility decode.\n */\nexport function extractRepairFecPayloadId(mmtpPacket: Uint8Array): {\n ssStart: number; rsbLength: number; rsId: number; ssbLength: number\n} | null {\n const fields = readRepairFecPayloadIdFields(mmtpPacket)\n if (!fields) return null\n return {\n ssStart: fields.ssStart,\n rsbLength: fields.rsbLength,\n rsId: fields.rsId,\n ssbLength: fields.ssbLength,\n }\n}\n\n/** Parsed MoQ repair frame per ISO/IEC 23008-1 Section C.4.3 + C.5.3 */\nexport interface MoqRepairFrame {\n /** MMTP packetId (1=video, 2=audio) \u2014 for routing to correct FEC decoder */\n packetId: number\n /** SS_Start (32 bits): SS_ID of first source symbol in block */\n ssStart: number\n /** RSB_length (24 bits): number of repair symbols (P) */\n rsbLength: number\n /** RS_ID (24 bits): repair symbol index (0-based) */\n rsId: number\n /** SSB_length (24 bits): authored source-symbol count (1..=configured K) */\n ssbLength: number\n /** Repair symbol data after the dynamic MMTP header and 13-byte FEC payload ID. */\n symbolData: Uint8Array\n}\n\n/**\n * Parse a MoQ repair frame (ISO/IEC 23008-1 Annex C format).\n *\n * Wire format per ISO 23008-1 \u00A7C.4.3 + \u00A7C.5.3:\n * MMTP Header: fixed 12 bytes plus C/X optional fields\n * then SS_Start (4 bytes) \u2192 block identifier\n * then RSB_length (3 bytes) \u2192 P\n * then RS_ID (3 bytes) \u2192 ESI = K + RS_ID\n * then SSB_length (3 bytes) \u2192 authored source-symbol count (1..=K)\n * then Repair Symbol Data (T bytes)\n *\n * @param frame - Raw MoQ repair frame bytes\n * @returns Parsed frame or null if invalid\n */\nexport function parseMoqRepairFrame(frame: Uint8Array): MoqRepairFrame | null {\n return moqRepairFrameFrom(frame, readRepairFecPayloadIdFields(frame))\n}\n\n/**\n * parseMoqRepairFrame for a caller that already holds this frame's parsed MMTP\n * header, so the header is not parsed a second time (BLO-36599). The router's\n * multicast repair branch uses it: one repair packet in every K + P otherwise\n * paid two header parses.\n *\n * `header` must be `parseMMTPHeader(frame)` of this same frame; the result is\n * then identical to `parseMoqRepairFrame(frame)`.\n */\nexport function parseMoqRepairFrameWithHeader(\n frame: Uint8Array,\n header: MMTPHeader,\n): MoqRepairFrame | null {\n return moqRepairFrameFrom(frame, readRepairFecPayloadIdFields(frame, header))\n}\n\nfunction moqRepairFrameFrom(\n frame: Uint8Array,\n repairId: ReturnType<typeof readRepairFecPayloadIdFields>,\n checkRsId = true,\n): MoqRepairFrame | null {\n if (!repairId) return null\n\n const symbolData = frame.subarray(repairId.dataOffset)\n if (\n repairId.ssbLength === 0 ||\n (checkRsId && repairId.rsId >= repairId.rsbLength) ||\n symbolData.length === 0\n ) return null\n\n return {\n packetId: repairId.packetId,\n ssStart: repairId.ssStart,\n rsbLength: repairId.rsbLength,\n rsId: repairId.rsId,\n ssbLength: repairId.ssbLength,\n symbolData,\n }\n}\n\n/**\n * Why a repair object of an mmtp track is discarded on admission:\n * `repairLength` -- the object is not exactly 12 + 13 + T bytes with a\n * 12-byte MMTP header (C = 0, X = 0; draft-ramadan-moq-mmt-02 7.3, judged\n * before any Repair FEC Payload ID field is read); `repairRsId` -- RS_ID is not\n * below RSB_length, under every scheme; `repairSchemeBound` -- the object\n * breaks its scheme's bound: reed-solomon SSB_length + RSB_length > 255, which\n * also caps RS_ID at 253, or raptorq SSB_length + RSB_length - 1 > 2^24 - 1\n * (draft-ramadan-moq-fec-core-00 7.5 and 10.3, -block-codes-00 7).\n */\nexport type RepairObjectReject = 'repairLength' | 'repairRsId' | 'repairSchemeBound'\n\n/**\n * Admission of one mmtp repair object under its instance's T and scheme. The\n * caller names the instance (by the repair track it subscribed); nothing here\n * infers it from the fields. `repairRsId` and `repairSchemeBound` carry the\n * frame so the caller can first route an RSB_length below a held layer's\n * O_i + P_i to the 7.3.2 stop, which takes precedence over the discard.\n * `null`: not an iso repair packet, or SSB_length 0.\n */\nexport function admitMmtpRepairObject(\n packet: Uint8Array,\n header: MMTPHeader,\n symbolSize: number,\n algorithm: string | undefined,\n): { frame: MoqRepairFrame; reject?: Exclude<RepairObjectReject, 'repairLength'> } | { reject: 'repairLength' } | null {\n if (header.packetType !== PACKET_TYPE_REPAIR || header.fecType !== 2) return null\n if (header.headerLength !== MMTP_HEADER_SIZE || packet.length !== MMTP_HEADER_SIZE + REPAIR_FEC_PID_SIZE + symbolSize) {\n return { reject: 'repairLength' }\n }\n const frame = moqRepairFrameFrom(packet, readRepairFecPayloadIdFields(packet, header), false)\n if (!frame) return null\n if (frame.rsId >= frame.rsbLength) return { frame, reject: 'repairRsId' }\n const blockSymbols = frame.ssbLength + frame.rsbLength\n if (algorithm === 'reed-solomon' ? blockSymbols > 255 : algorithm === 'raptorq' && blockSymbols - 1 > 0xffffff) {\n return { frame, reject: 'repairSchemeBound' }\n }\n return { frame }\n}\n\n// The signalling-message parsers (9.3.4.2 payload header, AL-FEC message,\n// PA/MPT) live in ./signalling.ts.\n", "/**\n * NTP short format (RFC 5905 \u00A76): an unsigned 32-bit, 16.16 fixed-point count\n * of seconds. The MMTP header `timestamp` field (ISO/IEC 23008-1 \u00A79.2.2)\n * carries each packet's UTC sending instant in this format, so the value wraps\n * to 0 every 2^16 s (~18.2 h).\n *\n * Plain numeric order on these values breaks at every wrap: a retained\n * `Math.max` stays at the pre-wrap value and every later instant compares as\n * older. Order send times only with {@link compareNtpShort}, which applies\n * RFC 1982 serial-number arithmetic over the field's width.\n */\n\n/** Width in bits of the MMTP `timestamp` field (NTP short). */\nexport const NTP_SHORT_BITS = 32\n\n/** Number of distinct NTP-short values; the field wraps to 0 here. */\nexport const NTP_SHORT_MODULUS = 2 ** NTP_SHORT_BITS\n\n/**\n * RFC 1982 \u00A73.2's `2^(SERIAL_BITS - 1)`. Two values whose forward distance is\n * below this are ordered; exactly this far apart they are not ordered at all.\n * Two instants must be less than this far apart (2^15 s, ~9.1 h of send time)\n * for the comparison to give their true order.\n */\nexport const NTP_SHORT_HALF_RANGE = 2 ** (NTP_SHORT_BITS - 1)\n\n/**\n * Serial order of one NTP-short value relative to another.\n *\n * `'unordered'` is RFC 1982's undefined case: the values are exactly\n * {@link NTP_SHORT_HALF_RANGE} apart, so neither precedes the other. It is\n * never folded into `'before'` or `'after'`; each caller decides what an\n * unorderable pair means for it.\n */\nexport type NtpShortOrder = 'before' | 'equal' | 'after' | 'unordered'\n\n/** True when `value` is an NTP-short value: an integer in [0, NTP_SHORT_MODULUS). */\nexport function isNtpShort(value: number): boolean {\n return Number.isInteger(value) && value >= 0 && value < NTP_SHORT_MODULUS\n}\n\n/**\n * RFC 1982 serial comparison of NTP-short `a` against `b`: `'after'` when `a`\n * is the later instant, `'before'` when it is the earlier one.\n *\n * The answer is the true order only while the two instants are less than\n * {@link NTP_SHORT_HALF_RANGE} apart. Past that a 32-bit field cannot tell\n * later from earlier, and the result names whichever direction is nearer.\n * This is not a total order (it is not transitive across the whole space), so\n * it must never be used to sort.\n *\n * @throws {RangeError} if either operand is not an NTP-short value\n */\nexport function compareNtpShort(a: number, b: number): NtpShortOrder {\n if (!isNtpShort(a) || !isNtpShort(b)) {\n throw new RangeError(\n `NTP-short values must be integers in [0, 2^${NTP_SHORT_BITS}), got ${String(a)} and ${String(b)}`,\n )\n }\n if (a === b) return 'equal'\n // Forward distance from b to a, reduced into [1, NTP_SHORT_MODULUS). Both\n // operands are below 2^32, so every step is exact in a double.\n const forward = (a - b + NTP_SHORT_MODULUS) % NTP_SHORT_MODULUS\n if (forward < NTP_SHORT_HALF_RANGE) return 'after'\n if (forward > NTP_SHORT_HALF_RANGE) return 'before'\n return 'unordered'\n}\n", "/**\n * FEC block profile (BLO-35597, ISO/IEC 23008-1 Annex C).\n *\n * Every catalog `fec` object that declares an FEC instance names its block\n * profile in `fec.blockProfile`. A receiver applies all of a profile's rules\n * or none of them, so the token is the one explicit per-stream discriminator:\n * it is never defaulted and never inferred from the wire.\n *\n * draft-ramadan-moq-fec \u00A75.1 defines exactly one value, and it is the only\n * profile this library implements:\n *\n * - `iso-ssbg1-v1`: ssbg_mode1 (C.3.3): a source symbol is\n * `u16 length || protected packet || zero padding` to T; a block is a\n * K' = SSB_length RaptorQ block (C.5.3) and repair ESI = SSB_length + RS_ID;\n * SS_ID increments by one per FEC source packet and wraps at 2^32 (C.5.2),\n * so a block is located by membership in `[SS_Start, SS_Start + SSB_length)`\n * under RFC 1982 serial arithmetic; the MoQ repair group id is the 62-bit\n * wrap-epoch-extended SS_Start. The AL-FEC coding byte for this profile is\n * 0x14 (one-stage, ssbg_mode1, fec_payload_id_mode 0, Amd 1:2025 Table C.3).\n *\n * A track whose `blockProfile` is absent or names any other token (the\n * retired Blockcast-private `k-padded-v1` included) has no FEC: a receiver\n * consumes its source without FEC and joins none of its repair tracks\n * (draft-ramadan-moq-fec \u00A75.1). `parseFecBlockProfile` throws for such a\n * token; the per-track catalog resolver turns the same rule into a typed\n * per-track \"FEC unavailable\" outcome instead of failing the session.\n */\nimport { serial32Add, serial32Distance, serial32Precedes } from './serial-number.js'\n\n/** The block profile a catalog `fec.blockProfile` may name: the one this library implements. */\nexport type FecBlockProfile = 'iso-ssbg1-v1'\n\n/** Every accepted `fec.blockProfile` token. */\nexport const FEC_BLOCK_PROFILES: readonly FecBlockProfile[] = Object.freeze([\n 'iso-ssbg1-v1',\n])\n\n/** True for a block-profile token this library implements. */\nexport function isFecBlockProfile(value: unknown): value is FecBlockProfile {\n return value === 'iso-ssbg1-v1'\n}\n\n/**\n * Read a required `fec.blockProfile`. Throws on an absent token and on any\n * token other than `iso-ssbg1-v1`: there is no default and nothing is\n * inferred from the wire (draft-ramadan-moq-fec \u00A75.1).\n *\n * @param where the catalog path of the field, for the error message.\n */\nexport function parseFecBlockProfile(value: unknown, where: string): FecBlockProfile {\n if (isFecBlockProfile(value)) return value\n if (value === undefined) {\n throw new Error(`${where} is required: name the FEC block profile (\"iso-ssbg1-v1\")`)\n }\n throw new Error(`${where} must be \"iso-ssbg1-v1\" (got ${JSON.stringify(value)})`)\n}\n\n/** ISO/IEC 23008-1 C.3.3 ssbg_mode1: the two-octet packet length field. */\nexport const SSBG1_LENGTH_FIELD_BYTES = 2\n\n/** The largest protected packet an ssbg_mode1 symbol of size T can carry. */\nexport function ssbg1MaxPacketLength(symbolSize: number): number {\n return symbolSize - SSBG1_LENGTH_FIELD_BYTES\n}\n\n/** Whether a protected packet of `packetLength` bytes fits one T-byte ssbg_mode1 symbol. */\nexport function ssbg1FitsSymbol(packetLength: number, symbolSize: number): boolean {\n return (\n Number.isSafeInteger(packetLength) &&\n packetLength >= 0 &&\n packetLength <= ssbg1MaxPacketLength(symbolSize) &&\n packetLength <= 0xffff\n )\n}\n\n/**\n * Build one ssbg_mode1 source symbol (C.3.3): the protected packet's length\n * in two octets, network byte order, then the packet, then zero octets up to\n * T. The protected packet is the MMTP packet before its Source FEC Payload ID\n * is appended (C.4.2).\n *\n * @throws when `length + 2 > T`: the packet cannot be protected under this\n * profile, and silently truncating it would corrupt every repair symbol.\n */\nexport function buildSsbg1SourceSymbol(packet: Uint8Array, symbolSize: number): Uint8Array {\n if (!Number.isSafeInteger(symbolSize) || symbolSize <= SSBG1_LENGTH_FIELD_BYTES) {\n throw new Error(`ssbg_mode1 symbol size must exceed ${SSBG1_LENGTH_FIELD_BYTES} (got ${symbolSize})`)\n }\n if (!ssbg1FitsSymbol(packet.byteLength, symbolSize)) {\n throw new Error(\n `ssbg_mode1 source symbol needs ${packet.byteLength} + ${SSBG1_LENGTH_FIELD_BYTES} bytes but T is ${symbolSize}`,\n )\n }\n const symbol = new Uint8Array(symbolSize)\n symbol[0] = (packet.byteLength >>> 8) & 0xff\n symbol[1] = packet.byteLength & 0xff\n symbol.set(packet, SSBG1_LENGTH_FIELD_BYTES)\n return symbol\n}\n\n/**\n * Recover the protected packet from one ssbg_mode1 source symbol: the bytes\n * the length field names. Returns null, never a guess, when the length field\n * points past the symbol or any padding octet is non-zero (C.3.3 padding is\n * all 00h), since either means the recovered symbol is not what was sent.\n */\nexport function trimSsbg1SourceSymbol(symbol: Uint8Array): Uint8Array | null {\n if (symbol.byteLength < SSBG1_LENGTH_FIELD_BYTES) return null\n const length = (symbol[0]! << 8) | symbol[1]!\n const end = SSBG1_LENGTH_FIELD_BYTES + length\n if (end > symbol.byteLength) return null\n for (let i = end; i < symbol.byteLength; i++) {\n if (symbol[i] !== 0) return null\n }\n return symbol.subarray(SSBG1_LENGTH_FIELD_BYTES, end)\n}\n\n/** The SS_ID after the last source symbol of `[ssStart, ssStart + ssbLength)`. */\nexport function isoBlockEnd(ssStart: number, ssbLength: number): number {\n return serial32Add(ssStart, ssbLength)\n}\n\n/**\n * Range membership (C.5.2, C.5.3): whether `ssId` is one of the\n * `ssbLength` consecutive SS_IDs starting at `ssStart`, modulo 2^32.\n */\nexport function isoBlockContains(ssStart: number, ssbLength: number, ssId: number): boolean {\n return serial32Distance(ssStart, ssId) < ssbLength\n}\n\n/**\n * A lower bound on how many blocks the block starting at `a` lies behind the\n * one starting at `b` (0 when `a` does not precede `b`). An iso-ssbg1-v1\n * block holds at most `kMax` source symbols (the catalog `sourceSymbols`, the\n * AL-FEC maximum_k_for_repair_flow), so `n * kMax` SS_IDs always span at least\n * `n` blocks. Every interleave horizon in the receiver (decoder retention,\n * worker retirement, ordered-relay settlement) uses this one bound, so the\n * manager's horizon can never fall inside the decoder's.\n */\nexport function isoBlocksBehindLowerBound(a: number, b: number, kMax: number): number {\n if (!Number.isSafeInteger(kMax) || kMax <= 0) {\n throw new Error(`iso-ssbg1-v1 block horizon needs a positive maximum K (got ${kMax})`)\n }\n return serial32Precedes(a, b) ? Math.floor(serial32Distance(a, b) / kMax) : 0\n}\n", "// FEC catalog binding validator (draft-ramadan-moq-fec-catalog-00, as posted\n// 2026-11-02, moqcast-draft 5f49172).\n//\n// The catalog-00 shape only: a track's `fec` is an array of instance objects,\n// and a repair or symbol track binds to its instance by `source` + `instanceId`\n// (or by being named in `repairTrack` / `symbolTrack`). The draft-01 object\n// shape (`fec: {...}`, `depends`) is not accepted: an object `fec` is a type\n// violation, and a repair track that only `depends` on its source is an orphan.\n//\n// Validation fails closed per instance (fec-core-00 10.3): a violation stops\n// FEC for that instance and no other. Three outcomes, each reported with the\n// catalog path that caused it:\n// ok - FEC may start for the instance.\n// unavailable - the receiver forgoes FEC for it (fec-core-00 10.3 rule 1,\n// 4.5); not a validation failure.\n// violation - the configuration is invalid for this receiver.\n// An orphan repair or symbol track (fec-catalog-00 5 rule 6) is reported and\n// stops nothing. `enhancementRepair` anywhere rejects the catalog (rule 4).\n//\n// The Rust twin is `mmt_fec::fec_catalog`; both run vectors/fec-catalog-00/.\n\nexport type FecCatalogOutcome = \"ok\" | \"unavailable\" | \"violation\";\n\nexport type FecCatalogCode =\n\t// Unavailable (fec-core-00 10.3 rule 1; fec-catalog-00 3.1).\n\t| \"fec_scheme_absent\"\n\t| \"fec_scheme_unimplemented\"\n\t| \"fec_block_profile_absent\"\n\t| \"fec_block_profile_unimplemented\"\n\t| \"fec_block_profile_packaging\"\n\t// Violations.\n\t| \"fec_member\" // fec-catalog-00 5 rule 1; fec-core-00 10.3 rule 11\n\t| \"fec_naming\" // fec-catalog-00 5 rule 2\n\t| \"fec_resolution\" // fec-catalog-00 5 rule 3\n\t| \"fec_enhancement_repair\" // fec-catalog-00 5 rule 4, 3.4\n\t| \"fec_orphan\" // fec-catalog-00 5 rule 6 (report only)\n\t| \"fec_layers\" // fec-core-00 10.3 rule 3\n\t| \"fec_layer0\" // fec-core-00 10.3 rule 4\n\t| \"fec_bounds\" // fec-core-00 10.3 rule 5; block-codes-00 7; mmt-02 7.1\n\t| \"fec_instance_id\" // fec-core-00 10.3 rule 7\n\t| \"fec_symbol_tracks\" // fec-core-00 10.3 rule 7\n\t| \"fec_track_geometry\" // fec-core-00 10.3 rule 8\n\t| \"fec_priority\" // fec-core-00 10.3 rule 10\n\t| \"fec_overlay\" // mmt-02 7.4.1\n\t| \"fec_overlay_unsupported\"; // mmt-02 7.4.4\n\nexport interface FecCatalogIssue {\n\treadonly code: FecCatalogCode;\n\t/** JSON Pointer into the catalog. */\n\treadonly path: string;\n}\n\nexport interface FecCatalogInstance {\n\t/** JSON Pointer of the instance object, `/tracks/<i>/fec/<j>`. */\n\treadonly path: string;\n\treadonly track: string | null;\n\t/** Resolved Instance ID; null when `instanceId` is malformed. */\n\treadonly instanceId: number | null;\n\treadonly outcome: FecCatalogOutcome;\n\treadonly issues: readonly FecCatalogIssue[];\n\t/** Index into `tracks` of the source track declaring the instance. */\n\treadonly trackIndex: number;\n\t/** Track index of the repair track `repairTrack` names, or null. */\n\treadonly repairTrack: number | null;\n\t/** Track indices of the other repair tracks bound by `source` + `instanceId`. */\n\treadonly boundRepairTracks: readonly number[];\n\t/** Track index of the instance's symbol track (named or bound), or null. */\n\treadonly symbolTrack: number | null;\n}\n\nexport interface FecCatalogReport {\n\treadonly instances: readonly FecCatalogInstance[];\n\t/** Every report, instance-attributed or not, in discovery order. */\n\treadonly issues: readonly FecCatalogIssue[];\n}\n\nexport interface FecCatalogReceiver {\n\t/** Schemes the receiver implements. Default: reed-solomon, raptorq. */\n\treadonly schemes?: readonly string[];\n\t/** Block profiles the receiver implements. Default: moq-object-v1, iso-ssbg1-v1. */\n\treadonly blockProfiles?: readonly string[];\n\t/** Whether the receiver implements the mmt-02 7.4 keyframe overlay. Default true. */\n\treadonly overlays?: boolean;\n}\n\nconst DEFAULT_SCHEMES = [\"reed-solomon\", \"raptorq\"];\nconst DEFAULT_PROFILES = [\"moq-object-v1\", \"iso-ssbg1-v1\"];\nconst RS_MAX_BLOCK = 255; // block-codes-00 7\nconst RAPTORQ_MAX_K = 56403; // block-codes-00 7\nconst MAX_24 = 0xffffff; // fec-core-00 10.3 rule 5\nconst ISO_MIN_T = 14; // mmt-02 7.1\nconst GEOMETRY_KEYS = [\n\t\"scheme\",\n\t\"blockProfile\",\n\t\"mmtpMode\",\n\t\"timescale\",\n\t\"symbolSize\",\n\t\"interleaveDepthMs\",\n\t\"reorderToleranceMs\",\n\t\"sourceSymbols\",\n\t\"fec\",\n];\n\ntype Json = unknown;\ntype Known =\n\t| \"tracks\" | \"fec\" | \"name\" | \"namespace\" | \"packaging\" | \"source\" | \"instanceId\"\n\t| \"repairLayer\" | \"repairSymbols\" | \"priority\" | \"scheme\" | \"blockProfile\"\n\t| \"sourceSymbols\" | \"symbolSize\" | \"repairTrack\" | \"symbolTrack\"\n\t| \"interleaveDepthMs\" | \"reorderToleranceMs\" | \"label\" | \"scope\";\ntype Obj = Record<string, Json> & Partial<Record<Known, Json>>;\n\nconst isObj = (v: Json): v is Obj => typeof v === \"object\" && v !== null && !Array.isArray(v);\nconst has = (o: Obj, k: string) => Object.prototype.hasOwnProperty.call(o, k);\nconst isInt = (v: Json): v is number => typeof v === \"number\" && Number.isInteger(v);\nconst intIn = (v: Json, lo: number, hi = Number.MAX_SAFE_INTEGER): v is number =>\n\tisInt(v) && v >= lo && v <= hi;\n\n// moq-object-v1 admits any packaging whose objects carry media as whole\n// objects (fec-core-00 15.3); iso-ssbg1-v1 admits mmtp (mmt-02 7.1).\nconst profileAdmits = (profile: string, packaging: Json) => profile !== \"iso-ssbg1-v1\" || packaging === \"mmtp\";\n\ninterface Inst {\n\tpath: string;\n\tti: number;\n\ttrack: Obj;\n\tobj: Obj | null;\n\tid: number | null;\n\tunavailable: boolean;\n\toverlay: boolean;\n\tissues: FecCatalogIssue[];\n\tlayer0: number | null; // track index of the resolved repairTrack\n\tsymbolNamed: number | null;\n\tattached: number[]; // unnamed fec-repair/fec-source tracks bound by source\n}\n\nexport function validateFecCatalog(catalog: Json, receiver: FecCatalogReceiver = {}): FecCatalogReport {\n\tconst schemes = receiver.schemes ?? DEFAULT_SCHEMES;\n\tconst profiles = receiver.blockProfiles ?? DEFAULT_PROFILES;\n\tconst overlays = receiver.overlays ?? true;\n\tconst all: FecCatalogIssue[] = [];\n\tconst report = (code: FecCatalogCode, path: string, inst?: Inst) => {\n\t\tconst issue = { code, path };\n\t\tall.push(issue);\n\t\tinst?.issues.push(issue);\n\t};\n\n\tconst tracks: Json[] = isObj(catalog) && Array.isArray(catalog.tracks) ? catalog.tracks : [];\n\tif (!isObj(catalog) || !Array.isArray(catalog.tracks)) report(\"fec_member\", \"/tracks\");\n\n\t// Rule 4: enhancementRepair at track level or inside an instance object.\n\tlet rejectAll = false;\n\ttracks.forEach((t, ti) => {\n\t\tif (!isObj(t)) return;\n\t\tif (has(t, \"enhancementRepair\")) {\n\t\t\trejectAll = true;\n\t\t\treport(\"fec_enhancement_repair\", `/tracks/${ti}/enhancementRepair`);\n\t\t}\n\t\tif (Array.isArray(t.fec))\n\t\t\tt.fec.forEach((o, ii) => {\n\t\t\t\tif (isObj(o) && has(o, \"enhancementRepair\")) {\n\t\t\t\t\trejectAll = true;\n\t\t\t\t\treport(\"fec_enhancement_repair\", `/tracks/${ti}/fec/${ii}/enhancementRepair`);\n\t\t\t\t}\n\t\t\t});\n\t});\n\n\t// Track lookup by (namespace, name): a name resolves in the source track's namespace.\n\tconst key = (ns: Json, name: string) => JSON.stringify([ns ?? null, name]);\n\tconst byName = new Map<string, number>();\n\ttracks.forEach((t, ti) => {\n\t\tif (isObj(t) && typeof t.name === \"string\") {\n\t\t\tconst k = key(t.namespace, t.name);\n\t\t\tif (!byName.has(k)) byName.set(k, ti);\n\t\t}\n\t});\n\tconst trackAt = (ti: number) => tracks[ti] as Obj;\n\n\t// Collect instances.\n\tconst insts: Inst[] = [];\n\ttracks.forEach((t, ti) => {\n\t\t// A repair or symbol track's own `fec` is geometry (core rule 8), not instances.\n\t\tif (!isObj(t) || !has(t, \"fec\") || t.packaging === \"fec-repair\" || t.packaging === \"fec-source\") return;\n\t\tif (!Array.isArray(t.fec)) {\n\t\t\treport(\"fec_member\", `/tracks/${ti}/fec`);\n\t\t\treturn;\n\t\t}\n\t\tt.fec.forEach((o, ii) => {\n\t\t\tconst path = `/tracks/${ti}/fec/${ii}`;\n\t\t\tconst inst: Inst = {\n\t\t\t\tpath,\n\t\t\t\tti,\n\t\t\t\ttrack: t,\n\t\t\t\tobj: isObj(o) ? o : null,\n\t\t\t\tid: null,\n\t\t\t\tunavailable: false,\n\t\t\t\toverlay: false,\n\t\t\t\tissues: [],\n\t\t\t\tlayer0: null,\n\t\t\t\tsymbolNamed: null,\n\t\t\t\tattached: [],\n\t\t\t};\n\t\t\tinsts.push(inst);\n\t\t\tif (!inst.obj) return report(\"fec_member\", path, inst);\n\t\t\tconst o2 = inst.obj;\n\t\t\tif (!has(o2, \"instanceId\")) inst.id = 0;\n\t\t\telse if (intIn(o2.instanceId, 0, 255)) inst.id = o2.instanceId;\n\t\t\telse report(\"fec_member\", `${path}/instanceId`, inst);\n\t\t\tinst.overlay = has(o2, \"scope\");\n\t\t});\n\t});\n\tconst declaresFec = insts.length > 0;\n\n\t// Availability (fec-core-00 10.3 rule 1). Scheme first, then profile.\n\tfor (const inst of insts) {\n\t\tconst o = inst.obj;\n\t\tif (!o) continue;\n\t\tconst avail = (field: \"scheme\" | \"blockProfile\", impl: readonly string[]) => {\n\t\t\tconst p = `${inst.path}/${field}`;\n\t\t\tconst absent = field === \"scheme\" ? \"fec_scheme_absent\" : \"fec_block_profile_absent\";\n\t\t\tconst unimpl = field === \"scheme\" ? \"fec_scheme_unimplemented\" : \"fec_block_profile_unimplemented\";\n\t\t\tif (!has(o, field)) return report(absent, p, inst), false;\n\t\t\tif (typeof o[field] !== \"string\") return report(\"fec_member\", p, inst), true;\n\t\t\tif (!impl.includes(o[field] as string)) return report(unimpl, p, inst), false;\n\t\t\treturn true;\n\t\t};\n\t\tif (!avail(\"scheme\", schemes) || !avail(\"blockProfile\", profiles)) inst.unavailable = true;\n\t\telse if (typeof o.blockProfile === \"string\" && !profileAdmits(o.blockProfile, inst.track.packaging)) {\n\t\t\treport(\"fec_block_profile_packaging\", `${inst.path}/blockProfile`, inst);\n\t\t\tinst.unavailable = true;\n\t\t}\n\t}\n\tconst live = (i: Inst) => !i.unavailable;\n\n\t// Rule 1 on instance members, and core rule 11.\n\tfor (const inst of insts.filter(live)) {\n\t\tconst o = inst.obj;\n\t\tif (!o) continue;\n\t\tconst p = inst.path;\n\t\tfor (const m of [\"sourceSymbols\", \"repairSymbols\", \"symbolSize\"])\n\t\t\tif (!intIn(o[m], 1)) report(\"fec_member\", `${p}/${m}`, inst);\n\t\tif (typeof o.repairTrack !== \"string\") report(\"fec_member\", `${p}/repairTrack`, inst);\n\t\tif (has(o, \"symbolTrack\") && typeof o.symbolTrack !== \"string\") report(\"fec_member\", `${p}/symbolTrack`, inst);\n\t\tif (has(o, \"interleaveDepthMs\")) {\n\t\t\tconst d = o.interleaveDepthMs;\n\t\t\tif (!intIn(d, 0) || (d === 0 && o.blockProfile !== \"iso-ssbg1-v1\"))\n\t\t\t\treport(\"fec_member\", `${p}/interleaveDepthMs`, inst);\n\t\t}\n\t\tif (has(o, \"reorderToleranceMs\")) {\n\t\t\tconst r = o.reorderToleranceMs;\n\t\t\tif (typeof r !== \"number\" || !Number.isFinite(r) || r < 0) report(\"fec_member\", `${p}/reorderToleranceMs`, inst);\n\t\t}\n\t\tif (has(o, \"label\") && typeof o.label !== \"string\") report(\"fec_member\", `${p}/label`, inst);\n\t\tif (inst.overlay && o.scope !== \"keyframe\") report(\"fec_member\", `${p}/scope`, inst);\n\t}\n\n\t// Core rule 7: unique Instance IDs within a track.\n\tconst idGroups = new Map<string, Inst[]>();\n\tfor (const inst of insts)\n\t\tif (inst.id !== null) {\n\t\t\tconst k = `${inst.ti}:${inst.id}`;\n\t\t\tidGroups.set(k, [...(idGroups.get(k) ?? []), inst]);\n\t\t}\n\tfor (const group of idGroups.values())\n\t\tif (group.length > 1)\n\t\t\tfor (const inst of group.filter(live))\n\t\t\t\treport(\"fec_instance_id\", inst.obj && has(inst.obj, \"instanceId\") ? `${inst.path}/instanceId` : inst.path, inst);\n\n\t// Rule 2: repairTrack / symbolTrack name a track of the right packaging, once\n\t// per catalog. A name used twice binds no layer 0 or symbol track for either\n\t// namer; the track is still claimed by the first, so it is not re-attached.\n\tconst nameUses = new Map<string, { inst: Inst; field: \"repairTrack\" | \"symbolTrack\" }[]>();\n\tfor (const inst of insts)\n\t\tfor (const field of [\"repairTrack\", \"symbolTrack\"] as const)\n\t\t\tif (inst.obj && typeof inst.obj[field] === \"string\") {\n\t\t\t\tconst k = key(inst.track.namespace, inst.obj[field] as string);\n\t\t\t\tnameUses.set(k, [...(nameUses.get(k) ?? []), { inst, field }]);\n\t\t\t}\n\tconst namedBy = new Map<number, Inst>();\n\tfor (const [k, uses] of nameUses) {\n\t\tconst tj = byName.get(k);\n\t\tfor (const { inst, field } of uses) {\n\t\t\tconst path = `${inst.path}/${field}`;\n\t\t\tif (tj === undefined || trackAt(tj).packaging !== (field === \"repairTrack\" ? \"fec-repair\" : \"fec-source\")) {\n\t\t\t\tif (live(inst)) report(\"fec_naming\", path, inst);\n\t\t\t} else if (uses.length > 1) {\n\t\t\t\tif (live(inst)) report(\"fec_naming\", path, inst);\n\t\t\t\tif (!namedBy.has(tj)) namedBy.set(tj, inst);\n\t\t\t} else {\n\t\t\t\tnamedBy.set(tj, inst);\n\t\t\t\tif (field === \"repairTrack\") inst.layer0 = tj;\n\t\t\t\telse inst.symbolNamed = tj;\n\t\t\t}\n\t\t}\n\t}\n\n\t// Repair and symbol tracks: resolve each to its instance.\n\tconst owner = new Map<number, Inst>();\n\tif (declaresFec)\n\t\ttracks.forEach((t, tj) => {\n\t\t\tif (!isObj(t) || (t.packaging !== \"fec-repair\" && t.packaging !== \"fec-source\")) return;\n\t\t\tconst tp = `/tracks/${tj}`;\n\t\t\tconst named = namedBy.get(tj);\n\t\t\tif (named) {\n\t\t\t\towner.set(tj, named);\n\t\t\t\tif (!live(named)) return;\n\t\t\t\t// Rule 3: a named track's source / instanceId agree with its namer.\n\t\t\t\tif (has(t, \"source\") && t.source !== named.track.name) report(\"fec_resolution\", `${tp}/source`, named);\n\t\t\t\tif (has(t, \"instanceId\") && t.instanceId !== named.id) report(\"fec_resolution\", `${tp}/instanceId`, named);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (!has(t, \"source\")) return report(\"fec_orphan\", tp); // rule 6, stops nothing\n\t\t\tconst id = has(t, \"instanceId\") ? t.instanceId : 0;\n\t\t\tconst src = typeof t.source === \"string\" ? byName.get(key(t.namespace, t.source)) : undefined;\n\t\t\tconst target = src === undefined ? undefined : insts.find((i) => i.ti === src && i.id === id);\n\t\t\tif (!target) return report(\"fec_naming\", has(t, \"instanceId\") && src !== undefined ? `${tp}/instanceId` : `${tp}/source`);\n\t\t\towner.set(tj, target);\n\t\t\ttarget.attached.push(tj);\n\t\t});\n\n\t// Track fields (rule 1) and no geometry (core rule 8), charged to the owner.\n\tfor (const [tj, inst] of owner) {\n\t\tif (!live(inst)) continue;\n\t\tconst t = trackAt(tj);\n\t\tconst tp = `/tracks/${tj}`;\n\t\tconst symbol = t.packaging === \"fec-source\";\n\t\tif (has(t, \"source\") && typeof t.source !== \"string\") report(\"fec_member\", `${tp}/source`, inst);\n\t\tif (has(t, \"instanceId\") && !intIn(t.instanceId, 0, 255)) report(\"fec_member\", `${tp}/instanceId`, inst);\n\t\tfor (const m of [\"repairLayer\", \"repairSymbols\"]) {\n\t\t\tif (!has(t, m)) continue;\n\t\t\tif (symbol || !intIn(t[m], m === \"repairLayer\" ? 0 : 1)) report(\"fec_member\", `${tp}/${m}`, inst);\n\t\t}\n\t\tif (has(t, \"priority\") && !intIn(t.priority, symbol ? 0 : 192, 255)) report(\"fec_member\", `${tp}/priority`, inst);\n\t\tfor (const k of Object.keys(t))\n\t\t\tif (GEOMETRY_KEYS.includes(k) || k.startsWith(\"groupDuration\"))\n\t\t\t\treport(\"fec_track_geometry\", `${tp}/${k.replace(/~/g, \"~0\").replace(/\\//g, \"~1\")}`, inst); // RFC 6901\n\t}\n\n\t// Per instance: layers, symbol tracks, priorities, scheme bounds.\n\tfor (const inst of insts.filter(live)) {\n\t\tconst o = inst.obj;\n\t\tif (!o) continue;\n\t\tconst repairs = inst.attached.filter((tj) => trackAt(tj).packaging === \"fec-repair\");\n\t\tconst symbols = inst.attached.filter((tj) => trackAt(tj).packaging === \"fec-source\");\n\t\tif (symbols.length + (inst.symbolNamed !== null ? 1 : 0) > 1)\n\t\t\tfor (const tj of symbols) report(\"fec_symbol_tracks\", `/tracks/${tj}`, inst);\n\n\t\tconst l0 = inst.layer0 === null ? null : trackAt(inst.layer0);\n\t\tconst l0p = `/tracks/${inst.layer0}`;\n\t\tif (inst.overlay) {\n\t\t\t// mmt-02 7.4.1: an overlay has one repair track and no repairLayer.\n\t\t\tif (l0 && has(l0, \"repairLayer\")) report(\"fec_overlay\", `${l0p}/repairLayer`, inst);\n\t\t\tfor (const tj of repairs)\n\t\t\t\treport(\"fec_overlay\", has(trackAt(tj), \"repairLayer\") ? `/tracks/${tj}/repairLayer` : `/tracks/${tj}`, inst);\n\t\t} else {\n\t\t\tif (l0 && has(l0, \"repairLayer\") && l0.repairLayer !== 0) report(\"fec_layers\", `${l0p}/repairLayer`, inst);\n\t\t\tif (l0 && has(l0, \"repairSymbols\") && intIn(o.repairSymbols, 1) && l0.repairSymbols !== o.repairSymbols)\n\t\t\t\treport(\"fec_layer0\", `${l0p}/repairSymbols`, inst);\n\t\t\tconst layers: { tj: number; layer: number }[] = [];\n\t\t\tfor (const tj of repairs) {\n\t\t\t\tconst t = trackAt(tj);\n\t\t\t\tif (!has(t, \"repairLayer\")) report(\"fec_member\", `/tracks/${tj}/repairLayer`, inst);\n\t\t\t\telse if (t.repairLayer === 0) report(\"fec_layers\", `/tracks/${tj}/repairLayer`, inst);\n\t\t\t\telse if (intIn(t.repairLayer, 1)) layers.push({ tj, layer: t.repairLayer });\n\t\t\t\tif (!has(t, \"repairSymbols\")) report(\"fec_member\", `/tracks/${tj}/repairSymbols`, inst);\n\t\t\t}\n\t\t\t// Core rule 3: layers 1..n contiguous, no duplicates.\n\t\t\tconst counts = new Map<number, number>();\n\t\t\tfor (const { layer } of layers) counts.set(layer, (counts.get(layer) ?? 0) + 1);\n\t\t\tlet missing = 1;\n\t\t\twhile (counts.has(missing)) missing++;\n\t\t\tfor (const { tj, layer } of layers)\n\t\t\t\tif (counts.get(layer)! > 1 || layer > missing) report(\"fec_layers\", `/tracks/${tj}/repairLayer`, inst);\n\t\t\t// Core rule 10: priority non-decreasing with layer; layer 0 defaults to 240.\n\t\t\tconst chain = [\n\t\t\t\t{ layer: 0, tj: inst.layer0, pri: l0 && intIn(l0.priority, 192, 255) ? l0.priority : l0 && has(l0, \"priority\") ? null : 240 },\n\t\t\t\t...layers.map(({ tj, layer }) => {\n\t\t\t\t\tconst pr = trackAt(tj).priority;\n\t\t\t\t\treturn { layer, tj, pri: intIn(pr, 192, 255) ? pr : null };\n\t\t\t\t}),\n\t\t\t].sort((a, b) => a.layer - b.layer);\n\t\t\tlet prev: number | null = null;\n\t\t\tfor (const c of chain) {\n\t\t\t\tif (c.pri === null) continue;\n\t\t\t\tif (prev !== null && c.pri < prev) report(\"fec_priority\", `/tracks/${c.tj}/priority`, inst);\n\t\t\t\tprev = Math.max(prev ?? c.pri, c.pri);\n\t\t\t}\n\t\t}\n\n\t\t// Core rule 5 and the scheme / profile bounds.\n\t\tconst K = o.sourceSymbols;\n\t\tconst T = o.symbolSize;\n\t\tconst P0 = o.repairSymbols;\n\t\tif (intIn(K, 1) && intIn(P0, 1)) {\n\t\t\tlet P = P0;\n\t\t\tif (!inst.overlay)\n\t\t\t\tfor (const tj of repairs) {\n\t\t\t\t\tconst t = trackAt(tj);\n\t\t\t\t\tif (intIn(t.repairLayer, 1) && intIn(t.repairSymbols, 1)) P += t.repairSymbols;\n\t\t\t\t}\n\t\t\tif (P > MAX_24 || K + P - 1 > MAX_24) report(\"fec_bounds\", inst.path, inst);\n\t\t\telse if (o.scheme === \"reed-solomon\" && K + P > RS_MAX_BLOCK) report(\"fec_bounds\", inst.path, inst);\n\t\t}\n\t\tif (o.scheme === \"raptorq\" && intIn(K, 1) && K > RAPTORQ_MAX_K) report(\"fec_bounds\", `${inst.path}/sourceSymbols`, inst);\n\t\tif (intIn(T, 1)) {\n\t\t\tif (o.scheme === \"raptorq\" && T % 8 !== 0) report(\"fec_bounds\", `${inst.path}/symbolSize`, inst);\n\t\t\telse if (o.blockProfile === \"iso-ssbg1-v1\" && T < ISO_MIN_T) report(\"fec_bounds\", `${inst.path}/symbolSize`, inst);\n\t\t}\n\t}\n\n\t// mmt-02 7.4.1 / 7.4.4: overlays, per source track.\n\tconst overlayTracks = new Set(insts.filter((i) => live(i) && i.overlay).map((i) => i.ti));\n\tfor (const ti of overlayTracks) {\n\t\tconst ofTrack = insts.filter((i) => i.ti === ti && live(i));\n\t\tconst ovs = ofTrack.filter((i) => i.overlay);\n\t\tif (!overlays) {\n\t\t\tfor (const ov of ovs) report(\"fec_overlay_unsupported\", `${ov.path}/scope`, ov);\n\t\t\tfor (const i of ofTrack) i.issues.length === 0 && i.issues.push({ code: \"fec_overlay_unsupported\", path: `${ovs[0].path}/scope` });\n\t\t\tcontinue;\n\t\t}\n\t\tif (ovs.length > 1) for (const ov of ovs) report(\"fec_overlay\", `${ov.path}/scope`, ov);\n\t\tfor (const ov of ovs)\n\t\t\tif (ov.obj?.blockProfile !== \"iso-ssbg1-v1\") report(\"fec_overlay\", `${ov.path}/blockProfile`, ov);\n\t\tconst cause = ovs.flatMap((ov) => ov.issues).find((x) => x.code === \"fec_overlay\");\n\t\tif (cause) for (const ov of ovs) if (ov.issues.length === 0) ov.issues.push(cause);\n\t}\n\n\t// Rule 4 rejects every instance; each carries the cause so a consumer reading\n\t// `instances[].issues` sees why.\n\tconst rule4 = all.find((x) => x.code === \"fec_enhancement_repair\");\n\tif (rejectAll && rule4) for (const i of insts) if (i.issues.length === 0) i.issues.push(rule4);\n\n\treturn {\n\t\tinstances: insts.map((i) => ({\n\t\t\tpath: i.path,\n\t\t\ttrack: typeof i.track.name === \"string\" ? i.track.name : null,\n\t\t\tinstanceId: i.id,\n\t\t\toutcome: rejectAll || (!i.unavailable && i.issues.length > 0) ? \"violation\" : i.unavailable ? \"unavailable\" : \"ok\",\n\t\t\tissues: i.issues,\n\t\t\ttrackIndex: i.ti,\n\t\t\trepairTrack: i.layer0,\n\t\t\tboundRepairTracks: i.attached.filter((tj) => trackAt(tj).packaging === \"fec-repair\"),\n\t\t\tsymbolTrack: i.symbolNamed ?? i.attached.find((tj) => trackAt(tj).packaging === \"fec-source\") ?? null,\n\t\t})),\n\t\tissues: all,\n\t};\n}\n", "/**\n * Runtime FEC bindings (BLO-50171): what each source track's FEC resolves to\n * for this library's receiver, read from `validateFecCatalog`'s per-instance\n * result and never re-derived.\n *\n * Every runtime reader (the container's `resolveCatalogTrackFec`,\n * `resolveRepairLayers`, `MulticastManager.configureMultiTrack`, transport's\n * `catalogRepairTracks`) goes through `resolveFecBindings`, so they cannot\n * disagree with the validator or with each other about which tracks have FEC\n * and which repair tracks belong to them.\n *\n * The draft-ramadan-moq-fec-catalog-00 array is the only shape. Both catalog\n * dialects bind through it: an MSF `tracks[]` entry directly, and a hang\n * `video`/`audio` rendition (`fec` the same array) with its `repair.renditions`\n * entries (`source` + `instanceId`), mapped onto a `tracks[]` view so the same\n * validator runs. A hang rendition is authoritative over a `tracks[]` entry of\n * the same name.\n *\n * Fail closed per instance: a source whose base instance is not `ok` resolves\n * to `unavailable` with the validator's code and path, and costs no other\n * track anything. A draft-01 object `fec` is `fec_member` at that track's\n * `fec`: the track plays without FEC.\n */\nimport { validateFecCatalog, type FecCatalogCode, type FecCatalogReceiver } from './fec-catalog.js'\n\n/** The receiver this runtime is: it decodes `iso-ssbg1-v1` only. */\nexport const RUNTIME_FEC_RECEIVER: FecCatalogReceiver = Object.freeze({\n schemes: Object.freeze(['raptorq', 'reed-solomon']),\n blockProfiles: Object.freeze(['iso-ssbg1-v1']),\n overlays: true,\n})\n\n/** Repair track priority when the track states none (MoQT scale, 192..255). */\nexport const DEFAULT_FEC_REPAIR_PRIORITY = 240\n\n/** One `ok` instance's geometry. */\nexport interface FecInstanceGeometry {\n readonly instanceId: number\n readonly scheme: 'raptorq' | 'reed-solomon'\n /** `iso-ssbg1-v1` under `RUNTIME_FEC_RECEIVER`. */\n readonly blockProfile: string\n /** K. */\n readonly sourceSymbols: number\n /** P_0 for a base instance; the overlay's own P for an overlay. */\n readonly repairSymbols: number\n /** T. */\n readonly symbolSize: number\n readonly interleaveDepthMs?: number\n readonly reorderToleranceMs?: number\n readonly repairTrack: string\n readonly symbolTrack?: string\n}\n\nexport interface FecLayerBinding {\n readonly layer: number\n readonly repairTrackName: string\n readonly repairSymbols: number\n /** O_i = sum(P_j, j < i). */\n readonly rsIdOffset: number\n readonly priority: number\n}\n\nexport interface FecOverlayBinding {\n readonly repairTrackName: string\n readonly scope: 'keyframe'\n readonly sourceSymbols: number\n readonly repairSymbols: number\n readonly priority: number\n readonly geometry: FecInstanceGeometry\n}\n\nexport interface FecBindingUnavailable {\n /** The validator's code, or `fec_base_instances` for a track with no base\n * instance or more than one `ok` one (the MMTP wire carries one base Source FEC Payload\n * ID, mmt-02 7.4.1). */\n readonly code: FecCatalogCode | 'fec_base_instances'\n /** JSON Pointer into the catalog (a hang path starts `/video` or `/audio`). */\n readonly path: string\n readonly outcome: 'unavailable' | 'violation'\n}\n\nexport type FecSourceBinding =\n | {\n readonly status: 'ok'\n readonly track: string\n /** JSON Pointer of the source track (`/tracks/<i>`, `/video/renditions/<name>`). */\n readonly pointer: string\n readonly base: FecInstanceGeometry\n /** Base layers in order, layer 0 first. */\n readonly layers: readonly FecLayerBinding[]\n /** Sum of P over the base layers: the wire RSB_length. */\n readonly totalBaseRepairSymbols: number\n readonly overlay?: FecOverlayBinding\n /** Why a declared overlay is not used, when one is not. */\n readonly overlayUnavailable?: FecBindingUnavailable\n }\n | {\n readonly status: 'unavailable'\n readonly track: string\n readonly pointer: string\n readonly reason: FecBindingUnavailable\n }\n\ntype Obj = Record<string, unknown>\nconst recordOf = (v: unknown): Obj | undefined =>\n typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Obj) : undefined\nconst escape = (s: string) => s.replace(/~/g, '~0').replace(/\\//g, '~1')\n\n/**\n * The `tracks[]` view of a catalog in either dialect, with each view track's\n * JSON Pointer in the original catalog.\n */\nexport function fecCatalogTracks(catalog: unknown): { tracks: unknown[]; pointers: string[] } {\n const root = recordOf(catalog) ?? {}\n const tracks: unknown[] = []\n const pointers: string[] = []\n const hangNames = new Set<string>()\n for (const role of ['video', 'audio'] as const) {\n for (const [name, r] of Object.entries(recordOf(recordOf(root[role])?.['renditions']) ?? {})) {\n const entry = recordOf(r)\n if (!entry) continue\n hangNames.add(name)\n const kind = recordOf(entry['container'])?.['kind']\n // A hang rendition carries no `packaging`; its container kind is it.\n // `mmtp-fec` is hang's kind for MMTP that carries FEC (the FFmpeg\n // publisher's `moq_publish_set_video_packaging`): MMTP packaging.\n const view: Obj = { name, packaging: kind === undefined || kind === 'mmtp-fec' ? 'mmtp' : kind }\n if (entry['fec'] !== undefined) view['fec'] = entry['fec']\n tracks.push(view)\n pointers.push(`/${role}/renditions/${escape(name)}`)\n }\n }\n const hangRenditions = tracks.length\n for (const [name, r] of Object.entries(recordOf(recordOf(root['repair'])?.['renditions']) ?? {})) {\n hangNames.add(name)\n tracks.push({ packaging: 'fec-repair', ...recordOf(r), name })\n pointers.push(`/repair/renditions/${escape(name)}`)\n }\n const raw = Array.isArray(root['tracks']) ? root['tracks'] : []\n const named = new Set(hangNames)\n raw.forEach((t, i) => {\n const name = recordOf(t)?.['name']\n if (typeof name === 'string') named.add(name)\n if (typeof name === 'string' && hangNames.has(name)) return\n tracks.push(t)\n pointers.push(`/tracks/${i}`)\n })\n // hang layer 0 needs no `repair.renditions` entry: the rendition's instance\n // names it, and a hang Root (hang's toHang) carries no repair block. Such a\n // name stands for a repair track of that instance.\n tracks.slice(0, hangRenditions).forEach((t, ti) => {\n const view = t as Obj\n if (!Array.isArray(view['fec'])) return\n view['fec'].forEach((o, ii) => {\n const repairTrack = recordOf(o)?.['repairTrack']\n if (typeof repairTrack !== 'string' || named.has(repairTrack)) return\n named.add(repairTrack)\n const instanceId = recordOf(o)?.['instanceId']\n tracks.push({ name: repairTrack, packaging: 'fec-repair', source: view['name'], ...(instanceId === undefined ? {} : { instanceId }) })\n pointers.push(`${pointers[ti]}/fec/${ii}/repairTrack`)\n })\n })\n return { tracks, pointers }\n}\n\n/**\n * Every source track's runtime FEC binding, by track name. A track with no\n * `fec`, or an empty `fec` array, declares no FEC and has no entry.\n */\nexport function resolveFecBindings(\n catalog: unknown,\n receiver: FecCatalogReceiver = RUNTIME_FEC_RECEIVER,\n): ReadonlyMap<string, FecSourceBinding> {\n const { tracks, pointers } = fecCatalogTracks(catalog)\n const report = validateFecCatalog({ tracks }, receiver)\n const at = (path: string) => {\n const m = /^\\/tracks\\/(\\d+)(.*)$/.exec(path)\n return m ? pointers[Number(m[1])] + m[2] : path\n }\n const trackAt = (i: number) => recordOf(tracks[i]) ?? {}\n const nameAt = (i: number | null) => (i === null ? undefined : (trackAt(i)['name'] as string))\n const why = (i: { outcome: string; issues: readonly { code: FecCatalogCode; path: string }[]; path: string }) =>\n Object.freeze({\n code: i.issues[0]?.code ?? 'fec_member',\n path: at(i.issues[0]?.path ?? i.path),\n outcome: i.outcome === 'unavailable' ? ('unavailable' as const) : ('violation' as const),\n })\n\n const out = new Map<string, FecSourceBinding>()\n tracks.forEach((t, ti) => {\n const track = recordOf(t)\n const name = track?.['name']\n if (!track || typeof name !== 'string' || out.has(name)) return\n if (track['packaging'] === 'fec-repair' || track['packaging'] === 'fec-source') return\n const fec = track['fec']\n if (fec === undefined) return\n if (!Array.isArray(fec)) {\n const reason = { code: 'fec_member' as const, path: at(`/tracks/${ti}/fec`), outcome: 'violation' as const }\n out.set(name, Object.freeze({ status: 'unavailable', track: name, pointer: pointers[ti]!, reason: Object.freeze(reason) }))\n return\n }\n if (fec.length === 0) return\n const insts = report.instances.filter((i) => i.trackIndex === ti)\n const isOverlay = (i: (typeof insts)[number]) => Object.hasOwn(recordOf(fec[Number(i.path.split('/').pop())]) ?? {}, 'scope')\n const bases = insts.filter((i) => !isOverlay(i))\n const okBases = bases.filter((i) => i.outcome === 'ok')\n if (okBases.length !== 1) {\n const reason =\n // No base instance at all (overlay-only `fec`) is a count of 0, not\n // the overlay's own issue.\n okBases.length > 1 || bases.length === 0\n ? Object.freeze({ code: 'fec_base_instances' as const, path: at(`/tracks/${ti}/fec`), outcome: 'violation' as const })\n : why(bases[0] ?? insts[0]!)\n out.set(name, Object.freeze({ status: 'unavailable', track: name, pointer: pointers[ti]!, reason }))\n return\n }\n const b = okBases[0]!\n const base = geometry(recordOf(fec[Number(b.path.split('/').pop())])!, b.instanceId!, nameAt(b.symbolTrack))\n const priorityOf = (i: number | null) => {\n const p = i === null ? undefined : trackAt(i)['priority']\n return typeof p === 'number' ? p : DEFAULT_FEC_REPAIR_PRIORITY\n }\n const layers: FecLayerBinding[] = [\n { layer: 0, repairTrackName: base.repairTrack, repairSymbols: base.repairSymbols, rsIdOffset: 0, priority: priorityOf(b.repairTrack) },\n ]\n const more = b.boundRepairTracks\n .map((tj) => ({ tj, layer: trackAt(tj)['repairLayer'] as number }))\n .sort((x, y) => x.layer - y.layer)\n let offset = base.repairSymbols\n for (const { tj, layer } of more) {\n const repairSymbols = trackAt(tj)['repairSymbols'] as number\n layers.push({ layer, repairTrackName: nameAt(tj)!, repairSymbols, rsIdOffset: offset, priority: priorityOf(tj) })\n offset += repairSymbols\n }\n let overlay: FecOverlayBinding | undefined\n let overlayUnavailable: FecBindingUnavailable | undefined\n const ov = insts.find(isOverlay)\n if (ov?.outcome === 'ok') {\n const g = geometry(recordOf(fec[Number(ov.path.split('/').pop())])!, ov.instanceId!, nameAt(ov.symbolTrack))\n overlay = Object.freeze({\n repairTrackName: g.repairTrack,\n scope: 'keyframe' as const,\n sourceSymbols: g.sourceSymbols,\n repairSymbols: g.repairSymbols,\n priority: priorityOf(ov.repairTrack),\n geometry: g,\n })\n } else if (ov) overlayUnavailable = why(ov)\n out.set(\n name,\n Object.freeze({\n status: 'ok' as const,\n track: name,\n pointer: pointers[ti]!,\n base,\n layers: Object.freeze(layers.map((l) => Object.freeze(l))),\n totalBaseRepairSymbols: offset,\n ...(overlay ? { overlay } : {}),\n ...(overlayUnavailable ? { overlayUnavailable } : {}),\n }),\n )\n })\n return out\n}\n\n// The validator passed this instance for this receiver, so every member read\n// here is present and in range.\nfunction geometry(o: Obj, instanceId: number, symbolTrack: string | undefined): FecInstanceGeometry {\n return Object.freeze({\n instanceId,\n scheme: o['scheme'] as 'raptorq' | 'reed-solomon',\n blockProfile: o['blockProfile'] as string,\n sourceSymbols: o['sourceSymbols'] as number,\n repairSymbols: o['repairSymbols'] as number,\n symbolSize: o['symbolSize'] as number,\n ...(typeof o['interleaveDepthMs'] === 'number' ? { interleaveDepthMs: o['interleaveDepthMs'] } : {}),\n ...(typeof o['reorderToleranceMs'] === 'number' ? { reorderToleranceMs: o['reorderToleranceMs'] } : {}),\n repairTrack: o['repairTrack'] as string,\n ...(symbolTrack !== undefined ? { symbolTrack } : {}),\n })\n}\n", "/**\n * TrackSession \u2014 per-track transport state machine.\n * SessionManager \u2014 orchestrates all sessions + TransportPool.\n *\n * Implements Patent Claim 9 (draft-ramadan-moq-fec APPLICATION-1):\n * (a) subscribe unicast\n * (b) simultaneously join multicast\n * (c) render from unicast during join\n * (d) activate multicast on RAP, suppress unicast\n * (e) cross-transport FEC repair during transition\n * (f) hysteresis before switching away from multicast\n * (g) re-subscribe unicast on sustained failure\n *\n * PIM: manages transport state without inspecting packet contents.\n */\n\nimport {\n TransportPool,\n type Subscription,\n type SubscriptionMode,\n type AcquireOptions,\n type DirectSocketHealth,\n type TransportPoolOptions,\n} from './transport-pool.js'\nimport { fecBlockBudgetMs } from '@blockcast/mmt-fec'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport type TrackState =\n | 'idle'\n | 'unicast'\n | 'unicast-fallback'\n | 'joining'\n | 'multicast'\n | 'amt'\n | 'failing'\n\nexport interface TrackSessionConfig {\n trackName: string\n trackType: 'video' | 'audio'\n packetId: number\n /** FEC interleave depth from catalog (ms). Used for hysteresis timer. */\n fecInterleaveMs: number\n /** Explicit catalog jitter/reorder allowance in ms. */\n fecReorderToleranceMs?: number\n /** FEC K from catalog. Used for audio worklet latency calculation. */\n fecK: number\n /** FEC repair symbols per block (P). NOT symbol size. */\n fecRepairCount: number\n /**\n * Repair symbols per block this receiver actually joined: the sum of\n * `repairSymbols` over the joined BASE layers, not a count of layers\n * (draft-ramadan-moq-fec 6.3). A keyframe overlay is a separate FEC instance\n * and is not included.\n *\n * This is runtime join state, not catalog geometry: a receiver on a clean\n * path joins layer 0 only while a lossy one adds layers, so the catalog's\n * total P is not what this receiver is receiving. Diagnostics that report P\n * (the worker snapshot, CMCD `x-bc-fec-p`) must read this.\n *\n * Absent when the caller joins no layers explicitly, in which case P_0 is\n * what was joined. That is an identity on a single-layer track, not a\n * catalog default: with one layer, P_0 is the total.\n */\n joinedRepairCount?: number\n /** FEC symbol size in bytes (T). Required for WASM decoder OTI. */\n fecSymbolSize: number\n /** MoQ repair track name from catalog FEC config. */\n repairTrackName?: string\n /** Frame samples from initData parsing (audio-init-parser.ts). */\n frameSamples?: number\n /** Audio sample rate from catalog. */\n sampleRate?: number\n /** Ordered transport preference: best first. */\n preferredTransports: SubscriptionMode[]\n /** Subscription key for multicast/AMT: \"(S,G,port)\". */\n multicastKey?: string\n /** MoQ track name for unicast subscription. */\n moqTrackName?: string\n /** AMT relay address (if AMT is in preference chain). */\n amtRelay?: string\n /** MoQ relay URL (for WebTransport subscription). */\n moqRelayUrl?: string\n}\n\n// ---------------------------------------------------------------------------\n// PIM callbacks (protocol-independent \u2014 transport state only)\n// ---------------------------------------------------------------------------\n\nexport interface SessionManagerCallbacks {\n /** Fired on every transport state transition. */\n onStateChange?(trackName: string, from: TrackState, to: TrackState): void\n /** Fired to suppress/unsuppress unicast for this track (transport arbitration). */\n onSuppress?(trackName: string, suppressed: boolean): void\n /**\n * Fired when a track sat in `joining` for SessionManager.takeoverDeadlineMs\n * without a RAP (BLO-9413/D9). The session has already returned to `unicast`\n * (nothing was suppressed, so nothing to restore) and it keeps its FEC\n * decoder armed, because the multicast socket is still joined and the router\n * still admits the stream's symbols. The host should abandon the router-side\n * attempt (e.g. MediaRouter.failbackToMoq()) so the router stops waiting for\n * a keyframe that is never coming; it must not dispose the decoder here. The\n * decoder is released on the next transport edge (unsubscribe, failbackAll,\n * or transportFailed falling back to unicast) through fec-teardown.\n */\n onTakeoverTimeout?(trackName: string): void\n}\n\n// ---------------------------------------------------------------------------\n// MMTP application-layer extension (FEC lifecycle, media resets)\n// ---------------------------------------------------------------------------\n\n/**\n * MMTP track events (discriminated union).\n * Each protocol layer defines its own event union \u2014 MMTP events cover\n * media resets, latency, and FEC lifecycle.\n */\nexport type MmtpTrackEvent =\n | { type: 'reset' }\n | { type: 'latency'; latencyMs: number }\n | { type: 'fec-setup' }\n | { type: 'fec-teardown' }\n\n/**\n * MMTP application-layer extension of SessionManagerCallbacks.\n * Single onTrackEvent dispatches all MMTP-specific events.\n * PIM consumers use the base SessionManagerCallbacks (no onTrackEvent).\n *\n * IWA can define its own subinterface with its own TrackEvent union\n * (e.g., IwaTrackEvent for socket health, AMT state) using the same pattern.\n */\nexport interface MmtpSessionCallbacks extends SessionManagerCallbacks {\n onTrackEvent?(trackName: string, event: MmtpTrackEvent, config: TrackSessionConfig): void\n}\n\n/**\n * Loss-hysteresis window covering the signaled FEC block span plus jitter.\n *\n * `fecK` no longer feeds the math; it is still validated as a catalog\n * well-formedness guard and kept in the signature for positional API compat\n * (removal is a follow-up with the next cross-repo pin bump).\n */\nexport function computeFecLossWindowMs(fecInterleaveMs: number, fecK: number, jitterBufferMs?: number): number {\n return computeFecDeliveryWindowMs({\n interleaveDepthMs: fecInterleaveMs,\n sourceSymbols: fecK,\n jitterBufferMs,\n })\n}\n\nexport interface FecDeliveryWindowConfig {\n interleaveDepthMs: number\n sourceSymbols: number\n repairSymbols?: number\n symbolSize?: number\n mediaUnitsPerSecond?: number\n bandwidthBps?: number\n /**\n * Explicit jitter/reorder allowance in ms, added once to the complete span.\n * Callers pass their signaled catalog allowance, never a local default.\n */\n jitterBufferMs?: number\n}\n\n/**\n * The catalog interleaveDepthMs is the complete FEC block span, including the\n * repair tail. K, P, cadence, and bandwidth must not expand it again. Geometry\n * fields remain accepted for existing callers, but do not feed deadline math.\n */\nexport function computeFecDeliveryWindowMs(\n config: FecDeliveryWindowConfig,\n): number {\n const fecK = config.sourceSymbols\n if (!Number.isFinite(fecK) || fecK <= 0) {\n throw new Error(\n `[TrackSession] fecK=${fecK}. ` +\n `Derive a positive FEC source-symbol count from the stream catalog.`\n )\n }\n return fecSpanDeliveryWindowMs(config.interleaveDepthMs, config.jitterBufferMs)\n}\n\n/**\n * The delivery window of a \u00A75.1 span alone. Every window is this; K only\n * validates a track's geometry in computeFecDeliveryWindowMs, so a caller\n * with a span and no K (FECManager's stream-global envelope) uses this\n * rather than invent one.\n */\nexport function fecSpanDeliveryWindowMs(interleaveDepthMs: number, jitterBufferMs?: number): number {\n if (!Number.isFinite(interleaveDepthMs) || interleaveDepthMs <= 0) {\n throw new Error(\n `[TrackSession] fecInterleaveMs=${interleaveDepthMs}. ` +\n `Derive a positive FEC interleave window from the stream catalog.`\n )\n }\n // Absence stays absent; it does not authorize a synthesized reorder budget.\n if (jitterBufferMs === undefined) return Math.ceil(interleaveDepthMs)\n return fecBlockBudgetMs(interleaveDepthMs, jitterBufferMs)\n}\n\n/** Convert the shared FEC delivery deadline into an integer media timescale. */\nexport function computeFecDeliveryWindowMediaUnits(\n config: FecDeliveryWindowConfig,\n timestampTimescale: number,\n): number {\n if (!Number.isFinite(timestampTimescale) || timestampTimescale <= 0) {\n throw new Error(\n `[TrackSession] timestampTimescale=${timestampTimescale}. ` +\n 'Derive a positive media timescale from the container.',\n )\n }\n const deliveryWindow = Math.ceil(\n computeFecDeliveryWindowMs(config) * timestampTimescale / 1000,\n )\n if (!Number.isSafeInteger(deliveryWindow)) {\n throw new Error('[TrackSession] FEC delivery window exceeds the media timestamp range.')\n }\n return deliveryWindow\n}\n\n// ---------------------------------------------------------------------------\n// TrackSession\n// ---------------------------------------------------------------------------\n\nexport class TrackSession {\n readonly config: TrackSessionConfig\n #state: TrackState = 'idle'\n #subscription: Subscription | null = null\n #hysteresisTimer: ReturnType<typeof setTimeout> | null = null\n #joinDeadlineTimer: ReturnType<typeof setTimeout> | null = null\n #previousPrimary: SubscriptionMode | null = null\n #manager: SessionManager\n // Single suppression authority flag (BLO-9413 failback coherence): every\n // suppress this session fires is recorded here, and every path that leaves\n // a suppressed state unwinds through #unsuppress(). Before this flag,\n // unsubscribe() only un-suppressed from multicast/amt \u2014 a session torn down\n // from `failing` (exactly where a starving track sits) was re-subscribed to\n // unicast with the host's media still muted: MoQ playing into a dead sink.\n #suppressFired = false\n // Single FEC authority flag: set by the fec-setup edges (joinStarted,\n // enterUnicastFallback), cleared by the fec-teardown edge in #transition.\n // Setup and teardown are paired by construction, so a takeover timeout that\n // parks the session in `unicast` keeps the decoder the still-joined\n // transport is feeding, and the later unsubscribe still tears it down.\n #fecArmed = false\n\n constructor(config: TrackSessionConfig, manager: SessionManager) {\n this.config = config\n this.#manager = manager\n }\n\n // -----------------------------------------------------------------------\n // State\n // -----------------------------------------------------------------------\n\n get state(): TrackState { return this.#state }\n get subscription(): Subscription | null { return this.#subscription }\n\n /** True while this session's onSuppress(true) is outstanding (no matching\n * onSuppress(false) yet). Test/assertion surface for failback coherence. */\n get isSuppressed(): boolean { return this.#suppressFired }\n\n /** Current active transport mode, or null if idle. */\n get transport(): SubscriptionMode | null {\n switch (this.#state) {\n case 'multicast': return 'multicast'\n case 'amt': return 'amt'\n case 'unicast': return 'moq'\n case 'unicast-fallback': return 'moq' // SSM bridge absent \u2014 MoQ unicast is the only source\n case 'joining': return 'moq' // MoQ still primary during join\n case 'failing': return this.#previousPrimary\n default: return null\n }\n }\n\n // -----------------------------------------------------------------------\n // Computed (no magic numbers)\n // -----------------------------------------------------------------------\n\n /** Audio worklet latency: max(fecInterleaveMs, frameDurationMs * fecK).\n * Returns undefined if frame duration unknown (no initData). */\n get audioLatencyMs(): number | undefined {\n const fd = this.frameDurationMs\n if (fd === undefined) {\n return this.config.fecInterleaveMs > 0 ? this.config.fecInterleaveMs : undefined\n }\n return Math.max(this.config.fecInterleaveMs, fd * this.config.fecK)\n }\n\n /** Frame duration in ms, derived from frameSamples + sampleRate. */\n get frameDurationMs(): number | undefined {\n const { frameSamples, sampleRate } = this.config\n if (!frameSamples || !sampleRate || sampleRate <= 0) return undefined\n return (frameSamples / sampleRate) * 1000\n }\n\n // -----------------------------------------------------------------------\n // Transitions (Patent Claim 9)\n // -----------------------------------------------------------------------\n\n /** idle \u2192 unicast: player subscribes to this track. */\n subscribe(): void {\n if (this.#state !== 'idle') return\n this.#transition('unicast')\n\n // Emit audio latency on first subscribe\n if (this.config.trackType === 'audio') {\n const latency = this.audioLatencyMs\n if (latency !== undefined) {\n this.#manager.mmtp?.onTrackEvent?.(this.config.trackName, { type: 'latency', latencyMs: latency }, this.config)\n }\n }\n }\n\n /** unicast \u2192 joining: multicast/AMT capability detected, join in progress. */\n joinStarted(): void {\n if (this.#state !== 'unicast') return\n this.#transition('joining')\n // FEC decoders must be ready before multicast data arrives (Claim 9e:\n // cross-transport repair). A session re-joining after a takeover timeout\n // still owns its decoder, so fec-setup fires once per FEC epoch.\n this.#armFec()\n }\n\n /**\n * any non-fallback state \u2192 unicast-fallback. Used when the SSM bridge is\n * absent (vanilla Chromium / no extension) and the host falls back to a\n * MoQ unicast subscription per track. Terminal-ish: activateMulticast,\n * failbackAll, and transportFailed are no-ops while in this state.\n *\n * Fires fec-setup so the per-track repair worker spins up \u2014 repair packets\n * still flow over the MoQ repair-track subscription, so FEC source+repair\n * can converge as in the multicast path.\n *\n * Audio latency is NOT re-emitted here: subscribe() already fires latency\n * once on the idle \u2192 unicast edge, and enterUnicastFallback is only ever\n * reached from a non-idle state (SessionManager skips idle sessions).\n */\n enterUnicastFallback(): void {\n if (this.#state === 'unicast-fallback') return\n this.#clearHysteresis()\n this.#transition('unicast-fallback')\n // Suppress the redundant unicast media decoder. On the no-bridge path the\n // host feeds MoQ-unicast source packets into MulticastManager (the render\n // consumer); a second base-media subscribe (the @moq/watch decoder) would\n // split the single connection's QUIC flow-control budget and stall delivery\n // (BLO-8969). Fire suppress here, on the fallback edge, so the decoder is\n // gated off before it subscribes rather than after the relay forward task\n // has already wedged.\n this.#suppress()\n this.#armFec()\n }\n\n /**\n * unicast-fallback \u2192 unicast. Called when the SSM bridge appears mid-session\n * (extension installed after fallback engaged). Host first aborts the fallback\n * drainers, then transitions sessions back to unicast so the regular\n * multicast/AMT preference chain can take over via joinStarted().\n */\n exitUnicastFallback(): void {\n if (this.#state !== 'unicast-fallback') return\n this.#transition('unicast')\n // Un-suppress the unicast decoder: the bridge appeared mid-session, so the\n // host tears down the fallback drainers and the regular preference chain\n // (joinStarted -> joining -> rapReceived) will re-suppress on multicast RAP.\n this.#unsuppress()\n }\n\n /**\n * joining \u2192 multicast or amt: RAP received on the joined transport (Claim 9d).\n * Resets audio worklet, then suppresses MoQ.\n */\n rapReceived(mode: 'multicast' | 'amt'): void {\n if (this.#state !== 'joining') return\n this.#previousPrimary = mode\n this.#transition(mode)\n if (this.config.trackType === 'audio') {\n this.#manager.mmtp?.onTrackEvent?.(this.config.trackName, { type: 'reset' }, this.config)\n }\n this.#suppress()\n }\n\n /** multicast/amt \u2192 failing: loss detected, start hysteresis timer (Claim 9f). */\n lossDetected(): void {\n if (this.#state !== 'multicast' && this.#state !== 'amt') return\n\n // A zero/absent catalog span carries no elapsed FEC deadline. The stall\n // detector that calls this method already supplied the generic loss\n // evidence, so there is no FEC hysteresis window to derive here. Follow\n // the existing transport-failure edge immediately instead of inventing a\n // local timeout for a catalog value that explicitly supplied none.\n if (this.config.fecInterleaveMs === 0 || this.config.fecInterleaveMs === undefined) {\n this.transportFailed()\n return\n }\n\n this.#previousPrimary = this.#state as SubscriptionMode\n this.#transition('failing')\n\n // Hysteresis timer: sustained loss before failback (Claim 9f). Use the\n // same complete span plus explicit allowance as FECManager and workers.\n const hysteresisMs = computeFecLossWindowMs(\n this.config.fecInterleaveMs,\n this.config.fecK,\n this.config.fecReorderToleranceMs,\n )\n this.#hysteresisTimer = setTimeout(() => {\n this.hysteresisExpired()\n }, hysteresisMs)\n }\n\n /** failing \u2192 previous primary: loss recovered within hysteresis window. */\n lossRecovered(): void {\n if (this.#state !== 'failing') return\n this.#clearHysteresis()\n if (this.#previousPrimary) {\n this.#transition(this.#previousPrimary as TrackState)\n }\n }\n\n /**\n * Transport failed (AMT relay down, multicast interface gone).\n * Try next transport in preference chain.\n */\n transportFailed(): void {\n this.#clearHysteresis()\n // Unicast-fallback is terminal: the host owns the subscription and\n // SessionManager transport machinery does not arbitrate it.\n if (this.#state === 'unicast-fallback') return\n const current = this.#previousPrimary ?? this.transport\n const prefs = this.config.preferredTransports\n const currentIdx = current ? prefs.indexOf(current) : -1\n const nextMode = prefs[currentIdx + 1]\n\n if (nextMode !== undefined && nextMode !== 'moq') {\n // Next is multicast or AMT: start joining. `joining` means MoQ is\n // primary again (see transport getter), so if the failed transport had\n // suppressed unicast, restore it for the join window or the viewer\n // watches black video until the next RAP (which may never come).\n // The chain position moves with the attempt: a join that times out\n // parks the session in `unicast` with this transport recorded, so the\n // next failure continues past it rather than re-trying the transport\n // that already failed and re-firing onTakeoverTimeout on every lap.\n this.#previousPrimary = nextMode\n this.#transition('joining')\n this.#unsuppress()\n return\n }\n // MoQ is next, or the chain is exhausted: unicast either way (Claim 9g).\n // For a session parked in `unicast` with its decoder retained after a\n // takeover timeout, this transition is a self-edge and fires no teardown,\n // so the release is explicit. It is the \"transportFailed falling back to\n // unicast\" release path the onTakeoverTimeout doc names, and #disarmFec is\n // idempotent, so it is a no-op when the transition already fired the\n // teardown. The audio reset is not flag-guarded, so it is tied to the edge\n // itself: a session already resting in unicast changed no transport and\n // owes the host no reset, however many pool subscriptions fail for it.\n const from = this.#state\n this.#previousPrimary = null\n this.#transition('unicast')\n this.#disarmFec()\n this.#unsuppress()\n if (from !== 'unicast' && this.config.trackType === 'audio') {\n this.#manager.mmtp?.onTrackEvent?.(this.config.trackName, { type: 'reset' }, this.config)\n }\n }\n\n /** Hysteresis expired \u2192 failback to next transport (Claim 9g). */\n hysteresisExpired(): void {\n if (this.#state !== 'failing') return\n this.#clearHysteresis()\n this.transportFailed()\n }\n\n /** \u2192 idle: player unsubscribes / tab close / cleanup. */\n unsubscribe(): void {\n this.#clearHysteresis()\n if (this.#state !== 'idle') {\n this.#transition('idle')\n // Unwind any outstanding suppression regardless of which state we left.\n // The old `wasActive` check (multicast/amt only) leaked suppression when\n // tearing down from `failing` or `unicast-fallback` \u2014 failbackAll()'s\n // unsubscribe\u2192subscribe cycle then re-entered unicast with the host's\n // media still muted (BLO-9413 freeze: \"failing back\" logged, video dead).\n this.#unsuppress()\n }\n }\n\n /** True when SessionManager.enterUnicastFallback put this track into the\n * host-driven unicast subscription mode. Host can iterate sessions to\n * decide whether to keep its fallback drainer alive across MediaRouter\n * arbitration events. */\n get isUnicastFallback(): boolean {\n return this.#state === 'unicast-fallback'\n }\n\n /**\n * True while this track owns a FEC decoder (fec-setup fired, fec-teardown\n * not yet). Stays true across a takeover timeout that parked the session in\n * `unicast`; SessionManager.failbackAll uses it to release such sessions.\n */\n get isFecArmed(): boolean {\n return this.#fecArmed\n }\n\n /**\n * Start the deadline a `joining` entry deferred while the join was inactive\n * (SessionManager.joinActive). The track gets the FULL deadline from here,\n * because that is when media can first arrive. No-op outside `joining` and\n * when a deadline is already running, so it never shortens or restarts one.\n *\n * The `joinActive` guard is what makes this safe to call from anywhere: a\n * track parked in `joining` with a deferred (null) timer satisfies the other\n * two, so without it a host calling this while the join is still inactive\n * would arm the exact deadline the gate exists to defer -- silently\n * reinstating BLO-34289. It is a no-op for the internal caller, which writes\n * `#joinActive = true` before iterating the sessions.\n */\n armDeferredJoinDeadline(): void {\n if (this.#state !== 'joining' || this.#joinDeadlineTimer !== null) return\n if (!this.#manager.joinActive) return\n this.#armJoinDeadline(this.#manager.takeoverDeadlineMs)\n }\n\n dispose(): void {\n this.#clearHysteresis()\n this.#clearJoinDeadline()\n this.#state = 'idle'\n this.#subscription = null\n // Release through the teardown edge, not a bare assignment: the\n // retained-FEC park makes `unicast` + isFecArmed a resting state, and\n // SessionManager.dispose() calls this directly (removeTrack() reaches it\n // via unsubscribe(), which has already disarmed). A bare assignment there\n // drops the host's decoder without a fec-teardown -- a leaked Worker.\n this.#disarmFec()\n }\n\n // -----------------------------------------------------------------------\n // Internal\n // -----------------------------------------------------------------------\n\n #transition(to: TrackState, opts?: { retainFec?: boolean }): void {\n const from = this.#state\n if (from === to) return\n // Takeover deadline (BLO-9413/D9): every entry into `joining` arms it\n // (joinStarted AND transportFailed-failover both route through here);\n // every exit cancels it. Without this, a transport that accepts the\n // subscribe but never delivers a RAP parks the session in `joining`\n // forever (observed live in the Phase-0 headless repro). The value is\n // resolved before any state write, so the one residual failure -- a\n // manager whose deadline was never set at all, which addTrack() refuses --\n // leaves the session in its previous state rather than half-transitioned.\n // Invalid values never reach here: SessionManager's setter refuses them.\n // The read is unconditional even when `joinActive` defers the arm, so a\n // manager without a deadline still fails closed on every joining edge.\n const joinDeadlineMs = to === 'joining' ? this.#manager.takeoverDeadlineMs : undefined\n this.#state = to\n if (from === 'joining') this.#clearJoinDeadline()\n if (joinDeadlineMs !== undefined && this.#manager.joinActive) {\n this.#armJoinDeadline(joinDeadlineMs)\n }\n // FEC teardown: entering a state without a FEC decoder releases the one\n // this track armed, unless the caller keeps it (takeover timeout: the\n // transport is still joined and still feeding the decoder).\n const fecActive = (s: TrackState) =>\n s === 'joining' ||\n s === 'multicast' ||\n s === 'amt' ||\n s === 'failing' ||\n s === 'unicast-fallback'\n if (!fecActive(to) && !opts?.retainFec) this.#disarmFec()\n this.#manager.callbacks?.onStateChange?.(this.config.trackName, from, to)\n }\n\n #clearHysteresis(): void {\n if (this.#hysteresisTimer !== null) {\n clearTimeout(this.#hysteresisTimer)\n this.#hysteresisTimer = null\n }\n }\n\n #armJoinDeadline(deadlineMs: number): void {\n this.#clearJoinDeadline()\n if (deadlineMs === 0) return // a declared 0 disables the deadline\n this.#joinDeadlineTimer = setTimeout(() => {\n this.#joinDeadlineTimer = null\n if (this.#state !== 'joining') return\n // No RAP within the deadline: return to unicast for arbitration only.\n // MoQ was never suppressed in `joining`, so there is nothing to restore\n // and this edge stays silent on onSuppress (asserted in tests). The\n // multicast socket is still joined and the router still admits this\n // stream's symbols, so the FEC decoder stays armed; the next transport\n // edge releases it.\n this.#transition('unicast', { retainFec: true })\n this.#manager.callbacks?.onTakeoverTimeout?.(this.config.trackName)\n }, deadlineMs)\n }\n\n #clearJoinDeadline(): void {\n if (this.#joinDeadlineTimer !== null) {\n clearTimeout(this.#joinDeadlineTimer)\n this.#joinDeadlineTimer = null\n }\n }\n\n /** Fire fec-setup exactly once per FEC epoch. */\n #armFec(): void {\n if (this.#fecArmed) return\n this.#fecArmed = true\n this.#manager.mmtp?.onTrackEvent?.(this.config.trackName, { type: 'fec-setup' }, this.config)\n }\n\n /** Fire fec-teardown for an armed decoder; no-op when none is armed. */\n #disarmFec(): void {\n if (!this.#fecArmed) return\n this.#fecArmed = false\n this.#manager.mmtp?.onTrackEvent?.(this.config.trackName, { type: 'fec-teardown' }, this.config)\n }\n\n /** Fire onSuppress(true) exactly once per suppression epoch. */\n #suppress(): void {\n if (this.#suppressFired) return\n this.#suppressFired = true\n this.#manager.callbacks?.onSuppress?.(this.config.trackName, true)\n }\n\n /** Unwind an outstanding suppression; no-op when none is outstanding. */\n #unsuppress(): void {\n if (!this.#suppressFired) return\n this.#suppressFired = false\n this.#manager.callbacks?.onSuppress?.(this.config.trackName, false)\n }\n}\n\n// ---------------------------------------------------------------------------\n// SessionManager\n// ---------------------------------------------------------------------------\n\n/**\n * Largest takeover deadline setTimeout represents faithfully. setTimeout\n * coerces its delay to a signed 32-bit int, so a larger value fires on the\n * next tick (Node also warns TimeoutOverflowWarning) instead of after the\n * window the host asked for -- silently inverting the feature. Rejecting it\n * keeps the classic units bug (seconds-as-ms, a wrong timescale) loud.\n */\nexport const MAX_TAKEOVER_DEADLINE_MS = 2_147_483_647\n\n/**\n * How long `joinActive` may stay false before the library says so. The deferral\n * is unbounded by construction -- that is the feature -- so a host that never\n * flips it back parks every track in `joining` with no timer at all: exactly the\n * forever-join the takeover deadline exists to prevent, only silent. MoQ stays\n * unsuppressed so the viewer sees video, but onTakeoverTimeout never fires, the\n * FEC decoder stays armed, and the preference chain never advances past the\n * transport that failed. It looks like a slow join. A host is one un-awaited\n * rejection between `false` and `true` away from it, so the library complains\n * rather than trusting every future host to get all of its exit paths right.\n *\n * Generous against a genuinely slow subscribe -- a bridge reconnect or AMT relay\n * handshake is seconds -- so anything still unsettled here is broken, not slow.\n */\nexport const JOIN_DEFER_WARN_MS = 30_000\n\nexport class SessionManager {\n readonly pool: TransportPool\n readonly sessions = new Map<string, TrackSession>()\n callbacks: SessionManagerCallbacks | null = null\n #takeoverDeadlineMs: number | undefined = undefined\n #joinActive = true\n #joinDeferWarnTimer: ReturnType<typeof setTimeout> | null = null\n\n /**\n * Takeover deadline (BLO-9413/D9): the time a session may sit in `joining`\n * without a RAP before returning to `unicast` and firing onTakeoverTimeout.\n * MoQ is never suppressed during `joining`, so expiry abandons the takeover\n * without a playback glitch. The host derives it from the catalog (RAP\n * cadence from groupDurationTicks/timescale plus the signaled FEC delivery\n * window or reorder tolerance) and sets it before addTrack(). There is no\n * default: an unset value fails addTrack() closed, and reading the getter\n * before assignment throws the same way, so no host ever holds a\n * `number | undefined` it could coalesce back into a default.\n * `hasTakeoverDeadline` answers whether one was admitted. A declared 0\n * disables the deadline. The value is an integer number of milliseconds:\n * setTimeout truncates fractions, so a host that divides ticks by a\n * timescale must round before assigning.\n *\n * Assignment validates, so the diagnostic lands at the host's own call site.\n * That is what bounds the blast radius: `joining` is entered from\n * joinStarted(), from the transportFailed() failover branch -- itself\n * reachable from a setTimeout via hysteresisExpired(), where no caller could\n * catch a throw -- and from the activateMulticast()/activateAmt()/pool-failure\n * loops, where a throw on one session would abandon its siblings mid-loop\n * (the split-brain those loops exist to prevent). Refusing a bad value at\n * assignment means no host mutation can make an already-admitted session\n * throw out of a transport edge.\n */\n get takeoverDeadlineMs(): number {\n return this.#requireTakeoverDeadlineMs()\n }\n\n set takeoverDeadlineMs(value: number) {\n if (!Number.isInteger(value) || value < 0 || value > MAX_TAKEOVER_DEADLINE_MS) {\n throw new Error(\n `[SessionManager] takeoverDeadlineMs must be an integer from 0 to ${MAX_TAKEOVER_DEADLINE_MS} (0 disables it, larger values overflow setTimeout); got ${String(value)}`,\n )\n }\n this.#takeoverDeadlineMs = value\n }\n\n /** True once the host has admitted a deadline; a declared 0 counts. */\n get hasTakeoverDeadline(): boolean {\n return this.#takeoverDeadlineMs !== undefined\n }\n\n /**\n * Whether the transport the joining tracks are waiting on is actually\n * subscribed. The takeover deadline is a media-arrival budget and media\n * cannot arrive before the join exists, so while this is false, entering\n * `joining` arms no deadline; flipping it true starts the FULL deadline for\n * every track already parked there.\n *\n * Default true, which is the arm-on-`joining` behaviour every host had\n * before: a host that only calls joinStarted() after its own join resolves\n * (moqtail-private's player, post-BLO-33339) is already at the right edge and\n * needs no gate. A host that arms joinStarted() up front \u2014 to have FEC\n * decoders ready before the first datagram \u2014 sets this false before addTrack()\n * and true when its subscribe settles. Charging a slow subscribe (bridge\n * reconnect, AMT relay handshake) against the media budget expired the\n * deadline before the subscription existed and abandoned the join on the\n * happy path. BLO-34289.\n *\n * The deadline is a media budget (group cadence plus FEC delivery window) and\n * budgets nothing for reaching the stream, so ideally it starts at\n * MulticastTransport.onDataPlaneLive, the join's first accepted packet. Over\n * AMT that is measurably later than a settled subscribe: the subscribe\n * settles once the gateway has sent its Membership Update, and the relay's\n * upstream join plus a path RTT (0.4-0.8 s measured to the prod relay) still\n * precede the first packet. But RFC 7450 gives a gateway no signal that the\n * relay has traffic for its (S,G), so a join whose source never sends never\n * reaches that edge. Starting the deadline there is therefore sound only when\n * the provider bounds the join phase itself, with a typed subscription error\n * for a join that delivers nothing (MulticastTransport forwards it to\n * onFailback). A host whose provider makes no such guarantee flips this true\n * when the subscribe settles and accepts that join latency is charged to the\n * media budget; holding it until the first packet would park a silent join\n * in `joining` forever.\n *\n * The flag tracks the host's primary join, not per-track transport state. A\n * track that fails over to the next transport in its own chain while this is\n * false (transportFailed's failover branch also enters `joining`) has that\n * deadline gated on the primary join activating, not on its own transport.\n * Under the documented usage -- false once at setup, true once the subscribe\n * settles -- the two coincide.\n *\n * The gate is on the ENTRY into `joining`, and false cannot take back a\n * deadline already running: the `true -> false` edge arms the warn below and\n * clears nothing. So set it false BEFORE the `joining` entry it is meant to\n * gate. A host that instead flipped it false after a `transportFailed()`\n * failover had already entered `joining` would leave the full deadline\n * counting against a subscribe that had not begun -- BLO-34289 again, on the\n * failover edge. Clearing running deadlines on that edge is deliberately NOT\n * done: this flag is manager-wide, so one transport's brief false would\n * cancel an unrelated track's live deadline and then re-arm it at FULL length\n * on the next activation (armDeferredJoinDeadline sees `joining` + a null\n * timer and cannot tell a cleared deadline from a deferred one). Every\n * unrelated transport blip would restart the whole budget, so under repeated\n * failover a track that should have timed out is extended indefinitely and\n * never advances. Only a false that never returns true loses the timer\n * outright, and JOIN_DEFER_WARN_MS already covers that.\n *\n * Leaving this false is not free: see JOIN_DEFER_WARN_MS.\n */\n get joinActive(): boolean {\n return this.#joinActive\n }\n\n set joinActive(active: boolean) {\n const wasActive = this.#joinActive\n this.#joinActive = active\n if (!active) {\n // Edge-triggered: a host re-declaring false has not restarted its\n // subscribe, and total deferral is what the warning is about.\n if (wasActive) this.#armJoinDeferWarn()\n return\n }\n this.#clearJoinDeferWarn()\n if (wasActive) return\n for (const [, session] of this.sessions) session.armDeferredJoinDeadline()\n }\n\n #armJoinDeferWarn(): void {\n this.#joinDeferWarnTimer = setTimeout(() => {\n this.#joinDeferWarnTimer = null\n const deferred = [...this.sessions.values()].filter(s => s.state === 'joining').length\n console.warn(\n `[SessionManager] joinActive has been false for ${JOIN_DEFER_WARN_MS} ms and ${deferred} track(s) sit in 'joining' with no takeover deadline. Set joinActive = true when the subscribe settles, on every exit path including failures, or these tracks never time out, never release their FEC decoders, and never advance to the next transport.`,\n )\n }, JOIN_DEFER_WARN_MS)\n }\n\n #clearJoinDeferWarn(): void {\n if (this.#joinDeferWarnTimer === null) return\n clearTimeout(this.#joinDeferWarnTimer)\n this.#joinDeferWarnTimer = null\n }\n\n /** Narrow to MMTP callbacks (onTrackEvent). Safe \u2014 optional chaining handles PIM-only consumers. */\n get mmtp(): MmtpSessionCallbacks | null {\n return this.callbacks as MmtpSessionCallbacks | null\n }\n\n constructor(poolOptions?: TransportPoolOptions) {\n this.pool = new TransportPool(poolOptions)\n\n // Wire pool failure \u2192 per-track failover\n this.pool.onSubscriptionFailed(sub => {\n for (const trackName of sub.consumers) {\n const session = this.sessions.get(trackName)\n if (session) session.transportFailed()\n }\n })\n }\n\n // -----------------------------------------------------------------------\n // Track management\n // -----------------------------------------------------------------------\n\n /**\n * The admitted takeover deadline. Throws only when the host never set one,\n * every other invalid value being refused by the setter, and returns a\n * declared 0 as 0. The getter and addTrack() both route through it, so a\n * session cannot exist without an admitted deadline, which is what makes\n * the #transition() read unable to throw on an already-admitted session.\n */\n #requireTakeoverDeadlineMs(): number {\n const value = this.#takeoverDeadlineMs\n if (value === undefined) {\n throw new Error(\n '[SessionManager] takeoverDeadlineMs is unset; set a catalog-derived takeover deadline (0 disables it) before adding tracks',\n )\n }\n return value\n }\n\n /** Add a track session from catalog config. */\n addTrack(config: TrackSessionConfig): TrackSession {\n if (this.sessions.has(config.trackName)) {\n throw new Error(`[SessionManager] Track \"${config.trackName}\" already exists`)\n }\n this.#requireTakeoverDeadlineMs()\n const session = new TrackSession(config, this)\n this.sessions.set(config.trackName, session)\n return session\n }\n\n /** Remove a track session. Releases subscription with grace period. */\n removeTrack(trackName: string): void {\n const session = this.sessions.get(trackName)\n if (!session) return\n session.unsubscribe()\n\n // Release pool subscription\n const subKey = session.config.multicastKey ?? session.config.moqTrackName\n if (subKey) this.pool.releaseSubscription(subKey, trackName)\n\n session.dispose()\n this.sessions.delete(trackName)\n }\n\n // -----------------------------------------------------------------------\n // Atomic multi-track operations\n // -----------------------------------------------------------------------\n\n /**\n * All tracks with multicast RAP \u2192 multicast state (atomic).\n * Called when MediaRouter fires onSourceSwitch('multicast').\n * Prevents split-brain between video and audio.\n *\n * A takeover deadline can return a track from `joining` to `unicast` while\n * the multicast socket keeps delivering packets and the track keeps its FEC\n * decoder armed. If a valid RAP arrives later, that late-RAP edge suppresses\n * unicast and promotes multicast without a second fec-setup.\n */\n activateMulticast(): void {\n for (const [, session] of this.sessions) {\n if (session.state === 'unicast') {\n session.joinStarted()\n }\n if (session.state === 'joining') {\n session.rapReceived('multicast')\n }\n }\n }\n\n /**\n * All tracks with AMT RAP \u2192 amt state (atomic).\n */\n activateAmt(): void {\n for (const [, session] of this.sessions) {\n if (session.state === 'unicast') {\n session.joinStarted()\n }\n if (session.state === 'joining') {\n session.rapReceived('amt')\n }\n }\n }\n\n /**\n * All tracks \u2192 unicast (failback, atomic).\n * Called when MulticastManager fires onFailback().\n * unicast-fallback sessions are skipped \u2014 the host owns those subscriptions\n * and they must drain uninterrupted across transport-state events.\n *\n * Coherence invariant (BLO-9413): after this returns, every non-fallback\n * session is `unicast` (or `idle`), unsuppressed, and without a FEC decoder.\n * unsubscribe() unwinds any outstanding suppression regardless of the state\n * it tears down from (`failing` included; see TrackSession.#suppressFired),\n * and a session that a takeover timeout parked in `unicast` with its decoder\n * still armed is cycled too, because this transport edge is what releases\n * that decoder (unsubscribe fires fec-teardown).\n */\n failbackAll(): void {\n for (const [, session] of this.sessions) {\n const parkedWithFec = session.state === 'unicast' && session.isFecArmed\n if (\n parkedWithFec ||\n (session.state !== 'idle' &&\n session.state !== 'unicast' &&\n session.state !== 'unicast-fallback')\n ) {\n session.unsubscribe()\n session.subscribe() // re-enter unicast\n }\n }\n }\n\n /**\n * All tracks \u2192 unicast-fallback (atomic).\n * Called by the host when the multicast gateway is absent and\n * the player will subscribe to source tracks over MoQ unicast and feed\n * packets via MulticastManager.feedUnicastSource() (or manager.router\n * directly). Sessions in this state stay terminal until the host calls\n * unsubscribe() / disposes the SessionManager.\n */\n enterUnicastFallback(): void {\n for (const [, session] of this.sessions) {\n if (session.state === 'idle') continue\n session.enterUnicastFallback()\n }\n }\n\n /**\n * True if any session is currently in unicast-fallback. Hosts use this to\n * gate `multicastTrack.close()` on failback events \u2014 closing the shared\n * broadcast would kill the host's fallback drainers.\n */\n hasActiveFallback(): boolean {\n for (const [, session] of this.sessions) {\n if (session.isUnicastFallback) return true\n }\n return false\n }\n\n /**\n * All fallback tracks \u2192 unicast (atomic).\n * Called when the SSM bridge appears mid-session and the host has aborted\n * the fallback drainers. The session re-enters the regular preference chain\n * via joinStarted() (issued by the host immediately after).\n */\n exitUnicastFallback(): void {\n for (const [, session] of this.sessions) {\n if (session.isUnicastFallback) session.exitUnicastFallback()\n }\n }\n\n // -----------------------------------------------------------------------\n // Cleanup lifecycle\n // -----------------------------------------------------------------------\n\n /** Tab closed \u2014 release all subscriptions for this tab (grace period). */\n tabClosed(tabId: string): void {\n // All tracks owned by this tab \u2192 idle\n for (const [trackName, session] of this.sessions) {\n session.unsubscribe()\n this.pool.releaseAllForConsumer(trackName)\n }\n }\n\n /** Bridge WebTransport connection dropped \u2014 same as tab close. */\n bridgeDisconnected(): void {\n this.tabClosed('bridge')\n }\n\n /**\n * Track selection changed (user or ABR).\n * Old track released (grace period), new track subscribed.\n */\n trackSelectionChanged(oldTrackName: string, newConfig: TrackSessionConfig): void {\n this.removeTrack(oldTrackName)\n const session = this.addTrack(newConfig)\n session.subscribe()\n }\n\n // -----------------------------------------------------------------------\n // Query\n // -----------------------------------------------------------------------\n\n /** Get a track session by name. */\n getTrack(trackName: string): TrackSession | undefined {\n return this.sessions.get(trackName)\n }\n\n // -----------------------------------------------------------------------\n // Cleanup\n // -----------------------------------------------------------------------\n\n dispose(): void {\n this.#clearJoinDeferWarn()\n for (const [, session] of this.sessions) {\n session.dispose()\n }\n this.sessions.clear()\n this.pool.dispose()\n this.callbacks = null\n }\n}\n", "/**\n * MediaRouter \u2014 source arbitration for MoQ \u2194 multicast switching.\n *\n * Both moq-lib and moqtail wire their MoQ and multicast frame outputs\n * through this router. The router decides which source reaches the decoder:\n *\n * - MoQ frames pass through while multicast is inactive.\n * - Multicast video frames are suppressed until the first keyframe.\n * - Once multicast has both a video keyframe and audio, takeover is requested.\n * - MoQ is suppressed only after the source-switch callback completes.\n * - failbackToMoq() reverses the switch immediately.\n *\n * Per-track setTrackSource() allows individual tracks to be wired to a\n * specific transport source (video=multicast, audio=moq, overlay=amt) without\n * changing the binary MoQ\u2194multicast switching for other tracks.\n */\n\nimport { computeFecDeliveryWindowMs } from './track-session.js'\n\nexport type FrameMeta = {\n timestamp: number\n keyframe: boolean\n packetId?: number\n packetSequenceNumber?: number\n source?: 'moq' | 'multicast' | 'repair'\n recovered?: boolean\n sourceDiscontinuity?: boolean\n /**\n * Raw MMTP header timestamp (RFC 5905 short NTP, 32 bits): the publisher's\n * send-wall clock for the packet that carried this frame. On timed media it\n * is distinct from `timestamp`, the authored media presentation time. On a\n * non-timed MFU there is no authored presentation time, so `MmtpRouter`\n * derives `timestamp` from this same clock and the two carry no latency\n * between them. Populated from `RoutedFrameMeta.mmtpTimestamp` and\n * preserved by every re-emit on this router (multicast, MoQ, and repair) so\n * players can measure network ingress on the multicast legs; absent when\n * the packet header could not be read.\n */\n mmtpTimestamp?: bigint\n}\nexport type FrameKind = 'video' | 'audio'\nexport type SourceKind = 'moq' | 'multicast'\n\ntype FrameCb = (data: Uint8Array, meta: FrameMeta) => void\ntype SwitchCb = (source: SourceKind) => void | Promise<void>\n\nexport interface MediaRouterOptions {\n /** Set false for video-only streams. Default: true. */\n requireAudio?: boolean\n /**\n * Audio fallback timeout (ms): force multicast activation if video keyframe\n * arrived but audio hasn't within this window. Catalog-derived via\n * computeFecDeliveryWindowMs (the single FEC recovery-window source of truth).\n * Omit to disable the independent audio-fallback timer (no magic default).\n */\n audioFallbackMs?: number\n /**\n * Number of binary-path frames to allow through during a crossfade transition.\n * Derive from FEC block size (e.g., ceil(K / groupsPerBlock)). Default: 10.\n */\n crossfadeFrames?: number\n /**\n * Max pre-boundary overlay frames to buffer before dropping.\n * Derive from FEC config (e.g., ceil(K * 1.5)). Default: 128.\n */\n maxOverlayBuffer?: number\n /**\n * Max concurrent in-flight Phase-13 keyframe-group repairs (relay SUBSCRIBE\n * flood mitigation, T-13-01). Required once repair is wired: registering\n * `onRepairRequest` without it throws \u2014 there is no default. A number is\n * validated at construction. A bound derived from live measurements that\n * arrive after construction (e.g. ceil(reliableRtt / keyframeCadence)) is\n * passed as a reader, evaluated at each repair decision: a throw or a value\n * that is not a positive safe integer skips that group's repair (fail closed\n * per decision, not per session) and the next decision reads it again.\n * Unused for FEC tracks, which never arm the keyframe-lost path; their QUIC\n * repair is the D-28 scheduler (`wireRepairFetch`), prioritised by block\n * class.\n */\n maxRepairInFlight?: number | (() => number)\n /**\n * Allow multicast takeover from a delta frame after the player has confirmed\n * that MoQ already seeded the same decoder timeline with a video keyframe.\n * Default false: decoder-reset paths still need multicast to provide a RAP.\n */\n allowDeltaMulticastTakeoverAfterMoqKeyframe?: boolean\n}\n\n/**\n * A per-track source that emits frames independently of the binary MoQ/multicast path.\n * Used by setTrackSource() to wire specific tracks to specific transport sources.\n */\nexport interface TrackSource {\n name: string\n /**\n * Register a frame callback. Returns an unsubscribe function.\n * MediaRouter calls unsubscribe() when clearTrackSource() is called.\n */\n onFrame(cb: (data: Uint8Array, meta: FrameMeta) => void): () => void\n /**\n * Optional error callback for when the source fails.\n * MediaRouter uses this to trigger failback to binary switching for this track.\n */\n onError?(cb: () => void): () => void\n /** Optional dispose hook called when clearTrackSource() is called. */\n dispose?(): void\n}\n\n/**\n * Thrown when setTrackSource() is called for a track that already has a source\n * without an intervening clearTrackSource(). Prevents silent concurrent-assignment\n * races (T-02-16 mitigation).\n */\nexport class TrackSourceConflictError extends Error {\n constructor(public readonly trackName: string) {\n super(`Track source already assigned for '${trackName}'; call clearTrackSource first`)\n this.name = 'TrackSourceConflictError'\n }\n}\n\n/**\n * Migration policy for setTrackSource() transport switching.\n *\n * - 'crossfade': Overlap old (binary) and new source briefly (CROSSFADE_WINDOW_FRAMES),\n * then suppress the binary path. Default for audio.\n * - 'drop': Cut to new source immediately. Binary path suppressed at once.\n * Suitable for latency-critical tracks.\n * - 'wait': Defer migration until first FEC block boundary (same as overlay=true).\n * Default for video \u2014 avoids decoder reset mid-block.\n */\nexport type MigrationPolicy = 'crossfade' | 'drop' | 'wait'\n\n/** Default crossfade window \u2014 override via MediaRouterOptions.crossfadeFrames. */\nconst DEFAULT_CROSSFADE_FRAMES = 10\n\n/** Options passed to setTrackSource(). */\nexport interface SetTrackSourceOptions {\n /**\n * If true, this source is an overlay track that must wait for the first FEC\n * block boundary signal (via notifyFecBlockBoundary()) before emitting frames.\n * Frames arriving before the first boundary are buffered up to MAX_OVERLAY_BUFFER.\n *\n * Backwards compat: equivalent to policy: 'wait'.\n */\n overlay?: boolean\n /**\n * Migration policy for the transport switch. Overrides `overlay` when provided.\n * See MigrationPolicy for semantics.\n */\n policy?: MigrationPolicy\n}\n\n/** Default overlay buffer cap \u2014 override via MediaRouterOptions.maxOverlayBuffer. */\nconst DEFAULT_MAX_OVERLAY_BUFFER = 128\n\n/** Internal per-track source record. */\ninterface TrackSourceRecord {\n source: TrackSource\n unsubscribeFrame: () => void\n unsubscribeError?: () => void\n emitCount: number\n}\n\n/** Catalog-derived inputs to {@link MediaRouter.configureDelta}. */\nexport interface DeltaFallbackConfig {\n framerate: number\n sourceSymbols: number\n repairSymbols: number\n deltaFailWindowBlocks?: number\n deltaProbeIntervalMs?: number\n interleaveDepthMs?: number\n}\n\n/**\n * The one admission check for {@link DeltaFallbackConfig}, shared by\n * {@link MediaRouter.configureDelta} and `resolveDeltaFallback` so a config the\n * resolver accepts is exactly one configureDelta accepts. Every present number\n * must be finite and positive (`typeof NaN === 'number'`, and `NaN <= 0` is\n * false, so a bare comparison admits it); the fail window is a block count and\n * must be an integer; and a probe interval must be derivable.\n */\nexport function assertDeltaFallbackConfig(cfg: DeltaFallbackConfig, prefix: string): void {\n if (!isPositiveFinite(cfg.framerate)) throw new Error(`${prefix}: framerate required and must be > 0`)\n if (!isPositiveFinite(cfg.sourceSymbols))\n throw new Error(`${prefix}: sourceSymbols (K_delta) required and must be > 0`)\n if (!isPositiveFinite(cfg.repairSymbols))\n throw new Error(`${prefix}: repairSymbols (P_delta) required and must be > 0`)\n if (\n cfg.deltaFailWindowBlocks !== undefined &&\n !(Number.isInteger(cfg.deltaFailWindowBlocks) && cfg.deltaFailWindowBlocks > 0)\n ) {\n throw new Error(`${prefix}: deltaFailWindowBlocks must be a positive integer when present`)\n }\n if (cfg.deltaProbeIntervalMs !== undefined && !isPositiveFinite(cfg.deltaProbeIntervalMs))\n throw new Error(`${prefix}: deltaProbeIntervalMs must be > 0 when present`)\n if (cfg.interleaveDepthMs !== undefined && !isPositiveFinite(cfg.interleaveDepthMs))\n throw new Error(`${prefix}: interleaveDepthMs must be > 0 when present`)\n if (cfg.deltaProbeIntervalMs === undefined && cfg.interleaveDepthMs === undefined) {\n throw new Error(\n `${prefix}: either deltaProbeIntervalMs or interleaveDepthMs required for probe interval derivation`\n )\n }\n}\n\nfunction isPositiveFinite(value: unknown): value is number {\n return typeof value === 'number' && Number.isFinite(value) && value > 0\n}\n\nexport class MediaRouter {\n #active: SourceKind = 'moq'\n #multicastVideoKeyed = false\n #multicastAudioReceived = false\n #moqVideoKeyframeEmitted = false\n #requireAudio: boolean\n #suppressed = false // true after multicast confirmed \u2014 suppresses MoQ\n #audioFallbackTimer: ReturnType<typeof setTimeout> | null = null\n\n // Configurable constants (from catalog/FEC config, not magic numbers)\n readonly #audioFallbackMs: number\n readonly #crossfadeFrames: number\n readonly #maxOverlayBuffer: number\n readonly #allowDeltaMulticastTakeoverAfterMoqKeyframe: boolean\n\n // \u2500\u2500 Keyframe repair state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n /** Groups currently being repaired \u2014 idempotent guard per Pitfall 2 (T-13-01 mitigation) */\n #repairInFlight = new Set<number>()\n /** Max concurrent repairs \u2014 prevents relay SUBSCRIBE flood (T-13-01 mitigation). A reader is evaluated per decision. */\n readonly #maxRepairInFlight: number | (() => number) | null\n /** Callback to request MoQ subscribe for a specific group (wired by player) */\n #onRepairRequest: ((expectedGroupId: number, trackName: string) => void) | null = null\n\n #onVideoFrame: FrameCb | null = null\n #onAudioFrame: FrameCb | null = null\n #onOverlayFrame: FrameCb | null = null\n #onSourceSwitch: SwitchCb | null = null\n #sourceSwitchReady: Promise<void> | null = null\n #sourceSwitchToken = 0\n #pendingMulticastToken: number | null = null\n #pendingMulticastVideo: { data: Uint8Array; meta: FrameMeta } | null = null\n #pendingMulticastAudio: { data: Uint8Array; meta: FrameMeta } | null = null\n\n // Per-track source state\n #trackSources = new Map<string, TrackSourceRecord>()\n\n // Track type registry: trackName \u2192 { type, source }.\n // Init data / inline signaling is authoritative until catalog updates.\n // Priority: 'init' > 'catalog' > 'name' (backward compat fallback).\n #trackTypes = new Map<string, { type: FrameKind; source: 'catalog' | 'init' }>()\n\n // Crossfade state: maps trackName \u2192 remaining binary-path frames allowed during window\n #crossfadeCounters = new Map<string, number>()\n\n // Overlay alignment state\n #overlayBoundaryReached = false\n #overlayBuffer: Array<{ data: Uint8Array; meta: FrameMeta }> = []\n\n // \u2500\u2500 Phase 14: deltaActive state machine (D-11..D-14) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // No timers \u2014 state transitions driven entirely by notifyDeltaBlock() callbacks.\n // All thresholds derived from catalog fields or throw at configureDelta() time.\n #deltaConfigured = false\n #deltaActive: boolean = true\n #deltaWindow: boolean[] = [] // ring buffer: true = block failed\n #deltaWindowSize: number = 0\n #deltaFailThresholdRatio: number = 0 // P/K\n #deltaRecoverThresholdRatio: number = 0 // (P/K)/2 \u2014 hysteresis (D-13)\n #deltaProbeIntervalMs: number = 0\n #lastProbeAt: number = 0\n #onDeltaFallbackCb: (() => void) | null = null\n #onDeltaRecoverCb: (() => void) | null = null\n\n constructor(options?: MediaRouterOptions) {\n this.#requireAudio = options?.requireAudio ?? true\n // Catalog-derived \u2014 the caller passes an audioFallbackMs computed from the\n // FEC recovery window (computeFecDeliveryWindowMs). No magic default: when\n // absent, the independent audio-fallback timer is disabled (0) and\n // activation waits for real audio arrival.\n this.#audioFallbackMs = options?.audioFallbackMs ?? 0\n this.#crossfadeFrames = options?.crossfadeFrames ?? DEFAULT_CROSSFADE_FRAMES\n this.#maxOverlayBuffer = options?.maxOverlayBuffer ?? DEFAULT_MAX_OVERLAY_BUFFER\n const maxRepairInFlight = options?.maxRepairInFlight\n if (\n maxRepairInFlight !== undefined &&\n typeof maxRepairInFlight !== 'function' &&\n (!Number.isSafeInteger(maxRepairInFlight) || maxRepairInFlight < 1)\n ) {\n throw new Error(\n `[MediaRouter] maxRepairInFlight=${maxRepairInFlight} must be a positive integer`,\n )\n }\n this.#maxRepairInFlight = maxRepairInFlight ?? null\n this.#allowDeltaMulticastTakeoverAfterMoqKeyframe =\n options?.allowDeltaMulticastTakeoverAfterMoqKeyframe ?? false\n }\n\n onVideoFrame(cb: FrameCb) { this.#onVideoFrame = cb }\n onAudioFrame(cb: FrameCb) { this.#onAudioFrame = cb }\n onOverlayFrame(cb: FrameCb) { this.#onOverlayFrame = cb }\n onSourceSwitch(cb: SwitchCb) { this.#onSourceSwitch = cb }\n\n get activeSource(): SourceKind { return this.#active }\n\n // \u2500\u2500 Keyframe repair API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n /** Register callback for keyframe repair requests. Player wires this to issue MoQ SUBSCRIBE. */\n onRepairRequest(cb: (expectedGroupId: number, trackName: string) => void): void {\n if (this.#maxRepairInFlight === null) {\n throw new Error(\n '[MediaRouter] onRepairRequest requires an explicit maxRepairInFlight option',\n )\n }\n this.#onRepairRequest = cb\n }\n\n /**\n * Request repair for a lost keyframe group. Only fires when activeSource is 'multicast'.\n * Idempotent per groupId \u2014 duplicate calls for the same group are suppressed.\n * A reader `maxRepairInFlight` is evaluated here; without a valid value the\n * group is skipped and a later call reads it again.\n * @param expectedGroupId - The group ID expected but not received\n * @param trackName - Track name for the SUBSCRIBE (e.g. 'video'). Default: 'video'.\n */\n repairLostGroup(expectedGroupId: number, trackName = 'video'): void {\n if (this.#active !== 'multicast') return // only repair on multicast path\n if (this.#maxRepairInFlight === null) return // repair is not wired\n if (this.#repairInFlight.has(expectedGroupId)) return // idempotent\n let maxRepairInFlight: number\n try {\n maxRepairInFlight =\n typeof this.#maxRepairInFlight === 'function'\n ? this.#maxRepairInFlight()\n : this.#maxRepairInFlight\n } catch (error) {\n console.warn(\n `[MediaRouter] maxRepairInFlight unmeasured, skipping repair for group ${expectedGroupId}:`,\n error,\n )\n return\n }\n if (!Number.isSafeInteger(maxRepairInFlight) || maxRepairInFlight < 1) {\n console.warn(\n `[MediaRouter] maxRepairInFlight=${maxRepairInFlight} is not a positive integer, skipping repair for group ${expectedGroupId}`,\n )\n return\n }\n if (this.#repairInFlight.size >= maxRepairInFlight) {\n console.warn(\n `[MediaRouter] max repair in flight (${maxRepairInFlight}), dropping repair for group ${expectedGroupId}`,\n )\n return\n }\n this.#repairInFlight.add(expectedGroupId)\n this.#onRepairRequest?.(expectedGroupId, trackName)\n }\n\n /**\n * Feed a recovered keyframe frame from the repair path.\n * Bypasses source arbitration entirely \u2014 goes directly to decoder callbacks.\n * Does NOT change activeSource. Does NOT trigger failbackToMoq.\n */\n feedRepair(data: Uint8Array, meta: FrameMeta, kind: FrameKind): void {\n // Repair only makes sense when multicast is active.\n // Silently drop if MoQ became active (race with failbackToMoq) to avoid\n // injecting stale-timestamp frames into the MoQ decoder path (WR-02).\n if (this.#active !== 'multicast') return\n const routedMeta = { ...meta, source: 'repair' as const, recovered: true }\n if (kind === 'video') this.#onVideoFrame?.(data, routedMeta)\n else this.#onAudioFrame?.(data, routedMeta)\n }\n\n /**\n * Clear repair-in-flight state for a group (called after repair completes or times out).\n */\n clearRepairInFlight(groupId: number): void {\n this.#repairInFlight.delete(groupId)\n }\n\n // \u2500\u2500 Phase 14: deltaActive state machine API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n /**\n * Configure the delta-track fallback state machine from catalog parameters.\n *\n * D-11: Thresholds derive from fec.sourceSymbols, fec.repairSymbols, framerate.\n * D-13: Probe interval derives from deltaProbeIntervalMs or interleaveDepthMs*2.\n * CLAUDE.md: error on bad init \u2014 throws if required inputs are absent.\n *\n * Calling again resets deltaActive=true and clears the window buffer (stream restart).\n * Pass null on teardown or when a replacement catalog cannot configure this policy.\n */\n configureDelta(cfg: DeltaFallbackConfig | null): void {\n if (cfg === null) {\n this.#deltaConfigured = false\n this.#deltaActive = true\n this.#deltaWindow = []\n this.#deltaWindowSize = 0\n this.#deltaFailThresholdRatio = 0\n this.#deltaRecoverThresholdRatio = 0\n this.#deltaProbeIntervalMs = 0\n this.#lastProbeAt = 0\n return\n }\n assertDeltaFallbackConfig(cfg, '[MediaRouter] configureDelta')\n\n // Derivations \u2014 no magic floors, no hardcoded defaults (CLAUDE.md).\n this.#deltaWindowSize = cfg.deltaFailWindowBlocks ?? Math.ceil(cfg.framerate)\n this.#deltaFailThresholdRatio = cfg.repairSymbols / cfg.sourceSymbols\n this.#deltaRecoverThresholdRatio = this.#deltaFailThresholdRatio / 2\n // Without an explicit probe interval, use one complete signaled block span.\n this.#deltaProbeIntervalMs = cfg.deltaProbeIntervalMs ?? computeFecDeliveryWindowMs({\n interleaveDepthMs: cfg.interleaveDepthMs!,\n sourceSymbols: cfg.sourceSymbols,\n })\n\n // Reset state \u2014 useful for stream restart.\n this.#deltaActive = true\n this.#deltaWindow = []\n this.#lastProbeAt = 0\n this.#deltaConfigured = true\n }\n\n /**\n * Notify the state machine of a completed FEC block for the delta track.\n *\n * @param failed - true if the block was unrecoverable (FEC failure)\n * @param nowMs - caller-injected monotonic ms (no internal timer \u2014 D-13, CLAUDE.md)\n *\n * If configureDelta() has not been called, this is a silent no-op (pre-catalog state).\n */\n notifyDeltaBlock(failed: boolean, nowMs: number): void {\n if (!this.#deltaConfigured) return\n\n // Maintain sliding window \u2014 ring buffer semantics.\n this.#deltaWindow.push(failed)\n if (this.#deltaWindow.length > this.#deltaWindowSize) {\n this.#deltaWindow.shift()\n }\n\n // Window not yet full \u2014 insufficient data for decision.\n if (this.#deltaWindow.length < this.#deltaWindowSize) return\n\n const failedCount = this.#deltaWindow.filter(x => x).length\n const ratio = failedCount / this.#deltaWindowSize\n\n if (this.#deltaActive) {\n // Fallback transition: active \u2192 inactive (T-14-03: fires exactly once)\n if (ratio > this.#deltaFailThresholdRatio) {\n this.#deltaActive = false\n this.#lastProbeAt = nowMs\n this.#onDeltaFallbackCb?.()\n }\n } else {\n // Recovery transition: inactive \u2192 active (after probe interval + low ratio)\n if (\n (nowMs - this.#lastProbeAt) >= this.#deltaProbeIntervalMs &&\n ratio < this.#deltaRecoverThresholdRatio\n ) {\n this.#deltaActive = true\n this.#lastProbeAt = nowMs\n this.#onDeltaRecoverCb?.()\n }\n }\n }\n\n /** Register callback fired when delta track degrades past threshold (D-12). */\n onDeltaFallback(cb: () => void): void {\n this.#onDeltaFallbackCb = cb\n }\n\n /** Register callback fired when delta track recovers below recovery threshold (D-13). */\n onDeltaRecover(cb: () => void): void {\n this.#onDeltaRecoverCb = cb\n }\n\n /** Whether the delta track is currently considered healthy (true = active). */\n get deltaActive(): boolean {\n return this.#deltaActive\n }\n\n // \u2500\u2500 Test-visible accessors for threshold verification \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // These expose internal derived values for test assertions (Tests 3,4,5,6,7).\n // Prefixed with 'delta' to namespace clearly within the public API surface.\n\n /** Derived window size \u2014 exposed for test assertions (Test 3, 4). */\n get deltaWindowSize(): number { return this.#deltaWindowSize }\n /** Derived fail threshold ratio (P/K) \u2014 exposed for test assertions (Test 6). */\n get deltaFailThresholdRatio(): number { return this.#deltaFailThresholdRatio }\n /** Derived recovery threshold ratio ((P/K)/2) \u2014 exposed for test assertions (Test 7). */\n get deltaRecoverThresholdRatio(): number { return this.#deltaRecoverThresholdRatio }\n /** Derived probe interval ms \u2014 exposed for test assertions (Test 5a). */\n get deltaProbeIntervalMs(): number { return this.#deltaProbeIntervalMs }\n\n /**\n * Register a track name \u2192 type mapping from catalog.\n * Catalog is the default authority. Init data / inline signaling\n * takes precedence until the next catalog update.\n */\n registerTrackType(trackName: string, type: FrameKind): void {\n // Catalog overwrites any previous registration (including init).\n this.#trackTypes.set(trackName, { type, source: 'catalog' })\n }\n\n /**\n * Register a track name \u2192 type from init data / inline signaling (MMTP FT=0).\n * Authoritative until the next catalog update per \u00A75.3.\n * Does NOT overwrite an existing catalog registration.\n */\n registerTrackTypeFromInit(trackName: string, type: FrameKind): void {\n const existing = this.#trackTypes.get(trackName)\n if (existing?.source === 'catalog') return // catalog already set \u2014 don't downgrade\n this.#trackTypes.set(trackName, { type, source: 'init' })\n }\n\n /**\n * Assign a per-track source. Frames from this source bypass the binary\n * MoQ\u2194multicast switching for this track only.\n *\n * Throws TrackSourceConflictError if a source is already assigned for this\n * track without a clearTrackSource() in between.\n */\n setTrackSource(trackName: string, source: TrackSource, opts?: SetTrackSourceOptions): void {\n if (this.#trackSources.has(trackName)) {\n throw new TrackSourceConflictError(trackName)\n }\n\n // Resolve effective policy. opts.policy takes precedence; overlay=true is legacy 'wait'.\n const effectivePolicy: MigrationPolicy | undefined =\n opts?.policy ?? (opts?.overlay ? 'wait' : undefined)\n\n // For crossfade: open the binary-path window immediately so the first per-track frame\n // triggers the window (counter starts before the frame callback is wired).\n if (effectivePolicy === 'crossfade') {\n this.#crossfadeCounters.set(trackName, this.#crossfadeFrames)\n }\n\n const record: TrackSourceRecord = {\n source,\n unsubscribeFrame: () => {},\n emitCount: 0,\n }\n\n // Wire the frame callback\n record.unsubscribeFrame = source.onFrame((data, meta) => {\n record.emitCount++\n if (effectivePolicy === 'wait') {\n this.#handleOverlayFrame(data, meta)\n } else {\n // 'drop', 'crossfade', or undefined: emit immediately\n this.#emitForTrack(trackName, data, meta)\n }\n })\n\n // Wire the optional error callback \u2014 failback to binary switching\n if (source.onError) {\n record.unsubscribeError = source.onError(() => {\n this.clearTrackSource(trackName)\n })\n }\n\n this.#trackSources.set(trackName, record)\n }\n\n /**\n * Remove the per-track source for the given track.\n * Binary switching resumes for this track after this call.\n */\n clearTrackSource(trackName: string): void {\n const record = this.#trackSources.get(trackName)\n if (!record) return\n\n record.unsubscribeFrame()\n record.unsubscribeError?.()\n record.source.dispose?.()\n this.#trackSources.delete(trackName)\n\n // Clean up crossfade window if one was active for this track\n this.#crossfadeCounters.delete(trackName)\n\n // Reset overlay buffer if removing a non-media track (overlay/unknown)\n const reg = this.#trackTypes.get(trackName)\n const isMedia = reg?.type === 'video' || reg?.type === 'audio' ||\n (reg === undefined && (trackName === 'video' || trackName === 'audio'))\n if (!isMedia) {\n this.#overlayBoundaryReached = false\n this.#overlayBuffer = []\n }\n }\n\n /**\n * Signal that a FEC block boundary has been reached on the base track.\n * Overlay tracks waiting for alignment flush their buffered frames now.\n */\n notifyFecBlockBoundary(): void {\n if (this.#overlayBoundaryReached) return\n this.#overlayBoundaryReached = true\n\n // Flush buffered overlay frames\n const buffered = this.#overlayBuffer.splice(0)\n for (const { data, meta } of buffered) {\n this.#onOverlayFrame?.(data, meta)\n }\n }\n\n /**\n * Return the number of frames emitted from the per-track source for a given\n * track. Used in test-mode to verify per-track routing histogram.\n * Returns 0 if no per-track source is active for the track.\n */\n trackSourceEmitCount(trackName: string): number {\n return this.#trackSources.get(trackName)?.emitCount ?? 0\n }\n\n /** Feed a frame from the MoQ path. Suppressed when multicast is active OR a per-track source owns this track. */\n feedMoq(data: Uint8Array, meta: FrameMeta, kind: FrameKind) {\n // Per-track source takes precedence; allow through during crossfade window\n if (this.#trackSources.has(kind)) {\n const remaining = this.#crossfadeCounters.get(kind)\n if (remaining && remaining > 0) {\n // Decrement crossfade counter and allow this frame through\n const next = remaining - 1\n if (next === 0) {\n this.#crossfadeCounters.delete(kind)\n } else {\n this.#crossfadeCounters.set(kind, next)\n }\n // Fall through to emit below (no return)\n } else {\n return // suppressed \u2014 no crossfade window active\n }\n }\n if (this.#suppressed) return\n // Audio suppression is handled by SessionManager onSuppress callback \u2014\n // do NOT independently suppress based on multicastAudioReceived.\n // The state machine decides when to switch; the router just routes.\n const routedMeta = { ...meta, source: 'moq' as const }\n if (kind === 'video') this.#onVideoFrame?.(data, routedMeta)\n else this.#onAudioFrame?.(data, routedMeta)\n }\n\n /** Feed a frame from the multicast path. Video waits for keyframe before activating. */\n feedMulticast(data: Uint8Array, meta: FrameMeta, kind: FrameKind) {\n // Per-track source takes precedence; allow through during crossfade window\n if (this.#trackSources.has(kind)) {\n const remaining = this.#crossfadeCounters.get(kind)\n if (remaining && remaining > 0) {\n const next = remaining - 1\n if (next === 0) {\n this.#crossfadeCounters.delete(kind)\n } else {\n this.#crossfadeCounters.set(kind, next)\n }\n // Fall through to emit below (no return)\n } else {\n return // suppressed\n }\n }\n\n if (kind === 'video') {\n if (!this.#multicastVideoKeyed) {\n const moqSeededDelta =\n !meta.keyframe &&\n this.#allowDeltaMulticastTakeoverAfterMoqKeyframe &&\n this.#moqVideoKeyframeEmitted\n if (!meta.keyframe && !moqSeededDelta) return // drop until first safe decode point\n this.#multicastVideoKeyed = true\n const pendingMeta: FrameMeta = { ...meta, source: 'multicast' }\n if (!moqSeededDelta) pendingMeta.sourceDiscontinuity = true\n this.#pendingMulticastVideo = {\n data: data.slice(),\n meta: pendingMeta,\n }\n // If audio is required but hasn't arrived yet, start a fallback timer.\n // Audio FEC blocks may recover after video \u2014 don't block activation forever.\n // Only arms when a catalog-derived audioFallbackMs was supplied (>0).\n if (this.#requireAudio && this.#audioFallbackMs > 0 && !this.#multicastAudioReceived && !this.#audioFallbackTimer) {\n this.#audioFallbackTimer = setTimeout(() => {\n if (!this.#multicastAudioReceived && this.#active !== 'multicast') {\n this.#multicastAudioReceived = true // force\n // Also release the MoQ-audio-warmup gate so the fallback actually\n // activates \u2014 otherwise #tryActivateMulticast would still bail.\n this.#moqAudioWarmed = true\n this.#tryActivateMulticast()\n }\n }, this.#audioFallbackMs)\n }\n } else if (this.#active !== 'multicast') {\n // Do not feed video into the multicast decoder while the source switch\n // is still pending. Keep the newest keyframe as the post-activation\n // decoder opener and drop deltas until takeover commits.\n if (meta.keyframe) {\n this.#pendingMulticastVideo = {\n data: data.slice(),\n meta: { ...meta, source: 'multicast', sourceDiscontinuity: true },\n }\n }\n this.#tryActivateMulticast()\n return\n }\n if (this.#active === 'multicast') this.#onVideoFrame?.(data, { ...meta, source: 'multicast' })\n this.#tryActivateMulticast()\n } else {\n this.#multicastAudioReceived = true\n if (this.#audioFallbackTimer) {\n clearTimeout(this.#audioFallbackTimer)\n this.#audioFallbackTimer = null\n }\n // BLO-3035 layer 5: in a multicast-first scenario (no MoQ unicast audio\n // source) `notifyMoqAudioEmit()` is never called and the audio-fallback\n // timer's force-set is bypassed (the timer's inner `!multicastAudioReceived`\n // check returns early because audio already arrived). Without this gate\n // `#moqAudioWarmed` stays false forever and `#tryActivateMulticast` never\n // fires `onSourceSwitch('multicast')` \u2014 the player can't know to emit\n // `__MULTICAST_TAKEOVER_TIME`. Treat the arrival of multicast audio as\n // sufficient evidence to release the warmup gate. For the MoQ\u2192multicast\n // handoff case `notifyMoqAudioEmit()` will already have set this flag, so\n // the assignment is idempotent.\n if (!this.#moqAudioWarmed) this.#moqAudioWarmed = true\n // Activate BEFORE delivering the frame \u2014 the source switch callback\n // (e.g. ring buffer reset) must fire before the first multicast audio\n // frame reaches the backend. Otherwise the NTP timestamp hits the\n // MoQ-era ring buffer base \u2192 overflow \u2192 silence gap.\n this.#tryActivateMulticast()\n // Only emit multicast audio AFTER takeover succeeds. Before takeover,\n // MoQ audio is still flowing through feedMoq \u2192 #onAudioFrame; emitting\n // multicast audio too would double-feed the backend (2x audio frames,\n // pitch wobble and distortion). Post-takeover, feedMoq is suppressed\n // (#suppressed=true) so only multicast audio reaches the backend.\n if (this.#active === 'multicast') {\n this.#onAudioFrame?.(data, { ...meta, source: 'multicast' })\n } else if (this.#pendingMulticastToken !== null) {\n this.#pendingMulticastAudio = { data: data.slice(), meta: { ...meta, source: 'multicast' } }\n }\n }\n }\n\n /**\n * MoQ unicast audio warmup gate. When audio is required, multicast takeover\n * waits until the MoQ unicast audio decoder has emitted at least one frame\n * (so downstream samples-before-takeover counters observe nonzero) or until\n * the existing audio-fallback timer forces the flag true.\n */\n #moqAudioWarmed = false\n\n /** Called by the player when the MoQ unicast audio decoder emits first frame. */\n public notifyMoqAudioEmit(): void {\n if (this.#moqAudioWarmed) return\n this.#moqAudioWarmed = true\n this.#tryActivateMulticast()\n }\n\n /**\n * Called by the player after a MoQ video keyframe has reached the active\n * decoder path. Used only by MSE/MMTP continuity switching; decoder-reset\n * paths leave allowDeltaMulticastTakeoverAfterMoqKeyframe disabled.\n */\n public notifyMoqVideoKeyframeEmit(): void {\n this.#moqVideoKeyframeEmitted = true\n this.#tryActivateMulticast()\n }\n\n #tryActivateMulticast() {\n if (this.#active === 'multicast') return\n if (this.#pendingMulticastToken !== null) return\n if (!this.#multicastVideoKeyed) return\n if (this.#requireAudio && !this.#multicastAudioReceived) return\n if (this.#requireAudio && !this.#moqAudioWarmed) return\n const token = ++this.#sourceSwitchToken\n const maybeReady = this.#onSourceSwitch?.('multicast')\n if (!maybeReady || typeof (maybeReady as Promise<void>).then !== 'function') {\n this.#commitMulticastActivation(token)\n return\n }\n const ready = Promise.resolve(maybeReady)\n this.#pendingMulticastToken = token\n this.#sourceSwitchReady = ready\n void ready.then(\n () => this.#commitMulticastActivation(token),\n err => this.#rejectMulticastActivation(token, err),\n ).finally(() => {\n if (this.#sourceSwitchReady === ready) this.#sourceSwitchReady = null\n })\n }\n\n #commitMulticastActivation(token: number) {\n if (token !== this.#sourceSwitchToken) return\n this.#active = 'multicast'\n this.#suppressed = true\n this.#pendingMulticastToken = null\n const video = this.#pendingMulticastVideo\n this.#pendingMulticastVideo = null\n const audio = this.#pendingMulticastAudio\n this.#pendingMulticastAudio = null\n if (video) this.#onVideoFrame?.(video.data, video.meta)\n if (audio) this.#onAudioFrame?.(audio.data, audio.meta)\n }\n\n #rejectMulticastActivation(token: number, err: unknown) {\n if (token !== this.#sourceSwitchToken) return\n if (this.#pendingMulticastToken === token) this.#pendingMulticastToken = null\n this.#pendingMulticastVideo = null\n this.#pendingMulticastAudio = null\n console.warn('[MediaRouter] onSourceSwitch(multicast) failed', err)\n }\n\n /** Switch back to MoQ immediately (called on multicast failback). */\n failbackToMoq() {\n const hadPendingMulticast = this.#pendingMulticastToken !== null\n if (this.#active === 'moq' && !hadPendingMulticast) return // already on MoQ, no-op\n ++this.#sourceSwitchToken\n this.#active = 'moq'\n this.#suppressed = false\n this.#pendingMulticastToken = null\n this.#pendingMulticastVideo = null\n this.#pendingMulticastAudio = null\n this.#sourceSwitchReady = null\n this.#multicastVideoKeyed = false\n this.#multicastAudioReceived = false\n this.#moqVideoKeyframeEmitted = false\n // A pending audio-fallback timer belongs to the abandoned takeover attempt.\n // Left running it would force-set the audio flags against the next attempt's\n // fresh state (BLO-9413 failback coherence \u2014 every takeover-era flag/timer\n // unwinds on this edge).\n if (this.#audioFallbackTimer) {\n clearTimeout(this.#audioFallbackTimer)\n this.#audioFallbackTimer = null\n }\n this.#onSourceSwitch?.('moq')\n }\n\n /** Dispose timers and per-track sources. Call on teardown to prevent\n * stale timer callbacks firing into garbage-collected closures (WR-03). */\n dispose(): void {\n this.configureDelta(null)\n if (this.#audioFallbackTimer) {\n clearTimeout(this.#audioFallbackTimer)\n this.#audioFallbackTimer = null\n }\n for (const [name] of this.#trackSources) {\n this.clearTrackSource(name)\n }\n }\n\n // \u2500\u2500 Private helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n /** Emit a frame on the correct per-track callback using the type registry.\n * Falls back to name match if no type registered (backward compat). */\n #emitForTrack(trackName: string, data: Uint8Array, meta: FrameMeta): void {\n // Prefer explicit type registry (catalog or init data)\n const reg = this.#trackTypes.get(trackName)\n if (reg?.type === 'video') {\n this.#onVideoFrame?.(data, meta)\n } else if (reg?.type === 'audio') {\n this.#onAudioFrame?.(data, meta)\n } else if (reg !== undefined) {\n // Registered but unknown type \u2014 overlay\n this.#onOverlayFrame?.(data, meta)\n } else {\n // Not registered \u2014 fall back to name match (backward compat for tests/legacy)\n if (trackName === 'video') this.#onVideoFrame?.(data, meta)\n else if (trackName === 'audio') this.#onAudioFrame?.(data, meta)\n else this.#onOverlayFrame?.(data, meta)\n }\n }\n\n /** Handle an overlay frame: buffer until first FEC block boundary, then pass through. */\n #handleOverlayFrame(data: Uint8Array, meta: FrameMeta): void {\n if (this.#overlayBoundaryReached) {\n this.#onOverlayFrame?.(data, meta)\n return\n }\n\n // Buffer until boundary \u2014 enforcing cap for T-02-17 DoS mitigation\n if (this.#overlayBuffer.length >= this.#maxOverlayBuffer) {\n console.warn(\n `[MediaRouter] overlay buffer full (${this.#maxOverlayBuffer} frames); dropping frame ts=${meta.timestamp}`\n )\n return\n }\n this.#overlayBuffer.push({ data, meta })\n }\n}\n", "/**\n * Maps one base/delta switching group's catalog FEC metadata onto\n * {@link MediaRouter.configureDelta} (BLO-45475).\n *\n * Generic client policy selects WHICH group to admit (roles, groups, endpoint\n * availability); this module owns the MMTP/FEC half of that decision \u2014 each\n * track's repair track and the delta block geometry \u2014 so no player re-states\n * the catalog FEC schema. It validates and never defaults: a group whose\n * required metadata is absent or contradictory throws, and the caller decides\n * that such a group invalidates the catalog rather than being skipped.\n *\n * FEC comes only from `resolveFecBindings` (BLO-50171), the validator's\n * per-instance result: base and delta each bind their own instance and their\n * own repair track (draft-ramadan-moq-fec-catalog-00 5 rule 2 forbids one\n * Track Name in two `repairTrack` members, so a shared repair track is no FEC\n * on either). The Phase-14 extension keys `deltaFailWindowBlocks` and\n * `deltaProbeIntervalMs` are read from the bound instance objects; the\n * validator ignores them as unrecognized instance keys (catalog-00 3.4).\n */\n\nimport { resolveFecBindings, type FecSourceBinding } from '@blockcast/mmt-base'\nimport { assertDeltaFallbackConfig, type DeltaFallbackConfig } from './media-router.js'\n\nexport interface ResolvedDeltaFallback {\n /** The base track's layer-0 repair track. */\n baseRepairTrackName: string\n /** The delta track's layer-0 repair track. */\n deltaRepairTrackName: string\n config: DeltaFallbackConfig\n}\n\ntype Obj = Record<string, unknown>\nconst recordOf = (v: unknown): Obj | undefined =>\n typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Obj) : undefined\n\n/** RFC 6901 lookup; the binding pointers are the validator's own. */\nfunction at(root: unknown, pointer: string): Obj | undefined {\n let cur: unknown = root\n for (const raw of pointer.split('/').slice(1)) {\n const key = raw.replace(/~1/g, '/').replace(/~0/g, '~')\n cur = Array.isArray(cur) ? cur[Number(key)] : recordOf(cur)?.[key]\n }\n return recordOf(cur)\n}\n\n/**\n * Resolve one switching group of `catalog` (either dialect). Throws when\n * either track has no `ok` FEC binding (the message carries the validator's\n * code and path), when the base framerate is not positive, or when the\n * assembled config fails {@link assertDeltaFallbackConfig}, the same check\n * configureDelta runs. Fallback-window and probe keys are read from the base\n * instance first, then the delta's; the interleave depth likewise.\n */\nexport function resolveDeltaFallback(catalog: unknown, baseName: string, deltaName: string): ResolvedDeltaFallback {\n const bindings = resolveFecBindings(catalog)\n const bound = (name: string, role: string) => {\n const b = bindings.get(name)\n if (!b) throw new Error(`[delta-fallback] ${role} track \"${name}\" declares no fec`)\n if (b.status !== 'ok')\n throw new Error(`[delta-fallback] ${role} track \"${name}\" has no FEC: ${b.reason.code} at ${b.reason.path}`)\n const track = at(catalog, b.pointer) ?? {}\n const instance = (Array.isArray(track['fec']) ? track['fec'] : [])\n .map(recordOf)\n .find((o) => o && (o['instanceId'] ?? 0) === b.base.instanceId) ?? {}\n return { b, track, instance }\n }\n const base = bound(baseName, 'base')\n const delta = bound(deltaName, 'delta')\n const framerate = base.track['framerate']\n if (!(typeof framerate === 'number' && Number.isFinite(framerate) && framerate > 0))\n throw new Error(`[delta-fallback] base track \"${baseName}\" missing positive framerate`)\n const key = (k: string) => (base.instance[k] ?? delta.instance[k]) as number | undefined\n const config: DeltaFallbackConfig = {\n framerate,\n sourceSymbols: delta.b.base.sourceSymbols,\n repairSymbols: delta.b.base.repairSymbols,\n deltaFailWindowBlocks: key('deltaFailWindowBlocks'),\n deltaProbeIntervalMs: key('deltaProbeIntervalMs'),\n interleaveDepthMs: base.b.base.interleaveDepthMs ?? delta.b.base.interleaveDepthMs,\n }\n assertDeltaFallbackConfig(config, `[delta-fallback] group ${baseName}/${deltaName}`)\n return { baseRepairTrackName: repairOf(base.b), deltaRepairTrackName: repairOf(delta.b), config }\n}\n\nconst repairOf = (b: Extract<FecSourceBinding, { status: 'ok' }>) => b.layers[0]!.repairTrackName\n"],
5
5
  "mappings": "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",
6
6
  "names": ["at", "recordOf"]
7
7
  }