tempest-react-sdk 0.56.0 → 0.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/lib/icons/generate.test.mjs +4 -4
- package/dist/br/geocode.cjs +1 -1
- package/dist/br/geocode.cjs.map +1 -1
- package/dist/br/geocode.js +1 -1
- package/dist/br/geocode.js.map +1 -1
- package/dist/capture/use-barcode-scanner.cjs +1 -1
- package/dist/capture/use-barcode-scanner.cjs.map +1 -1
- package/dist/capture/use-barcode-scanner.js +1 -1
- package/dist/capture/use-barcode-scanner.js.map +1 -1
- package/dist/components/AIChat/AIChat.cjs +1 -1
- package/dist/components/AIChat/AIChat.cjs.map +1 -1
- package/dist/components/AIChat/AIChat.js +1 -1
- package/dist/components/AIChat/AIChat.js.map +1 -1
- package/dist/components/AIChat/AIChatTurn.cjs +1 -1
- package/dist/components/AIChat/AIChatTurn.cjs.map +1 -1
- package/dist/components/AIChat/AIChatTurn.js +1 -1
- package/dist/components/AIChat/AIChatTurn.js.map +1 -1
- package/dist/components/AppBar/AppBar.module.cjs.map +1 -1
- package/dist/components/AppBar/AppBar.module.js.map +1 -1
- package/dist/components/AppBar/use-sticky-body-warning.cjs +1 -1
- package/dist/components/AppBar/use-sticky-body-warning.cjs.map +1 -1
- package/dist/components/AppBar/use-sticky-body-warning.js +1 -1
- package/dist/components/AppBar/use-sticky-body-warning.js.map +1 -1
- package/dist/components/AudioPlayer/AudioPlayer.cjs +1 -1
- package/dist/components/AudioPlayer/AudioPlayer.cjs.map +1 -1
- package/dist/components/AudioPlayer/AudioPlayer.js +2 -2
- package/dist/components/AudioPlayer/AudioPlayer.js.map +1 -1
- package/dist/components/AudioRecorder/AudioRecorder.cjs +1 -1
- package/dist/components/AudioRecorder/AudioRecorder.cjs.map +1 -1
- package/dist/components/AudioRecorder/AudioRecorder.js +1 -1
- package/dist/components/AudioRecorder/AudioRecorder.js.map +1 -1
- package/dist/components/Chat/Chat.cjs +1 -1
- package/dist/components/Chat/Chat.cjs.map +1 -1
- package/dist/components/Chat/Chat.js +1 -1
- package/dist/components/Chat/Chat.js.map +1 -1
- package/dist/components/FilterBar/FilterBar.cjs +1 -1
- package/dist/components/FilterBar/FilterBar.cjs.map +1 -1
- package/dist/components/FilterBar/FilterBar.js +1 -1
- package/dist/components/FilterBar/FilterBar.js.map +1 -1
- package/dist/components/FilterBar/filter-apply.cjs +1 -1
- package/dist/components/FilterBar/filter-apply.cjs.map +1 -1
- package/dist/components/FilterBar/filter-apply.js +1 -1
- package/dist/components/FilterBar/filter-apply.js.map +1 -1
- package/dist/components/ImageCropper/ImageCropper.cjs +1 -1
- package/dist/components/ImageCropper/ImageCropper.cjs.map +1 -1
- package/dist/components/ImageCropper/ImageCropper.js +1 -1
- package/dist/components/ImageCropper/ImageCropper.js.map +1 -1
- package/dist/components/Scheduler/Scheduler.module.cjs.map +1 -1
- package/dist/components/Scheduler/Scheduler.module.js.map +1 -1
- package/dist/components/VideoPlayer/VideoPlayer.cjs +1 -1
- package/dist/components/VideoPlayer/VideoPlayer.cjs.map +1 -1
- package/dist/components/VideoPlayer/VideoPlayer.js +2 -2
- package/dist/components/VideoPlayer/VideoPlayer.js.map +1 -1
- package/dist/components/VirtualTable/VirtualTable.cjs +1 -1
- package/dist/components/VirtualTable/VirtualTable.cjs.map +1 -1
- package/dist/components/VirtualTable/VirtualTable.js +1 -1
- package/dist/components/VirtualTable/VirtualTable.js.map +1 -1
- package/dist/feature-flags/growthbook-adapter.cjs +1 -1
- package/dist/feature-flags/growthbook-adapter.cjs.map +1 -1
- package/dist/feature-flags/growthbook-adapter.js +1 -1
- package/dist/feature-flags/growthbook-adapter.js.map +1 -1
- package/dist/hooks/use-focus-trap.cjs +1 -1
- package/dist/hooks/use-focus-trap.cjs.map +1 -1
- package/dist/hooks/use-focus-trap.js +1 -1
- package/dist/hooks/use-focus-trap.js.map +1 -1
- package/dist/hooks/use-push-to-talk.cjs +1 -1
- package/dist/hooks/use-push-to-talk.cjs.map +1 -1
- package/dist/hooks/use-push-to-talk.js +2 -2
- package/dist/hooks/use-push-to-talk.js.map +1 -1
- package/dist/http/resumable-upload.cjs +1 -1
- package/dist/http/resumable-upload.cjs.map +1 -1
- package/dist/http/resumable-upload.js +1 -1
- package/dist/http/resumable-upload.js.map +1 -1
- package/dist/icons/generated/aliases.cjs +1 -1
- package/dist/icons/generated/aliases.cjs.map +1 -1
- package/dist/icons/generated/aliases.js +1 -0
- package/dist/icons/generated/aliases.js.map +1 -1
- package/dist/icons/generated/icon-names.cjs +1 -1
- package/dist/icons/generated/icon-names.cjs.map +1 -1
- package/dist/icons/generated/icon-names.js +1 -1
- package/dist/icons/generated/icon-names.js.map +1 -1
- package/dist/icons/generated/loaders.cjs +1 -1
- package/dist/icons/generated/loaders.cjs.map +1 -1
- package/dist/icons/generated/loaders.js +48 -42
- package/dist/icons/generated/loaders.js.map +1 -1
- package/dist/icons/generated/shard-02.cjs +1 -1
- package/dist/icons/generated/shard-02.cjs.map +1 -1
- package/dist/icons/generated/shard-02.js +20 -20
- package/dist/icons/generated/shard-02.js.map +1 -1
- package/dist/icons/generated/shard-03.cjs +1 -1
- package/dist/icons/generated/shard-03.cjs.map +1 -1
- package/dist/icons/generated/shard-03.js +41 -41
- package/dist/icons/generated/shard-03.js.map +1 -1
- package/dist/icons/generated/shard-04.cjs +1 -1
- package/dist/icons/generated/shard-04.cjs.map +1 -1
- package/dist/icons/generated/shard-04.js +41 -41
- package/dist/icons/generated/shard-04.js.map +1 -1
- package/dist/icons/generated/shard-05.cjs +1 -1
- package/dist/icons/generated/shard-05.cjs.map +1 -1
- package/dist/icons/generated/shard-05.js +41 -41
- package/dist/icons/generated/shard-05.js.map +1 -1
- package/dist/icons/generated/shard-06.cjs +1 -1
- package/dist/icons/generated/shard-06.cjs.map +1 -1
- package/dist/icons/generated/shard-06.js +41 -41
- package/dist/icons/generated/shard-06.js.map +1 -1
- package/dist/icons/generated/shard-07.cjs +1 -1
- package/dist/icons/generated/shard-07.cjs.map +1 -1
- package/dist/icons/generated/shard-07.js +41 -41
- package/dist/icons/generated/shard-07.js.map +1 -1
- package/dist/icons/generated/shard-08.cjs +1 -1
- package/dist/icons/generated/shard-08.cjs.map +1 -1
- package/dist/icons/generated/shard-08.js +41 -41
- package/dist/icons/generated/shard-08.js.map +1 -1
- package/dist/icons/generated/shard-09.cjs +1 -1
- package/dist/icons/generated/shard-09.cjs.map +1 -1
- package/dist/icons/generated/shard-09.js +41 -41
- package/dist/icons/generated/shard-09.js.map +1 -1
- package/dist/icons/generated/shard-10.cjs +1 -1
- package/dist/icons/generated/shard-10.cjs.map +1 -1
- package/dist/icons/generated/shard-10.js +41 -41
- package/dist/icons/generated/shard-10.js.map +1 -1
- package/dist/icons/generated/shard-11.cjs +1 -1
- package/dist/icons/generated/shard-11.cjs.map +1 -1
- package/dist/icons/generated/shard-11.js +41 -41
- package/dist/icons/generated/shard-11.js.map +1 -1
- package/dist/icons/generated/shard-12.cjs +1 -1
- package/dist/icons/generated/shard-12.cjs.map +1 -1
- package/dist/icons/generated/shard-12.js +41 -41
- package/dist/icons/generated/shard-12.js.map +1 -1
- package/dist/icons/generated/shard-13.cjs +1 -1
- package/dist/icons/generated/shard-13.cjs.map +1 -1
- package/dist/icons/generated/shard-13.js +41 -41
- package/dist/icons/generated/shard-13.js.map +1 -1
- package/dist/icons/generated/shard-14.cjs +1 -1
- package/dist/icons/generated/shard-14.cjs.map +1 -1
- package/dist/icons/generated/shard-14.js +41 -41
- package/dist/icons/generated/shard-14.js.map +1 -1
- package/dist/icons/generated/shard-15.cjs +1 -1
- package/dist/icons/generated/shard-15.cjs.map +1 -1
- package/dist/icons/generated/shard-15.js +41 -41
- package/dist/icons/generated/shard-15.js.map +1 -1
- package/dist/icons/generated/shard-16.cjs +1 -1
- package/dist/icons/generated/shard-16.cjs.map +1 -1
- package/dist/icons/generated/shard-16.js +41 -41
- package/dist/icons/generated/shard-16.js.map +1 -1
- package/dist/icons/generated/shard-17.cjs +1 -1
- package/dist/icons/generated/shard-17.cjs.map +1 -1
- package/dist/icons/generated/shard-17.js +41 -41
- package/dist/icons/generated/shard-17.js.map +1 -1
- package/dist/icons/generated/shard-18.cjs +1 -1
- package/dist/icons/generated/shard-18.cjs.map +1 -1
- package/dist/icons/generated/shard-18.js +41 -41
- package/dist/icons/generated/shard-18.js.map +1 -1
- package/dist/icons/generated/shard-19.cjs +1 -1
- package/dist/icons/generated/shard-19.cjs.map +1 -1
- package/dist/icons/generated/shard-19.js +41 -41
- package/dist/icons/generated/shard-19.js.map +1 -1
- package/dist/icons/generated/shard-20.cjs +1 -1
- package/dist/icons/generated/shard-20.cjs.map +1 -1
- package/dist/icons/generated/shard-20.js +41 -41
- package/dist/icons/generated/shard-20.js.map +1 -1
- package/dist/icons/generated/shard-21.cjs +1 -1
- package/dist/icons/generated/shard-21.cjs.map +1 -1
- package/dist/icons/generated/shard-21.js +41 -41
- package/dist/icons/generated/shard-21.js.map +1 -1
- package/dist/icons/generated/shard-22.cjs +1 -1
- package/dist/icons/generated/shard-22.cjs.map +1 -1
- package/dist/icons/generated/shard-22.js +41 -41
- package/dist/icons/generated/shard-22.js.map +1 -1
- package/dist/icons/generated/shard-23.cjs +1 -1
- package/dist/icons/generated/shard-23.cjs.map +1 -1
- package/dist/icons/generated/shard-23.js +41 -41
- package/dist/icons/generated/shard-23.js.map +1 -1
- package/dist/icons/generated/shard-24.cjs +1 -1
- package/dist/icons/generated/shard-24.cjs.map +1 -1
- package/dist/icons/generated/shard-24.js +41 -41
- package/dist/icons/generated/shard-24.js.map +1 -1
- package/dist/icons/generated/shard-25.cjs +1 -1
- package/dist/icons/generated/shard-25.cjs.map +1 -1
- package/dist/icons/generated/shard-25.js +41 -41
- package/dist/icons/generated/shard-25.js.map +1 -1
- package/dist/icons/generated/shard-26.cjs +1 -1
- package/dist/icons/generated/shard-26.cjs.map +1 -1
- package/dist/icons/generated/shard-26.js +41 -41
- package/dist/icons/generated/shard-26.js.map +1 -1
- package/dist/icons/generated/shard-27.cjs +1 -1
- package/dist/icons/generated/shard-27.cjs.map +1 -1
- package/dist/icons/generated/shard-27.js +41 -41
- package/dist/icons/generated/shard-27.js.map +1 -1
- package/dist/icons/generated/shard-28.cjs +1 -1
- package/dist/icons/generated/shard-28.cjs.map +1 -1
- package/dist/icons/generated/shard-28.js +41 -41
- package/dist/icons/generated/shard-28.js.map +1 -1
- package/dist/icons/generated/shard-29.cjs +1 -1
- package/dist/icons/generated/shard-29.cjs.map +1 -1
- package/dist/icons/generated/shard-29.js +41 -41
- package/dist/icons/generated/shard-29.js.map +1 -1
- package/dist/icons/generated/shard-30.cjs +1 -1
- package/dist/icons/generated/shard-30.cjs.map +1 -1
- package/dist/icons/generated/shard-30.js +41 -41
- package/dist/icons/generated/shard-30.js.map +1 -1
- package/dist/icons/generated/shard-31.cjs +1 -1
- package/dist/icons/generated/shard-31.cjs.map +1 -1
- package/dist/icons/generated/shard-31.js +41 -41
- package/dist/icons/generated/shard-31.js.map +1 -1
- package/dist/icons/generated/shard-32.cjs +1 -1
- package/dist/icons/generated/shard-32.cjs.map +1 -1
- package/dist/icons/generated/shard-32.js +41 -41
- package/dist/icons/generated/shard-32.js.map +1 -1
- package/dist/icons/generated/shard-33.cjs +1 -1
- package/dist/icons/generated/shard-33.cjs.map +1 -1
- package/dist/icons/generated/shard-33.js +41 -41
- package/dist/icons/generated/shard-33.js.map +1 -1
- package/dist/icons/generated/shard-34.cjs +1 -1
- package/dist/icons/generated/shard-34.cjs.map +1 -1
- package/dist/icons/generated/shard-34.js +41 -41
- package/dist/icons/generated/shard-34.js.map +1 -1
- package/dist/icons/generated/shard-35.cjs +1 -1
- package/dist/icons/generated/shard-35.cjs.map +1 -1
- package/dist/icons/generated/shard-35.js +41 -41
- package/dist/icons/generated/shard-35.js.map +1 -1
- package/dist/icons/generated/shard-36.cjs +1 -1
- package/dist/icons/generated/shard-36.cjs.map +1 -1
- package/dist/icons/generated/shard-36.js +41 -41
- package/dist/icons/generated/shard-36.js.map +1 -1
- package/dist/icons/generated/shard-37.cjs +1 -1
- package/dist/icons/generated/shard-37.cjs.map +1 -1
- package/dist/icons/generated/shard-37.js +41 -41
- package/dist/icons/generated/shard-37.js.map +1 -1
- package/dist/icons/generated/shard-38.cjs +1 -1
- package/dist/icons/generated/shard-38.cjs.map +1 -1
- package/dist/icons/generated/shard-38.js +41 -41
- package/dist/icons/generated/shard-38.js.map +1 -1
- package/dist/icons/generated/shard-39.cjs +1 -1
- package/dist/icons/generated/shard-39.cjs.map +1 -1
- package/dist/icons/generated/shard-39.js +41 -41
- package/dist/icons/generated/shard-39.js.map +1 -1
- package/dist/icons/generated/shard-40.cjs +1 -1
- package/dist/icons/generated/shard-40.cjs.map +1 -1
- package/dist/icons/generated/shard-40.js +41 -41
- package/dist/icons/generated/shard-40.js.map +1 -1
- package/dist/icons/generated/shard-41.cjs +1 -1
- package/dist/icons/generated/shard-41.cjs.map +1 -1
- package/dist/icons/generated/shard-41.js +41 -41
- package/dist/icons/generated/shard-41.js.map +1 -1
- package/dist/icons/generated/shard-42.cjs +1 -1
- package/dist/icons/generated/shard-42.cjs.map +1 -1
- package/dist/icons/generated/shard-42.js +41 -41
- package/dist/icons/generated/shard-42.js.map +1 -1
- package/dist/icons/generated/shard-43.cjs +1 -1
- package/dist/icons/generated/shard-43.cjs.map +1 -1
- package/dist/icons/generated/shard-43.js +41 -41
- package/dist/icons/generated/shard-43.js.map +1 -1
- package/dist/icons/generated/shard-44.cjs +1 -1
- package/dist/icons/generated/shard-44.cjs.map +1 -1
- package/dist/icons/generated/shard-44.js +43 -10
- package/dist/icons/generated/shard-44.js.map +1 -1
- package/dist/icons/generated/shard-45.cjs +2 -0
- package/dist/icons/generated/shard-45.cjs.map +1 -0
- package/dist/icons/generated/shard-45.js +15 -0
- package/dist/icons/generated/shard-45.js.map +1 -0
- package/dist/icons/material-symbols.cjs +1 -1
- package/dist/icons/material-symbols.cjs.map +1 -1
- package/dist/icons/material-symbols.js +3 -3
- package/dist/icons/material-symbols.js.map +1 -1
- package/dist/icons.d.ts +4 -4
- package/dist/query/foreign-client-warning.cjs +1 -1
- package/dist/query/foreign-client-warning.cjs.map +1 -1
- package/dist/query/foreign-client-warning.js +1 -1
- package/dist/query/foreign-client-warning.js.map +1 -1
- package/dist/styles/AppBar.css +1 -1
- package/dist/styles/Scheduler.css +2 -2
- package/dist/styles/core.css +1 -1
- package/dist/styles/layout.css +2 -2
- package/dist/styles/navigation.css +1 -1
- package/dist/styles.css +1 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +175 -12
- package/dist/tempest-react-sdk.js +33 -33
- package/dist/utils/json-frame.cjs +1 -1
- package/dist/utils/json-frame.cjs.map +1 -1
- package/dist/utils/json-frame.js +1 -1
- package/dist/utils/json-frame.js.map +1 -1
- package/dist/vision/core/providers.cjs +1 -1
- package/dist/vision/core/providers.cjs.map +1 -1
- package/dist/vision/core/providers.js +22 -3
- package/dist/vision/core/providers.js.map +1 -1
- package/dist/vision/core/session.cjs +1 -1
- package/dist/vision/core/session.cjs.map +1 -1
- package/dist/vision/core/session.js +22 -15
- package/dist/vision/core/session.js.map +1 -1
- package/dist/vision/fusion.cjs.map +1 -1
- package/dist/vision/fusion.js.map +1 -1
- package/dist/vision/index.cjs +1 -1
- package/dist/vision/index.cjs.map +1 -1
- package/dist/vision/index.js +17 -16
- package/dist/vision/index.js.map +1 -1
- package/dist/vision/normalization.cjs +2 -0
- package/dist/vision/normalization.cjs.map +1 -0
- package/dist/vision/normalization.js +49 -0
- package/dist/vision/normalization.js.map +1 -0
- package/dist/vision/results.cjs +1 -1
- package/dist/vision/results.cjs.map +1 -1
- package/dist/vision/results.js +8 -2
- package/dist/vision/results.js.map +1 -1
- package/dist/vision/tasks/classifier.cjs +1 -1
- package/dist/vision/tasks/classifier.cjs.map +1 -1
- package/dist/vision/tasks/classifier.js +36 -32
- package/dist/vision/tasks/classifier.js.map +1 -1
- package/dist/vision.cjs +1 -1
- package/dist/vision.d.ts +218 -13
- package/dist/vision.js +19 -18
- package/dist/vite/tempest-pwa-manifest.cjs +1 -1
- package/dist/vite/tempest-pwa-manifest.cjs.map +1 -1
- package/dist/vite/tempest-pwa-manifest.js +1 -1
- package/dist/vite/tempest-pwa-manifest.js.map +1 -1
- package/dist/webrtc/link-stats.cjs +1 -1
- package/dist/webrtc/link-stats.cjs.map +1 -1
- package/dist/webrtc/link-stats.js +134 -44
- package/dist/webrtc/link-stats.js.map +1 -1
- package/dist/webrtc/mesh-quality.cjs +1 -1
- package/dist/webrtc/mesh-quality.cjs.map +1 -1
- package/dist/webrtc/mesh-quality.js +8 -2
- package/dist/webrtc/mesh-quality.js.map +1 -1
- package/dist/webrtc/peer-link.cjs +1 -1
- package/dist/webrtc/peer-link.cjs.map +1 -1
- package/dist/webrtc/peer-link.js +1 -1
- package/dist/webrtc/peer-link.js.map +1 -1
- package/dist/webrtc/peer-mesh.cjs +1 -1
- package/dist/webrtc/peer-mesh.cjs.map +1 -1
- package/dist/webrtc/peer-mesh.js +86 -64
- package/dist/webrtc/peer-mesh.js.map +1 -1
- package/dist/ws/create-web-socket.cjs +1 -1
- package/dist/ws/create-web-socket.cjs.map +1 -1
- package/dist/ws/create-web-socket.js +1 -1
- package/dist/ws/create-web-socket.js.map +1 -1
- 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
|
|
16
|
-
|
|
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
|
|
19
|
-
let
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
n.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
|
33
|
-
let
|
|
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
|
|
41
|
-
let
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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:
|
|
62
|
-
sample: async (e) =>
|
|
151
|
+
read: o,
|
|
152
|
+
sample: async (e) => o(await e.getStats()),
|
|
63
153
|
reset: () => {
|
|
64
|
-
|
|
65
|
-
...
|
|
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 {
|
|
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=
|
|
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
|
|
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 =
|
|
22
|
-
|
|
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
|
|
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
|
-
function e(e){return new RTCPeerConnection({iceServers:e,bundlePolicy:`max-bundle`,rtcpMuxPolicy:`require`})}function t(e,t,n){let r=Number(e.mid);if(e.mid!==null&&Number.isInteger(r)&&n[r])return n[r];let i=t.transceivers.indexOf(e);return i>=0?n[i]??null:null}async function n(e){let t=e.pendingCandidates.splice(0,e.pendingCandidates.length);for(let n of t)try{await e.pc.addIceCandidate(n)}catch{}}async function r(e,t){if(!e.remoteReady){e.pendingCandidates.push(t);return}try{await e.pc.addIceCandidate(t)}catch{}}async function i(e,t,n){for(let[r,i]of t.entries()){let t=e.transceivers[r],a=n.get(i.name)??null;if(
|
|
1
|
+
function e(e){return new RTCPeerConnection({iceServers:e,bundlePolicy:`max-bundle`,rtcpMuxPolicy:`require`})}function t(e,t,n){let r=Number(e.mid);if(e.mid!==null&&Number.isInteger(r)&&n[r])return n[r];let i=t.transceivers.indexOf(e);return i>=0?n[i]??null:null}async function n(e){let t=e.pendingCandidates.splice(0,e.pendingCandidates.length);for(let n of t)try{await e.pc.addIceCandidate(n)}catch{}}async function r(e,t){if(!e.remoteReady){e.pendingCandidates.push(t);return}try{await e.pc.addIceCandidate(t)}catch{}}async function i(e,t,n){for(let[r,i]of t.entries()){let t=e.transceivers[r],a=n.get(i.name)??null;if(t&&a)try{await t.sender.replaceTrack(a)}catch{}}}function a(e){e.transceivers=e.pc.getTransceivers().filter(e=>e.mid!==null).sort((e,t)=>Number(e.mid)-Number(t.mid));for(let t of e.transceivers)t.direction!==`sendrecv`&&(t.direction=`sendrecv`)}exports.acceptCandidate=r,exports.adoptTransceivers=a,exports.attachLocalTracks=i,exports.createPeerConnection=e,exports.drainCandidates=n,exports.slotOf=t;
|
|
2
2
|
//# sourceMappingURL=peer-link.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"peer-link.cjs","names":[],"sources":["../../src/webrtc/peer-link.ts"],"sourcesContent":["import type { MeshSlot } from \"./mesh-types\";\n\n/** One remote peer's connection and everything the mesh tracks about it. */\nexport interface PeerLink {\n peerId: string;\n pc: RTCPeerConnection;\n /**\n * Transceivers in slot order.\n *\n * Held explicitly rather than re-read from `getTransceivers()` so slot\n * lookup stays O(1) and cannot drift if a browser reorders.\n */\n transceivers: RTCRtpTransceiver[];\n /** Candidates that arrived before the remote description they belong to. */\n pendingCandidates: RTCIceCandidateInit[];\n /** Whether `setRemoteDescription` has landed, which gates the queue. */\n remoteReady: boolean;\n /** Whether an offer is in flight, so a re-entrant call does not stack one. */\n makingOffer: boolean;\n /** Whether this side offers. Exactly one side of a link does. */\n isOfferer: boolean;\n}\n\n/** Open a connection with the policies a mesh needs. */\nexport function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {\n return new RTCPeerConnection({\n iceServers,\n bundlePolicy: \"max-bundle\",\n rtcpMuxPolicy: \"require\",\n });\n}\n\n/**\n * Resolve which slot a transceiver carries.\n *\n * The offerer allocates the m-lines in slot order, so a transceiver's `mid` is\n * its ordinal and both ends read the same one. That makes `mid` the slot's wire\n * identity, and the **only** identifier correct on both sides of a link.\n *\n * Position in the link's own array is a fallback for a `mid` that is not a plain\n * ordinal, and never the primary: a browser answering an offer appends the\n * negotiated transceivers *after* any it already held, so positional lookup\n * silently resolves to the wrong slot — the camera arriving in the screen tile —\n * or to none at all, dropping the track.\n *\n * @param transceiver - The transceiver a track arrived on.\n * @param link - The link it belongs to.\n * @param slots - The mesh's slot list.\n * @returns The slot, or `null` when neither route names one.\n */\nexport function slotOf(\n transceiver: RTCRtpTransceiver,\n link: PeerLink,\n slots: readonly MeshSlot[],\n): MeshSlot | null {\n const ordinal = Number(transceiver.mid);\n if (transceiver.mid !== null && Number.isInteger(ordinal) && slots[ordinal]) {\n return slots[ordinal];\n }\n const index = link.transceivers.indexOf(transceiver);\n return index >= 0 ? (slots[index] ?? null) : null;\n}\n\n/**\n * Feed a link every candidate that arrived before its remote description.\n *\n * `addIceCandidate` throws while `remoteDescription` is `null`, and candidates\n * routinely beat the offer or answer they belong to. Without the queue the\n * connection loses them and sits in `checking` until it times out — a failure\n * with no error anywhere.\n *\n * @param link - The link whose queue is drained.\n */\nexport async function drainCandidates(link: PeerLink): Promise<void> {\n const queued = link.pendingCandidates.splice(0, link.pendingCandidates.length);\n for (const candidate of queued) {\n try {\n await link.pc.addIceCandidate(candidate);\n } catch {\n /* a candidate the browser rejects is not fatal to the connection */\n }\n }\n}\n\n/**\n * Hand a candidate to the connection, or park it until the description lands.\n *\n * @param link - The link the candidate belongs to.\n * @param init - The candidate, already in `RTCIceCandidateInit` shape.\n */\nexport async function acceptCandidate(link: PeerLink, init: RTCIceCandidateInit): Promise<void> {\n if (!link.remoteReady) {\n link.pendingCandidates.push(init);\n return;\n }\n try {\n await link.pc.addIceCandidate(init);\n } catch {\n /* a candidate the browser rejects is not fatal to the connection */\n }\n}\n\n/**\n * Publish every held local track onto a link's slots.\n *\n * Called once the transceivers exist — up front for the offerer, after the offer\n * lands for the answerer — so a microphone that was already live when the peer\n * joined starts flowing without waiting for a toggle.\n *\n * @param link - The link to publish onto.\n * @param slots - The mesh's slot list.\n * @param tracks - The current local track per slot name.\n */\nexport async function attachLocalTracks(\n link: PeerLink,\n slots: readonly MeshSlot[],\n tracks: Map<string, MediaStreamTrack | null>,\n): Promise<void> {\n for (const [index, slot] of slots.entries()) {\n const transceiver = link.transceivers[index];\n const track = tracks.get(slot.name) ?? null;\n if (!transceiver || !track) continue;\n try {\n await transceiver.sender.replaceTrack(track);\n } catch {\n /* the browser refused the track; the slot simply stays silent */\n }\n }\n}\n\n/**\n * Adopt the transceivers a remote offer created, in `mid` order.\n *\n * The answering side allocates **nothing** up front, and that is the whole\n * subtlety: applying a remote offer creates one transceiver per m-line, in\n * m-line order, and browsers do not reuse transceivers the answerer made\n * beforehand. Pre-allocating leaves dead, never-negotiated transceivers sitting\n * in front of the live ones, which is how slot lookup ends up pointing at the\n * wrong media or at nothing.\n *\n * Each adopted transceiver is switched to `sendrecv` so this side may publish on\n * it too; without that a peer that answered could hear but never be heard.\n *\n * @param link - The link that just applied a remote offer.\n */\nexport function adoptTransceivers(link: PeerLink): void {\n link.transceivers = link.pc\n .getTransceivers()\n .filter((transceiver) => transceiver.mid !== null)\n .sort((a, b) => Number(a.mid) - Number(b.mid));\n for (const transceiver of link.transceivers) {\n if (transceiver.direction !== \"sendrecv\") transceiver.direction = \"sendrecv\";\n }\n}\n"],"mappings":"AAwBA,SAAgB,EAAqB,EAA+C,CAChF,OAAO,IAAI,kBAAkB,CACzB,aACA,aAAc,aACd,cAAe,SACnB,CAAC,CACL,CAoBA,SAAgB,EACZ,EACA,EACA,EACe,CACf,IAAM,EAAU,OAAO,EAAY,GAAG,EACtC,GAAI,EAAY,MAAQ,MAAQ,OAAO,UAAU,CAAO,GAAK,EAAM,GAC/D,OAAO,EAAM,GAEjB,IAAM,EAAQ,EAAK,aAAa,QAAQ,CAAW,EACnD,OAAO,GAAS,EAAK,EAAM,IAAU,KAAQ,IACjD,CAYA,eAAsB,EAAgB,EAA+B,CACjE,IAAM,EAAS,EAAK,kBAAkB,OAAO,EAAG,EAAK,kBAAkB,MAAM,EAC7E,IAAK,IAAM,KAAa,EACpB,GAAI,CACA,MAAM,EAAK,GAAG,gBAAgB,CAAS,CAC3C,MAAQ,CAER,CAER,CAQA,eAAsB,EAAgB,EAAgB,EAA0C,CAC5F,GAAI,CAAC,EAAK,YAAa,CACnB,EAAK,kBAAkB,KAAK,CAAI,EAChC,MACJ,CACA,GAAI,CACA,MAAM,EAAK,GAAG,gBAAgB,CAAI,CACtC,MAAQ,CAER,CACJ,CAaA,eAAsB,EAClB,EACA,EACA,EACa,CACb,IAAK,GAAM,CAAC,EAAO,KAAS,EAAM,QAAQ,EAAG,CACzC,IAAM,EAAc,EAAK,aAAa,GAChC,EAAQ,EAAO,IAAI,EAAK,IAAI,GAAK,KACnC,
|
|
1
|
+
{"version":3,"file":"peer-link.cjs","names":[],"sources":["../../src/webrtc/peer-link.ts"],"sourcesContent":["import type { MeshSlot } from \"./mesh-types\";\n\n/** One remote peer's connection and everything the mesh tracks about it. */\nexport interface PeerLink {\n peerId: string;\n pc: RTCPeerConnection;\n /**\n * Transceivers in slot order.\n *\n * Held explicitly rather than re-read from `getTransceivers()` so slot\n * lookup stays O(1) and cannot drift if a browser reorders.\n */\n transceivers: RTCRtpTransceiver[];\n /** Candidates that arrived before the remote description they belong to. */\n pendingCandidates: RTCIceCandidateInit[];\n /** Whether `setRemoteDescription` has landed, which gates the queue. */\n remoteReady: boolean;\n /** Whether an offer is in flight, so a re-entrant call does not stack one. */\n makingOffer: boolean;\n /** Whether this side offers. Exactly one side of a link does. */\n isOfferer: boolean;\n}\n\n/** Open a connection with the policies a mesh needs. */\nexport function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {\n return new RTCPeerConnection({\n iceServers,\n bundlePolicy: \"max-bundle\",\n rtcpMuxPolicy: \"require\",\n });\n}\n\n/**\n * Resolve which slot a transceiver carries.\n *\n * The offerer allocates the m-lines in slot order, so a transceiver's `mid` is\n * its ordinal and both ends read the same one. That makes `mid` the slot's wire\n * identity, and the **only** identifier correct on both sides of a link.\n *\n * Position in the link's own array is a fallback for a `mid` that is not a plain\n * ordinal, and never the primary: a browser answering an offer appends the\n * negotiated transceivers *after* any it already held, so positional lookup\n * silently resolves to the wrong slot — the camera arriving in the screen tile —\n * or to none at all, dropping the track.\n *\n * @param transceiver - The transceiver a track arrived on.\n * @param link - The link it belongs to.\n * @param slots - The mesh's slot list.\n * @returns The slot, or `null` when neither route names one.\n */\nexport function slotOf(\n transceiver: RTCRtpTransceiver,\n link: PeerLink,\n slots: readonly MeshSlot[],\n): MeshSlot | null {\n const ordinal = Number(transceiver.mid);\n if (transceiver.mid !== null && Number.isInteger(ordinal) && slots[ordinal]) {\n return slots[ordinal];\n }\n const index = link.transceivers.indexOf(transceiver);\n return index >= 0 ? (slots[index] ?? null) : null;\n}\n\n/**\n * Feed a link every candidate that arrived before its remote description.\n *\n * `addIceCandidate` throws while `remoteDescription` is `null`, and candidates\n * routinely beat the offer or answer they belong to. Without the queue the\n * connection loses them and sits in `checking` until it times out — a failure\n * with no error anywhere.\n *\n * @param link - The link whose queue is drained.\n */\nexport async function drainCandidates(link: PeerLink): Promise<void> {\n const queued = link.pendingCandidates.splice(0, link.pendingCandidates.length);\n for (const candidate of queued) {\n try {\n await link.pc.addIceCandidate(candidate);\n } catch {\n /* a candidate the browser rejects is not fatal to the connection */\n }\n }\n}\n\n/**\n * Hand a candidate to the connection, or park it until the description lands.\n *\n * @param link - The link the candidate belongs to.\n * @param init - The candidate, already in `RTCIceCandidateInit` shape.\n */\nexport async function acceptCandidate(link: PeerLink, init: RTCIceCandidateInit): Promise<void> {\n if (!link.remoteReady) {\n link.pendingCandidates.push(init);\n return;\n }\n try {\n await link.pc.addIceCandidate(init);\n } catch {\n /* a candidate the browser rejects is not fatal to the connection */\n }\n}\n\n/**\n * Publish every held local track onto a link's slots.\n *\n * Called once the transceivers exist — up front for the offerer, after the offer\n * lands for the answerer — so a microphone that was already live when the peer\n * joined starts flowing without waiting for a toggle.\n *\n * @param link - The link to publish onto.\n * @param slots - The mesh's slot list.\n * @param tracks - The current local track per slot name.\n */\nexport async function attachLocalTracks(\n link: PeerLink,\n slots: readonly MeshSlot[],\n tracks: Map<string, MediaStreamTrack | null>,\n): Promise<void> {\n for (const [index, slot] of slots.entries()) {\n const transceiver = link.transceivers[index];\n const track = tracks.get(slot.name) ?? null;\n if (!transceiver || !track) continue;\n try {\n await transceiver.sender.replaceTrack(track);\n } catch {\n /* the browser refused the track; the slot simply stays silent */\n }\n }\n}\n\n/**\n * Adopt the transceivers a remote offer created, in `mid` order.\n *\n * The answering side allocates **nothing** up front, and that is the whole\n * subtlety: applying a remote offer creates one transceiver per m-line, in\n * m-line order, and browsers do not reuse transceivers the answerer made\n * beforehand. Pre-allocating leaves dead, never-negotiated transceivers sitting\n * in front of the live ones, which is how slot lookup ends up pointing at the\n * wrong media or at nothing.\n *\n * Each adopted transceiver is switched to `sendrecv` so this side may publish on\n * it too; without that a peer that answered could hear but never be heard.\n *\n * @param link - The link that just applied a remote offer.\n */\nexport function adoptTransceivers(link: PeerLink): void {\n link.transceivers = link.pc\n .getTransceivers()\n .filter((transceiver) => transceiver.mid !== null)\n .sort((a, b) => Number(a.mid) - Number(b.mid));\n for (const transceiver of link.transceivers) {\n if (transceiver.direction !== \"sendrecv\") transceiver.direction = \"sendrecv\";\n }\n}\n"],"mappings":"AAwBA,SAAgB,EAAqB,EAA+C,CAChF,OAAO,IAAI,kBAAkB,CACzB,aACA,aAAc,aACd,cAAe,SACnB,CAAC,CACL,CAoBA,SAAgB,EACZ,EACA,EACA,EACe,CACf,IAAM,EAAU,OAAO,EAAY,GAAG,EACtC,GAAI,EAAY,MAAQ,MAAQ,OAAO,UAAU,CAAO,GAAK,EAAM,GAC/D,OAAO,EAAM,GAEjB,IAAM,EAAQ,EAAK,aAAa,QAAQ,CAAW,EACnD,OAAO,GAAS,EAAK,EAAM,IAAU,KAAQ,IACjD,CAYA,eAAsB,EAAgB,EAA+B,CACjE,IAAM,EAAS,EAAK,kBAAkB,OAAO,EAAG,EAAK,kBAAkB,MAAM,EAC7E,IAAK,IAAM,KAAa,EACpB,GAAI,CACA,MAAM,EAAK,GAAG,gBAAgB,CAAS,CAC3C,MAAQ,CAER,CAER,CAQA,eAAsB,EAAgB,EAAgB,EAA0C,CAC5F,GAAI,CAAC,EAAK,YAAa,CACnB,EAAK,kBAAkB,KAAK,CAAI,EAChC,MACJ,CACA,GAAI,CACA,MAAM,EAAK,GAAG,gBAAgB,CAAI,CACtC,MAAQ,CAER,CACJ,CAaA,eAAsB,EAClB,EACA,EACA,EACa,CACb,IAAK,GAAM,CAAC,EAAO,KAAS,EAAM,QAAQ,EAAG,CACzC,IAAM,EAAc,EAAK,aAAa,GAChC,EAAQ,EAAO,IAAI,EAAK,IAAI,GAAK,KACnC,GAAC,GAAgB,EACrB,GAAI,CACA,MAAM,EAAY,OAAO,aAAa,CAAK,CAC/C,MAAQ,CAER,CACJ,CACJ,CAiBA,SAAgB,EAAkB,EAAsB,CACpD,EAAK,aAAe,EAAK,GACpB,gBAAgB,CAAC,CACjB,OAAQ,GAAgB,EAAY,MAAQ,IAAI,CAAC,CACjD,MAAM,EAAG,IAAM,OAAO,EAAE,GAAG,EAAI,OAAO,EAAE,GAAG,CAAC,EACjD,IAAK,IAAM,KAAe,EAAK,aACvB,EAAY,YAAc,aAAY,EAAY,UAAY,WAE1E"}
|
package/dist/webrtc/peer-link.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"peer-link.js","names":[],"sources":["../../src/webrtc/peer-link.ts"],"sourcesContent":["import type { MeshSlot } from \"./mesh-types\";\n\n/** One remote peer's connection and everything the mesh tracks about it. */\nexport interface PeerLink {\n peerId: string;\n pc: RTCPeerConnection;\n /**\n * Transceivers in slot order.\n *\n * Held explicitly rather than re-read from `getTransceivers()` so slot\n * lookup stays O(1) and cannot drift if a browser reorders.\n */\n transceivers: RTCRtpTransceiver[];\n /** Candidates that arrived before the remote description they belong to. */\n pendingCandidates: RTCIceCandidateInit[];\n /** Whether `setRemoteDescription` has landed, which gates the queue. */\n remoteReady: boolean;\n /** Whether an offer is in flight, so a re-entrant call does not stack one. */\n makingOffer: boolean;\n /** Whether this side offers. Exactly one side of a link does. */\n isOfferer: boolean;\n}\n\n/** Open a connection with the policies a mesh needs. */\nexport function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {\n return new RTCPeerConnection({\n iceServers,\n bundlePolicy: \"max-bundle\",\n rtcpMuxPolicy: \"require\",\n });\n}\n\n/**\n * Resolve which slot a transceiver carries.\n *\n * The offerer allocates the m-lines in slot order, so a transceiver's `mid` is\n * its ordinal and both ends read the same one. That makes `mid` the slot's wire\n * identity, and the **only** identifier correct on both sides of a link.\n *\n * Position in the link's own array is a fallback for a `mid` that is not a plain\n * ordinal, and never the primary: a browser answering an offer appends the\n * negotiated transceivers *after* any it already held, so positional lookup\n * silently resolves to the wrong slot — the camera arriving in the screen tile —\n * or to none at all, dropping the track.\n *\n * @param transceiver - The transceiver a track arrived on.\n * @param link - The link it belongs to.\n * @param slots - The mesh's slot list.\n * @returns The slot, or `null` when neither route names one.\n */\nexport function slotOf(\n transceiver: RTCRtpTransceiver,\n link: PeerLink,\n slots: readonly MeshSlot[],\n): MeshSlot | null {\n const ordinal = Number(transceiver.mid);\n if (transceiver.mid !== null && Number.isInteger(ordinal) && slots[ordinal]) {\n return slots[ordinal];\n }\n const index = link.transceivers.indexOf(transceiver);\n return index >= 0 ? (slots[index] ?? null) : null;\n}\n\n/**\n * Feed a link every candidate that arrived before its remote description.\n *\n * `addIceCandidate` throws while `remoteDescription` is `null`, and candidates\n * routinely beat the offer or answer they belong to. Without the queue the\n * connection loses them and sits in `checking` until it times out — a failure\n * with no error anywhere.\n *\n * @param link - The link whose queue is drained.\n */\nexport async function drainCandidates(link: PeerLink): Promise<void> {\n const queued = link.pendingCandidates.splice(0, link.pendingCandidates.length);\n for (const candidate of queued) {\n try {\n await link.pc.addIceCandidate(candidate);\n } catch {\n /* a candidate the browser rejects is not fatal to the connection */\n }\n }\n}\n\n/**\n * Hand a candidate to the connection, or park it until the description lands.\n *\n * @param link - The link the candidate belongs to.\n * @param init - The candidate, already in `RTCIceCandidateInit` shape.\n */\nexport async function acceptCandidate(link: PeerLink, init: RTCIceCandidateInit): Promise<void> {\n if (!link.remoteReady) {\n link.pendingCandidates.push(init);\n return;\n }\n try {\n await link.pc.addIceCandidate(init);\n } catch {\n /* a candidate the browser rejects is not fatal to the connection */\n }\n}\n\n/**\n * Publish every held local track onto a link's slots.\n *\n * Called once the transceivers exist — up front for the offerer, after the offer\n * lands for the answerer — so a microphone that was already live when the peer\n * joined starts flowing without waiting for a toggle.\n *\n * @param link - The link to publish onto.\n * @param slots - The mesh's slot list.\n * @param tracks - The current local track per slot name.\n */\nexport async function attachLocalTracks(\n link: PeerLink,\n slots: readonly MeshSlot[],\n tracks: Map<string, MediaStreamTrack | null>,\n): Promise<void> {\n for (const [index, slot] of slots.entries()) {\n const transceiver = link.transceivers[index];\n const track = tracks.get(slot.name) ?? null;\n if (!transceiver || !track) continue;\n try {\n await transceiver.sender.replaceTrack(track);\n } catch {\n /* the browser refused the track; the slot simply stays silent */\n }\n }\n}\n\n/**\n * Adopt the transceivers a remote offer created, in `mid` order.\n *\n * The answering side allocates **nothing** up front, and that is the whole\n * subtlety: applying a remote offer creates one transceiver per m-line, in\n * m-line order, and browsers do not reuse transceivers the answerer made\n * beforehand. Pre-allocating leaves dead, never-negotiated transceivers sitting\n * in front of the live ones, which is how slot lookup ends up pointing at the\n * wrong media or at nothing.\n *\n * Each adopted transceiver is switched to `sendrecv` so this side may publish on\n * it too; without that a peer that answered could hear but never be heard.\n *\n * @param link - The link that just applied a remote offer.\n */\nexport function adoptTransceivers(link: PeerLink): void {\n link.transceivers = link.pc\n .getTransceivers()\n .filter((transceiver) => transceiver.mid !== null)\n .sort((a, b) => Number(a.mid) - Number(b.mid));\n for (const transceiver of link.transceivers) {\n if (transceiver.direction !== \"sendrecv\") transceiver.direction = \"sendrecv\";\n }\n}\n"],"mappings":";AAwBA,SAAgB,EAAqB,GAA+C;CAChF,OAAO,IAAI,kBAAkB;EACzB;EACA,cAAc;EACd,eAAe;CACnB,CAAC;AACL;AAoBA,SAAgB,EACZ,GACA,GACA,GACe;CACf,IAAM,IAAU,OAAO,EAAY,GAAG;CACtC,IAAI,EAAY,QAAQ,QAAQ,OAAO,UAAU,CAAO,KAAK,EAAM,IAC/D,OAAO,EAAM;CAEjB,IAAM,IAAQ,EAAK,aAAa,QAAQ,CAAW;CACnD,OAAO,KAAS,IAAK,EAAM,MAAU,OAAQ;AACjD;AAYA,eAAsB,EAAgB,GAA+B;CACjE,IAAM,IAAS,EAAK,kBAAkB,OAAO,GAAG,EAAK,kBAAkB,MAAM;CAC7E,KAAK,IAAM,KAAa,GACpB,IAAI;EACA,MAAM,EAAK,GAAG,gBAAgB,CAAS;CAC3C,QAAQ,CAER;AAER;AAQA,eAAsB,EAAgB,GAAgB,GAA0C;CAC5F,IAAI,CAAC,EAAK,aAAa;EACnB,EAAK,kBAAkB,KAAK,CAAI;EAChC;CACJ;CACA,IAAI;EACA,MAAM,EAAK,GAAG,gBAAgB,CAAI;CACtC,QAAQ,CAER;AACJ;AAaA,eAAsB,EAClB,GACA,GACA,GACa;CACb,KAAK,IAAM,CAAC,GAAO,MAAS,EAAM,QAAQ,GAAG;EACzC,IAAM,IAAc,EAAK,aAAa,IAChC,IAAQ,EAAO,IAAI,EAAK,IAAI,KAAK;EACnC,
|
|
1
|
+
{"version":3,"file":"peer-link.js","names":[],"sources":["../../src/webrtc/peer-link.ts"],"sourcesContent":["import type { MeshSlot } from \"./mesh-types\";\n\n/** One remote peer's connection and everything the mesh tracks about it. */\nexport interface PeerLink {\n peerId: string;\n pc: RTCPeerConnection;\n /**\n * Transceivers in slot order.\n *\n * Held explicitly rather than re-read from `getTransceivers()` so slot\n * lookup stays O(1) and cannot drift if a browser reorders.\n */\n transceivers: RTCRtpTransceiver[];\n /** Candidates that arrived before the remote description they belong to. */\n pendingCandidates: RTCIceCandidateInit[];\n /** Whether `setRemoteDescription` has landed, which gates the queue. */\n remoteReady: boolean;\n /** Whether an offer is in flight, so a re-entrant call does not stack one. */\n makingOffer: boolean;\n /** Whether this side offers. Exactly one side of a link does. */\n isOfferer: boolean;\n}\n\n/** Open a connection with the policies a mesh needs. */\nexport function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {\n return new RTCPeerConnection({\n iceServers,\n bundlePolicy: \"max-bundle\",\n rtcpMuxPolicy: \"require\",\n });\n}\n\n/**\n * Resolve which slot a transceiver carries.\n *\n * The offerer allocates the m-lines in slot order, so a transceiver's `mid` is\n * its ordinal and both ends read the same one. That makes `mid` the slot's wire\n * identity, and the **only** identifier correct on both sides of a link.\n *\n * Position in the link's own array is a fallback for a `mid` that is not a plain\n * ordinal, and never the primary: a browser answering an offer appends the\n * negotiated transceivers *after* any it already held, so positional lookup\n * silently resolves to the wrong slot — the camera arriving in the screen tile —\n * or to none at all, dropping the track.\n *\n * @param transceiver - The transceiver a track arrived on.\n * @param link - The link it belongs to.\n * @param slots - The mesh's slot list.\n * @returns The slot, or `null` when neither route names one.\n */\nexport function slotOf(\n transceiver: RTCRtpTransceiver,\n link: PeerLink,\n slots: readonly MeshSlot[],\n): MeshSlot | null {\n const ordinal = Number(transceiver.mid);\n if (transceiver.mid !== null && Number.isInteger(ordinal) && slots[ordinal]) {\n return slots[ordinal];\n }\n const index = link.transceivers.indexOf(transceiver);\n return index >= 0 ? (slots[index] ?? null) : null;\n}\n\n/**\n * Feed a link every candidate that arrived before its remote description.\n *\n * `addIceCandidate` throws while `remoteDescription` is `null`, and candidates\n * routinely beat the offer or answer they belong to. Without the queue the\n * connection loses them and sits in `checking` until it times out — a failure\n * with no error anywhere.\n *\n * @param link - The link whose queue is drained.\n */\nexport async function drainCandidates(link: PeerLink): Promise<void> {\n const queued = link.pendingCandidates.splice(0, link.pendingCandidates.length);\n for (const candidate of queued) {\n try {\n await link.pc.addIceCandidate(candidate);\n } catch {\n /* a candidate the browser rejects is not fatal to the connection */\n }\n }\n}\n\n/**\n * Hand a candidate to the connection, or park it until the description lands.\n *\n * @param link - The link the candidate belongs to.\n * @param init - The candidate, already in `RTCIceCandidateInit` shape.\n */\nexport async function acceptCandidate(link: PeerLink, init: RTCIceCandidateInit): Promise<void> {\n if (!link.remoteReady) {\n link.pendingCandidates.push(init);\n return;\n }\n try {\n await link.pc.addIceCandidate(init);\n } catch {\n /* a candidate the browser rejects is not fatal to the connection */\n }\n}\n\n/**\n * Publish every held local track onto a link's slots.\n *\n * Called once the transceivers exist — up front for the offerer, after the offer\n * lands for the answerer — so a microphone that was already live when the peer\n * joined starts flowing without waiting for a toggle.\n *\n * @param link - The link to publish onto.\n * @param slots - The mesh's slot list.\n * @param tracks - The current local track per slot name.\n */\nexport async function attachLocalTracks(\n link: PeerLink,\n slots: readonly MeshSlot[],\n tracks: Map<string, MediaStreamTrack | null>,\n): Promise<void> {\n for (const [index, slot] of slots.entries()) {\n const transceiver = link.transceivers[index];\n const track = tracks.get(slot.name) ?? null;\n if (!transceiver || !track) continue;\n try {\n await transceiver.sender.replaceTrack(track);\n } catch {\n /* the browser refused the track; the slot simply stays silent */\n }\n }\n}\n\n/**\n * Adopt the transceivers a remote offer created, in `mid` order.\n *\n * The answering side allocates **nothing** up front, and that is the whole\n * subtlety: applying a remote offer creates one transceiver per m-line, in\n * m-line order, and browsers do not reuse transceivers the answerer made\n * beforehand. Pre-allocating leaves dead, never-negotiated transceivers sitting\n * in front of the live ones, which is how slot lookup ends up pointing at the\n * wrong media or at nothing.\n *\n * Each adopted transceiver is switched to `sendrecv` so this side may publish on\n * it too; without that a peer that answered could hear but never be heard.\n *\n * @param link - The link that just applied a remote offer.\n */\nexport function adoptTransceivers(link: PeerLink): void {\n link.transceivers = link.pc\n .getTransceivers()\n .filter((transceiver) => transceiver.mid !== null)\n .sort((a, b) => Number(a.mid) - Number(b.mid));\n for (const transceiver of link.transceivers) {\n if (transceiver.direction !== \"sendrecv\") transceiver.direction = \"sendrecv\";\n }\n}\n"],"mappings":";AAwBA,SAAgB,EAAqB,GAA+C;CAChF,OAAO,IAAI,kBAAkB;EACzB;EACA,cAAc;EACd,eAAe;CACnB,CAAC;AACL;AAoBA,SAAgB,EACZ,GACA,GACA,GACe;CACf,IAAM,IAAU,OAAO,EAAY,GAAG;CACtC,IAAI,EAAY,QAAQ,QAAQ,OAAO,UAAU,CAAO,KAAK,EAAM,IAC/D,OAAO,EAAM;CAEjB,IAAM,IAAQ,EAAK,aAAa,QAAQ,CAAW;CACnD,OAAO,KAAS,IAAK,EAAM,MAAU,OAAQ;AACjD;AAYA,eAAsB,EAAgB,GAA+B;CACjE,IAAM,IAAS,EAAK,kBAAkB,OAAO,GAAG,EAAK,kBAAkB,MAAM;CAC7E,KAAK,IAAM,KAAa,GACpB,IAAI;EACA,MAAM,EAAK,GAAG,gBAAgB,CAAS;CAC3C,QAAQ,CAER;AAER;AAQA,eAAsB,EAAgB,GAAgB,GAA0C;CAC5F,IAAI,CAAC,EAAK,aAAa;EACnB,EAAK,kBAAkB,KAAK,CAAI;EAChC;CACJ;CACA,IAAI;EACA,MAAM,EAAK,GAAG,gBAAgB,CAAI;CACtC,QAAQ,CAER;AACJ;AAaA,eAAsB,EAClB,GACA,GACA,GACa;CACb,KAAK,IAAM,CAAC,GAAO,MAAS,EAAM,QAAQ,GAAG;EACzC,IAAM,IAAc,EAAK,aAAa,IAChC,IAAQ,EAAO,IAAI,EAAK,IAAI,KAAK;EACnC,IAAC,KAAgB,GACrB,IAAI;GACA,MAAM,EAAY,OAAO,aAAa,CAAK;EAC/C,QAAQ,CAER;CACJ;AACJ;AAiBA,SAAgB,EAAkB,GAAsB;CACpD,EAAK,eAAe,EAAK,GACpB,gBAAgB,CAAC,CACjB,QAAQ,MAAgB,EAAY,QAAQ,IAAI,CAAC,CACjD,MAAM,GAAG,MAAM,OAAO,EAAE,GAAG,IAAI,OAAO,EAAE,GAAG,CAAC;CACjD,KAAK,IAAM,KAAe,EAAK,cAC3B,AAAI,EAAY,cAAc,eAAY,EAAY,YAAY;AAE1E"}
|