tempest-react-sdk 0.55.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +74 -73
  2. package/dist/components/AppBar/AppBar.module.cjs.map +1 -1
  3. package/dist/components/AppBar/AppBar.module.js.map +1 -1
  4. package/dist/components/AudioPlayer/AudioPlayer.cjs +1 -1
  5. package/dist/components/AudioPlayer/AudioPlayer.js +6 -6
  6. package/dist/components/Scheduler/Scheduler.module.cjs.map +1 -1
  7. package/dist/components/Scheduler/Scheduler.module.js.map +1 -1
  8. package/dist/components/VideoPlayer/VideoPlayer.cjs +2 -0
  9. package/dist/components/VideoPlayer/VideoPlayer.cjs.map +1 -0
  10. package/dist/components/VideoPlayer/VideoPlayer.js +229 -0
  11. package/dist/components/VideoPlayer/VideoPlayer.js.map +1 -0
  12. package/dist/components/VideoPlayer/VideoPlayer.module.cjs +2 -0
  13. package/dist/components/VideoPlayer/VideoPlayer.module.cjs.map +1 -0
  14. package/dist/components/VideoPlayer/VideoPlayer.module.js +19 -0
  15. package/dist/components/VideoPlayer/VideoPlayer.module.js.map +1 -0
  16. package/dist/components/VideoPlayer/playback-rates.cjs +2 -0
  17. package/dist/components/VideoPlayer/playback-rates.cjs.map +1 -0
  18. package/dist/components/VideoPlayer/playback-rates.js +11 -0
  19. package/dist/components/VideoPlayer/playback-rates.js.map +1 -0
  20. package/dist/hooks/use-push-to-talk.cjs +1 -1
  21. package/dist/hooks/use-push-to-talk.cjs.map +1 -1
  22. package/dist/hooks/use-push-to-talk.js +2 -2
  23. package/dist/hooks/use-push-to-talk.js.map +1 -1
  24. package/dist/imaging/exceptions.cjs +1 -1
  25. package/dist/imaging/exceptions.cjs.map +1 -1
  26. package/dist/imaging/exceptions.js +5 -1
  27. package/dist/imaging/exceptions.js.map +1 -1
  28. package/dist/imaging/frame.cjs +2 -0
  29. package/dist/imaging/frame.cjs.map +1 -0
  30. package/dist/imaging/frame.js +82 -0
  31. package/dist/imaging/frame.js.map +1 -0
  32. package/dist/imaging.cjs +1 -1
  33. package/dist/imaging.d.ts +611 -494
  34. package/dist/imaging.js +10 -9
  35. package/dist/styles/AppBar.css +1 -1
  36. package/dist/styles/Scheduler.css +2 -2
  37. package/dist/styles/VideoPlayer.css +23 -0
  38. package/dist/styles/core.css +1 -1
  39. package/dist/styles/layout.css +2 -2
  40. package/dist/styles/media.css +22 -0
  41. package/dist/styles/navigation.css +1 -1
  42. package/dist/styles.css +1 -1
  43. package/dist/tempest-react-sdk.cjs +1 -1
  44. package/dist/tempest-react-sdk.d.ts +317 -35
  45. package/dist/tempest-react-sdk.js +213 -211
  46. package/dist/webrtc/link-stats.cjs +1 -1
  47. package/dist/webrtc/link-stats.cjs.map +1 -1
  48. package/dist/webrtc/link-stats.js +134 -44
  49. package/dist/webrtc/link-stats.js.map +1 -1
  50. package/dist/webrtc/mesh-quality.cjs +1 -1
  51. package/dist/webrtc/mesh-quality.cjs.map +1 -1
  52. package/dist/webrtc/mesh-quality.js +8 -2
  53. package/dist/webrtc/mesh-quality.js.map +1 -1
  54. package/dist/webrtc/peer-mesh.cjs +1 -1
  55. package/dist/webrtc/peer-mesh.cjs.map +1 -1
  56. package/dist/webrtc/peer-mesh.js +85 -63
  57. package/dist/webrtc/peer-mesh.js.map +1 -1
  58. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"link-stats.cjs","names":[],"sources":["../../src/webrtc/link-stats.ts"],"sourcesContent":["/**\n * One link's outbound picture, in the shape a call UI actually renders.\n *\n * Everything here is derived, not reported: WebRTC hands out cumulative\n * counters and a graph of candidate pairs, and turning that into\n * `\"1,2 Mbps · 42 ms · 1080p60\"` is the work {@link createLinkStatsSampler}\n * does.\n */\nexport interface LinkStats {\n /** Throughput since the previous sample. `0` on the first one — there is no delta yet. */\n kbps: number;\n /** Width of the largest stream being sent, or `0` before one is reported. */\n width: number;\n /** Height of the largest stream being sent, or `0` before one is reported. */\n height: number;\n /** Frame rate of the largest stream being sent, or `0` when the browser omits it. */\n fps: number;\n /** Round trip to the peer in milliseconds, or `null` before the first reading. */\n rttMs: number | null;\n}\n\n/** Which media a sampler counts. */\nexport type LinkStatsKind = \"video\" | \"audio\" | \"all\";\n\n/** Options for {@link createLinkStatsSampler}. */\nexport interface LinkStatsSamplerOptions {\n /**\n * Which media the throughput counts. Default `\"video\"`.\n *\n * Video is the default because it is what saturates an uplink — audio is an\n * order of magnitude cheaper, and mixing it in moves the number by less than\n * the noise between two samples. Use `\"all\"` when the figure is meant to be\n * the connection's real cost rather than the picture's.\n */\n kind?: LinkStatsKind;\n}\n\n/**\n * A sampler bound to one connection.\n *\n * Holds the previous byte counter and timestamp, which is the whole reason this\n * is an object rather than a function: the rate is a delta, so somebody has to\n * remember the last reading. One sampler per link — sharing one across peers\n * subtracts one connection's counter from another's and reports nonsense.\n */\nexport interface LinkStatsSampler {\n /**\n * Reduce a report you already have.\n *\n * @param report - A report from `RTCPeerConnection.getStats()`.\n * @returns The stats for this sample.\n */\n read: (report: RTCStatsReport) => LinkStats;\n /**\n * Fetch a report and reduce it.\n *\n * @param connection - The connection to sample.\n * @returns The stats for this sample.\n */\n sample: (connection: RTCPeerConnection) => Promise<LinkStats>;\n /**\n * Drop the baseline the rate is derived from.\n *\n * Call it after an ICE restart, a reconnect, or a pause — otherwise the next\n * sample divides the bytes of the whole gap by the whole gap and reports the\n * average of a period nobody is asking about. The next reading comes back at\n * `0` kbps and starts a fresh baseline; the resolution and round trip already\n * on screen are kept, so the badge does not blank out.\n */\n reset: () => void;\n}\n\nfunction numberField(entry: unknown, key: string): number | null {\n if (typeof entry !== \"object\" || entry === null) return null;\n const value: unknown = (entry as Record<string, unknown>)[key];\n return typeof value === \"number\" && Number.isFinite(value) ? value : null;\n}\n\nfunction stringField(entry: unknown, key: string): string | null {\n if (typeof entry !== \"object\" || entry === null) return null;\n const value: unknown = (entry as Record<string, unknown>)[key];\n return typeof value === \"string\" ? value : null;\n}\n\n/**\n * Resolve the media an RTP entry carries.\n *\n * `kind` is the standard field and `mediaType` is what older Chrome reported;\n * both are still in the wild, and a sampler that reads only one of them\n * silently counts nothing on the browser that uses the other.\n */\nfunction entryKind(entry: unknown): string | null {\n return stringField(entry, \"kind\") ?? stringField(entry, \"mediaType\");\n}\n\nfunction matchesKind(entry: unknown, kind: LinkStatsKind): boolean {\n if (kind === \"all\") return true;\n return entryKind(entry) === kind;\n}\n\n/**\n * Read the round trip of the candidate pair actually carrying the link.\n *\n * A connection routinely keeps several viable pairs alive at once — host,\n * server-reflexive, relayed — and only one of them carries traffic. Reading the\n * first `succeeded` pair makes the number jump between paths that are not being\n * travelled: 8 ms on an idle host pair alternating with 180 ms on the TURN pair\n * doing the work. The pair the transport names in `selectedCandidatePairId` is\n * the one being used.\n *\n * A succeeded pair is kept as a fallback because not every browser fills that\n * field in — losing the reading entirely is worse than an occasionally\n * optimistic one.\n *\n * @param report - A report from `RTCPeerConnection.getStats()`.\n * @returns Round trip in milliseconds, rounded, or `null` when nothing reported\n * one — which is the normal state before the connection settles.\n *\n * @example\n * const rttMs = readRoundTripMs(await pc.getStats());\n */\nexport function readRoundTripMs(report: RTCStatsReport): number | null {\n let selectedId: string | null = null;\n report.forEach((entry: unknown) => {\n if (stringField(entry, \"type\") !== \"transport\") return;\n selectedId = stringField(entry, \"selectedCandidatePairId\") ?? selectedId;\n });\n\n let selected: number | null = null;\n let fallback: number | null = null;\n report.forEach((entry: unknown) => {\n if (stringField(entry, \"type\") !== \"candidate-pair\") return;\n const seconds = numberField(entry, \"currentRoundTripTime\");\n if (seconds === null) return;\n if (selectedId !== null && stringField(entry, \"id\") === selectedId)\n selected = seconds * 1000;\n else if (fallback === null && stringField(entry, \"state\") === \"succeeded\")\n fallback = seconds * 1000;\n });\n\n const value: number | null = selected ?? fallback;\n return value === null ? null : Math.round(value);\n}\n\n/**\n * Track one link's throughput, resolution and round trip across samples.\n *\n * Every rate here is a **delta**. `bytesSent` is cumulative since the connection\n * opened, so dividing it by the session length gives the historical average —\n * a number that only ever falls and never shows what is happening now. The\n * previous reading is kept on the sampler and subtracted, which is the part\n * every hand-rolled copy of this ends up rewriting.\n *\n * Bytes are summed across every matching sender, because a peer publishing a\n * camera and a screen at once occupies one uplink with both — and the uplink is\n * what runs out. Resolution and frame rate come from the **largest** stream by\n * area, which is the one that dominates that bandwidth and the one somebody\n * watching the call is looking at.\n *\n * @param options - See {@link LinkStatsSamplerOptions}.\n * @returns A sampler. Use one per `RTCPeerConnection`.\n *\n * @example\n * const sampler = createLinkStatsSampler();\n *\n * setInterval(async () => {\n * const stats = await sampler.sample(pc);\n * badge.textContent = `${stats.kbps} kbps · ${stats.rttMs ?? \"—\"} ms`;\n * }, 2000);\n */\nexport function createLinkStatsSampler(options: LinkStatsSamplerOptions = {}): LinkStatsSampler {\n const kind: LinkStatsKind = options.kind ?? \"video\";\n let lastBytes: number | null = null;\n let lastSampleAt = 0;\n let last: LinkStats = { kbps: 0, width: 0, height: 0, fps: 0, rttMs: null };\n\n function read(report: RTCStatsReport): LinkStats {\n const now = performance.now();\n const rttMs = readRoundTripMs(report);\n\n let bytes = 0;\n let sawSender = false;\n let bestArea = 0;\n let width = 0;\n let height = 0;\n let fps = 0;\n\n report.forEach((entry: unknown) => {\n if (stringField(entry, \"type\") !== \"outbound-rtp\") return;\n if (!matchesKind(entry, kind)) return;\n sawSender = true;\n bytes += numberField(entry, \"bytesSent\") ?? 0;\n\n const entryWidth = numberField(entry, \"frameWidth\") ?? 0;\n const entryHeight = numberField(entry, \"frameHeight\") ?? 0;\n const area = entryWidth * entryHeight;\n if (area < bestArea) return;\n bestArea = area;\n width = entryWidth;\n height = entryHeight;\n fps = Math.round(numberField(entry, \"framesPerSecond\") ?? 0);\n });\n\n if (!sawSender) {\n last = { ...last, rttMs };\n return last;\n }\n\n const elapsed = lastBytes === null ? 0 : (now - lastSampleAt) / 1000;\n const delta = lastBytes === null ? 0 : bytes - lastBytes;\n const kbps = elapsed > 0 && delta > 0 ? Math.round((delta * 8) / 1000 / elapsed) : 0;\n\n lastBytes = bytes;\n lastSampleAt = now;\n last = {\n kbps,\n width: width > 0 ? width : last.width,\n height: height > 0 ? height : last.height,\n fps: fps > 0 ? fps : last.fps,\n rttMs,\n };\n return last;\n }\n\n return {\n read,\n sample: async (connection: RTCPeerConnection): Promise<LinkStats> =>\n read(await connection.getStats()),\n reset: (): void => {\n lastBytes = null;\n lastSampleAt = 0;\n last = { ...last, kbps: 0 };\n },\n };\n}\n"],"mappings":"AAwEA,SAAS,EAAY,EAAgB,EAA4B,CAC7D,GAAI,OAAO,GAAU,WAAY,EAAgB,OAAO,KACxD,IAAM,EAAkB,EAAkC,GAC1D,OAAO,OAAO,GAAU,UAAY,OAAO,SAAS,CAAK,EAAI,EAAQ,IACzE,CAEA,SAAS,EAAY,EAAgB,EAA4B,CAC7D,GAAI,OAAO,GAAU,WAAY,EAAgB,OAAO,KACxD,IAAM,EAAkB,EAAkC,GAC1D,OAAO,OAAO,GAAU,SAAW,EAAQ,IAC/C,CASA,SAAS,EAAU,EAA+B,CAC9C,OAAO,EAAY,EAAO,MAAM,GAAK,EAAY,EAAO,WAAW,CACvE,CAEA,SAAS,EAAY,EAAgB,EAA8B,CAE/D,OADI,IAAS,OACN,EAAU,CAAK,IAAM,CAChC,CAuBA,SAAgB,EAAgB,EAAuC,CACnE,IAAI,EAA4B,KAChC,EAAO,QAAS,GAAmB,CAC3B,EAAY,EAAO,MAAM,IAAM,cACnC,EAAa,EAAY,EAAO,yBAAyB,GAAK,EAClE,CAAC,EAED,IAAI,EAA0B,KAC1B,EAA0B,KAC9B,EAAO,QAAS,GAAmB,CAC/B,GAAI,EAAY,EAAO,MAAM,IAAM,iBAAkB,OACrD,IAAM,EAAU,EAAY,EAAO,sBAAsB,EACrD,IAAY,OACZ,IAAe,MAAQ,EAAY,EAAO,IAAI,IAAM,EACpD,EAAW,EAAU,IAChB,IAAa,MAAQ,EAAY,EAAO,OAAO,IAAM,cAC1D,EAAW,EAAU,KAC7B,CAAC,EAED,IAAM,EAAuB,GAAY,EACzC,OAAO,IAAU,KAAO,KAAO,KAAK,MAAM,CAAK,CACnD,CA4BA,SAAgB,EAAuB,EAAmC,CAAC,EAAqB,CAC5F,IAAM,EAAsB,EAAQ,MAAQ,QACxC,EAA2B,KAC3B,EAAe,EACf,EAAkB,CAAE,KAAM,EAAG,MAAO,EAAG,OAAQ,EAAG,IAAK,EAAG,MAAO,IAAK,EAE1E,SAAS,EAAK,EAAmC,CAC7C,IAAM,EAAM,YAAY,IAAI,EACtB,EAAQ,EAAgB,CAAM,EAEhC,EAAQ,EACR,EAAY,GACZ,EAAW,EACX,EAAQ,EACR,EAAS,EACT,EAAM,EAkBV,GAhBA,EAAO,QAAS,GAAmB,CAE/B,GADI,EAAY,EAAO,MAAM,IAAM,gBAC/B,CAAC,EAAY,EAAO,CAAI,EAAG,OAC/B,EAAY,GACZ,GAAS,EAAY,EAAO,WAAW,GAAK,EAE5C,IAAM,EAAa,EAAY,EAAO,YAAY,GAAK,EACjD,EAAc,EAAY,EAAO,aAAa,GAAK,EACnD,EAAO,EAAa,EACtB,EAAO,IACX,EAAW,EACX,EAAQ,EACR,EAAS,EACT,EAAM,KAAK,MAAM,EAAY,EAAO,iBAAiB,GAAK,CAAC,EAC/D,CAAC,EAEG,CAAC,EAED,MADA,GAAO,CAAE,GAAG,EAAM,OAAM,EACjB,EAGX,IAAM,EAAU,IAAc,KAAO,GAAK,EAAM,GAAgB,IAC1D,EAAQ,IAAc,KAAO,EAAI,EAAQ,EACzC,EAAO,EAAU,GAAK,EAAQ,EAAI,KAAK,MAAO,EAAQ,EAAK,IAAO,CAAO,EAAI,EAWnF,MATA,GAAY,EACZ,EAAe,EACf,EAAO,CACH,OACA,MAAO,EAAQ,EAAI,EAAQ,EAAK,MAChC,OAAQ,EAAS,EAAI,EAAS,EAAK,OACnC,IAAK,EAAM,EAAI,EAAM,EAAK,IAC1B,OACJ,EACO,CACX,CAEA,MAAO,CACH,OACA,OAAQ,KAAO,IACX,EAAK,MAAM,EAAW,SAAS,CAAC,EACpC,UAAmB,CACf,EAAY,KACZ,EAAe,EACf,EAAO,CAAE,GAAG,EAAM,KAAM,CAAE,CAC9B,CACJ,CACJ"}
1
+ {"version":3,"file":"link-stats.cjs","names":[],"sources":["../../src/webrtc/link-stats.ts"],"sourcesContent":["/**\n * One link's outbound picture, in the shape a call UI actually renders.\n *\n * Everything here is derived, not reported: WebRTC hands out cumulative\n * counters and a graph of candidate pairs, and turning that into\n * `\"1,2 Mbps · 42 ms · 1080p60\"` is the work {@link createLinkStatsSampler}\n * does.\n */\nexport interface LinkStats {\n /** Throughput since the previous sample. `0` on the first one — there is no delta yet. */\n kbps: number;\n /** Width of the largest stream being sent, or `0` before one is reported. */\n width: number;\n /** Height of the largest stream being sent, or `0` before one is reported. */\n height: number;\n /** Frame rate of the largest stream being sent, or `0` when the browser omits it. */\n fps: number;\n /** Round trip to the peer in milliseconds, or `null` before the first reading. */\n rttMs: number | null;\n /**\n * Uplink the transport estimates for this path, in kbps, or `null`.\n *\n * `null` and not `0`, because the two mean opposite things: no estimate yet\n * is the normal state for the first seconds of every call and the permanent\n * state on an engine that publishes none, while `0` is indistinguishable\n * from a path that died. A consumer that reads absence as zero drops the\n * quality at the start of every call.\n *\n * This is the field that separates \"healthy at 2.5 Mbps\" from \"capped at\n * 2.5 Mbps and drowning\" — `kbps` reports the cap being honoured either\n * way, while the queue behind it grows.\n */\n availableKbps: number | null;\n /**\n * What the encoder says is holding the picture back, or `null` for nothing.\n *\n * `\"bandwidth\"` wins over the other values when senders disagree, because\n * it is the only one a lower cap answers. Reacting to bandwidth on a\n * machine that is actually CPU-bound buys a worse picture and no relief.\n *\n * The spec's `\"none\"` is reported as `null`: a consumer should not have to\n * know that one of the truthy strings means \"nothing\".\n */\n limitedBy: RTCQualityLimitationReason | null;\n /**\n * Whether the link is travelling through a TURN relay.\n *\n * On a self-hosted mesh this is the hosting bill: a relayed stream goes up\n * and down through the machine somebody is paying for, and the person who\n * picked 4K is not that somebody.\n *\n * Resolved only from the pair the transport **names**, never from a merely\n * `succeeded` one — guessing the route from a pair that carries nothing\n * would report a cost nobody is paying.\n */\n relayed: boolean;\n}\n\n/** Which media a sampler counts. */\nexport type LinkStatsKind = \"video\" | \"audio\" | \"all\";\n\n/** Options for {@link createLinkStatsSampler}. */\nexport interface LinkStatsSamplerOptions {\n /**\n * Which media the throughput counts. Default `\"video\"`.\n *\n * Video is the default because it is what saturates an uplink — audio is an\n * order of magnitude cheaper, and mixing it in moves the number by less than\n * the noise between two samples. Use `\"all\"` when the figure is meant to be\n * the connection's real cost rather than the picture's.\n */\n kind?: LinkStatsKind;\n}\n\n/**\n * A sampler bound to one connection.\n *\n * Holds the previous byte counter and timestamp, which is the whole reason this\n * is an object rather than a function: the rate is a delta, so somebody has to\n * remember the last reading. One sampler per link — sharing one across peers\n * subtracts one connection's counter from another's and reports nonsense.\n */\nexport interface LinkStatsSampler {\n /**\n * Reduce a report you already have.\n *\n * @param report - A report from `RTCPeerConnection.getStats()`.\n * @returns The stats for this sample.\n */\n read: (report: RTCStatsReport) => LinkStats;\n /**\n * Fetch a report and reduce it.\n *\n * @param connection - The connection to sample.\n * @returns The stats for this sample.\n */\n sample: (connection: RTCPeerConnection) => Promise<LinkStats>;\n /**\n * Drop the baseline the rate is derived from.\n *\n * Call it after an ICE restart, a reconnect, or a pause — otherwise the next\n * sample divides the bytes of the whole gap by the whole gap and reports the\n * average of a period nobody is asking about. The next reading comes back at\n * `0` kbps and starts a fresh baseline; the resolution and round trip already\n * on screen are kept, so the badge does not blank out.\n */\n reset: () => void;\n}\n\n/**\n * The three field readers below take an entry the collector has already\n * established is an object.\n *\n * A report is a `Map` whose values the browser fills, and nothing says they\n * have to be objects — a polyfill or a mock can put anything in there. That\n * check belongs at the door of the one loop that walks the report, not repeated\n * in each reader: three copies of the same guard means three branches no test\n * can reach past the first, and a reader that silently returns `null` for a\n * primitive hides the case instead of skipping it.\n */\ntype StatsEntry = Record<string, unknown>;\n\nfunction numberField(entry: StatsEntry, key: string): number | null {\n const value: unknown = entry[key];\n return typeof value === \"number\" && Number.isFinite(value) ? value : null;\n}\n\nfunction stringField(entry: StatsEntry, key: string): string | null {\n const value: unknown = entry[key];\n return typeof value === \"string\" ? value : null;\n}\n\nfunction booleanField(entry: StatsEntry, key: string): boolean | null {\n const value: unknown = entry[key];\n return typeof value === \"boolean\" ? value : null;\n}\n\n/**\n * Whether a string is one of the reasons the spec defines.\n *\n * `\"none\"` is deliberately not one of them here: the field it feeds reports\n * \"nothing is limiting\" as `null`, so a consumer never has to know that one of\n * the truthy strings means no.\n */\nfunction isLimitationReason(value: string | null): value is RTCQualityLimitationReason {\n return value === \"bandwidth\" || value === \"cpu\" || value === \"other\";\n}\n\n/**\n * Resolve the media an RTP entry carries.\n *\n * `kind` is the standard field and `mediaType` is what older Chrome reported;\n * both are still in the wild, and a sampler that reads only one of them\n * silently counts nothing on the browser that uses the other.\n */\nfunction entryKind(entry: StatsEntry): string | null {\n return stringField(entry, \"kind\") ?? stringField(entry, \"mediaType\");\n}\n\n/** What one candidate pair says about the path it describes. */\ninterface PairFacts {\n id: string;\n /** `true` when the browser marks this pair as the chosen one non-standardly. */\n selected: boolean;\n state: string | null;\n rttMs: number | null;\n availableKbps: number | null;\n localCandidateId: string | null;\n}\n\n/** What one sender says about what it is sending. */\ninterface SenderFacts {\n kind: string | null;\n bytes: number;\n width: number;\n height: number;\n fps: number;\n limitedBy: RTCQualityLimitationReason | null;\n}\n\n/** Everything a single walk over a report yields. */\ninterface CollectedReport {\n namedPairId: string | null;\n pairs: PairFacts[];\n relayCandidateIds: Set<string>;\n senders: SenderFacts[];\n}\n\n/**\n * Reduce a report in **one** pass.\n *\n * One pass is the point rather than tidiness. Every field below lives in the\n * same report, and the pair the transport selected has to be resolved before\n * any of the path fields can be read — so a consumer that asks for round trip,\n * then throughput headroom, then whether the route is relayed, walks the same\n * report three times and resolves the same pair three times, per link, on every\n * tick. On a mesh of eight at one sample every two seconds that is the most\n * expensive recurring work in the call, on the device least able to pay it.\n *\n * @param report - A report from `RTCPeerConnection.getStats()`.\n * @returns The entries that matter, grouped.\n */\nfunction collect(report: RTCStatsReport): CollectedReport {\n let namedPairId: string | null = null;\n const pairs: PairFacts[] = [];\n const relayCandidateIds = new Set<string>();\n const senders: SenderFacts[] = [];\n\n report.forEach((raw: unknown) => {\n if (typeof raw !== \"object\" || raw === null) return;\n const entry = raw as StatsEntry;\n const type = stringField(entry, \"type\");\n if (type === \"transport\") {\n namedPairId = stringField(entry, \"selectedCandidatePairId\") ?? namedPairId;\n return;\n }\n if (type === \"candidate-pair\") {\n const seconds = numberField(entry, \"currentRoundTripTime\");\n const bps = numberField(entry, \"availableOutgoingBitrate\");\n pairs.push({\n id: stringField(entry, \"id\") ?? \"\",\n selected: booleanField(entry, \"selected\") === true,\n state: stringField(entry, \"state\"),\n rttMs: seconds === null ? null : seconds * 1000,\n availableKbps: bps === null ? null : Math.round(bps / 1000),\n localCandidateId: stringField(entry, \"localCandidateId\"),\n });\n return;\n }\n if (type === \"local-candidate\") {\n const id = stringField(entry, \"id\");\n if (id !== null && stringField(entry, \"candidateType\") === \"relay\") {\n relayCandidateIds.add(id);\n }\n return;\n }\n if (type !== \"outbound-rtp\") return;\n const reason = stringField(entry, \"qualityLimitationReason\");\n senders.push({\n kind: entryKind(entry),\n bytes: numberField(entry, \"bytesSent\") ?? 0,\n width: numberField(entry, \"frameWidth\") ?? 0,\n height: numberField(entry, \"frameHeight\") ?? 0,\n fps: Math.round(numberField(entry, \"framesPerSecond\") ?? 0),\n limitedBy: isLimitationReason(reason) ? reason : null,\n });\n });\n\n return { namedPairId, pairs, relayCandidateIds, senders };\n}\n\n/**\n * The candidate pair carrying the link, and how sure we are that it is.\n *\n * A connection routinely keeps several viable pairs alive at once — host,\n * server-reflexive, relayed — and only one of them carries traffic. Reading the\n * first `succeeded` pair makes a reading jump between paths that are not being\n * travelled: 8 ms on an idle host pair alternating with 180 ms on the TURN pair\n * doing the work.\n *\n * The chain is `transport.selectedCandidatePairId` → a pair flagged\n * `selected: true` → the first `succeeded` one. The middle step is not in the\n * spec and is there because an engine that fills neither the transport field\n * nor it does not appear to exist, while one that fills only the flag does: a\n * reader that skips straight to `succeeded` silently answers about the wrong\n * path there. The last step is a guess, and `named` says so — the fields where\n * guessing would report something false refuse it.\n *\n * @param collected - A collected report.\n * @returns The pair and whether the browser actually named it.\n */\nfunction carryingPair(collected: CollectedReport): { pair: PairFacts | null; named: boolean } {\n const byId =\n collected.namedPairId === null\n ? undefined\n : collected.pairs.find((pair) => pair.id === collected.namedPairId);\n if (byId !== undefined) return { pair: byId, named: true };\n\n const flagged = collected.pairs.find((pair) => pair.selected);\n if (flagged !== undefined) return { pair: flagged, named: true };\n\n const succeeded = collected.pairs.find((pair) => pair.state === \"succeeded\");\n return { pair: succeeded ?? null, named: false };\n}\n\n/**\n * Read the round trip of the candidate pair actually carrying the link.\n *\n * A `succeeded` pair is kept as a last resort because not every browser names\n * the selected one — losing the reading entirely is worse than an occasionally\n * optimistic one. See {@link carryingPair} for the chain and why the middle\n * step exists.\n *\n * @param report - A report from `RTCPeerConnection.getStats()`.\n * @returns Round trip in milliseconds, rounded, or `null` when nothing reported\n * one — which is the normal state before the connection settles.\n *\n * @example\n * const rttMs = readRoundTripMs(await pc.getStats());\n */\nexport function readRoundTripMs(report: RTCStatsReport): number | null {\n return roundTripOf(collect(report));\n}\n\n/**\n * Read the uplink the transport estimates for this path, in kbps.\n *\n * This is the field that tells a cap being honoured apart from a cap that is\n * drowning: `bytesSent` reports the same 2500 kbps whether the path has room\n * for it or the queue behind it is growing. No fixed budget can stand in for it\n * — a domestic uplink of 1 Mbps and a fibre link differ by an order of\n * magnitude, and in Brazil the upload routinely is a tenth of the download\n * beside it.\n *\n * @param report - A report from `RTCPeerConnection.getStats()`.\n * @returns The estimate in kbps, or `null` while there is none. Every reader\n * needs a fallback for that, not a default of zero.\n *\n * @example\n * const headroom = readAvailableOutgoingKbps(await pc.getStats());\n * if (headroom !== null && headroom < asked) lowerTheCap(headroom);\n */\nexport function readAvailableOutgoingKbps(report: RTCStatsReport): number | null {\n return availableOf(collect(report));\n}\n\n/**\n * Read what the encoder says is holding the picture back.\n *\n * `\"bandwidth\"` wins when senders disagree, because it is the only reason a\n * lower cap answers. The spec's `\"none\"` comes back as `null`.\n *\n * @param report - A report from `RTCPeerConnection.getStats()`.\n * @returns The reason, or `null` when nothing is limiting the picture.\n *\n * @example\n * if (readQualityLimitation(await pc.getStats()) === \"cpu\") stopBlurringTheBackground();\n */\nexport function readQualityLimitation(report: RTCStatsReport): RTCQualityLimitationReason | null {\n return limitationOf(collect(report));\n}\n\n/**\n * Read whether the link is travelling through a TURN relay.\n *\n * Resolved only from the pair the browser names, never from a merely\n * `succeeded` one: a relayed route is somebody's hosting bill, and reporting\n * one from a pair that carries nothing bills a cost nobody is paying.\n *\n * @param report - A report from `RTCPeerConnection.getStats()`.\n * @returns `true` when the carrying pair's local candidate is a relay.\n *\n * @example\n * if (readRelayed(await pc.getStats())) capTheStreamThatCostsMoney();\n */\nexport function readRelayed(report: RTCStatsReport): boolean {\n return relayedOf(collect(report));\n}\n\n/** Round trip of the carrying pair, in whole milliseconds. */\nfunction roundTripOf(collected: CollectedReport): number | null {\n const withTiming: CollectedReport = {\n ...collected,\n pairs: collected.pairs.filter((pair) => pair.rttMs !== null),\n };\n const { pair } = carryingPair(withTiming);\n return pair?.rttMs === undefined || pair.rttMs === null ? null : Math.round(pair.rttMs);\n}\n\n/** Estimated uplink of the carrying pair, in kbps. */\nfunction availableOf(collected: CollectedReport): number | null {\n const { pair } = carryingPair(collected);\n return pair?.availableKbps ?? null;\n}\n\n/** Whether the pair the browser named travels through a relay. */\nfunction relayedOf(collected: CollectedReport): boolean {\n const { pair, named } = carryingPair(collected);\n if (!named || pair === null || pair.localCandidateId === null) return false;\n return collected.relayCandidateIds.has(pair.localCandidateId);\n}\n\n/** The strongest limitation any sender reports, with bandwidth winning. */\nfunction limitationOf(collected: CollectedReport): RTCQualityLimitationReason | null {\n let found: RTCQualityLimitationReason | null = null;\n for (const sender of collected.senders) {\n if (sender.limitedBy === null) continue;\n if (sender.limitedBy === \"bandwidth\") return \"bandwidth\";\n found = found ?? sender.limitedBy;\n }\n return found;\n}\n\n/**\n * Track one link's throughput, resolution and round trip across samples.\n *\n * Every rate here is a **delta**. `bytesSent` is cumulative since the connection\n * opened, so dividing it by the session length gives the historical average —\n * a number that only ever falls and never shows what is happening now. The\n * previous reading is kept on the sampler and subtracted, which is the part\n * every hand-rolled copy of this ends up rewriting.\n *\n * Bytes are summed across every matching sender, because a peer publishing a\n * camera and a screen at once occupies one uplink with both — and the uplink is\n * what runs out. Resolution and frame rate come from the **largest** stream by\n * area, which is the one that dominates that bandwidth and the one somebody\n * watching the call is looking at.\n *\n * @param options - See {@link LinkStatsSamplerOptions}.\n * @returns A sampler. Use one per `RTCPeerConnection`.\n *\n * @example\n * const sampler = createLinkStatsSampler();\n *\n * setInterval(async () => {\n * const stats = await sampler.sample(pc);\n * badge.textContent = `${stats.kbps} kbps · ${stats.rttMs ?? \"—\"} ms`;\n * }, 2000);\n */\nexport function createLinkStatsSampler(options: LinkStatsSamplerOptions = {}): LinkStatsSampler {\n const kind: LinkStatsKind = options.kind ?? \"video\";\n let lastBytes: number | null = null;\n let lastSampleAt = 0;\n let last: LinkStats = {\n kbps: 0,\n width: 0,\n height: 0,\n fps: 0,\n rttMs: null,\n availableKbps: null,\n limitedBy: null,\n relayed: false,\n };\n\n function read(report: RTCStatsReport): LinkStats {\n const now = performance.now();\n const collected = collect(report);\n const path = {\n rttMs: roundTripOf(collected),\n availableKbps: availableOf(collected),\n limitedBy: limitationOf(collected),\n relayed: relayedOf(collected),\n };\n\n let bytes = 0;\n let sawSender = false;\n let bestArea = 0;\n let width = 0;\n let height = 0;\n let fps = 0;\n\n for (const sender of collected.senders) {\n if (kind !== \"all\" && sender.kind !== kind) continue;\n sawSender = true;\n bytes += sender.bytes;\n\n const area = sender.width * sender.height;\n if (area < bestArea) continue;\n bestArea = area;\n width = sender.width;\n height = sender.height;\n fps = sender.fps;\n }\n\n if (!sawSender) {\n last = { ...last, ...path };\n return last;\n }\n\n const elapsed = lastBytes === null ? 0 : (now - lastSampleAt) / 1000;\n const delta = lastBytes === null ? 0 : bytes - lastBytes;\n const kbps = elapsed > 0 && delta > 0 ? Math.round((delta * 8) / 1000 / elapsed) : 0;\n\n lastBytes = bytes;\n lastSampleAt = now;\n last = {\n kbps,\n width: width > 0 ? width : last.width,\n height: height > 0 ? height : last.height,\n fps: fps > 0 ? fps : last.fps,\n ...path,\n };\n return last;\n }\n\n return {\n read,\n sample: async (connection: RTCPeerConnection): Promise<LinkStats> =>\n read(await connection.getStats()),\n reset: (): void => {\n lastBytes = null;\n lastSampleAt = 0;\n last = { ...last, kbps: 0 };\n },\n };\n}\n"],"mappings":"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"}
@@ -1,74 +1,164 @@
1
1
  //#region src/webrtc/link-stats.ts
2
2
  function e(e, t) {
3
- if (typeof e != "object" || !e) return null;
4
3
  let n = e[t];
5
4
  return typeof n == "number" && Number.isFinite(n) ? n : null;
6
5
  }
7
6
  function t(e, t) {
8
- if (typeof e != "object" || !e) return null;
9
7
  let n = e[t];
10
8
  return typeof n == "string" ? n : null;
11
9
  }
12
- function n(e) {
10
+ function n(e, t) {
11
+ let n = e[t];
12
+ return typeof n == "boolean" ? n : null;
13
+ }
14
+ function r(e) {
15
+ return e === "bandwidth" || e === "cpu" || e === "other";
16
+ }
17
+ function i(e) {
13
18
  return t(e, "kind") ?? t(e, "mediaType");
14
19
  }
15
- function r(e, t) {
16
- return t === "all" || n(e) === t;
20
+ function a(a) {
21
+ let o = null, s = [], c = /* @__PURE__ */ new Set(), l = [];
22
+ return a.forEach((a) => {
23
+ if (typeof a != "object" || !a) return;
24
+ let u = a, d = t(u, "type");
25
+ if (d === "transport") {
26
+ o = t(u, "selectedCandidatePairId") ?? o;
27
+ return;
28
+ }
29
+ if (d === "candidate-pair") {
30
+ let r = e(u, "currentRoundTripTime"), i = e(u, "availableOutgoingBitrate");
31
+ s.push({
32
+ id: t(u, "id") ?? "",
33
+ selected: n(u, "selected") === !0,
34
+ state: t(u, "state"),
35
+ rttMs: r === null ? null : r * 1e3,
36
+ availableKbps: i === null ? null : Math.round(i / 1e3),
37
+ localCandidateId: t(u, "localCandidateId")
38
+ });
39
+ return;
40
+ }
41
+ if (d === "local-candidate") {
42
+ let e = t(u, "id");
43
+ e !== null && t(u, "candidateType") === "relay" && c.add(e);
44
+ return;
45
+ }
46
+ if (d !== "outbound-rtp") return;
47
+ let f = t(u, "qualityLimitationReason");
48
+ l.push({
49
+ kind: i(u),
50
+ bytes: e(u, "bytesSent") ?? 0,
51
+ width: e(u, "frameWidth") ?? 0,
52
+ height: e(u, "frameHeight") ?? 0,
53
+ fps: Math.round(e(u, "framesPerSecond") ?? 0),
54
+ limitedBy: r(f) ? f : null
55
+ });
56
+ }), {
57
+ namedPairId: o,
58
+ pairs: s,
59
+ relayCandidateIds: c,
60
+ senders: l
61
+ };
17
62
  }
18
- function i(n) {
19
- let r = null;
20
- n.forEach((e) => {
21
- t(e, "type") === "transport" && (r = t(e, "selectedCandidatePairId") ?? r);
22
- });
23
- let i = null, a = null;
24
- n.forEach((n) => {
25
- if (t(n, "type") !== "candidate-pair") return;
26
- let o = e(n, "currentRoundTripTime");
27
- o !== null && (r !== null && t(n, "id") === r ? i = o * 1e3 : a === null && t(n, "state") === "succeeded" && (a = o * 1e3));
63
+ function o(e) {
64
+ let t = e.namedPairId === null ? void 0 : e.pairs.find((t) => t.id === e.namedPairId);
65
+ if (t !== void 0) return {
66
+ pair: t,
67
+ named: !0
68
+ };
69
+ let n = e.pairs.find((e) => e.selected);
70
+ return n === void 0 ? {
71
+ pair: e.pairs.find((e) => e.state === "succeeded") ?? null,
72
+ named: !1
73
+ } : {
74
+ pair: n,
75
+ named: !0
76
+ };
77
+ }
78
+ function s(e) {
79
+ return d(a(e));
80
+ }
81
+ function c(e) {
82
+ return f(a(e));
83
+ }
84
+ function l(e) {
85
+ return m(a(e));
86
+ }
87
+ function u(e) {
88
+ return p(a(e));
89
+ }
90
+ function d(e) {
91
+ let { pair: t } = o({
92
+ ...e,
93
+ pairs: e.pairs.filter((e) => e.rttMs !== null)
28
94
  });
29
- let o = i ?? a;
30
- return o === null ? null : Math.round(o);
95
+ return t?.rttMs === void 0 || t.rttMs === null ? null : Math.round(t.rttMs);
31
96
  }
32
- function a(n = {}) {
33
- let a = n.kind ?? "video", o = null, s = 0, c = {
97
+ function f(e) {
98
+ let { pair: t } = o(e);
99
+ return t?.availableKbps ?? null;
100
+ }
101
+ function p(e) {
102
+ let { pair: t, named: n } = o(e);
103
+ return !n || t === null || t.localCandidateId === null ? !1 : e.relayCandidateIds.has(t.localCandidateId);
104
+ }
105
+ function m(e) {
106
+ let t = null;
107
+ for (let n of e.senders) if (n.limitedBy !== null) {
108
+ if (n.limitedBy === "bandwidth") return "bandwidth";
109
+ t ??= n.limitedBy;
110
+ }
111
+ return t;
112
+ }
113
+ function h(e = {}) {
114
+ let t = e.kind ?? "video", n = null, r = 0, i = {
34
115
  kbps: 0,
35
116
  width: 0,
36
117
  height: 0,
37
118
  fps: 0,
38
- rttMs: null
119
+ rttMs: null,
120
+ availableKbps: null,
121
+ limitedBy: null,
122
+ relayed: !1
39
123
  };
40
- function l(n) {
41
- let l = performance.now(), u = i(n), d = 0, f = !1, p = 0, m = 0, h = 0, g = 0;
42
- if (n.forEach((n) => {
43
- if (t(n, "type") !== "outbound-rtp" || !r(n, a)) return;
44
- f = !0, d += e(n, "bytesSent") ?? 0;
45
- let i = e(n, "frameWidth") ?? 0, o = e(n, "frameHeight") ?? 0, s = i * o;
46
- s < p || (p = s, m = i, h = o, g = Math.round(e(n, "framesPerSecond") ?? 0));
47
- }), !f) return c = {
48
- ...c,
49
- rttMs: u
50
- }, c;
51
- let _ = o === null ? 0 : (l - s) / 1e3, v = o === null ? 0 : d - o, y = _ > 0 && v > 0 ? Math.round(v * 8 / 1e3 / _) : 0;
52
- return o = d, s = l, c = {
53
- kbps: y,
54
- width: m > 0 ? m : c.width,
55
- height: h > 0 ? h : c.height,
56
- fps: g > 0 ? g : c.fps,
57
- rttMs: u
58
- }, c;
124
+ function o(e) {
125
+ let o = performance.now(), s = a(e), c = {
126
+ rttMs: d(s),
127
+ availableKbps: f(s),
128
+ limitedBy: m(s),
129
+ relayed: p(s)
130
+ }, l = 0, u = !1, h = 0, g = 0, _ = 0, v = 0;
131
+ for (let e of s.senders) {
132
+ if (t !== "all" && e.kind !== t) continue;
133
+ u = !0, l += e.bytes;
134
+ let n = e.width * e.height;
135
+ n < h || (h = n, g = e.width, _ = e.height, v = e.fps);
136
+ }
137
+ if (!u) return i = {
138
+ ...i,
139
+ ...c
140
+ }, i;
141
+ let y = n === null ? 0 : (o - r) / 1e3, b = n === null ? 0 : l - n, x = y > 0 && b > 0 ? Math.round(b * 8 / 1e3 / y) : 0;
142
+ return n = l, r = o, i = {
143
+ kbps: x,
144
+ width: g > 0 ? g : i.width,
145
+ height: _ > 0 ? _ : i.height,
146
+ fps: v > 0 ? v : i.fps,
147
+ ...c
148
+ }, i;
59
149
  }
60
150
  return {
61
- read: l,
62
- sample: async (e) => l(await e.getStats()),
151
+ read: o,
152
+ sample: async (e) => o(await e.getStats()),
63
153
  reset: () => {
64
- o = null, s = 0, c = {
65
- ...c,
154
+ n = null, r = 0, i = {
155
+ ...i,
66
156
  kbps: 0
67
157
  };
68
158
  }
69
159
  };
70
160
  }
71
161
  //#endregion
72
- export { a as createLinkStatsSampler, i as readRoundTripMs };
162
+ export { h as createLinkStatsSampler, c as readAvailableOutgoingKbps, l as readQualityLimitation, u as readRelayed, s as readRoundTripMs };
73
163
 
74
164
  //# sourceMappingURL=link-stats.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"link-stats.js","names":[],"sources":["../../src/webrtc/link-stats.ts"],"sourcesContent":["/**\n * One link's outbound picture, in the shape a call UI actually renders.\n *\n * Everything here is derived, not reported: WebRTC hands out cumulative\n * counters and a graph of candidate pairs, and turning that into\n * `\"1,2 Mbps · 42 ms · 1080p60\"` is the work {@link createLinkStatsSampler}\n * does.\n */\nexport interface LinkStats {\n /** Throughput since the previous sample. `0` on the first one — there is no delta yet. */\n kbps: number;\n /** Width of the largest stream being sent, or `0` before one is reported. */\n width: number;\n /** Height of the largest stream being sent, or `0` before one is reported. */\n height: number;\n /** Frame rate of the largest stream being sent, or `0` when the browser omits it. */\n fps: number;\n /** Round trip to the peer in milliseconds, or `null` before the first reading. */\n rttMs: number | null;\n}\n\n/** Which media a sampler counts. */\nexport type LinkStatsKind = \"video\" | \"audio\" | \"all\";\n\n/** Options for {@link createLinkStatsSampler}. */\nexport interface LinkStatsSamplerOptions {\n /**\n * Which media the throughput counts. Default `\"video\"`.\n *\n * Video is the default because it is what saturates an uplink — audio is an\n * order of magnitude cheaper, and mixing it in moves the number by less than\n * the noise between two samples. Use `\"all\"` when the figure is meant to be\n * the connection's real cost rather than the picture's.\n */\n kind?: LinkStatsKind;\n}\n\n/**\n * A sampler bound to one connection.\n *\n * Holds the previous byte counter and timestamp, which is the whole reason this\n * is an object rather than a function: the rate is a delta, so somebody has to\n * remember the last reading. One sampler per link — sharing one across peers\n * subtracts one connection's counter from another's and reports nonsense.\n */\nexport interface LinkStatsSampler {\n /**\n * Reduce a report you already have.\n *\n * @param report - A report from `RTCPeerConnection.getStats()`.\n * @returns The stats for this sample.\n */\n read: (report: RTCStatsReport) => LinkStats;\n /**\n * Fetch a report and reduce it.\n *\n * @param connection - The connection to sample.\n * @returns The stats for this sample.\n */\n sample: (connection: RTCPeerConnection) => Promise<LinkStats>;\n /**\n * Drop the baseline the rate is derived from.\n *\n * Call it after an ICE restart, a reconnect, or a pause — otherwise the next\n * sample divides the bytes of the whole gap by the whole gap and reports the\n * average of a period nobody is asking about. The next reading comes back at\n * `0` kbps and starts a fresh baseline; the resolution and round trip already\n * on screen are kept, so the badge does not blank out.\n */\n reset: () => void;\n}\n\nfunction numberField(entry: unknown, key: string): number | null {\n if (typeof entry !== \"object\" || entry === null) return null;\n const value: unknown = (entry as Record<string, unknown>)[key];\n return typeof value === \"number\" && Number.isFinite(value) ? value : null;\n}\n\nfunction stringField(entry: unknown, key: string): string | null {\n if (typeof entry !== \"object\" || entry === null) return null;\n const value: unknown = (entry as Record<string, unknown>)[key];\n return typeof value === \"string\" ? value : null;\n}\n\n/**\n * Resolve the media an RTP entry carries.\n *\n * `kind` is the standard field and `mediaType` is what older Chrome reported;\n * both are still in the wild, and a sampler that reads only one of them\n * silently counts nothing on the browser that uses the other.\n */\nfunction entryKind(entry: unknown): string | null {\n return stringField(entry, \"kind\") ?? stringField(entry, \"mediaType\");\n}\n\nfunction matchesKind(entry: unknown, kind: LinkStatsKind): boolean {\n if (kind === \"all\") return true;\n return entryKind(entry) === kind;\n}\n\n/**\n * Read the round trip of the candidate pair actually carrying the link.\n *\n * A connection routinely keeps several viable pairs alive at once — host,\n * server-reflexive, relayed — and only one of them carries traffic. Reading the\n * first `succeeded` pair makes the number jump between paths that are not being\n * travelled: 8 ms on an idle host pair alternating with 180 ms on the TURN pair\n * doing the work. The pair the transport names in `selectedCandidatePairId` is\n * the one being used.\n *\n * A succeeded pair is kept as a fallback because not every browser fills that\n * field in — losing the reading entirely is worse than an occasionally\n * optimistic one.\n *\n * @param report - A report from `RTCPeerConnection.getStats()`.\n * @returns Round trip in milliseconds, rounded, or `null` when nothing reported\n * one — which is the normal state before the connection settles.\n *\n * @example\n * const rttMs = readRoundTripMs(await pc.getStats());\n */\nexport function readRoundTripMs(report: RTCStatsReport): number | null {\n let selectedId: string | null = null;\n report.forEach((entry: unknown) => {\n if (stringField(entry, \"type\") !== \"transport\") return;\n selectedId = stringField(entry, \"selectedCandidatePairId\") ?? selectedId;\n });\n\n let selected: number | null = null;\n let fallback: number | null = null;\n report.forEach((entry: unknown) => {\n if (stringField(entry, \"type\") !== \"candidate-pair\") return;\n const seconds = numberField(entry, \"currentRoundTripTime\");\n if (seconds === null) return;\n if (selectedId !== null && stringField(entry, \"id\") === selectedId)\n selected = seconds * 1000;\n else if (fallback === null && stringField(entry, \"state\") === \"succeeded\")\n fallback = seconds * 1000;\n });\n\n const value: number | null = selected ?? fallback;\n return value === null ? null : Math.round(value);\n}\n\n/**\n * Track one link's throughput, resolution and round trip across samples.\n *\n * Every rate here is a **delta**. `bytesSent` is cumulative since the connection\n * opened, so dividing it by the session length gives the historical average —\n * a number that only ever falls and never shows what is happening now. The\n * previous reading is kept on the sampler and subtracted, which is the part\n * every hand-rolled copy of this ends up rewriting.\n *\n * Bytes are summed across every matching sender, because a peer publishing a\n * camera and a screen at once occupies one uplink with both — and the uplink is\n * what runs out. Resolution and frame rate come from the **largest** stream by\n * area, which is the one that dominates that bandwidth and the one somebody\n * watching the call is looking at.\n *\n * @param options - See {@link LinkStatsSamplerOptions}.\n * @returns A sampler. Use one per `RTCPeerConnection`.\n *\n * @example\n * const sampler = createLinkStatsSampler();\n *\n * setInterval(async () => {\n * const stats = await sampler.sample(pc);\n * badge.textContent = `${stats.kbps} kbps · ${stats.rttMs ?? \"—\"} ms`;\n * }, 2000);\n */\nexport function createLinkStatsSampler(options: LinkStatsSamplerOptions = {}): LinkStatsSampler {\n const kind: LinkStatsKind = options.kind ?? \"video\";\n let lastBytes: number | null = null;\n let lastSampleAt = 0;\n let last: LinkStats = { kbps: 0, width: 0, height: 0, fps: 0, rttMs: null };\n\n function read(report: RTCStatsReport): LinkStats {\n const now = performance.now();\n const rttMs = readRoundTripMs(report);\n\n let bytes = 0;\n let sawSender = false;\n let bestArea = 0;\n let width = 0;\n let height = 0;\n let fps = 0;\n\n report.forEach((entry: unknown) => {\n if (stringField(entry, \"type\") !== \"outbound-rtp\") return;\n if (!matchesKind(entry, kind)) return;\n sawSender = true;\n bytes += numberField(entry, \"bytesSent\") ?? 0;\n\n const entryWidth = numberField(entry, \"frameWidth\") ?? 0;\n const entryHeight = numberField(entry, \"frameHeight\") ?? 0;\n const area = entryWidth * entryHeight;\n if (area < bestArea) return;\n bestArea = area;\n width = entryWidth;\n height = entryHeight;\n fps = Math.round(numberField(entry, \"framesPerSecond\") ?? 0);\n });\n\n if (!sawSender) {\n last = { ...last, rttMs };\n return last;\n }\n\n const elapsed = lastBytes === null ? 0 : (now - lastSampleAt) / 1000;\n const delta = lastBytes === null ? 0 : bytes - lastBytes;\n const kbps = elapsed > 0 && delta > 0 ? Math.round((delta * 8) / 1000 / elapsed) : 0;\n\n lastBytes = bytes;\n lastSampleAt = now;\n last = {\n kbps,\n width: width > 0 ? width : last.width,\n height: height > 0 ? height : last.height,\n fps: fps > 0 ? fps : last.fps,\n rttMs,\n };\n return last;\n }\n\n return {\n read,\n sample: async (connection: RTCPeerConnection): Promise<LinkStats> =>\n read(await connection.getStats()),\n reset: (): void => {\n lastBytes = null;\n lastSampleAt = 0;\n last = { ...last, kbps: 0 };\n },\n };\n}\n"],"mappings":";AAwEA,SAAS,EAAY,GAAgB,GAA4B;CAC7D,IAAI,OAAO,KAAU,aAAY,GAAgB,OAAO;CACxD,IAAM,IAAkB,EAAkC;CAC1D,OAAO,OAAO,KAAU,YAAY,OAAO,SAAS,CAAK,IAAI,IAAQ;AACzE;AAEA,SAAS,EAAY,GAAgB,GAA4B;CAC7D,IAAI,OAAO,KAAU,aAAY,GAAgB,OAAO;CACxD,IAAM,IAAkB,EAAkC;CAC1D,OAAO,OAAO,KAAU,WAAW,IAAQ;AAC/C;AASA,SAAS,EAAU,GAA+B;CAC9C,OAAO,EAAY,GAAO,MAAM,KAAK,EAAY,GAAO,WAAW;AACvE;AAEA,SAAS,EAAY,GAAgB,GAA8B;CAE/D,OADI,MAAS,SACN,EAAU,CAAK,MAAM;AAChC;AAuBA,SAAgB,EAAgB,GAAuC;CACnE,IAAI,IAA4B;CAChC,EAAO,SAAS,MAAmB;EAC3B,EAAY,GAAO,MAAM,MAAM,gBACnC,IAAa,EAAY,GAAO,yBAAyB,KAAK;CAClE,CAAC;CAED,IAAI,IAA0B,MAC1B,IAA0B;CAC9B,EAAO,SAAS,MAAmB;EAC/B,IAAI,EAAY,GAAO,MAAM,MAAM,kBAAkB;EACrD,IAAM,IAAU,EAAY,GAAO,sBAAsB;EACrD,MAAY,SACZ,MAAe,QAAQ,EAAY,GAAO,IAAI,MAAM,IACpD,IAAW,IAAU,MAChB,MAAa,QAAQ,EAAY,GAAO,OAAO,MAAM,gBAC1D,IAAW,IAAU;CAC7B,CAAC;CAED,IAAM,IAAuB,KAAY;CACzC,OAAO,MAAU,OAAO,OAAO,KAAK,MAAM,CAAK;AACnD;AA4BA,SAAgB,EAAuB,IAAmC,CAAC,GAAqB;CAC5F,IAAM,IAAsB,EAAQ,QAAQ,SACxC,IAA2B,MAC3B,IAAe,GACf,IAAkB;EAAE,MAAM;EAAG,OAAO;EAAG,QAAQ;EAAG,KAAK;EAAG,OAAO;CAAK;CAE1E,SAAS,EAAK,GAAmC;EAC7C,IAAM,IAAM,YAAY,IAAI,GACtB,IAAQ,EAAgB,CAAM,GAEhC,IAAQ,GACR,IAAY,IACZ,IAAW,GACX,IAAQ,GACR,IAAS,GACT,IAAM;EAkBV,IAhBA,EAAO,SAAS,MAAmB;GAE/B,IADI,EAAY,GAAO,MAAM,MAAM,kBAC/B,CAAC,EAAY,GAAO,CAAI,GAAG;GAE/B,AADA,IAAY,IACZ,KAAS,EAAY,GAAO,WAAW,KAAK;GAE5C,IAAM,IAAa,EAAY,GAAO,YAAY,KAAK,GACjD,IAAc,EAAY,GAAO,aAAa,KAAK,GACnD,IAAO,IAAa;GACtB,IAAO,MACX,IAAW,GACX,IAAQ,GACR,IAAS,GACT,IAAM,KAAK,MAAM,EAAY,GAAO,iBAAiB,KAAK,CAAC;EAC/D,CAAC,GAEG,CAAC,GAED,OADA,IAAO;GAAE,GAAG;GAAM;EAAM,GACjB;EAGX,IAAM,IAAU,MAAc,OAAO,KAAK,IAAM,KAAgB,KAC1D,IAAQ,MAAc,OAAO,IAAI,IAAQ,GACzC,IAAO,IAAU,KAAK,IAAQ,IAAI,KAAK,MAAO,IAAQ,IAAK,MAAO,CAAO,IAAI;EAWnF,OATA,IAAY,GACZ,IAAe,GACf,IAAO;GACH;GACA,OAAO,IAAQ,IAAI,IAAQ,EAAK;GAChC,QAAQ,IAAS,IAAI,IAAS,EAAK;GACnC,KAAK,IAAM,IAAI,IAAM,EAAK;GAC1B;EACJ,GACO;CACX;CAEA,OAAO;EACH;EACA,QAAQ,OAAO,MACX,EAAK,MAAM,EAAW,SAAS,CAAC;EACpC,aAAmB;GAGf,AAFA,IAAY,MACZ,IAAe,GACf,IAAO;IAAE,GAAG;IAAM,MAAM;GAAE;EAC9B;CACJ;AACJ"}
1
+ {"version":3,"file":"link-stats.js","names":[],"sources":["../../src/webrtc/link-stats.ts"],"sourcesContent":["/**\n * One link's outbound picture, in the shape a call UI actually renders.\n *\n * Everything here is derived, not reported: WebRTC hands out cumulative\n * counters and a graph of candidate pairs, and turning that into\n * `\"1,2 Mbps · 42 ms · 1080p60\"` is the work {@link createLinkStatsSampler}\n * does.\n */\nexport interface LinkStats {\n /** Throughput since the previous sample. `0` on the first one — there is no delta yet. */\n kbps: number;\n /** Width of the largest stream being sent, or `0` before one is reported. */\n width: number;\n /** Height of the largest stream being sent, or `0` before one is reported. */\n height: number;\n /** Frame rate of the largest stream being sent, or `0` when the browser omits it. */\n fps: number;\n /** Round trip to the peer in milliseconds, or `null` before the first reading. */\n rttMs: number | null;\n /**\n * Uplink the transport estimates for this path, in kbps, or `null`.\n *\n * `null` and not `0`, because the two mean opposite things: no estimate yet\n * is the normal state for the first seconds of every call and the permanent\n * state on an engine that publishes none, while `0` is indistinguishable\n * from a path that died. A consumer that reads absence as zero drops the\n * quality at the start of every call.\n *\n * This is the field that separates \"healthy at 2.5 Mbps\" from \"capped at\n * 2.5 Mbps and drowning\" — `kbps` reports the cap being honoured either\n * way, while the queue behind it grows.\n */\n availableKbps: number | null;\n /**\n * What the encoder says is holding the picture back, or `null` for nothing.\n *\n * `\"bandwidth\"` wins over the other values when senders disagree, because\n * it is the only one a lower cap answers. Reacting to bandwidth on a\n * machine that is actually CPU-bound buys a worse picture and no relief.\n *\n * The spec's `\"none\"` is reported as `null`: a consumer should not have to\n * know that one of the truthy strings means \"nothing\".\n */\n limitedBy: RTCQualityLimitationReason | null;\n /**\n * Whether the link is travelling through a TURN relay.\n *\n * On a self-hosted mesh this is the hosting bill: a relayed stream goes up\n * and down through the machine somebody is paying for, and the person who\n * picked 4K is not that somebody.\n *\n * Resolved only from the pair the transport **names**, never from a merely\n * `succeeded` one — guessing the route from a pair that carries nothing\n * would report a cost nobody is paying.\n */\n relayed: boolean;\n}\n\n/** Which media a sampler counts. */\nexport type LinkStatsKind = \"video\" | \"audio\" | \"all\";\n\n/** Options for {@link createLinkStatsSampler}. */\nexport interface LinkStatsSamplerOptions {\n /**\n * Which media the throughput counts. Default `\"video\"`.\n *\n * Video is the default because it is what saturates an uplink — audio is an\n * order of magnitude cheaper, and mixing it in moves the number by less than\n * the noise between two samples. Use `\"all\"` when the figure is meant to be\n * the connection's real cost rather than the picture's.\n */\n kind?: LinkStatsKind;\n}\n\n/**\n * A sampler bound to one connection.\n *\n * Holds the previous byte counter and timestamp, which is the whole reason this\n * is an object rather than a function: the rate is a delta, so somebody has to\n * remember the last reading. One sampler per link — sharing one across peers\n * subtracts one connection's counter from another's and reports nonsense.\n */\nexport interface LinkStatsSampler {\n /**\n * Reduce a report you already have.\n *\n * @param report - A report from `RTCPeerConnection.getStats()`.\n * @returns The stats for this sample.\n */\n read: (report: RTCStatsReport) => LinkStats;\n /**\n * Fetch a report and reduce it.\n *\n * @param connection - The connection to sample.\n * @returns The stats for this sample.\n */\n sample: (connection: RTCPeerConnection) => Promise<LinkStats>;\n /**\n * Drop the baseline the rate is derived from.\n *\n * Call it after an ICE restart, a reconnect, or a pause — otherwise the next\n * sample divides the bytes of the whole gap by the whole gap and reports the\n * average of a period nobody is asking about. The next reading comes back at\n * `0` kbps and starts a fresh baseline; the resolution and round trip already\n * on screen are kept, so the badge does not blank out.\n */\n reset: () => void;\n}\n\n/**\n * The three field readers below take an entry the collector has already\n * established is an object.\n *\n * A report is a `Map` whose values the browser fills, and nothing says they\n * have to be objects — a polyfill or a mock can put anything in there. That\n * check belongs at the door of the one loop that walks the report, not repeated\n * in each reader: three copies of the same guard means three branches no test\n * can reach past the first, and a reader that silently returns `null` for a\n * primitive hides the case instead of skipping it.\n */\ntype StatsEntry = Record<string, unknown>;\n\nfunction numberField(entry: StatsEntry, key: string): number | null {\n const value: unknown = entry[key];\n return typeof value === \"number\" && Number.isFinite(value) ? value : null;\n}\n\nfunction stringField(entry: StatsEntry, key: string): string | null {\n const value: unknown = entry[key];\n return typeof value === \"string\" ? value : null;\n}\n\nfunction booleanField(entry: StatsEntry, key: string): boolean | null {\n const value: unknown = entry[key];\n return typeof value === \"boolean\" ? value : null;\n}\n\n/**\n * Whether a string is one of the reasons the spec defines.\n *\n * `\"none\"` is deliberately not one of them here: the field it feeds reports\n * \"nothing is limiting\" as `null`, so a consumer never has to know that one of\n * the truthy strings means no.\n */\nfunction isLimitationReason(value: string | null): value is RTCQualityLimitationReason {\n return value === \"bandwidth\" || value === \"cpu\" || value === \"other\";\n}\n\n/**\n * Resolve the media an RTP entry carries.\n *\n * `kind` is the standard field and `mediaType` is what older Chrome reported;\n * both are still in the wild, and a sampler that reads only one of them\n * silently counts nothing on the browser that uses the other.\n */\nfunction entryKind(entry: StatsEntry): string | null {\n return stringField(entry, \"kind\") ?? stringField(entry, \"mediaType\");\n}\n\n/** What one candidate pair says about the path it describes. */\ninterface PairFacts {\n id: string;\n /** `true` when the browser marks this pair as the chosen one non-standardly. */\n selected: boolean;\n state: string | null;\n rttMs: number | null;\n availableKbps: number | null;\n localCandidateId: string | null;\n}\n\n/** What one sender says about what it is sending. */\ninterface SenderFacts {\n kind: string | null;\n bytes: number;\n width: number;\n height: number;\n fps: number;\n limitedBy: RTCQualityLimitationReason | null;\n}\n\n/** Everything a single walk over a report yields. */\ninterface CollectedReport {\n namedPairId: string | null;\n pairs: PairFacts[];\n relayCandidateIds: Set<string>;\n senders: SenderFacts[];\n}\n\n/**\n * Reduce a report in **one** pass.\n *\n * One pass is the point rather than tidiness. Every field below lives in the\n * same report, and the pair the transport selected has to be resolved before\n * any of the path fields can be read — so a consumer that asks for round trip,\n * then throughput headroom, then whether the route is relayed, walks the same\n * report three times and resolves the same pair three times, per link, on every\n * tick. On a mesh of eight at one sample every two seconds that is the most\n * expensive recurring work in the call, on the device least able to pay it.\n *\n * @param report - A report from `RTCPeerConnection.getStats()`.\n * @returns The entries that matter, grouped.\n */\nfunction collect(report: RTCStatsReport): CollectedReport {\n let namedPairId: string | null = null;\n const pairs: PairFacts[] = [];\n const relayCandidateIds = new Set<string>();\n const senders: SenderFacts[] = [];\n\n report.forEach((raw: unknown) => {\n if (typeof raw !== \"object\" || raw === null) return;\n const entry = raw as StatsEntry;\n const type = stringField(entry, \"type\");\n if (type === \"transport\") {\n namedPairId = stringField(entry, \"selectedCandidatePairId\") ?? namedPairId;\n return;\n }\n if (type === \"candidate-pair\") {\n const seconds = numberField(entry, \"currentRoundTripTime\");\n const bps = numberField(entry, \"availableOutgoingBitrate\");\n pairs.push({\n id: stringField(entry, \"id\") ?? \"\",\n selected: booleanField(entry, \"selected\") === true,\n state: stringField(entry, \"state\"),\n rttMs: seconds === null ? null : seconds * 1000,\n availableKbps: bps === null ? null : Math.round(bps / 1000),\n localCandidateId: stringField(entry, \"localCandidateId\"),\n });\n return;\n }\n if (type === \"local-candidate\") {\n const id = stringField(entry, \"id\");\n if (id !== null && stringField(entry, \"candidateType\") === \"relay\") {\n relayCandidateIds.add(id);\n }\n return;\n }\n if (type !== \"outbound-rtp\") return;\n const reason = stringField(entry, \"qualityLimitationReason\");\n senders.push({\n kind: entryKind(entry),\n bytes: numberField(entry, \"bytesSent\") ?? 0,\n width: numberField(entry, \"frameWidth\") ?? 0,\n height: numberField(entry, \"frameHeight\") ?? 0,\n fps: Math.round(numberField(entry, \"framesPerSecond\") ?? 0),\n limitedBy: isLimitationReason(reason) ? reason : null,\n });\n });\n\n return { namedPairId, pairs, relayCandidateIds, senders };\n}\n\n/**\n * The candidate pair carrying the link, and how sure we are that it is.\n *\n * A connection routinely keeps several viable pairs alive at once — host,\n * server-reflexive, relayed — and only one of them carries traffic. Reading the\n * first `succeeded` pair makes a reading jump between paths that are not being\n * travelled: 8 ms on an idle host pair alternating with 180 ms on the TURN pair\n * doing the work.\n *\n * The chain is `transport.selectedCandidatePairId` → a pair flagged\n * `selected: true` → the first `succeeded` one. The middle step is not in the\n * spec and is there because an engine that fills neither the transport field\n * nor it does not appear to exist, while one that fills only the flag does: a\n * reader that skips straight to `succeeded` silently answers about the wrong\n * path there. The last step is a guess, and `named` says so — the fields where\n * guessing would report something false refuse it.\n *\n * @param collected - A collected report.\n * @returns The pair and whether the browser actually named it.\n */\nfunction carryingPair(collected: CollectedReport): { pair: PairFacts | null; named: boolean } {\n const byId =\n collected.namedPairId === null\n ? undefined\n : collected.pairs.find((pair) => pair.id === collected.namedPairId);\n if (byId !== undefined) return { pair: byId, named: true };\n\n const flagged = collected.pairs.find((pair) => pair.selected);\n if (flagged !== undefined) return { pair: flagged, named: true };\n\n const succeeded = collected.pairs.find((pair) => pair.state === \"succeeded\");\n return { pair: succeeded ?? null, named: false };\n}\n\n/**\n * Read the round trip of the candidate pair actually carrying the link.\n *\n * A `succeeded` pair is kept as a last resort because not every browser names\n * the selected one — losing the reading entirely is worse than an occasionally\n * optimistic one. See {@link carryingPair} for the chain and why the middle\n * step exists.\n *\n * @param report - A report from `RTCPeerConnection.getStats()`.\n * @returns Round trip in milliseconds, rounded, or `null` when nothing reported\n * one — which is the normal state before the connection settles.\n *\n * @example\n * const rttMs = readRoundTripMs(await pc.getStats());\n */\nexport function readRoundTripMs(report: RTCStatsReport): number | null {\n return roundTripOf(collect(report));\n}\n\n/**\n * Read the uplink the transport estimates for this path, in kbps.\n *\n * This is the field that tells a cap being honoured apart from a cap that is\n * drowning: `bytesSent` reports the same 2500 kbps whether the path has room\n * for it or the queue behind it is growing. No fixed budget can stand in for it\n * — a domestic uplink of 1 Mbps and a fibre link differ by an order of\n * magnitude, and in Brazil the upload routinely is a tenth of the download\n * beside it.\n *\n * @param report - A report from `RTCPeerConnection.getStats()`.\n * @returns The estimate in kbps, or `null` while there is none. Every reader\n * needs a fallback for that, not a default of zero.\n *\n * @example\n * const headroom = readAvailableOutgoingKbps(await pc.getStats());\n * if (headroom !== null && headroom < asked) lowerTheCap(headroom);\n */\nexport function readAvailableOutgoingKbps(report: RTCStatsReport): number | null {\n return availableOf(collect(report));\n}\n\n/**\n * Read what the encoder says is holding the picture back.\n *\n * `\"bandwidth\"` wins when senders disagree, because it is the only reason a\n * lower cap answers. The spec's `\"none\"` comes back as `null`.\n *\n * @param report - A report from `RTCPeerConnection.getStats()`.\n * @returns The reason, or `null` when nothing is limiting the picture.\n *\n * @example\n * if (readQualityLimitation(await pc.getStats()) === \"cpu\") stopBlurringTheBackground();\n */\nexport function readQualityLimitation(report: RTCStatsReport): RTCQualityLimitationReason | null {\n return limitationOf(collect(report));\n}\n\n/**\n * Read whether the link is travelling through a TURN relay.\n *\n * Resolved only from the pair the browser names, never from a merely\n * `succeeded` one: a relayed route is somebody's hosting bill, and reporting\n * one from a pair that carries nothing bills a cost nobody is paying.\n *\n * @param report - A report from `RTCPeerConnection.getStats()`.\n * @returns `true` when the carrying pair's local candidate is a relay.\n *\n * @example\n * if (readRelayed(await pc.getStats())) capTheStreamThatCostsMoney();\n */\nexport function readRelayed(report: RTCStatsReport): boolean {\n return relayedOf(collect(report));\n}\n\n/** Round trip of the carrying pair, in whole milliseconds. */\nfunction roundTripOf(collected: CollectedReport): number | null {\n const withTiming: CollectedReport = {\n ...collected,\n pairs: collected.pairs.filter((pair) => pair.rttMs !== null),\n };\n const { pair } = carryingPair(withTiming);\n return pair?.rttMs === undefined || pair.rttMs === null ? null : Math.round(pair.rttMs);\n}\n\n/** Estimated uplink of the carrying pair, in kbps. */\nfunction availableOf(collected: CollectedReport): number | null {\n const { pair } = carryingPair(collected);\n return pair?.availableKbps ?? null;\n}\n\n/** Whether the pair the browser named travels through a relay. */\nfunction relayedOf(collected: CollectedReport): boolean {\n const { pair, named } = carryingPair(collected);\n if (!named || pair === null || pair.localCandidateId === null) return false;\n return collected.relayCandidateIds.has(pair.localCandidateId);\n}\n\n/** The strongest limitation any sender reports, with bandwidth winning. */\nfunction limitationOf(collected: CollectedReport): RTCQualityLimitationReason | null {\n let found: RTCQualityLimitationReason | null = null;\n for (const sender of collected.senders) {\n if (sender.limitedBy === null) continue;\n if (sender.limitedBy === \"bandwidth\") return \"bandwidth\";\n found = found ?? sender.limitedBy;\n }\n return found;\n}\n\n/**\n * Track one link's throughput, resolution and round trip across samples.\n *\n * Every rate here is a **delta**. `bytesSent` is cumulative since the connection\n * opened, so dividing it by the session length gives the historical average —\n * a number that only ever falls and never shows what is happening now. The\n * previous reading is kept on the sampler and subtracted, which is the part\n * every hand-rolled copy of this ends up rewriting.\n *\n * Bytes are summed across every matching sender, because a peer publishing a\n * camera and a screen at once occupies one uplink with both — and the uplink is\n * what runs out. Resolution and frame rate come from the **largest** stream by\n * area, which is the one that dominates that bandwidth and the one somebody\n * watching the call is looking at.\n *\n * @param options - See {@link LinkStatsSamplerOptions}.\n * @returns A sampler. Use one per `RTCPeerConnection`.\n *\n * @example\n * const sampler = createLinkStatsSampler();\n *\n * setInterval(async () => {\n * const stats = await sampler.sample(pc);\n * badge.textContent = `${stats.kbps} kbps · ${stats.rttMs ?? \"—\"} ms`;\n * }, 2000);\n */\nexport function createLinkStatsSampler(options: LinkStatsSamplerOptions = {}): LinkStatsSampler {\n const kind: LinkStatsKind = options.kind ?? \"video\";\n let lastBytes: number | null = null;\n let lastSampleAt = 0;\n let last: LinkStats = {\n kbps: 0,\n width: 0,\n height: 0,\n fps: 0,\n rttMs: null,\n availableKbps: null,\n limitedBy: null,\n relayed: false,\n };\n\n function read(report: RTCStatsReport): LinkStats {\n const now = performance.now();\n const collected = collect(report);\n const path = {\n rttMs: roundTripOf(collected),\n availableKbps: availableOf(collected),\n limitedBy: limitationOf(collected),\n relayed: relayedOf(collected),\n };\n\n let bytes = 0;\n let sawSender = false;\n let bestArea = 0;\n let width = 0;\n let height = 0;\n let fps = 0;\n\n for (const sender of collected.senders) {\n if (kind !== \"all\" && sender.kind !== kind) continue;\n sawSender = true;\n bytes += sender.bytes;\n\n const area = sender.width * sender.height;\n if (area < bestArea) continue;\n bestArea = area;\n width = sender.width;\n height = sender.height;\n fps = sender.fps;\n }\n\n if (!sawSender) {\n last = { ...last, ...path };\n return last;\n }\n\n const elapsed = lastBytes === null ? 0 : (now - lastSampleAt) / 1000;\n const delta = lastBytes === null ? 0 : bytes - lastBytes;\n const kbps = elapsed > 0 && delta > 0 ? Math.round((delta * 8) / 1000 / elapsed) : 0;\n\n lastBytes = bytes;\n lastSampleAt = now;\n last = {\n kbps,\n width: width > 0 ? width : last.width,\n height: height > 0 ? height : last.height,\n fps: fps > 0 ? fps : last.fps,\n ...path,\n };\n return last;\n }\n\n return {\n read,\n sample: async (connection: RTCPeerConnection): Promise<LinkStats> =>\n read(await connection.getStats()),\n reset: (): void => {\n lastBytes = null;\n lastSampleAt = 0;\n last = { ...last, kbps: 0 };\n },\n };\n}\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- const e=require("./sender-bitrate.cjs");var t=6e3,n=300,r=900;function i(e,r){let i=e.video??{};if(r<=1)return e;let a=Object.values(i).reduce((e,t)=>e+(t??0),0);if(a===0)return e;let o=(e.uplinkBudgetKbps??t)/r;if(a<=o)return e;let s=e.minVideoKbps??n,c=o/a,l={};for(let[e,t]of Object.entries(i))l[e]=t===null?null:Math.max(s,Math.round(t*c));return{...e,video:l}}function a(e,t){let n=e.degradationPreference;if(n!==`maintain-framerate`)return n;let i=Object.values(t.video??{}).filter(e=>e!==null);return i.length===0?n:Math.max(...i)>=(e.fluidFloorKbps??r)?`maintain-framerate`:`maintain-resolution`}function o(e,t,n){let r=e.getParameters();if(t!==void 0&&(r.degradationPreference=t),n!==void 0)for(let e of r.encodings??[])e.maxFramerate=n;return r}async function s(t,n,r,i){for(let[a,s]of n.entries()){let n=t[a]?.sender;if(!n)continue;if(s.kind===`audio`){let t=r.audio?.[s.name];t!==void 0&&await e.setSenderBitrate(n,t);continue}let c=r.video?.[s.name];if(c!==void 0&&await e.setSenderBitrate(n,c===null?null:c*1e3),i!==void 0||r.maxFramerate!==void 0)try{await n.setParameters(o(n,i,r.maxFramerate))}catch{try{await n.setParameters(o(n,void 0,r.maxFramerate))}catch{}}}}exports.applyQualityToLink=s,exports.resolveDegradation=a,exports.scaleForRoom=i;
1
+ const e=require("./sender-bitrate.cjs");var t=6e3,n=300,r=900;function i(e,r){let i=e.video??{};if(r<=1)return e;let a=Object.values(i).reduce((e,t)=>e+(t??0),0);if(a===0)return e;let o=(e.uplinkBudgetKbps??t)/r;if(a<=o)return e;let s=e.minVideoKbps??n,c=o/a,l={};for(let[e,t]of Object.entries(i))l[e]=t===null?null:Math.max(s,Math.round(t*c));return{...e,video:l}}function a(e,t){let n=e.degradationPreference;if(n!==`maintain-framerate`)return n;let i=t.video??{},a=e.fluidFloorKbps??r,o=e.degradationAnchor;if(o!==void 0){if(!(o in i))return n;let e=i[o];return e===null?n:e>=a?`maintain-framerate`:`maintain-resolution`}let s=Object.values(i);return s.length===0||s.some(e=>e===null)?n:Math.max(...s.filter(e=>e!==null))>=a?`maintain-framerate`:`maintain-resolution`}function o(e,t,n){let r=e.getParameters();if(t!==void 0&&(r.degradationPreference=t),n!==void 0)for(let e of r.encodings??[])e.maxFramerate=n;return r}async function s(t,n,r,i){for(let[a,s]of n.entries()){let n=t[a]?.sender;if(!n)continue;if(s.kind===`audio`){let t=r.audio?.[s.name];t!==void 0&&await e.setSenderBitrate(n,t);continue}let c=r.video?.[s.name];if(c!==void 0&&await e.setSenderBitrate(n,c===null?null:c*1e3),i!==void 0||r.maxFramerate!==void 0)try{await n.setParameters(o(n,i,r.maxFramerate))}catch{try{await n.setParameters(o(n,void 0,r.maxFramerate))}catch{}}}}exports.applyQualityToLink=s,exports.resolveDegradation=a,exports.scaleForRoom=i;
2
2
  //# sourceMappingURL=mesh-quality.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"mesh-quality.cjs","names":[],"sources":["../../src/webrtc/mesh-quality.ts"],"sourcesContent":["import { setSenderBitrate } from \"./sender-bitrate\";\nimport type { MeshQuality, MeshSlot } from \"./mesh-types\";\n\n/** Uplink assumed available when the caller names no budget, in kbps. */\nconst DEFAULT_UPLINK_BUDGET_KBPS = 6000;\n\n/** Floor a video slot keeps after the division, in kbps. */\nconst DEFAULT_MIN_VIDEO_KBPS = 300;\n\n/** Budget below which `maintain-framerate` stops being worth holding, in kbps. */\nconst DEFAULT_FLUID_FLOOR_KBPS = 900;\n\n/**\n * Divide the video caps by the size of the room.\n *\n * A mesh sends one copy of everything per participant, so the uplink is the\n * shared resource and the caps are what compete for it. Nothing is divided while\n * the caller is alone with one peer — that is the case the largest sizes exist\n * for, and dividing a budget that is not being shared would make them\n * unreachable in the only call where they fit.\n *\n * The floor is what keeps the division honest: without it a busy room allocates\n * tens of kbps per stream, and everybody loses the picture instead of the excess\n * giving way.\n *\n * @param quality - The caps as asked for.\n * @param peers - How many links are live.\n * @returns The caps to actually apply. The input is never mutated, so a room\n * that empties out climbs back to what was asked for.\n */\nexport function scaleForRoom(quality: MeshQuality, peers: number): MeshQuality {\n const video = quality.video ?? {};\n if (peers <= 1) return quality;\n\n const asked = Object.values(video).reduce<number>((sum, cap) => sum + (cap ?? 0), 0);\n if (asked === 0) return quality;\n\n const perPeer = (quality.uplinkBudgetKbps ?? DEFAULT_UPLINK_BUDGET_KBPS) / peers;\n if (asked <= perPeer) return quality;\n\n const floor = quality.minVideoKbps ?? DEFAULT_MIN_VIDEO_KBPS;\n const factor = perPeer / asked;\n const scaled: Record<string, number | null> = {};\n for (const [slot, cap] of Object.entries(video)) {\n scaled[slot] = cap === null ? null : Math.max(floor, Math.round(cap * factor));\n }\n return { ...quality, video: scaled };\n}\n\n/**\n * Decide what the encoder gives up, letting physics override the preference.\n *\n * `maintain-framerate` is honoured while there are bits enough for the frames to\n * be worth keeping. Once the room's division has taken the budget below the\n * fluid floor, holding the rate halves what each frame receives and the picture\n * is worse than the lower rate it replaced — so below that line the preference\n * is overridden rather than obeyed.\n *\n * @param quality - What was asked for.\n * @param effective - What the room's share actually allows.\n * @returns The degradation preference to write onto the senders, or `undefined`\n * when the caller expressed no preference.\n */\nexport function resolveDegradation(\n quality: MeshQuality,\n effective: MeshQuality,\n): RTCDegradationPreference | undefined {\n const asked = quality.degradationPreference;\n if (asked !== \"maintain-framerate\") return asked;\n\n const caps = Object.values(effective.video ?? {}).filter((cap): cap is number => cap !== null);\n if (caps.length === 0) return asked;\n\n const budget = Math.max(...caps);\n return budget >= (quality.fluidFloorKbps ?? DEFAULT_FLUID_FLOOR_KBPS)\n ? \"maintain-framerate\"\n : \"maintain-resolution\";\n}\n\n/**\n * Read a video sender's parameters back with the motion settings written on.\n *\n * Read fresh on every call because `setParameters` only accepts the object the\n * **same** sender's `getParameters` returned, so a rejected attempt cannot be\n * retried with the object that was rejected.\n *\n * @param sender - The video sender to read from.\n * @param degradation - What to give up first, or `undefined` to leave it alone.\n * @param fps - Frame ceiling, or `undefined` to leave it alone.\n * @returns Parameters ready to be written back to `sender`.\n */\nfunction videoParameters(\n sender: RTCRtpSender,\n degradation: RTCDegradationPreference | undefined,\n fps: number | undefined,\n): RTCRtpSendParameters {\n const params = sender.getParameters();\n if (degradation !== undefined) params.degradationPreference = degradation;\n if (fps !== undefined) {\n for (const encoding of params.encodings ?? []) encoding.maxFramerate = fps;\n }\n return params;\n}\n\n/**\n * Write the quality settings onto one link's senders.\n *\n * `degradationPreference` goes only on video: it describes trading resolution\n * against frame rate, which an audio sender has no analogue for, and some\n * browsers reject it outright there.\n *\n * The retry without `degradationPreference` is for Firefox, which rejects the\n * member entirely — sending both together would lose the frame-rate cap to an\n * objection about something else.\n *\n * @param transceivers - The link's transceivers, in slot order.\n * @param slots - The slot list those transceivers were allocated from.\n * @param effective - Caps after the room's division.\n * @param degradation - Already resolved against the fluid floor.\n */\nexport async function applyQualityToLink(\n transceivers: readonly RTCRtpTransceiver[],\n slots: readonly MeshSlot[],\n effective: MeshQuality,\n degradation: RTCDegradationPreference | undefined,\n): Promise<void> {\n for (const [index, slot] of slots.entries()) {\n const sender = transceivers[index]?.sender;\n if (!sender) continue;\n\n if (slot.kind === \"audio\") {\n const bps = effective.audio?.[slot.name];\n if (bps !== undefined) await setSenderBitrate(sender, bps);\n continue;\n }\n\n const kbps = effective.video?.[slot.name];\n if (kbps !== undefined) await setSenderBitrate(sender, kbps === null ? null : kbps * 1000);\n\n if (degradation === undefined && effective.maxFramerate === undefined) continue;\n try {\n await sender.setParameters(\n videoParameters(sender, degradation, effective.maxFramerate),\n );\n } catch {\n try {\n await sender.setParameters(\n videoParameters(sender, undefined, effective.maxFramerate),\n );\n } catch {\n /* the sender refused both; the bitrate cap above still applies */\n }\n }\n }\n}\n"],"mappings":"wCAIA,IAAM,EAA6B,IAG7B,EAAyB,IAGzB,EAA2B,IAoBjC,SAAgB,EAAa,EAAsB,EAA4B,CAC3E,IAAM,EAAQ,EAAQ,OAAS,CAAC,EAChC,GAAI,GAAS,EAAG,OAAO,EAEvB,IAAM,EAAQ,OAAO,OAAO,CAAK,CAAC,CAAC,QAAgB,EAAK,IAAQ,GAAO,GAAO,GAAI,CAAC,EACnF,GAAI,IAAU,EAAG,OAAO,EAExB,IAAM,GAAW,EAAQ,kBAAoB,GAA8B,EAC3E,GAAI,GAAS,EAAS,OAAO,EAE7B,IAAM,EAAQ,EAAQ,cAAgB,EAChC,EAAS,EAAU,EACnB,EAAwC,CAAC,EAC/C,IAAK,GAAM,CAAC,EAAM,KAAQ,OAAO,QAAQ,CAAK,EAC1C,EAAO,GAAQ,IAAQ,KAAO,KAAO,KAAK,IAAI,EAAO,KAAK,MAAM,EAAM,CAAM,CAAC,EAEjF,MAAO,CAAE,GAAG,EAAS,MAAO,CAAO,CACvC,CAgBA,SAAgB,EACZ,EACA,EACoC,CACpC,IAAM,EAAQ,EAAQ,sBACtB,GAAI,IAAU,qBAAsB,OAAO,EAE3C,IAAM,EAAO,OAAO,OAAO,EAAU,OAAS,CAAC,CAAC,CAAC,CAAC,OAAQ,GAAuB,IAAQ,IAAI,EAI7F,OAHI,EAAK,SAAW,EAAU,EAEf,KAAK,IAAI,GAAG,CACpB,IAAW,EAAQ,gBAAkB,GACtC,qBACA,qBACV,CAcA,SAAS,EACL,EACA,EACA,EACoB,CACpB,IAAM,EAAS,EAAO,cAAc,EAEpC,GADI,IAAgB,IAAA,KAAW,EAAO,sBAAwB,GAC1D,IAAQ,IAAA,GACR,IAAK,IAAM,KAAY,EAAO,WAAa,CAAC,EAAG,EAAS,aAAe,EAE3E,OAAO,CACX,CAkBA,eAAsB,EAClB,EACA,EACA,EACA,EACa,CACb,IAAK,GAAM,CAAC,EAAO,KAAS,EAAM,QAAQ,EAAG,CACzC,IAAM,EAAS,EAAa,EAAM,EAAE,OACpC,GAAI,CAAC,EAAQ,SAEb,GAAI,EAAK,OAAS,QAAS,CACvB,IAAM,EAAM,EAAU,QAAQ,EAAK,MAC/B,IAAQ,IAAA,IAAW,MAAM,EAAA,iBAAiB,EAAQ,CAAG,EACzD,QACJ,CAEA,IAAM,EAAO,EAAU,QAAQ,EAAK,MACpC,GAAI,IAAS,IAAA,IAAW,MAAM,EAAA,iBAAiB,EAAQ,IAAS,KAAO,KAAO,EAAO,GAAI,EAErF,IAAgB,IAAA,IAAa,EAAU,eAAiB,IAAA,GAC5D,GAAI,CACA,MAAM,EAAO,cACT,EAAgB,EAAQ,EAAa,EAAU,YAAY,CAC/D,CACJ,MAAQ,CACJ,GAAI,CACA,MAAM,EAAO,cACT,EAAgB,EAAQ,IAAA,GAAW,EAAU,YAAY,CAC7D,CACJ,MAAQ,CAER,CACJ,CACJ,CACJ"}
1
+ {"version":3,"file":"mesh-quality.cjs","names":[],"sources":["../../src/webrtc/mesh-quality.ts"],"sourcesContent":["import { setSenderBitrate } from \"./sender-bitrate\";\nimport type { MeshQuality, MeshSlot } from \"./mesh-types\";\n\n/** Uplink assumed available when the caller names no budget, in kbps. */\nconst DEFAULT_UPLINK_BUDGET_KBPS = 6000;\n\n/** Floor a video slot keeps after the division, in kbps. */\nconst DEFAULT_MIN_VIDEO_KBPS = 300;\n\n/** Budget below which `maintain-framerate` stops being worth holding, in kbps. */\nconst DEFAULT_FLUID_FLOOR_KBPS = 900;\n\n/**\n * Divide the video caps by the size of the room.\n *\n * A mesh sends one copy of everything per participant, so the uplink is the\n * shared resource and the caps are what compete for it. Nothing is divided while\n * the caller is alone with one peer — that is the case the largest sizes exist\n * for, and dividing a budget that is not being shared would make them\n * unreachable in the only call where they fit.\n *\n * The floor is what keeps the division honest: without it a busy room allocates\n * tens of kbps per stream, and everybody loses the picture instead of the excess\n * giving way.\n *\n * @param quality - The caps as asked for.\n * @param peers - How many links are live.\n * @returns The caps to actually apply. The input is never mutated, so a room\n * that empties out climbs back to what was asked for.\n */\nexport function scaleForRoom(quality: MeshQuality, peers: number): MeshQuality {\n const video = quality.video ?? {};\n if (peers <= 1) return quality;\n\n const asked = Object.values(video).reduce<number>((sum, cap) => sum + (cap ?? 0), 0);\n if (asked === 0) return quality;\n\n const perPeer = (quality.uplinkBudgetKbps ?? DEFAULT_UPLINK_BUDGET_KBPS) / peers;\n if (asked <= perPeer) return quality;\n\n const floor = quality.minVideoKbps ?? DEFAULT_MIN_VIDEO_KBPS;\n const factor = perPeer / asked;\n const scaled: Record<string, number | null> = {};\n for (const [slot, cap] of Object.entries(video)) {\n scaled[slot] = cap === null ? null : Math.max(floor, Math.round(cap * factor));\n }\n return { ...quality, video: scaled };\n}\n\n/**\n * Decide what the encoder gives up, letting physics override the preference.\n *\n * `maintain-framerate` is honoured while there are bits enough for the frames to\n * be worth keeping. Once the room's division has taken the budget below the\n * fluid floor, holding the rate halves what each frame receives and the picture\n * is worse than the lower rate it replaced — so below that line the preference\n * is overridden rather than obeyed.\n *\n * Two things decide *which* budget answers that question:\n *\n * - **`null` is the most generous case, not the absent one.** A slot with no cap\n * is unbounded, which is exactly where fluidity should hold. Reading it as\n * missing — and then deciding from a modest camera beside it — is the answer\n * backwards.\n * - **{@link MeshQuality.degradationAnchor} names the slot the choice was\n * about.** Without it the largest cap across the video slots answers, which\n * is right when the slots are interchangeable and wrong when they are not.\n *\n * @param quality - What was asked for, including the anchor and the floor.\n * @param effective - What the room's share actually allows.\n * @returns The degradation preference to write onto the senders, or `undefined`\n * when the caller expressed no preference.\n */\nexport function resolveDegradation(\n quality: MeshQuality,\n effective: MeshQuality,\n): RTCDegradationPreference | undefined {\n const asked = quality.degradationPreference;\n if (asked !== \"maintain-framerate\") return asked;\n\n const video = effective.video ?? {};\n const floor = quality.fluidFloorKbps ?? DEFAULT_FLUID_FLOOR_KBPS;\n const anchor = quality.degradationAnchor;\n\n if (anchor !== undefined) {\n if (!(anchor in video)) return asked;\n const cap = video[anchor];\n if (cap === null) return asked;\n return cap >= floor ? \"maintain-framerate\" : \"maintain-resolution\";\n }\n\n const caps = Object.values(video);\n if (caps.length === 0) return asked;\n if (caps.some((cap) => cap === null)) return asked;\n\n const budget = Math.max(...caps.filter((cap): cap is number => cap !== null));\n return budget >= floor ? \"maintain-framerate\" : \"maintain-resolution\";\n}\n\n/**\n * Read a video sender's parameters back with the motion settings written on.\n *\n * Read fresh on every call because `setParameters` only accepts the object the\n * **same** sender's `getParameters` returned, so a rejected attempt cannot be\n * retried with the object that was rejected.\n *\n * @param sender - The video sender to read from.\n * @param degradation - What to give up first, or `undefined` to leave it alone.\n * @param fps - Frame ceiling, or `undefined` to leave it alone.\n * @returns Parameters ready to be written back to `sender`.\n */\nfunction videoParameters(\n sender: RTCRtpSender,\n degradation: RTCDegradationPreference | undefined,\n fps: number | undefined,\n): RTCRtpSendParameters {\n const params = sender.getParameters();\n if (degradation !== undefined) params.degradationPreference = degradation;\n if (fps !== undefined) {\n for (const encoding of params.encodings ?? []) encoding.maxFramerate = fps;\n }\n return params;\n}\n\n/**\n * Write the quality settings onto one link's senders.\n *\n * `degradationPreference` goes only on video: it describes trading resolution\n * against frame rate, which an audio sender has no analogue for, and some\n * browsers reject it outright there.\n *\n * The retry without `degradationPreference` is for Firefox, which rejects the\n * member entirely — sending both together would lose the frame-rate cap to an\n * objection about something else.\n *\n * @param transceivers - The link's transceivers, in slot order.\n * @param slots - The slot list those transceivers were allocated from.\n * @param effective - Caps after the room's division.\n * @param degradation - Already resolved against the fluid floor.\n */\nexport async function applyQualityToLink(\n transceivers: readonly RTCRtpTransceiver[],\n slots: readonly MeshSlot[],\n effective: MeshQuality,\n degradation: RTCDegradationPreference | undefined,\n): Promise<void> {\n for (const [index, slot] of slots.entries()) {\n const sender = transceivers[index]?.sender;\n if (!sender) continue;\n\n if (slot.kind === \"audio\") {\n const bps = effective.audio?.[slot.name];\n if (bps !== undefined) await setSenderBitrate(sender, bps);\n continue;\n }\n\n const kbps = effective.video?.[slot.name];\n if (kbps !== undefined) await setSenderBitrate(sender, kbps === null ? null : kbps * 1000);\n\n if (degradation === undefined && effective.maxFramerate === undefined) continue;\n try {\n await sender.setParameters(\n videoParameters(sender, degradation, effective.maxFramerate),\n );\n } catch {\n try {\n await sender.setParameters(\n videoParameters(sender, undefined, effective.maxFramerate),\n );\n } catch {\n /* the sender refused both; the bitrate cap above still applies */\n }\n }\n }\n}\n"],"mappings":"wCAIA,IAAM,EAA6B,IAG7B,EAAyB,IAGzB,EAA2B,IAoBjC,SAAgB,EAAa,EAAsB,EAA4B,CAC3E,IAAM,EAAQ,EAAQ,OAAS,CAAC,EAChC,GAAI,GAAS,EAAG,OAAO,EAEvB,IAAM,EAAQ,OAAO,OAAO,CAAK,CAAC,CAAC,QAAgB,EAAK,IAAQ,GAAO,GAAO,GAAI,CAAC,EACnF,GAAI,IAAU,EAAG,OAAO,EAExB,IAAM,GAAW,EAAQ,kBAAoB,GAA8B,EAC3E,GAAI,GAAS,EAAS,OAAO,EAE7B,IAAM,EAAQ,EAAQ,cAAgB,EAChC,EAAS,EAAU,EACnB,EAAwC,CAAC,EAC/C,IAAK,GAAM,CAAC,EAAM,KAAQ,OAAO,QAAQ,CAAK,EAC1C,EAAO,GAAQ,IAAQ,KAAO,KAAO,KAAK,IAAI,EAAO,KAAK,MAAM,EAAM,CAAM,CAAC,EAEjF,MAAO,CAAE,GAAG,EAAS,MAAO,CAAO,CACvC,CA0BA,SAAgB,EACZ,EACA,EACoC,CACpC,IAAM,EAAQ,EAAQ,sBACtB,GAAI,IAAU,qBAAsB,OAAO,EAE3C,IAAM,EAAQ,EAAU,OAAS,CAAC,EAC5B,EAAQ,EAAQ,gBAAkB,EAClC,EAAS,EAAQ,kBAEvB,GAAI,IAAW,IAAA,GAAW,CACtB,GAAI,EAAE,KAAU,GAAQ,OAAO,EAC/B,IAAM,EAAM,EAAM,GAElB,OADI,IAAQ,KAAa,EAClB,GAAO,EAAQ,qBAAuB,qBACjD,CAEA,IAAM,EAAO,OAAO,OAAO,CAAK,EAKhC,OAJI,EAAK,SAAW,GAChB,EAAK,KAAM,GAAQ,IAAQ,IAAI,EAAU,EAE9B,KAAK,IAAI,GAAG,EAAK,OAAQ,GAAuB,IAAQ,IAAI,CACpE,GAAU,EAAQ,qBAAuB,qBACpD,CAcA,SAAS,EACL,EACA,EACA,EACoB,CACpB,IAAM,EAAS,EAAO,cAAc,EAEpC,GADI,IAAgB,IAAA,KAAW,EAAO,sBAAwB,GAC1D,IAAQ,IAAA,GACR,IAAK,IAAM,KAAY,EAAO,WAAa,CAAC,EAAG,EAAS,aAAe,EAE3E,OAAO,CACX,CAkBA,eAAsB,EAClB,EACA,EACA,EACA,EACa,CACb,IAAK,GAAM,CAAC,EAAO,KAAS,EAAM,QAAQ,EAAG,CACzC,IAAM,EAAS,EAAa,EAAM,EAAE,OACpC,GAAI,CAAC,EAAQ,SAEb,GAAI,EAAK,OAAS,QAAS,CACvB,IAAM,EAAM,EAAU,QAAQ,EAAK,MAC/B,IAAQ,IAAA,IAAW,MAAM,EAAA,iBAAiB,EAAQ,CAAG,EACzD,QACJ,CAEA,IAAM,EAAO,EAAU,QAAQ,EAAK,MACpC,GAAI,IAAS,IAAA,IAAW,MAAM,EAAA,iBAAiB,EAAQ,IAAS,KAAO,KAAO,EAAO,GAAI,EAErF,IAAgB,IAAA,IAAa,EAAU,eAAiB,IAAA,GAC5D,GAAI,CACA,MAAM,EAAO,cACT,EAAgB,EAAQ,EAAa,EAAU,YAAY,CAC/D,CACJ,MAAQ,CACJ,GAAI,CACA,MAAM,EAAO,cACT,EAAgB,EAAQ,IAAA,GAAW,EAAU,YAAY,CAC7D,CACJ,MAAQ,CAER,CACJ,CACJ,CACJ"}
@@ -18,8 +18,14 @@ function i(e, r) {
18
18
  function a(e, t) {
19
19
  let n = e.degradationPreference;
20
20
  if (n !== "maintain-framerate") return n;
21
- let i = Object.values(t.video ?? {}).filter((e) => e !== null);
22
- return i.length === 0 ? n : Math.max(...i) >= (e.fluidFloorKbps ?? r) ? "maintain-framerate" : "maintain-resolution";
21
+ let i = t.video ?? {}, a = e.fluidFloorKbps ?? r, o = e.degradationAnchor;
22
+ if (o !== void 0) {
23
+ if (!(o in i)) return n;
24
+ let e = i[o];
25
+ return e === null ? n : e >= a ? "maintain-framerate" : "maintain-resolution";
26
+ }
27
+ let s = Object.values(i);
28
+ return s.length === 0 || s.some((e) => e === null) ? n : Math.max(...s.filter((e) => e !== null)) >= a ? "maintain-framerate" : "maintain-resolution";
23
29
  }
24
30
  function o(e, t, n) {
25
31
  let r = e.getParameters();
@@ -1 +1 @@
1
- {"version":3,"file":"mesh-quality.js","names":[],"sources":["../../src/webrtc/mesh-quality.ts"],"sourcesContent":["import { setSenderBitrate } from \"./sender-bitrate\";\nimport type { MeshQuality, MeshSlot } from \"./mesh-types\";\n\n/** Uplink assumed available when the caller names no budget, in kbps. */\nconst DEFAULT_UPLINK_BUDGET_KBPS = 6000;\n\n/** Floor a video slot keeps after the division, in kbps. */\nconst DEFAULT_MIN_VIDEO_KBPS = 300;\n\n/** Budget below which `maintain-framerate` stops being worth holding, in kbps. */\nconst DEFAULT_FLUID_FLOOR_KBPS = 900;\n\n/**\n * Divide the video caps by the size of the room.\n *\n * A mesh sends one copy of everything per participant, so the uplink is the\n * shared resource and the caps are what compete for it. Nothing is divided while\n * the caller is alone with one peer — that is the case the largest sizes exist\n * for, and dividing a budget that is not being shared would make them\n * unreachable in the only call where they fit.\n *\n * The floor is what keeps the division honest: without it a busy room allocates\n * tens of kbps per stream, and everybody loses the picture instead of the excess\n * giving way.\n *\n * @param quality - The caps as asked for.\n * @param peers - How many links are live.\n * @returns The caps to actually apply. The input is never mutated, so a room\n * that empties out climbs back to what was asked for.\n */\nexport function scaleForRoom(quality: MeshQuality, peers: number): MeshQuality {\n const video = quality.video ?? {};\n if (peers <= 1) return quality;\n\n const asked = Object.values(video).reduce<number>((sum, cap) => sum + (cap ?? 0), 0);\n if (asked === 0) return quality;\n\n const perPeer = (quality.uplinkBudgetKbps ?? DEFAULT_UPLINK_BUDGET_KBPS) / peers;\n if (asked <= perPeer) return quality;\n\n const floor = quality.minVideoKbps ?? DEFAULT_MIN_VIDEO_KBPS;\n const factor = perPeer / asked;\n const scaled: Record<string, number | null> = {};\n for (const [slot, cap] of Object.entries(video)) {\n scaled[slot] = cap === null ? null : Math.max(floor, Math.round(cap * factor));\n }\n return { ...quality, video: scaled };\n}\n\n/**\n * Decide what the encoder gives up, letting physics override the preference.\n *\n * `maintain-framerate` is honoured while there are bits enough for the frames to\n * be worth keeping. Once the room's division has taken the budget below the\n * fluid floor, holding the rate halves what each frame receives and the picture\n * is worse than the lower rate it replaced — so below that line the preference\n * is overridden rather than obeyed.\n *\n * @param quality - What was asked for.\n * @param effective - What the room's share actually allows.\n * @returns The degradation preference to write onto the senders, or `undefined`\n * when the caller expressed no preference.\n */\nexport function resolveDegradation(\n quality: MeshQuality,\n effective: MeshQuality,\n): RTCDegradationPreference | undefined {\n const asked = quality.degradationPreference;\n if (asked !== \"maintain-framerate\") return asked;\n\n const caps = Object.values(effective.video ?? {}).filter((cap): cap is number => cap !== null);\n if (caps.length === 0) return asked;\n\n const budget = Math.max(...caps);\n return budget >= (quality.fluidFloorKbps ?? DEFAULT_FLUID_FLOOR_KBPS)\n ? \"maintain-framerate\"\n : \"maintain-resolution\";\n}\n\n/**\n * Read a video sender's parameters back with the motion settings written on.\n *\n * Read fresh on every call because `setParameters` only accepts the object the\n * **same** sender's `getParameters` returned, so a rejected attempt cannot be\n * retried with the object that was rejected.\n *\n * @param sender - The video sender to read from.\n * @param degradation - What to give up first, or `undefined` to leave it alone.\n * @param fps - Frame ceiling, or `undefined` to leave it alone.\n * @returns Parameters ready to be written back to `sender`.\n */\nfunction videoParameters(\n sender: RTCRtpSender,\n degradation: RTCDegradationPreference | undefined,\n fps: number | undefined,\n): RTCRtpSendParameters {\n const params = sender.getParameters();\n if (degradation !== undefined) params.degradationPreference = degradation;\n if (fps !== undefined) {\n for (const encoding of params.encodings ?? []) encoding.maxFramerate = fps;\n }\n return params;\n}\n\n/**\n * Write the quality settings onto one link's senders.\n *\n * `degradationPreference` goes only on video: it describes trading resolution\n * against frame rate, which an audio sender has no analogue for, and some\n * browsers reject it outright there.\n *\n * The retry without `degradationPreference` is for Firefox, which rejects the\n * member entirely — sending both together would lose the frame-rate cap to an\n * objection about something else.\n *\n * @param transceivers - The link's transceivers, in slot order.\n * @param slots - The slot list those transceivers were allocated from.\n * @param effective - Caps after the room's division.\n * @param degradation - Already resolved against the fluid floor.\n */\nexport async function applyQualityToLink(\n transceivers: readonly RTCRtpTransceiver[],\n slots: readonly MeshSlot[],\n effective: MeshQuality,\n degradation: RTCDegradationPreference | undefined,\n): Promise<void> {\n for (const [index, slot] of slots.entries()) {\n const sender = transceivers[index]?.sender;\n if (!sender) continue;\n\n if (slot.kind === \"audio\") {\n const bps = effective.audio?.[slot.name];\n if (bps !== undefined) await setSenderBitrate(sender, bps);\n continue;\n }\n\n const kbps = effective.video?.[slot.name];\n if (kbps !== undefined) await setSenderBitrate(sender, kbps === null ? null : kbps * 1000);\n\n if (degradation === undefined && effective.maxFramerate === undefined) continue;\n try {\n await sender.setParameters(\n videoParameters(sender, degradation, effective.maxFramerate),\n );\n } catch {\n try {\n await sender.setParameters(\n videoParameters(sender, undefined, effective.maxFramerate),\n );\n } catch {\n /* the sender refused both; the bitrate cap above still applies */\n }\n }\n }\n}\n"],"mappings":";;AAIA,IAAM,IAA6B,KAG7B,IAAyB,KAGzB,IAA2B;AAoBjC,SAAgB,EAAa,GAAsB,GAA4B;CAC3E,IAAM,IAAQ,EAAQ,SAAS,CAAC;CAChC,IAAI,KAAS,GAAG,OAAO;CAEvB,IAAM,IAAQ,OAAO,OAAO,CAAK,CAAC,CAAC,QAAgB,GAAK,MAAQ,KAAO,KAAO,IAAI,CAAC;CACnF,IAAI,MAAU,GAAG,OAAO;CAExB,IAAM,KAAW,EAAQ,oBAAoB,KAA8B;CAC3E,IAAI,KAAS,GAAS,OAAO;CAE7B,IAAM,IAAQ,EAAQ,gBAAgB,GAChC,IAAS,IAAU,GACnB,IAAwC,CAAC;CAC/C,KAAK,IAAM,CAAC,GAAM,MAAQ,OAAO,QAAQ,CAAK,GAC1C,EAAO,KAAQ,MAAQ,OAAO,OAAO,KAAK,IAAI,GAAO,KAAK,MAAM,IAAM,CAAM,CAAC;CAEjF,OAAO;EAAE,GAAG;EAAS,OAAO;CAAO;AACvC;AAgBA,SAAgB,EACZ,GACA,GACoC;CACpC,IAAM,IAAQ,EAAQ;CACtB,IAAI,MAAU,sBAAsB,OAAO;CAE3C,IAAM,IAAO,OAAO,OAAO,EAAU,SAAS,CAAC,CAAC,CAAC,CAAC,QAAQ,MAAuB,MAAQ,IAAI;CAI7F,OAHI,EAAK,WAAW,IAAU,IAEf,KAAK,IAAI,GAAG,CACpB,MAAW,EAAQ,kBAAkB,KACtC,uBACA;AACV;AAcA,SAAS,EACL,GACA,GACA,GACoB;CACpB,IAAM,IAAS,EAAO,cAAc;CAEpC,IADI,MAAgB,KAAA,MAAW,EAAO,wBAAwB,IAC1D,MAAQ,KAAA,GACR,KAAK,IAAM,KAAY,EAAO,aAAa,CAAC,GAAG,EAAS,eAAe;CAE3E,OAAO;AACX;AAkBA,eAAsB,EAClB,GACA,GACA,GACA,GACa;CACb,KAAK,IAAM,CAAC,GAAO,MAAS,EAAM,QAAQ,GAAG;EACzC,IAAM,IAAS,EAAa,EAAM,EAAE;EACpC,IAAI,CAAC,GAAQ;EAEb,IAAI,EAAK,SAAS,SAAS;GACvB,IAAM,IAAM,EAAU,QAAQ,EAAK;GACnC,AAAI,MAAQ,KAAA,KAAW,MAAM,EAAiB,GAAQ,CAAG;GACzD;EACJ;EAEA,IAAM,IAAO,EAAU,QAAQ,EAAK;EACpC,IAAI,MAAS,KAAA,KAAW,MAAM,EAAiB,GAAQ,MAAS,OAAO,OAAO,IAAO,GAAI,GAErF,MAAgB,KAAA,KAAa,EAAU,iBAAiB,KAAA,GAC5D,IAAI;GACA,MAAM,EAAO,cACT,EAAgB,GAAQ,GAAa,EAAU,YAAY,CAC/D;EACJ,QAAQ;GACJ,IAAI;IACA,MAAM,EAAO,cACT,EAAgB,GAAQ,KAAA,GAAW,EAAU,YAAY,CAC7D;GACJ,QAAQ,CAER;EACJ;CACJ;AACJ"}
1
+ {"version":3,"file":"mesh-quality.js","names":[],"sources":["../../src/webrtc/mesh-quality.ts"],"sourcesContent":["import { setSenderBitrate } from \"./sender-bitrate\";\nimport type { MeshQuality, MeshSlot } from \"./mesh-types\";\n\n/** Uplink assumed available when the caller names no budget, in kbps. */\nconst DEFAULT_UPLINK_BUDGET_KBPS = 6000;\n\n/** Floor a video slot keeps after the division, in kbps. */\nconst DEFAULT_MIN_VIDEO_KBPS = 300;\n\n/** Budget below which `maintain-framerate` stops being worth holding, in kbps. */\nconst DEFAULT_FLUID_FLOOR_KBPS = 900;\n\n/**\n * Divide the video caps by the size of the room.\n *\n * A mesh sends one copy of everything per participant, so the uplink is the\n * shared resource and the caps are what compete for it. Nothing is divided while\n * the caller is alone with one peer — that is the case the largest sizes exist\n * for, and dividing a budget that is not being shared would make them\n * unreachable in the only call where they fit.\n *\n * The floor is what keeps the division honest: without it a busy room allocates\n * tens of kbps per stream, and everybody loses the picture instead of the excess\n * giving way.\n *\n * @param quality - The caps as asked for.\n * @param peers - How many links are live.\n * @returns The caps to actually apply. The input is never mutated, so a room\n * that empties out climbs back to what was asked for.\n */\nexport function scaleForRoom(quality: MeshQuality, peers: number): MeshQuality {\n const video = quality.video ?? {};\n if (peers <= 1) return quality;\n\n const asked = Object.values(video).reduce<number>((sum, cap) => sum + (cap ?? 0), 0);\n if (asked === 0) return quality;\n\n const perPeer = (quality.uplinkBudgetKbps ?? DEFAULT_UPLINK_BUDGET_KBPS) / peers;\n if (asked <= perPeer) return quality;\n\n const floor = quality.minVideoKbps ?? DEFAULT_MIN_VIDEO_KBPS;\n const factor = perPeer / asked;\n const scaled: Record<string, number | null> = {};\n for (const [slot, cap] of Object.entries(video)) {\n scaled[slot] = cap === null ? null : Math.max(floor, Math.round(cap * factor));\n }\n return { ...quality, video: scaled };\n}\n\n/**\n * Decide what the encoder gives up, letting physics override the preference.\n *\n * `maintain-framerate` is honoured while there are bits enough for the frames to\n * be worth keeping. Once the room's division has taken the budget below the\n * fluid floor, holding the rate halves what each frame receives and the picture\n * is worse than the lower rate it replaced — so below that line the preference\n * is overridden rather than obeyed.\n *\n * Two things decide *which* budget answers that question:\n *\n * - **`null` is the most generous case, not the absent one.** A slot with no cap\n * is unbounded, which is exactly where fluidity should hold. Reading it as\n * missing — and then deciding from a modest camera beside it — is the answer\n * backwards.\n * - **{@link MeshQuality.degradationAnchor} names the slot the choice was\n * about.** Without it the largest cap across the video slots answers, which\n * is right when the slots are interchangeable and wrong when they are not.\n *\n * @param quality - What was asked for, including the anchor and the floor.\n * @param effective - What the room's share actually allows.\n * @returns The degradation preference to write onto the senders, or `undefined`\n * when the caller expressed no preference.\n */\nexport function resolveDegradation(\n quality: MeshQuality,\n effective: MeshQuality,\n): RTCDegradationPreference | undefined {\n const asked = quality.degradationPreference;\n if (asked !== \"maintain-framerate\") return asked;\n\n const video = effective.video ?? {};\n const floor = quality.fluidFloorKbps ?? DEFAULT_FLUID_FLOOR_KBPS;\n const anchor = quality.degradationAnchor;\n\n if (anchor !== undefined) {\n if (!(anchor in video)) return asked;\n const cap = video[anchor];\n if (cap === null) return asked;\n return cap >= floor ? \"maintain-framerate\" : \"maintain-resolution\";\n }\n\n const caps = Object.values(video);\n if (caps.length === 0) return asked;\n if (caps.some((cap) => cap === null)) return asked;\n\n const budget = Math.max(...caps.filter((cap): cap is number => cap !== null));\n return budget >= floor ? \"maintain-framerate\" : \"maintain-resolution\";\n}\n\n/**\n * Read a video sender's parameters back with the motion settings written on.\n *\n * Read fresh on every call because `setParameters` only accepts the object the\n * **same** sender's `getParameters` returned, so a rejected attempt cannot be\n * retried with the object that was rejected.\n *\n * @param sender - The video sender to read from.\n * @param degradation - What to give up first, or `undefined` to leave it alone.\n * @param fps - Frame ceiling, or `undefined` to leave it alone.\n * @returns Parameters ready to be written back to `sender`.\n */\nfunction videoParameters(\n sender: RTCRtpSender,\n degradation: RTCDegradationPreference | undefined,\n fps: number | undefined,\n): RTCRtpSendParameters {\n const params = sender.getParameters();\n if (degradation !== undefined) params.degradationPreference = degradation;\n if (fps !== undefined) {\n for (const encoding of params.encodings ?? []) encoding.maxFramerate = fps;\n }\n return params;\n}\n\n/**\n * Write the quality settings onto one link's senders.\n *\n * `degradationPreference` goes only on video: it describes trading resolution\n * against frame rate, which an audio sender has no analogue for, and some\n * browsers reject it outright there.\n *\n * The retry without `degradationPreference` is for Firefox, which rejects the\n * member entirely — sending both together would lose the frame-rate cap to an\n * objection about something else.\n *\n * @param transceivers - The link's transceivers, in slot order.\n * @param slots - The slot list those transceivers were allocated from.\n * @param effective - Caps after the room's division.\n * @param degradation - Already resolved against the fluid floor.\n */\nexport async function applyQualityToLink(\n transceivers: readonly RTCRtpTransceiver[],\n slots: readonly MeshSlot[],\n effective: MeshQuality,\n degradation: RTCDegradationPreference | undefined,\n): Promise<void> {\n for (const [index, slot] of slots.entries()) {\n const sender = transceivers[index]?.sender;\n if (!sender) continue;\n\n if (slot.kind === \"audio\") {\n const bps = effective.audio?.[slot.name];\n if (bps !== undefined) await setSenderBitrate(sender, bps);\n continue;\n }\n\n const kbps = effective.video?.[slot.name];\n if (kbps !== undefined) await setSenderBitrate(sender, kbps === null ? null : kbps * 1000);\n\n if (degradation === undefined && effective.maxFramerate === undefined) continue;\n try {\n await sender.setParameters(\n videoParameters(sender, degradation, effective.maxFramerate),\n );\n } catch {\n try {\n await sender.setParameters(\n videoParameters(sender, undefined, effective.maxFramerate),\n );\n } catch {\n /* the sender refused both; the bitrate cap above still applies */\n }\n }\n }\n}\n"],"mappings":";;AAIA,IAAM,IAA6B,KAG7B,IAAyB,KAGzB,IAA2B;AAoBjC,SAAgB,EAAa,GAAsB,GAA4B;CAC3E,IAAM,IAAQ,EAAQ,SAAS,CAAC;CAChC,IAAI,KAAS,GAAG,OAAO;CAEvB,IAAM,IAAQ,OAAO,OAAO,CAAK,CAAC,CAAC,QAAgB,GAAK,MAAQ,KAAO,KAAO,IAAI,CAAC;CACnF,IAAI,MAAU,GAAG,OAAO;CAExB,IAAM,KAAW,EAAQ,oBAAoB,KAA8B;CAC3E,IAAI,KAAS,GAAS,OAAO;CAE7B,IAAM,IAAQ,EAAQ,gBAAgB,GAChC,IAAS,IAAU,GACnB,IAAwC,CAAC;CAC/C,KAAK,IAAM,CAAC,GAAM,MAAQ,OAAO,QAAQ,CAAK,GAC1C,EAAO,KAAQ,MAAQ,OAAO,OAAO,KAAK,IAAI,GAAO,KAAK,MAAM,IAAM,CAAM,CAAC;CAEjF,OAAO;EAAE,GAAG;EAAS,OAAO;CAAO;AACvC;AA0BA,SAAgB,EACZ,GACA,GACoC;CACpC,IAAM,IAAQ,EAAQ;CACtB,IAAI,MAAU,sBAAsB,OAAO;CAE3C,IAAM,IAAQ,EAAU,SAAS,CAAC,GAC5B,IAAQ,EAAQ,kBAAkB,GAClC,IAAS,EAAQ;CAEvB,IAAI,MAAW,KAAA,GAAW;EACtB,IAAI,EAAE,KAAU,IAAQ,OAAO;EAC/B,IAAM,IAAM,EAAM;EAElB,OADI,MAAQ,OAAa,IAClB,KAAO,IAAQ,uBAAuB;CACjD;CAEA,IAAM,IAAO,OAAO,OAAO,CAAK;CAKhC,OAJI,EAAK,WAAW,KAChB,EAAK,MAAM,MAAQ,MAAQ,IAAI,IAAU,IAE9B,KAAK,IAAI,GAAG,EAAK,QAAQ,MAAuB,MAAQ,IAAI,CACpE,KAAU,IAAQ,uBAAuB;AACpD;AAcA,SAAS,EACL,GACA,GACA,GACoB;CACpB,IAAM,IAAS,EAAO,cAAc;CAEpC,IADI,MAAgB,KAAA,MAAW,EAAO,wBAAwB,IAC1D,MAAQ,KAAA,GACR,KAAK,IAAM,KAAY,EAAO,aAAa,CAAC,GAAG,EAAS,eAAe;CAE3E,OAAO;AACX;AAkBA,eAAsB,EAClB,GACA,GACA,GACA,GACa;CACb,KAAK,IAAM,CAAC,GAAO,MAAS,EAAM,QAAQ,GAAG;EACzC,IAAM,IAAS,EAAa,EAAM,EAAE;EACpC,IAAI,CAAC,GAAQ;EAEb,IAAI,EAAK,SAAS,SAAS;GACvB,IAAM,IAAM,EAAU,QAAQ,EAAK;GACnC,AAAI,MAAQ,KAAA,KAAW,MAAM,EAAiB,GAAQ,CAAG;GACzD;EACJ;EAEA,IAAM,IAAO,EAAU,QAAQ,EAAK;EACpC,IAAI,MAAS,KAAA,KAAW,MAAM,EAAiB,GAAQ,MAAS,OAAO,OAAO,IAAO,GAAI,GAErF,MAAgB,KAAA,KAAa,EAAU,iBAAiB,KAAA,GAC5D,IAAI;GACA,MAAM,EAAO,cACT,EAAgB,GAAQ,GAAa,EAAU,YAAY,CAC/D;EACJ,QAAQ;GACJ,IAAI;IACA,MAAM,EAAO,cACT,EAAgB,GAAQ,KAAA,GAAW,EAAU,YAAY,CAC7D;GACJ,QAAQ,CAER;EACJ;CACJ;AACJ"}
@@ -1,2 +1,2 @@
1
- const e=require("./mesh-quality.cjs"),t=require("./peer-link.cjs");function n(n){let{slots:r,send:i}=n,a=new Map,o=new Map,s=new Map,c=n.iceServers??[],l=n.setLocalDescription??((e,t)=>e.setLocalDescription(t)),u=n.quality??{},d=!1,f=()=>Object.fromEntries(r.map(e=>[e.name,null])),p=()=>{n.onPeers?.([...o.values()].map(e=>({...e})))},m=()=>{if(d)return;let e=[...a.values()].map(e=>e.pc.connectionState);e.some(e=>e===`connected`)?n.onState?.(`connected`):e.length>0?n.onState?.(`connecting`,`negotiating`):n.onState?.(`connected`,`alone`)},h=async t=>{u=t;let n=e.scaleForRoom(t,a.size),i=e.resolveDegradation(t,n);for(let t of a.values())await e.applyQualityToLink(t.transceivers,r,n,i)},g=async e=>{if(!e.makingOffer){e.makingOffer=!0;try{let t=await e.pc.createOffer();if(!t.sdp){n.onNotice?.(`empty_local_offer_sdp`);return}await l(e.pc,t),i({type:`offer`,to:e.peerId,sdp:e.pc.localDescription?.sdp??t.sdp})}catch{n.onNotice?.(`offer_failed`)}finally{e.makingOffer=!1}}},_=e=>e.streams?.[0]||(typeof MediaStream>`u`?null:new MediaStream([e.track])),v=(e,n)=>{let i=t.slotOf(n.transceiver,e,r),a=o.get(e.peerId);!i||!a||(a.streams={...a.streams,[i.name]:_(n)},n.track.onended=()=>{let t=o.get(e.peerId);t&&(t.streams={...t.streams,[i.name]:null},p())},p())},y=e=>{let t=a.get(e);t&&(t.pc.close(),a.delete(e)),o.delete(e),h(u),p(),m()},b=async(e,{offerer:n})=>{if(a.has(e))return;let l=t.createPeerConnection(c),u={peerId:e,pc:l,transceivers:[],pendingCandidates:[],remoteReady:!1,makingOffer:!1,isOfferer:n};a.set(e,u),o.set(e,{peerId:e,connection:l.connectionState,streams:o.get(e)?.streams??f()}),n&&(u.transceivers=r.map(e=>l.addTransceiver(e.kind,{direction:`sendrecv`})),await t.attachLocalTracks(u,r,s)),l.onicecandidate=t=>{i({type:`ice`,to:e,candidate:t.candidate?.candidate??null,sdpMid:t.candidate?.sdpMid??null,sdpMLineIndex:t.candidate?.sdpMLineIndex??null})},l.ontrack=e=>v(u,e),l.onconnectionstatechange=()=>{let t=o.get(e);if(t&&(t.connection=l.connectionState),l.connectionState===`failed`||l.connectionState===`closed`){y(e);return}p(),m()},l.onnegotiationneeded=()=>{u.isOfferer&&g(u)},p(),m(),n&&await g(u)},x=async(e,o)=>{a.has(e)||await b(e,{offerer:!1});let c=a.get(e);if(!c)return;await c.pc.setRemoteDescription({type:`offer`,sdp:o}),c.remoteReady=!0,await t.drainCandidates(c),c.isOfferer||(t.adoptTransceivers(c),await t.attachLocalTracks(c,r,s));let d=await c.pc.createAnswer();if(!d.sdp){n.onNotice?.(`empty_local_answer_sdp`);return}await l(c.pc,d),i({type:`answer`,to:e,sdp:c.pc.localDescription?.sdp??d.sdp}),await h(u)};return n.onState?.(`connecting`,`signaling`),{addPeer:b,removePeer:y,accept:async e=>{if(e.type===`offer`){await x(e.from,e.sdp);return}let n=a.get(e.from);if(n){if(e.type===`answer`){await n.pc.setRemoteDescription({type:`answer`,sdp:e.sdp}),n.remoteReady=!0,await t.drainCandidates(n),await h(u);return}e.candidate!==null&&await t.acceptCandidate(n,{candidate:e.candidate,sdpMid:e.sdpMid??void 0,sdpMLineIndex:e.sdpMLineIndex??void 0})}},setLocalTrack:async(e,t)=>{s.set(e,t);let n=r.findIndex(t=>t.name===e);if(!(n<0)){for(let e of a.values()){let r=e.transceivers[n];if(r)try{await r.sender.replaceTrack(t)}catch{}}await h(u)}},applyQuality:h,setIceServers:e=>{c=e},stop:()=>{for(let e of a.values())e.pc.close();a.clear(),o.clear(),d=!0,p(),n.onState?.(`closed`)},get peers(){return[...o.values()].map(e=>({...e}))}}}exports.createPeerMesh=n;
1
+ const e=require("./link-stats.cjs"),t=require("./mesh-quality.cjs"),n=require("./peer-link.cjs");function r(r){let{slots:i,send:a}=r,o=new Map,s=new Map,c=new Map,l=r.iceServers??[],u=r.setLocalDescription??((e,t)=>e.setLocalDescription(t)),d=r.quality??{},f=!1,p=new Map,m=null,h=()=>Object.fromEntries(i.map(e=>[e.name,null])),g=()=>{r.onPeers?.([...s.values()].map(e=>({...e})))},_=async t=>{if(f)return;let n=!1;for(let r of[...o.values()]){if(r.pc.connectionState!==`connected`)continue;let i=p.get(r.peerId);i===void 0&&(i=e.createLinkStatsSampler(t.kind===void 0?{}:{kind:t.kind}),p.set(r.peerId,i));let a=await i.sample(r.pc),o=s.get(r.peerId);o!==void 0&&(o.stats=a,n=!0,t.onStats?.(r.peerId,a))}n&&g()},v=()=>{let e=r.stats;if(e===void 0)return;let t=!f&&o.size>0;if(t&&m===null){m=setInterval(()=>void _(e),e.intervalMs??2e3);return}!t&&m!==null&&(clearInterval(m),m=null)},y=()=>{if(f)return;let e=[...o.values()].map(e=>e.pc.connectionState);e.some(e=>e===`connected`)?r.onState?.(`connected`):e.length>0?r.onState?.(`connecting`,`negotiating`):r.onState?.(`connected`,`alone`)},b=async e=>{d=e;let n=t.scaleForRoom(e,o.size),r=t.resolveDegradation(e,n);for(let e of o.values())await t.applyQualityToLink(e.transceivers,i,n,r)},x=async e=>{if(!e.makingOffer){e.makingOffer=!0;try{let t=await e.pc.createOffer();if(!t.sdp){r.onNotice?.(`empty_local_offer_sdp`);return}await u(e.pc,t),a({type:`offer`,to:e.peerId,sdp:e.pc.localDescription?.sdp??t.sdp})}catch{r.onNotice?.(`offer_failed`)}finally{e.makingOffer=!1}}},S=e=>e.streams?.[0]||(typeof MediaStream>`u`?null:new MediaStream([e.track])),C=(e,t)=>{let r=n.slotOf(t.transceiver,e,i),a=s.get(e.peerId);!r||!a||(a.streams={...a.streams,[r.name]:S(t)},t.track.onended=()=>{let t=s.get(e.peerId);t&&(t.streams={...t.streams,[r.name]:null},g())},g())},w=e=>{let t=o.get(e);t&&(t.pc.close(),o.delete(e)),s.delete(e),p.delete(e),b(d),v(),g(),y()},T=async(e,{offerer:t})=>{if(o.has(e))return;let r=n.createPeerConnection(l),u={peerId:e,pc:r,transceivers:[],pendingCandidates:[],remoteReady:!1,makingOffer:!1,isOfferer:t};o.set(e,u),s.set(e,{peerId:e,connection:r.connectionState,streams:s.get(e)?.streams??h()}),t&&(u.transceivers=i.map(e=>r.addTransceiver(e.kind,{direction:`sendrecv`})),await n.attachLocalTracks(u,i,c)),r.onicecandidate=t=>{a({type:`ice`,to:e,candidate:t.candidate?.candidate??null,sdpMid:t.candidate?.sdpMid??null,sdpMLineIndex:t.candidate?.sdpMLineIndex??null})},r.ontrack=e=>C(u,e),r.onconnectionstatechange=()=>{let t=s.get(e);if(t&&(t.connection=r.connectionState),r.connectionState===`failed`||r.connectionState===`closed`){w(e);return}g(),y()},r.onnegotiationneeded=()=>{u.isOfferer&&x(u)},v(),g(),y(),t&&await x(u)},E=async(e,t)=>{o.has(e)||await T(e,{offerer:!1});let s=o.get(e);if(!s)return;await s.pc.setRemoteDescription({type:`offer`,sdp:t}),s.remoteReady=!0,await n.drainCandidates(s),s.isOfferer||(n.adoptTransceivers(s),await n.attachLocalTracks(s,i,c));let l=await s.pc.createAnswer();if(!l.sdp){r.onNotice?.(`empty_local_answer_sdp`);return}await u(s.pc,l),a({type:`answer`,to:e,sdp:s.pc.localDescription?.sdp??l.sdp}),await b(d)};return r.onState?.(`connecting`,`signaling`),{addPeer:T,removePeer:w,accept:async e=>{if(e.type===`offer`){await E(e.from,e.sdp);return}let t=o.get(e.from);if(t){if(e.type===`answer`){await t.pc.setRemoteDescription({type:`answer`,sdp:e.sdp}),t.remoteReady=!0,await n.drainCandidates(t),await b(d);return}e.candidate!==null&&await n.acceptCandidate(t,{candidate:e.candidate,sdpMid:e.sdpMid??void 0,sdpMLineIndex:e.sdpMLineIndex??void 0})}},setLocalTrack:async(e,t)=>{c.set(e,t);let n=i.findIndex(t=>t.name===e);if(!(n<0)){for(let e of o.values()){let r=e.transceivers[n];if(r)try{await r.sender.replaceTrack(t)}catch{}}await b(d)}},applyQuality:b,setIceServers:e=>{l=e},stop:()=>{for(let e of o.values())e.pc.close();o.clear(),s.clear(),p.clear(),f=!0,v(),g(),r.onState?.(`closed`)},getConnection:e=>o.get(e)?.pc??null,get peers(){return[...s.values()].map(e=>({...e}))}}}exports.createPeerMesh=r;
2
2
  //# sourceMappingURL=peer-mesh.cjs.map