{"version":3,"file":"opus-sdp.cjs","names":[],"sources":["../../src/webrtc/opus-sdp.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines — parsing a session description, merging one\n * `fmtp` line and inserting a missing one are the same pass over the same\n * grammar; split apart, each half is a parser of half an SDP.\n */\n\n/** What an Opus `fmtp` line can be asked to carry. Every field is optional. */\nexport interface OpusProfile {\n    /**\n     * Ceiling the encoder is asked to respect, in bits per second.\n     *\n     * This describes what we want **to receive**. To cap what we *send*, use\n     * `setSenderBitrate` — in a mesh that is the one that matters, because the\n     * uplink carries one copy per participant.\n     */\n    maxAverageBitrate?: number;\n    /** Highest sample rate worth decoding, in Hz. `48000` for full band. */\n    maxPlaybackRate?: number;\n    /**\n     * Two channels instead of one.\n     *\n     * Sets `stereo` **and** `sprop-stereo`, which point in opposite directions:\n     * `stereo=1` asks the *remote* to send two channels, `sprop-stereo=1`\n     * announces that *we* will. Setting only one leaves the link asymmetric,\n     * which is the recurring reason \"I asked for stereo and got mono\".\n     */\n    stereo?: boolean;\n    /**\n     * In-band forward error correction (`useinbandfec`).\n     *\n     * Rebuilds a lost packet from the next one, which keeps speech intelligible\n     * on a lossy link — and smears music, since it spends bitrate on redundancy\n     * instead of detail.\n     */\n    fec?: boolean;\n    /**\n     * Discontinuous transmission (`usedtx`): stop sending during silence.\n     *\n     * Saves uplink in a mesh, at the cost of clipping the first instant after a\n     * pause. Wrong for music and for a shared screen, where the quiet passages\n     * are content.\n     */\n    dtx?: boolean;\n    /** Constant bitrate (`cbr`). Off by default, as Opus intends. */\n    cbr?: boolean;\n    /**\n     * Any other `fmtp` key, merged verbatim.\n     *\n     * The escape hatch for a parameter this type does not model. Values are\n     * written as given; a key with an empty string is emitted as a bare flag.\n     */\n    extra?: Record<string, string>;\n}\n\n/**\n * Profiles to apply, keyed by audio m-line index (`0`, `1`, …) or by `mid`.\n *\n * Position and `mid` can be mixed in one object. A key that matches nothing is\n * ignored rather than an error — an SDP is negotiated, and a slot that was not\n * offered this time is normal.\n */\nexport type OpusProfileMap = Record<string | number, OpusProfile>;\n\n/** Keys that identify a bare {@link OpusProfile} rather than a map of them. */\nconst PROFILE_KEYS: ReadonlySet<string> = new Set([\n    \"maxAverageBitrate\",\n    \"maxPlaybackRate\",\n    \"stereo\",\n    \"fec\",\n    \"dtx\",\n    \"cbr\",\n    \"extra\",\n]);\n\n/**\n * Whether the caller passed one profile for every audio m-line, or a map.\n *\n * An empty object is read as a profile, which makes `tuneOpus(sdp, {})` a no-op\n * instead of a silent surprise.\n */\nfunction isSingleProfile(value: OpusProfile | OpusProfileMap): value is OpusProfile {\n    const keys = Object.keys(value);\n    return keys.length === 0 || keys.some((key) => PROFILE_KEYS.has(key));\n}\n\n/** The `fmtp` parameters a profile asks for, in the order Opus documents them. */\nfunction fmtpParams(profile: OpusProfile): Record<string, string> {\n    const params: Record<string, string> = {};\n    if (profile.maxAverageBitrate !== undefined) {\n        params.maxaveragebitrate = String(profile.maxAverageBitrate);\n    }\n    if (profile.maxPlaybackRate !== undefined) {\n        params.maxplaybackrate = String(profile.maxPlaybackRate);\n    }\n    if (profile.stereo !== undefined) {\n        params.stereo = profile.stereo ? \"1\" : \"0\";\n        params[\"sprop-stereo\"] = profile.stereo ? \"1\" : \"0\";\n    }\n    if (profile.fec !== undefined) params.useinbandfec = profile.fec ? \"1\" : \"0\";\n    if (profile.dtx !== undefined) params.usedtx = profile.dtx ? \"1\" : \"0\";\n    if (profile.cbr !== undefined) params.cbr = profile.cbr ? \"1\" : \"0\";\n    return { ...params, ...profile.extra };\n}\n\n/**\n * Merge parameters into an existing `fmtp` payload, key by key.\n *\n * Replacing the whole line is the obvious move and it is wrong: the browser\n * emits `minptime=10;useinbandfec=1` on its own, and overwriting drops the\n * `minptime` — a packetization decision nobody meant to change.\n */\nfunction mergeFmtp(existing: string, params: Record<string, string>): string {\n    const merged = new Map<string, string>();\n    for (const entry of existing.split(\";\")) {\n        const trimmed = entry.trim();\n        if (!trimmed) continue;\n        const eq = trimmed.indexOf(\"=\");\n        if (eq < 0) merged.set(trimmed, \"\");\n        else merged.set(trimmed.slice(0, eq), trimmed.slice(eq + 1));\n    }\n    for (const [key, value] of Object.entries(params)) merged.set(key, value);\n    return [...merged.entries()]\n        .map(([key, value]) => (value === \"\" ? key : `${key}=${value}`))\n        .join(\";\");\n}\n\n/** Payload types this media block maps to Opus. There can be more than one. */\nfunction opusPayloads(block: readonly string[]): string[] {\n    const payloads: string[] = [];\n    for (const line of block) {\n        const match = /^a=rtpmap:(\\d+)\\s+opus\\/48000/i.exec(line);\n        if (match) payloads.push(match[1]);\n    }\n    return payloads;\n}\n\n/** The block's `mid`, when it declares one. */\nfunction blockMid(block: readonly string[]): string | null {\n    for (const line of block) {\n        const match = /^a=mid:(.+)$/.exec(line);\n        if (match) return match[1].trim();\n    }\n    return null;\n}\n\n/**\n * Rewrite one media block's Opus `fmtp` lines to carry a profile.\n *\n * A payload type with no `fmtp` of its own gets one inserted right after its\n * `rtpmap`, because \"the parameter is missing\" and \"the parameter is empty\" are\n * the same request from the caller's side.\n */\nfunction tuneBlock(block: readonly string[], profile: OpusProfile): string[] {\n    const params = fmtpParams(profile);\n    const payloads = opusPayloads(block);\n    if (payloads.length === 0 || Object.keys(params).length === 0) return [...block];\n\n    const out: string[] = [];\n    for (const line of block) {\n        const fmtp = /^a=fmtp:(\\d+)\\s+(.*)$/.exec(line);\n        if (fmtp && payloads.includes(fmtp[1])) {\n            out.push(`a=fmtp:${fmtp[1]} ${mergeFmtp(fmtp[2], params)}`);\n            continue;\n        }\n        out.push(line);\n\n        const rtpmap = /^a=rtpmap:(\\d+)\\s+opus\\/48000/i.exec(line);\n        if (rtpmap && !block.some((other) => other.startsWith(`a=fmtp:${rtpmap[1]} `))) {\n            out.push(`a=fmtp:${rtpmap[1]} ${mergeFmtp(\"\", params)}`);\n        }\n    }\n    return out;\n}\n\n/** Split a session description into its session part plus one block per m-line. */\nfunction mediaBlocks(sdp: string): string[][] {\n    const blocks: string[][] = [];\n    let current: string[] = [];\n    for (const line of sdp.split(/\\r\\n|\\n/)) {\n        if (line.startsWith(\"m=\") && current.length > 0) {\n            blocks.push(current);\n            current = [];\n        }\n        current.push(line);\n    }\n    if (current.length > 0) blocks.push(current);\n    return blocks;\n}\n\n/**\n * Apply Opus profiles to the audio m-lines of a session description.\n *\n * Audio over WebRTC is mono and narrow by default, and the only place that is\n * corrected is the SDP — which is why shared-screen audio famously sounds like a\n * telephone: music and video inherit the speech profile (mono, ~32 kbps, FEC on,\n * DTX gating the quiet passages) and no high-level API lets you change it.\n *\n * Deliberately **without** built-in presets: which values to use is the\n * consumer's call — voice in a mesh does not want what system audio wants — and\n * a preset table is the kind of thing that has no business inside a dependency.\n * What lives here is the parsing, merging and insertion, which is where the long\n * tail is. Getting any of it wrong degrades silently: nobody sees an exception,\n * the audio is just worse.\n *\n * @param sdp - The description from `createOffer` or `createAnswer`.\n * @param profiles - One {@link OpusProfile} for every audio m-line, or an\n *   {@link OpusProfileMap} keyed by audio m-line index or by `mid`.\n * @returns The rewritten SDP, with CRLF line endings as RFC 4566 requires.\n *   Untouched when there is no Opus, when no profile matches, or when a profile\n *   asks for nothing.\n *\n * @example\n * const tuned = tuneOpus(offer.sdp, {\n *   0: { maxAverageBitrate: 48_000, stereo: false, fec: true, dtx: true },\n *   1: { maxAverageBitrate: 192_000, stereo: true, fec: false, dtx: false },\n * });\n *\n * @example\n * const tuned = tuneOpus(offer.sdp, { stereo: true, dtx: false });\n */\nexport function tuneOpus(sdp: string, profiles: OpusProfile | OpusProfileMap): string {\n    const single = isSingleProfile(profiles) ? profiles : null;\n    const map = single ? null : (profiles as OpusProfileMap);\n\n    let audioIndex = 0;\n    const tuned = mediaBlocks(sdp).map((block) => {\n        if (!block[0]?.startsWith(\"m=audio\")) return block;\n\n        const index = audioIndex;\n        audioIndex += 1;\n        if (single) return tuneBlock(block, single);\n\n        const mid = blockMid(block);\n        const profile = map?.[index] ?? (mid !== null ? map?.[mid] : undefined);\n        return profile ? tuneBlock(block, profile) : block;\n    });\n\n    return tuned.flat().join(\"\\r\\n\");\n}\n\n/** Which description a peer connection actually accepted. */\nexport type TunedDescriptionResult = \"tuned\" | \"original\";\n\n/**\n * Set a local description, falling back to the untouched one if it is refused.\n *\n * Chrome has been tightening what `setLocalDescription` accepts from edited SDP,\n * and there is no way to know in advance. Without a fallback the call dies\n * instead of merely losing the profile — which is the wrong trade by a wide\n * margin: worse audio beats no audio.\n *\n * @param connection - The peer connection.\n * @param description - The description from `createOffer` / `createAnswer`.\n * @param profiles - Passed straight to {@link tuneOpus}.\n * @returns `\"tuned\"` when the rewritten SDP was accepted, `\"original\"` when the\n *   fallback was used — worth reporting, because it means the profile silently\n *   did not apply.\n * @throws Whatever `setLocalDescription` throws for the original description: at\n *   that point the failure is not about the rewrite and the caller has to know.\n *\n * @example\n * const applied = await setTunedLocalDescription(pc, await pc.createOffer(), profiles);\n * if (applied === \"original\") logger.warn(\"opus profile refused by the browser\");\n */\nexport async function setTunedLocalDescription(\n    connection: RTCPeerConnection,\n    description: RTCSessionDescriptionInit,\n    profiles: OpusProfile | OpusProfileMap,\n): Promise<TunedDescriptionResult> {\n    if (description.sdp) {\n        const sdp = tuneOpus(description.sdp, profiles);\n        if (sdp !== description.sdp) {\n            try {\n                await connection.setLocalDescription({ ...description, sdp });\n                return \"tuned\";\n            } catch {\n                /* the browser refused the edit; the untouched offer still works */\n            }\n        }\n    }\n    await connection.setLocalDescription(description);\n    return \"original\";\n}\n"],"mappings":"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"}