/** * @mmt/container - MMTP Parser * * MMTP (MMT Protocol) packet parser based on ISO/IEC 23008-1:2023 Section 9.2 * * MMTP header format (12 bytes): * - Byte 0: V(2) | C(1) | FEC_type(2) | r(1) | X(1) | R(1) * - Byte 1: reserved(2) | packet_type(6) * - Bytes 2-3: packet_id (big-endian) * - Bytes 4-7: timestamp (big-endian, 32-bit) * - Bytes 8-11: sequence_number (big-endian) * * FEC_type values: * - 0: No FEC / source packet * - 1: FEC mode 0 (source with FEC payload ID) * - 2: FEC repair packet mode 0 * - 3: FEC repair packet mode 1 * * @packageDocumentation */ export interface MMTPHeader { version: number /** Packet type: 0x00=MPU, 0x02=Signaling, 0x03=Repair */ packetType: number packetId: number timestamp: bigint sequenceNumber: number payloadLength: number fecType: number /** C flag: header carries a 32-bit packet_counter. */ packetCounterFlag: boolean packetCounter?: number /** X flag: header carries an extension header and value. */ extensionFlag: boolean extension?: { type: number; data: Uint8Array } /** Byte offset at which the MMTP payload begins. */ headerLength: number /** Random Access Point flag - indicates keyframe (ISO 23008-1, byte 0 bit 0) */ rapFlag: boolean } export interface MPUHeader { mpuSequenceNumber: number fragmentationIndicator: number // FI: 0=complete, 1=first, 2=middle, 3=last /** 8-bit MPU fragment_counter from byte 3. */ fragmentCounter: number /** T flag: 1=timed media (14-byte MFU DU header), 0=non-timed (4-byte header) */ timedFlag: number /** A flag: aggregated MPU payload. This strict profile requires A=0. */ aggregationFlag: boolean /** MPU payload_length (bytes 0-1): bytes after the length field, including * the remaining six MPU-header bytes and all data-unit bytes. */ payloadLength: number } export interface MFUDuHeader { movieFragmentSequenceNumber: number sampleNumber: number offset: number priority: number dependencyCounter: number } export interface MMTPPacket { header: MMTPHeader payload: Uint8Array } export const MMTP_HEADER_SIZE = 12 export const MPU_HEADER_SIZE = 8 export const MFU_DU_HEADER_SIZE_TIMED = 14 // T=1: timed media (video/audio samples) export const MFU_DU_HEADER_SIZE_NONTIMED = 4 // T=0: non-timed media (item_id only) const MPU_LENGTH_FIELD_SIZE = 2 const MPU_HEADER_REMAINDER_SIZE = MPU_HEADER_SIZE - MPU_LENGTH_FIELD_SIZE const SOURCE_FEC_PAYLOAD_ID_SIZE = 4 // MMTP Packet Types (ISO/IEC 23008-1:2023 Section 9.2.2) export const PACKET_TYPE_MPU = 0x00 export const PACKET_TYPE_SIGNALING = 0x02 export const PACKET_TYPE_REPAIR = 0x03 // AL-FEC Signaling Message ID (ISO/IEC 23008-1:2023 Amendment 1:2025) export const ALFEC_MESSAGE_ID = 0x0203 /** * MMTP `packet_id` of the signaling flow, which carries PACKET_TYPE_SIGNALING * packets such as the AL-FEC message (ALFEC_MESSAGE_ID). The FFmpeg `moq_mmt` * muxer sends that message on this packet_id into the same multicast group as * the media, and moqcast-draft (PR #44) makes advertising it for a track a * MUST NOT. A track's packet_id is therefore 1..MMTP_PACKET_ID_MAX. */ export const MMTP_SIGNALING_PACKET_ID = 0 /** Largest value of the 16-bit MMTP `packet_id` header field. */ export const MMTP_PACKET_ID_MAX = 0xffff // FEC Code IDs export const FEC_CODE_XOR = 1 export const FEC_CODE_RAPTORQ = 2 /** * Parse MMTP header from buffer */ export function parseMMTPHeader(buffer: Uint8Array): MMTPHeader { if (buffer.length < MMTP_HEADER_SIZE) { throw new Error(`MMTP packet too small: ${buffer.length} bytes`) } const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength) const version = (buffer[0]! >> 6) & 0x03 if (version !== 0) { throw new Error(`Unsupported MMTP version: ${version}`) } // Extract FEC type from byte 0, bits 3-4 const fecType = (buffer[0]! >> 3) & 0x03 const packetCounterFlag = (buffer[0]! & 0x20) !== 0 const extensionFlag = (buffer[0]! & 0x02) !== 0 // Extract RAP flag from byte 0, bit 0 (R bit - Random Access Point) const rapFlag = (buffer[0]! & 0x01) === 1 // ISO/IEC 23008-1:2023 Figure 8 (V=0) places reserved(2) before // packet_type(6), so packet_type occupies the low six bits of byte 1. const packetType = buffer[1]! & 0x3F let offset = 2 // Packet ID (2 bytes, big-endian) const packetId = view.getUint16(offset, false) offset += 2 // Timestamp (4 bytes, big-endian) const timestamp = BigInt(view.getUint32(offset, false)) offset += 4 // Sequence number (4 bytes, big-endian) const sequenceNumber = view.getUint32(offset, false) offset += 4 let packetCounter: number | undefined if (packetCounterFlag) { if (offset + 4 > buffer.length) throw new Error('MMTP packet truncated in packet_counter') packetCounter = view.getUint32(offset, false) offset += 4 } let extension: { type: number; data: Uint8Array } | undefined if (extensionFlag) { if (offset + 4 > buffer.length) throw new Error('MMTP packet truncated in header extension') const type = view.getUint16(offset, false) const length = view.getUint16(offset + 2, false) offset += 4 if (offset + length > buffer.length) throw new Error('MMTP packet truncated in header extension value') extension = { type, data: buffer.subarray(offset, offset + length) } offset += length } return { version, packetType, packetId, timestamp, sequenceNumber, payloadLength: buffer.length - offset, fecType, packetCounterFlag, packetCounter, extensionFlag, extension, headerLength: offset, rapFlag, } } /** * The MMTP `packet_id` of `buffer`, or null exactly when parseMMTPHeader would * throw for it (short buffer, V != 0, truncated packet_counter or header * extension). * * This is the per-packet filter a receiver runs before any other work, so it * builds no header object, DataView or BigInt timestamp (BLO-36599). Use * parseMMTPHeader when any other header field is needed. */ export function readMMTPPacketId(buffer: Uint8Array): number | null { if (buffer.length < MMTP_HEADER_SIZE) return null const flags = buffer[0]! if (((flags >> 6) & 0x03) !== 0) return null let offset = MMTP_HEADER_SIZE if ((flags & 0x20) !== 0) { if (offset + 4 > buffer.length) return null offset += 4 } if ((flags & 0x02) !== 0) { if (offset + 4 > buffer.length) return null const length = (buffer[offset + 2]! << 8) | buffer[offset + 3]! if (offset + 4 + length > buffer.length) return null } return (buffer[2]! << 8) | buffer[3]! } /** * Unwrap MMTP packet to extract payload */ export function unwrapMMTP(buffer: Uint8Array): MMTPPacket { const header = parseMMTPHeader(buffer) const payload = buffer.subarray(header.headerLength) return { header, payload } } /** * Parse MPU header from payload * ISO/IEC 23008-1:2023 Section 9.2.3.3 * * MPU Header format (8 bytes) as written by FFmpeg/libmmt: * - Bytes 0-1: payload_length (16 bits, big-endian) * - Byte 2: FT(4) | T(1) | FI(2) | A(1) * - Byte 3: fragment_counter (8 bits) * - Bytes 4-7: MPU_sequence_number (32 bits, big-endian) */ export function parseMPUHeader(payload: Uint8Array): MPUHeader { if (payload.length < MPU_HEADER_SIZE) { throw new Error(`Payload too small for MPU header: ${payload.length} bytes`) } const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength) // Bytes 0-1: payload_length (16 bits, big-endian) const payloadLength = view.getUint16(0, false) // Byte 2: FT(4) | T(1) | FI(2) | A(1) const flagsByte = payload[2]! const timedFlag = (flagsByte >> 3) & 0x01 const fragmentationIndicator = (flagsByte >> 1) & 0x03 const aggregationFlag = (flagsByte & 0x01) !== 0 // Byte 3: fragment_counter (8 bits). The high bit is part of the counter in // the current libmmt/FFmpeg writer ABI. const fragmentCounter = payload[3]! // Bytes 4-7: MPU_sequence_number (32 bits, big-endian) const mpuSequenceNumber = view.getUint32(4, false) return { mpuSequenceNumber, fragmentationIndicator, fragmentCounter, timedFlag, aggregationFlag, payloadLength, } } export function parseTimedMfuDuHeader(payload: Uint8Array, offset: number): MFUDuHeader | null { if (offset < 0 || offset + MFU_DU_HEADER_SIZE_TIMED > payload.length) return null const view = new DataView(payload.buffer, payload.byteOffset + offset, MFU_DU_HEADER_SIZE_TIMED) return { movieFragmentSequenceNumber: view.getUint32(0, false), sampleNumber: view.getUint32(4, false), offset: view.getUint32(8, false), priority: payload[offset + 12]!, dependencyCounter: payload[offset + 13]!, } } /** * Why `extractMFUPayload` refused a packet. * * These identifiers are part of the public contract: callers surface them in * diagnostics, so renaming one is a breaking change. */ export type MFURejectionGate = | 'packet-shorter-than-mmtp-plus-mpu-header' | 'mmtp-header-unparseable' | 'not-an-mpu-packet' | 'payload-shorter-than-mpu-header' | 'aggregation-flag-set' | 'mpu-payload-length-below-header-remainder' | 'mpu-declared-end-exceeds-payload' | 'mfu-offset-at-or-past-declared-end' | 'source-fec-payload-id-width-mismatch' | 'declared-trailers-unresolved' export interface MFURejection { gate: MFURejectionGate /** Human-readable detail, including the byte arithmetic that decided it. */ detail: string } interface MfuPayloadLayout { hasMfuDuHeader: boolean mfuOffset: number dataEnd: number } function resolveMfuPayloadLayout( mpu: MPUHeader, fragmentType: number, payload: Uint8Array, ): { layout: MfuPayloadLayout } | { rejection: MFURejection } { if (mpu.payloadLength < MPU_HEADER_REMAINDER_SIZE) { return { rejection: { gate: 'mpu-payload-length-below-header-remainder', detail: `payload_length=${mpu.payloadLength} < ${MPU_HEADER_REMAINDER_SIZE}`, }, } } // ISO/IEC 23008-1 defines payload_length from immediately after its own // two-byte field. It therefore includes the remaining six MPU-header bytes. const declaredEnd = MPU_LENGTH_FIELD_SIZE + mpu.payloadLength if (declaredEnd > payload.length) { return { rejection: { gate: 'mpu-declared-end-exceeds-payload', detail: `payload_length=${mpu.payloadLength} implies end=${declaredEnd}, ` + `payload is ${payload.length} bytes`, }, } } const hasHeader = fragmentType === 2 && mpu.fragmentationIndicator <= 1 const mfuHeaderSize = mpu.timedFlag ? MFU_DU_HEADER_SIZE_TIMED : MFU_DU_HEADER_SIZE_NONTIMED const mfuOffset = MPU_HEADER_SIZE + (hasHeader ? mfuHeaderSize : 0) if (mfuOffset >= declaredEnd) { return { rejection: { gate: 'mfu-offset-at-or-past-declared-end', detail: `mfuOffset=${mfuOffset} >= declaredEnd=${declaredEnd}`, }, } } return { layout: { hasMfuDuHeader: hasHeader, mfuOffset, dataEnd: declaredEnd, }, } } // Legacy detached per-packet authenticator suffix `[0xf0a1][len][auth]`. Its // producer was removed (BLO-16483) and nothing verifies it any more; provenance // is the signed per-group manifest. Receivers still skip an exact suffix so a // stale producer degrades to unauthenticated playback instead of dropped // packets. ponytail: hard-cut to reject once no deployed producer can emit it. const LEGACY_TRAILER_EXT_TYPE = 0xf0a1 const LEGACY_TRAILER_HEADER_SIZE = 4 function isEmptyOrExactLegacyTrailer(payload: Uint8Array, start: number, end: number): boolean { if (start === end) return true if (start < 0 || end > payload.length || start + LEGACY_TRAILER_HEADER_SIZE > end) { return false } const view = new DataView( payload.buffer, payload.byteOffset + start, end - start, ) if (view.getUint16(0, false) !== LEGACY_TRAILER_EXT_TYPE) return false const authLength = view.getUint16(2, false) return start + LEGACY_TRAILER_HEADER_SIZE + authLength === end } interface DeclaredTrailerLayout { hasFecId: boolean } function resolveDeclaredTrailers( payload: Uint8Array, declaredEnd: number, fecType: number, options?: { skipFecId?: boolean }, ): DeclaredTrailerLayout | null { if (declaredEnd > payload.length) return null if (options?.skipFecId) { return isEmptyOrExactLegacyTrailer(payload, declaredEnd, payload.length) ? { hasFecId: false } : null } if (fecType === 1) { const fecStart = payload.length - SOURCE_FEC_PAYLOAD_ID_SIZE if (fecStart < declaredEnd || !isEmptyOrExactLegacyTrailer(payload, declaredEnd, fecStart)) { return null } return { hasFecId: true } } if (fecType !== 0) return null if (isEmptyOrExactLegacyTrailer(payload, declaredEnd, payload.length)) { return { hasFecId: false } } return null } /** * Extract NAL unit payload from MMTP packet */ export function extractNALUnit(mmtpPacket: Uint8Array): Uint8Array { const extraction = extractMFUPayload(mmtpPacket) if (!extraction) throw new Error('Malformed MMTP MPU payload') return extraction.data } /** Result of extracting MFU payload from an MMTP packet */ export interface MFUExtraction { /** MMTP header fields */ header: MMTPHeader /** MPU header fields */ mpu: MPUHeader /** Fragment type (FT field, 4 bits): 0=metadata, 2=MFU data */ fragmentType: number /** MFU payload data (video/audio bytes, headers stripped, FEC ID excluded) */ data: Uint8Array /** Parsed timed MFU DU header when present, otherwise null */ mfuDuHeader: MFUDuHeader | null /** Non-timed MFU Item_ID when the 4-byte DU header is present. */ itemId: number | null /** Source FEC Payload ID if present (`FEC_type == 1`), or null */ fecPayloadId: { ssId: number } | null } /** * Extract MFU payload from a raw MMTP packet. * * Handles all the byte arithmetic for: * - MMTP header (12 bytes) * - MPU sub-header (8 bytes, payloadLength field) * - MFU DU header (14 bytes timed / 4 bytes non-timed, only for FI ≤ 1) * - Source FEC Payload ID (4 bytes at end, only when `FEC_type == 1`) * * `payloadLength` is the ISO length-after-field value: six remaining MPU-header * bytes, then the MFU DU header when present, then media. The legacy * authenticator suffix and the Source FEC trailer are outside that declared span and must consume the exact suffix. * * @param mmtpPacket Raw MMTP packet (from multicast or MoQ) * @param options.skipFecId When true, do NOT subtract the 4-byte Source FEC Payload ID * from the payload boundary, even if fecType >= 1 in the MMTP header. Use this for * FEC-recovered symbols where the FEC PID was already stripped before WASM decoding * but the fecType bits in byte 0 still reflect the original packet (FEC Type=1 per * draft-ramadan-moq-mmt-00 §3.1). * @returns Extracted MFU data + metadata, or null if packet is too small */ interface MfuPlan { header: MMTPHeader payload: Uint8Array mpu: MPUHeader fragmentType: number layout: MfuPayloadLayout trailerLayout: DeclaredTrailerLayout } /** * Single source of truth for whether a packet yields an MFU payload. * * `extractMFUPayload` and `explainMFURejection` both delegate here, so the * reason reported to operators can never drift from the decision the parser * actually made. */ function planMfuExtraction( mmtpPacket: Uint8Array, options?: { skipFecId?: boolean }, parsedHeader?: MMTPHeader, ): { plan: MfuPlan } | { rejection: MFURejection } { if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) { return { rejection: { gate: 'packet-shorter-than-mmtp-plus-mpu-header', detail: `${mmtpPacket.length} bytes < ${MMTP_HEADER_SIZE + MPU_HEADER_SIZE}`, }, } } let header: MMTPHeader if (parsedHeader) { header = parsedHeader } else { try { header = parseMMTPHeader(mmtpPacket) } catch (error) { return { rejection: { gate: 'mmtp-header-unparseable', detail: error instanceof Error ? error.message : String(error), }, } } } if (header.packetType !== PACKET_TYPE_MPU) { return { rejection: { gate: 'not-an-mpu-packet', detail: `packetType=0x${header.packetType.toString(16).padStart(2, '0')}`, }, } } const payload = mmtpPacket.subarray(header.headerLength) if (payload.length < MPU_HEADER_SIZE) { return { rejection: { gate: 'payload-shorter-than-mpu-header', detail: `${payload.length} bytes < ${MPU_HEADER_SIZE}`, }, } } const mpu = parseMPUHeader(payload) if (mpu.aggregationFlag) { return { rejection: { gate: 'aggregation-flag-set', detail: 'strict profile requires A=0' }, } } // Fragment type from byte 2 upper nibble (not returned by parseMPUHeader) const fragmentType = (payload[2]! >> 4) & 0x0f const layoutResult = resolveMfuPayloadLayout(mpu, fragmentType, payload) if ('rejection' in layoutResult) return layoutResult const { layout } = layoutResult const trailerLayout = resolveDeclaredTrailers( payload, layout.dataEnd, header.fecType, options, ) if (!trailerLayout) { // The most common cause is a producer whose Source FEC Payload ID width // disagrees with ISO/IEC 23008-1 §C.5.2. Name that explicitly rather than // leaving operators to infer it from a generic trailer error. if (!options?.skipFecId && header.fecType === 1) { const observed = payload.length - layout.dataEnd if (observed !== SOURCE_FEC_PAYLOAD_ID_SIZE) { return { rejection: { gate: 'source-fec-payload-id-width-mismatch', detail: `producer wrote a ${observed}-byte Source FEC Payload ID, ` + `ISO/IEC 23008-1 §C.5.2 requires ${SOURCE_FEC_PAYLOAD_ID_SIZE} ` + `(payload=${payload.length}, declaredEnd=${layout.dataEnd})`, }, } } } return { rejection: { gate: 'declared-trailers-unresolved', detail: `trailer bytes [${layout.dataEnd}, ${payload.length}) are neither empty ` + `nor an exact legacy authenticator trailer (fecType=${header.fecType})`, }, } } return { plan: { header, payload, mpu, fragmentType, layout, trailerLayout } } } /** * Report why `extractMFUPayload` would refuse this packet, or null if it would * accept it. * * `extractMFUPayload` returns bare `null` on ten distinct conditions, which * makes a producer/consumer framing disagreement indistinguishable from a * publisher that never started — both render as silence. Use this wherever * that distinction matters: diagnostics, contract tests, and operator-facing * logs. */ export function explainMFURejection( mmtpPacket: Uint8Array, options?: { skipFecId?: boolean }, ): MFURejection | null { const result = planMfuExtraction(mmtpPacket, options) return 'rejection' in result ? result.rejection : null } export function extractMFUPayload( mmtpPacket: Uint8Array, options?: { skipFecId?: boolean }, ): MFUExtraction | null { return extractPlannedMfu(mmtpPacket, planMfuExtraction(mmtpPacket, options)) } /** * extractMFUPayload for a caller that already holds this packet's parsed MMTP * header, so the header is not parsed a second time (BLO-36599). * * `header` must be `parseMMTPHeader(mmtpPacket)` of this same packet; the * result is then identical to `extractMFUPayload(mmtpPacket, options)`. */ export function extractMFUPayloadWithHeader( mmtpPacket: Uint8Array, header: MMTPHeader, options?: { skipFecId?: boolean }, ): MFUExtraction | null { return extractPlannedMfu(mmtpPacket, planMfuExtraction(mmtpPacket, options, header)) } function extractPlannedMfu( mmtpPacket: Uint8Array, result: { plan: MfuPlan } | { rejection: MFURejection }, ): MFUExtraction | null { if ('rejection' in result) return null const { header, payload, mpu, fragmentType, layout, trailerLayout } = result.plan const mfuDuHeaderPresent = layout.hasMfuDuHeader const mfuOffset = layout.mfuOffset const dataEnd = layout.dataEnd const mfuDuHeader = mfuDuHeaderPresent && mpu.timedFlag === 1 ? parseTimedMfuDuHeader(payload, MPU_HEADER_SIZE) : null const itemId = mfuDuHeaderPresent && mpu.timedFlag === 0 ? new DataView(payload.buffer, payload.byteOffset + MPU_HEADER_SIZE, 4).getUint32(0, false) : null const data = payload.subarray(mfuOffset, dataEnd) const fecPayloadId = trailerLayout.hasFecId ? { ssId: new DataView( mmtpPacket.buffer, mmtpPacket.byteOffset + mmtpPacket.length - SOURCE_FEC_PAYLOAD_ID_SIZE, SOURCE_FEC_PAYLOAD_ID_SIZE, ).getUint32(0, false), } : null return { header, mpu, fragmentType, data, mfuDuHeader, itemId, fecPayloadId } } /** * Extract timestamp from MMTP packet */ export function extractTimestamp(mmtpPacket: Uint8Array): bigint { const { header } = unwrapMMTP(mmtpPacket) return header.timestamp } /** * Check if MMTP packet is an FEC repair packet * FEC_type 2 and 3 are repair packets */ export function isFecRepairPacket(mmtpPacket: Uint8Array): boolean { if (mmtpPacket.length < 1) return false const fecType = (mmtpPacket[0]! >> 3) & 0x03 return fecType >= 2 } /** * Extract Source FEC Payload ID (SS_ID) from the last 4 bytes of an MMTP * source packet per ISO/IEC 23008-1:2023 Section C.5.2 Figure C.10. * * SS_ID is a flat 32-bit monotonic counter (incremented by 1 per packet * for ssbg_mode0 and ssbg_mode1). Block membership and ESI use the * configured K; repair SS_Start identifies the first SS_ID of the block and * SSB_length records the authored source-symbol count: * SBN = floor(SS_ID / K), ESI = SS_ID % K * SS_Start = SBN * K (first SS_ID of the block) * * This matches FFmpeg moqenc_mmt.c, the drafts (draft-ramadan-moq-fec §6.2), * and ATSC A/331 §8.1.2.5 which defers to ISO 23008-1 Annex C. */ export function extractFecPayloadId(mmtpPacket: Uint8Array): { ssId: number } | null { const header = parseHeaderForSourceFecId(mmtpPacket) return header ? sourceFecPayloadIdOf(mmtpPacket, header) : null } /** * Return true when FEC_type=1 and the exact suffix after the ISO-declared MPU * payload contains a four-byte Source FEC Payload ID. */ export function hasSourceFecPayloadId(mmtpPacket: Uint8Array): boolean { const header = parseHeaderForSourceFecId(mmtpPacket) return header !== null && sourceFecPayloadIdOf(mmtpPacket, header) !== null } function parseHeaderForSourceFecId(mmtpPacket: Uint8Array): MMTPHeader | null { if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) return null try { return parseMMTPHeader(mmtpPacket) } catch { return null } } /** * The Source FEC Payload ID of a packet whose MMTP header the caller already * parsed, or null when the packet carries none: present only when FEC_type=1 * and the exact suffix after the ISO-declared MPU payload holds the four-byte * ID (ISO/IEC 23008-1 §C.5.2). Same answer as extractFecPayloadId, without a * second header parse (BLO-36599). * * `header` must be `parseMMTPHeader(mmtpPacket)` of this same packet. */ export function sourceFecPayloadIdOf( mmtpPacket: Uint8Array, header: MMTPHeader, ): { ssId: number } | null { if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) return null if (header.packetType !== PACKET_TYPE_MPU) return null const payload = mmtpPacket.subarray(header.headerLength) if (payload.length < MPU_HEADER_SIZE) return null const payloadLength = (payload[0]! << 8) | payload[1]! if (payloadLength < MPU_HEADER_REMAINDER_SIZE) return null const declaredEnd = MPU_LENGTH_FIELD_SIZE + payloadLength if (!resolveDeclaredTrailers(payload, declaredEnd, header.fecType)?.hasFecId) return null // Flat 4-byte SS_ID: encoder writes block_id * K + symbol_id const off = mmtpPacket.length - SOURCE_FEC_PAYLOAD_ID_SIZE return { ssId: ((mmtpPacket[off]! << 24) | (mmtpPacket[off + 1]! << 16) | (mmtpPacket[off + 2]! << 8) | mmtpPacket[off + 3]!) >>> 0, } } /** Size of Repair FEC Payload ID per ISO/IEC 23008-1 Section C.5.3 */ export const REPAIR_FEC_PID_SIZE = 13 /** * Parse the 13-byte Repair FEC Payload ID after the complete dynamic MMTP header. * `parsedHeader`, when given, must be `parseMMTPHeader(mmtpPacket)` of this * same packet; it spares the second header parse (BLO-36599). */ function readRepairFecPayloadIdFields(mmtpPacket: Uint8Array, parsedHeader?: MMTPHeader): { packetId: number; ssStart: number; rsbLength: number; rsId: number; ssbLength: number; dataOffset: number } | null { let header: MMTPHeader if (parsedHeader) { header = parsedHeader } else { try { header = parseMMTPHeader(mmtpPacket) } catch { return null } } if (header.packetType !== PACKET_TYPE_REPAIR || header.fecType !== 2) return null const off = header.headerLength if (mmtpPacket.length < off + REPAIR_FEC_PID_SIZE) return null const ssStart = ((mmtpPacket[off]! << 24) | (mmtpPacket[off + 1]! << 16) | (mmtpPacket[off + 2]! << 8) | mmtpPacket[off + 3]!) >>> 0 // 32 bits const rsbLength = (mmtpPacket[off + 4]! << 16) | (mmtpPacket[off + 5]! << 8) | mmtpPacket[off + 6]! // 24 bits const rsId = (mmtpPacket[off + 7]! << 16) | (mmtpPacket[off + 8]! << 8) | mmtpPacket[off + 9]! // 24 bits const ssbLength = (mmtpPacket[off + 10]! << 16) | (mmtpPacket[off + 11]! << 8) | mmtpPacket[off + 12]! // 24 bits return { packetId: header.packetId, ssStart, rsbLength, rsId, ssbLength, dataOffset: off + REPAIR_FEC_PID_SIZE, } } /** * Extract Repair FEC Payload ID from a raw-multicast MMTP repair packet. * Per ISO/IEC 23008-1:2023 Section C.5.3 (ssbg_mode0, one-stage, no FFSRP_TS): * [MMTP header][SS_Start:4][RSB_length:3][RS_ID:3][SSB_length:3][repair data] * OTI is delivered via AL-FEC signaling message (Table C.3), not per-packet. * * Byte 1 is reserved(2)|packet_type(6) for V=0, so repair type 0x03 is 0x03. * Pre-migration high-bits bytes (0x0c) are rejected — no compatibility decode. */ export function extractRepairFecPayloadId(mmtpPacket: Uint8Array): { ssStart: number; rsbLength: number; rsId: number; ssbLength: number } | null { const fields = readRepairFecPayloadIdFields(mmtpPacket) if (!fields) return null return { ssStart: fields.ssStart, rsbLength: fields.rsbLength, rsId: fields.rsId, ssbLength: fields.ssbLength, } } /** Parsed MoQ repair frame per ISO/IEC 23008-1 Section C.4.3 + C.5.3 */ export interface MoqRepairFrame { /** MMTP packetId (1=video, 2=audio) — for routing to correct FEC decoder */ packetId: number /** SS_Start (32 bits): SS_ID of first source symbol in block */ ssStart: number /** RSB_length (24 bits): number of repair symbols (P) */ rsbLength: number /** RS_ID (24 bits): repair symbol index (0-based) */ rsId: number /** SSB_length (24 bits): authored source-symbol count (1..=configured K) */ ssbLength: number /** Repair symbol data after the dynamic MMTP header and 13-byte FEC payload ID. */ symbolData: Uint8Array } /** * Parse a MoQ repair frame (ISO/IEC 23008-1 Annex C format). * * Wire format per ISO 23008-1 §C.4.3 + §C.5.3: * MMTP Header: fixed 12 bytes plus C/X optional fields * then SS_Start (4 bytes) → block identifier * then RSB_length (3 bytes) → P * then RS_ID (3 bytes) → ESI = K + RS_ID * then SSB_length (3 bytes) → authored source-symbol count (1..=K) * then Repair Symbol Data (T bytes) * * @param frame - Raw MoQ repair frame bytes * @returns Parsed frame or null if invalid */ export function parseMoqRepairFrame(frame: Uint8Array): MoqRepairFrame | null { return moqRepairFrameFrom(frame, readRepairFecPayloadIdFields(frame)) } /** * parseMoqRepairFrame for a caller that already holds this frame's parsed MMTP * header, so the header is not parsed a second time (BLO-36599). The router's * multicast repair branch uses it: one repair packet in every K + P otherwise * paid two header parses. * * `header` must be `parseMMTPHeader(frame)` of this same frame; the result is * then identical to `parseMoqRepairFrame(frame)`. */ export function parseMoqRepairFrameWithHeader( frame: Uint8Array, header: MMTPHeader, ): MoqRepairFrame | null { return moqRepairFrameFrom(frame, readRepairFecPayloadIdFields(frame, header)) } function moqRepairFrameFrom( frame: Uint8Array, repairId: ReturnType, ): MoqRepairFrame | null { if (!repairId) return null const symbolData = frame.subarray(repairId.dataOffset) if ( repairId.rsbLength === 0 || repairId.ssbLength === 0 || repairId.rsId >= repairId.rsbLength || symbolData.length === 0 ) return null return { packetId: repairId.packetId, ssStart: repairId.ssStart, rsbLength: repairId.rsbLength, rsId: repairId.rsId, ssbLength: repairId.ssbLength, symbolData, } } // The signalling-message parsers (9.3.4.2 payload header, AL-FEC message, // PA/MPT) live in ./signalling.ts.