feat(web): add audio and loss diagnostics (#120)
This commit is contained in:
1 parent
6a51ae45ce
commit
b78e25b4fb
13 files changed
+528
-41
No files matched your search
@@ -53,7 +53,7 @@ Last updated: 2026-08-21
|
||||
- Chrome 151 synthetic topology/quality runs kept fanout 2/1 and decoding; one relay close recovered in 5.32 seconds. Control evidence only.
|
||||
- Deployed Web source uses LiveKit 2.22.0's two-layer `q,h` layout. Local Chrome 151/LiveKit 1.13.5 kept one SFU/UDP root with growing inbound/decoded/rendered counters, clean leaves and no TURN. Production SFU media, zero-child BWE, and calibrated C+B loss remain open.
|
||||
- C+B uses three hard-bad pairs, one-use samples and guarded intent/cooldown. Deployed admission rescue moves the oldest childless zero-capacity Host leaf below an unassigned one-slot relay in one revision; no score/timer/global rebalance. Deployment passed, but no real room triggered it.
|
||||
- Web names/window audio are deployed; current source adds full-display system audio and the music hint. Native WGC/MF remains source-only: 203 rendered frames, 500 Opus packets, and matched PID/LUID `VideoEncode`. VP8 remains the Web default.
|
||||
- Web names/window audio are deployed; source adds full-display system audio, music hint, interval AV loss% and audio codec/bitrate/jitter details. Native WGC/MF remains source-only: 203 rendered frames, 500 Opus packets, matched PID/LUID `VideoEncode`. New Web audio work is undeployed; VP8 remains default.
|
||||
|
||||
## Provisional Quality Targets
|
||||
|
||||
|
||||
@@ -19,9 +19,11 @@ The Web sender keeps the current minimal behavior:
|
||||
- mark returned audio tracks with `contentHint = "music"`;
|
||||
- treat an audio track as optional and warn before publishing when none exists;
|
||||
- preserve returned audio through source changes, picture pause, P2P, relay, and
|
||||
configured SFU routes; and
|
||||
configured SFU routes;
|
||||
- expose no channel-count, sample-rate, Opus bitrate, stereo, DTX, or FEC
|
||||
quality control.
|
||||
quality control; and
|
||||
- expose local, read-only audio RTP diagnostics without treating negotiated
|
||||
codec fields as source-quality facts.
|
||||
|
||||
The music hint is not a codec or quality mode. No codec-quality control is
|
||||
justified yet. In particular, Screener will not add
|
||||
@@ -128,7 +130,7 @@ behavior.
|
||||
## Reference Implementations
|
||||
|
||||
No code was copied. All repositories were inspected at pinned commits on
|
||||
2026-08-19.
|
||||
2026-08-19 and their cited source pages were rechecked on 2026-08-21.
|
||||
|
||||
- LiveKit client-sdk-js, Apache-2.0, commit
|
||||
[`0a2110d`](https://github.com/livekit/client-sdk-js/tree/0a2110d39904a06722a0c4d1ddbb9390bb06ad4d):
|
||||
@@ -155,19 +157,39 @@ The Apache and MIT implementations support the same conclusion: a library can
|
||||
offer product-specific audio modes, but the stereo/DTX/FEC/Opus-fmtp portion is
|
||||
not equivalent to a portable browser sender control.
|
||||
|
||||
## Honest Future Diagnostics
|
||||
Discord and Oopz have a narrower evidence boundary:
|
||||
|
||||
If audio diagnostics become necessary, keep four layers separate:
|
||||
- Discord's official Go Live architecture says its native client captures the
|
||||
selected process and child-process audio with OS-specific APIs and sends
|
||||
audio and video in separate RTP packets. Discord's public Voice protocol
|
||||
requires Opus at the protocol boundary and distinguishes a `Soundshare`
|
||||
speaking bit, but neither source publishes the exact negotiated/runtime
|
||||
audio parameters for each consumer Go Live session. This is useful native
|
||||
product evidence, not a preset Screener can copy or an excuse to infer codec
|
||||
state without stats.
|
||||
- The retained read-only
|
||||
[Oopz 0.87.425 package inspection](./native-shared-encode-sender.md) found a native Agora
|
||||
RTC library, while its public help center does not publish screen-share audio
|
||||
codec, bitrate, stereo, DTX, or FEC settings. Those artifacts do not prove
|
||||
the codec or parameters used by a live Oopz session.
|
||||
|
||||
## Honest Diagnostics Contract
|
||||
|
||||
Audio diagnostics keep four layers separate:
|
||||
|
||||
1. **Capture:** track absent/present, plus `channelCount` and `sampleRate` only
|
||||
when the browser returns numeric values.
|
||||
2. **Requested/applied:** an RTP ceiling only if Screener actually calls
|
||||
`setParameters()`, followed by immediate `getParameters()` readback.
|
||||
3. **Negotiated:** the in-use codec MIME type and bounded derived fmtp evidence
|
||||
from `RTCCodecStats`, labelled as negotiated parameters rather than actual
|
||||
stereo/DTX/FEC; raw SDP/fmtp is not uploaded or persisted.
|
||||
4. **Observed:** interval audio bitrate from byte deltas and receiver FEC
|
||||
counters when implemented; missing values remain unknown.
|
||||
3. **Negotiated:** current local details follow the unique audio RTP object's
|
||||
`codecId` to a same-transport `RTCCodecStats` and display MIME type, clock
|
||||
rate, channels and bounded fmtp as negotiated fields. Raw SDP/stats/fmtp are
|
||||
not uploaded or persisted.
|
||||
4. **Observed:** current local details derive interval audio bitrate from byte
|
||||
deltas and interval loss from `lostDelta / (receivedDelta + lostDelta)`, and
|
||||
display reported jitter. The first sample, changed identity, reset, negative
|
||||
delta, zero denominator or absent field is unknown. Concealment, jitter
|
||||
buffer and FEC counters remain an acceptance-matrix follow-up.
|
||||
|
||||
Never substitute `48000`, `2`, `false`, or `0` for an unavailable field.
|
||||
|
||||
@@ -273,6 +295,9 @@ Add a user-facing audio setting only when all of these are true:
|
||||
- [W3C WebCodecs Opus registration](https://www.w3.org/TR/webcodecs-opus-codec-registration/)
|
||||
- [RFC 7587: RTP Payload Format for Opus](https://www.rfc-editor.org/rfc/rfc7587.html)
|
||||
- [RFC 7874: WebRTC Audio Codec and Processing Requirements](https://www.rfc-editor.org/rfc/rfc7874.html)
|
||||
- [Discord Go Live architecture](https://discord.com/blog/how-it-all-goes-live-an-overview-of-discords-streaming-technology)
|
||||
- [Discord Voice protocol](https://docs.discord.com/developers/topics/voice-connections)
|
||||
- [Oopz help center](https://help.oopz.cn/)
|
||||
- [MDN `getDisplayMedia()`](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia)
|
||||
- [MDN `MediaTrackSettings.channelCount`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackSettings/channelCount)
|
||||
- [MDN Browser Compatibility Data: `MediaDevices`](https://github.com/mdn/browser-compat-data/blob/main/api/MediaDevices.json)
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ Flagship; parallel; min run/smoke/rollback; benchmark later. Active UI/config/lo
|
||||
- After an answer, a generation-bound 15s initial-connect deadline enters ICE restart; success/replacement/disposal cancels it. It is deployed but not mobile-verified.
|
||||
- Production includes LiveKit's two-layer `q,h` sender guard; its functional media proof remains local, not production.
|
||||
- Room `1` C+B reparenting remains uncalibrated. Deployed admission rescue promotes an unassigned one-slot relay over the oldest childless zero-capacity Host leaf, with no scores or periodic optimization.
|
||||
- Web roster has Host names. Source requests window/system audio by surface and uses the music hint; the full-display change awaits deploy.
|
||||
- Web roster names are deployed. Source window/system audio, music hint, interval AV loss% and audio codec/bitrate/jitter details await deploy.
|
||||
|
||||
## Verified Evidence
|
||||
|
||||
@@ -37,7 +37,7 @@ Flagship; parallel; min run/smoke/rollback; benchmark later. Active UI/config/lo
|
||||
- Access protocol/config/HTTP/storage/SQLite/signaling focused tests pass, including commit-first teardown and v1 rollback.
|
||||
- Native wire/session tests enforce at most two authoritative children, no presence-created edge, stale-revision ignore, and one bounded unsupported-route failure per revision.
|
||||
- Production includes initial/active selected Host ingress and peer last-mile; focused tests cover exactly-once retry, cleanup, rollback, and ordinary STUN-only. No relay media ran.
|
||||
- Host names, the window-audio hint, all-room routing, and all-room Host selected ingress are active; the stale restart assertion is historical only.
|
||||
- Host names/window audio/all-room routing/Host selected ingress are active. New AV stats pass 42 focused tests/typecheck; this is not audible proof.
|
||||
- Exact `16f6eab` passed typecheck, both builds and 30 files/445 tests. Deploy preserved all recorded config/network/SQLite hashes; DB, routes, services, endpoints, site access, and old-endpoint 404 passed. It triggered no TURN/SFU session. Rollback is exact `22119b907d3cf03ce8b06d6fb4596ce1a26fedd7`; `fd76277b05d491af8840b28f3132b7ff445d3cbe` remains secondary.
|
||||
|
||||
## Unverified Boundaries
|
||||
|
||||
+2
-1
@@ -300,8 +300,9 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md)
|
||||
- `currentRoundTripTime`、发送码率和 `availableOutgoingBitrate`;
|
||||
- 实际帧率、分辨率和相邻有效采样窗的 RTP 丢包率;
|
||||
- codec、编码实现、最近采样区间内的每帧编解码耗时和 `qualityLimitationReason`;同一连接不并发采样,首个样本、无新帧、统计对象换代或计数器重置时显示未知并重新建立基线,不用连接生命周期均值掩盖后续过载。
|
||||
- 同一连接上实际存在且唯一的 audio RTP 对象所对应的区间码率、RTP 丢包率、jitter,以及由其 `codecId` 引用且绑定同一 transport 的 `RTCCodecStats` MIME、clock rate、channels 和最长 512 个可打印 ASCII 字符的 fmtp。clock/channels/fmtp 只标成 codec 协商事实,不推断捕获采样率、有效载荷立体声、DTX 或 FEC 当前行为。
|
||||
|
||||
观看者显示接收码率、帧率、分辨率、RTP 丢包率、jitter、解码/丢帧和当前路径。丢包率只在同一 RTP generation 的相邻样本中以 `lostDelta / (receivedDelta + lostDelta)` 计算;首样本、对象/计数器重置、负 delta、零分母或字段缺失时显示“未知”,不把累计 `packetsLost` 伪装成百分比。RTT 只标为网络往返时间,不能冒充玻璃到玻璃延迟。
|
||||
观看者显示接收码率、帧率、分辨率、RTP 丢包率、jitter、解码/丢帧和当前路径;Host 对 outbound 使用其链接的 `remote-inbound-rtp` 计算接收侧丢包率。视频和音频丢包率都只在同一 RTP generation 的相邻样本中以 `lostDelta / (receivedDelta + lostDelta)` 计算;首样本、对象/计数器重置、负 delta、零分母或字段缺失时显示“未知”,不把累计 `packetsLost` 伪装成百分比。RTT 只标为网络往返时间,不能冒充玻璃到玻璃延迟。
|
||||
|
||||
## 界面结构
|
||||
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@
|
||||
- [ ] 路径级质量状态:应用的 `HIGH`/`FALLBACK` 非对称窗口只决定拓扑资格和诊断,不命令正常的媒体层切换,也不计算综合分数。SFU BWE downshift 先进入 `suspect`;多个连续窗口的 sender/viewer 关联证据确认 `FALLBACK` 后,应用不得稳定保留该 parent,并把 downstream capacity 降为 0。计划或显式质量 fallback 仅是内建路径失败后的候选:已有 children 时先在 connection/assignment generation guard 下全部迁走,失败或代次过期则保持原 assignment/quality。Autonomous downshift 无法 packet-level 预先迁移,default-on 前必须验收 descendants 的短暂影响。只有更长的稳定恢复窗口和 cooldown 后才能重新声明 relay capacity。Viewer 请求必须鉴权、限频、去重,并由 sender 的 GCC/Transport-CC、实际码率、RTT、丢包、重传、编码限制、协商 codec/profile/parameters 与 viewer 解码、丢帧、冻结证据交叉验证;不能仅凭自报生效,也不得上传 raw SDP、candidate 地址或原始设备/网络标识。
|
||||
- [ ] 断线恢复:临时网络变化时按不同层级做一次性恢复,而不是把同一动作机械重复三次。`failed` 边先做一次 ICE restart,再重建一次同 parent 的 `RTCPeerConnection`,随后尝试一个不同的合格 peer parent;只有 peer 路径耗尽才进入 SFU。`connecting`/`checking` 先显示不改变拓扑的慢连接状态,硬超时必须由真实移动网络证据确定,不能为了更快使用服务器而过早放弃最终可成功的 P2P。任一步恢复即停止后续动作;所有异步结果受 session、route revision、assignment/connection generation 约束。active SFU 只允许一次 fresh-token 重试,再失败即有界回到 peer baseline 或明确失败,并向 Host 显示不含 URL、地址、token 或原始错误的失败阶段。
|
||||
- [ ] 可观测性:按目标 RTP transport 与不透明连接/ICE 代次关联 selected path、`iceGatheringState`/ICE/connection state、事件推导的建立时长、generation-baselined pair-change delta,以及 RTT、RTP 丢包率、抖动、码率、帧率、编码耗时和受限原因。候选与错误的 wire、日志和导出只使用[低服务器成本媒体路由](./research/low-server-media-routes.md)的脱敏字段和 code bucket,禁止 raw SDP、candidate、地址、URL、`errorText` 或网络标识。只有创建该 `RTCPeerConnection` 的本机可在折叠详情中显示浏览器对 selected local/remote candidate 实际暴露的地址与端口;该值不上传、不持久化、不用于地理/NAT 推断,缺失或被隐私保护时显示未知。
|
||||
- [ ] 渐进式诊断:分享者和观看者默认只看到房间、画面、核心状态和常用控制;候选路径、RTT、丢包、编解码器、编码耗时等技术详情由一个明确的“显示连接详情”开关展开。隐藏诊断不能隐藏失败原因、音频缺失、持续画质受限或需要用户操作的告警。
|
||||
- [ ] 渐进式诊断:分享者和观看者默认只看到房间、画面、核心状态和常用控制;候选路径、RTT、视频/音频 RTP 区间丢包率、实际编解码器、音频区间码率/jitter 和视频编码耗时等技术详情由一个明确的“显示连接详情”开关展开。音频 codec MIME、RTP clock、channels 与 fmtp 只能来自当前 RTP 对象引用的 `RTCCodecStats`,缺失即未知;`opus/48000/2` 不得写成 48 kHz 音源或实际立体声。隐藏诊断不能隐藏失败原因、音频缺失、持续画质受限或需要用户操作的告警。
|
||||
- [ ] 站点访问:生产部署必须配置独立的 `SITE_ACCESS_PASSWORD` 控制建房、Host 信令角色和所有 code-only Viewer 入口;只有本地开发/测试可显式关闭。非空值接受 8 至 128 个可见 ASCII bytes,不能复用 TURN、LiveKit、TLS 或其他部署 secret。`POST /api/site-access` 验证成功签发 12 小时、无服务端会话状态的 HttpOnly、`SameSite=Strict` site-access cookie。有效 room-scoped fragment grant 可绕过站点 gate 直接进入对应房间;其余 `/r/{code}`、`/join` 和 public-watch 必须先持有效站点 cookie,之后才检查房间 public/private policy 和房间密码。失败统一且不得泄露房间是否存在或 policy;建房 HTTP 只认该 cookie。已移除的旧环境变量、endpoint 和 cookie 不保留兼容。不引入账号、JWT、服务端 session Map 或通用 logout。
|
||||
- [ ] 房间级观看授权:新房间默认生成含 256-bit 随机量、限时且可撤销的 room-scoped Viewer grant;服务端只保存完整 grant 的 SHA-256 摘要。到期阻止以后鉴权/重连,但不为已鉴权 Viewer 设置踢出定时器;即时清退使用 rotate/revoke。浏览器从 URL fragment 严格读取 grant 后,只写该房间的 `sessionStorage` 并立即用 `history.replaceState` 清除 fragment;刷新和页面内重连可继续。私密房间缺失 grant 时必须先通过站点访问,再提供正确房间观看密码;任一缺失都 fail closed。grant 不得进入 `localStorage`、cookie、query、WebSocket URL、Referrer、日志或错误。Host 可 rotate 或 revoke;revoke 只锁定 grant 路径,已配置的房间密码仍是独立的后续进入方式。新 grant 摘要成功提交后必须清除 Viewer grace/路由、终止旧授权代次的信令和媒体并断开全部旧 Viewer,数据库写失败则不得改变连接。public-watch 是显式、可逆的 room policy,只放宽房间级观看,不绕过站点访问。
|
||||
- [ ] 最小持久化:`ROOM_DATABASE_PATH` 可选且必须与 `SITE_ACCESS_PASSWORD` 同时配置。两者都存在时,SQLite 只持久保存从 `1` 开始自增的房间 ID、Host token 摘要、nullable Viewer grant 摘要,以及 nullable 48-byte Viewer-password material(16-byte salt + 32-byte scrypt verifier);数据库 `CHECK` 约束非空 BLOB 长度,不新增用户、成员或 session 表。明文房间密码只短暂存在于当前页面和鉴权/派生调用内存,不进入 URL、cookie、浏览器持久存储、日志或 SQLite。持久房间停止后仍显示等待状态;私密邀请按期限或 Host rotate 更新。旧 schema 行在原子迁移时必须 fail-closed 为没有可用明文 grant 的锁定私密状态,Host rotate 后才能生成新邀请。
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ConnectionMetrics } from "../types";
|
||||
import type { VideoSenderParameterReadback } from "../media/quality";
|
||||
import { formatPacketLossPercent } from "./connection-details";
|
||||
|
||||
function readableNumber(value: number | null, digits = 0): string {
|
||||
return value === null || !Number.isFinite(value) ? "未知" : value.toFixed(digits);
|
||||
@@ -71,7 +72,10 @@ export function StatsGrid({
|
||||
<Metric label="帧率" value={`${readableNumber(metrics.framesPerSecond, 1)} fps`} />
|
||||
<Metric label="分辨率" value={metrics.resolution ?? "未知"} />
|
||||
<Metric label="RTT" value={`${readableNumber(metrics.rttMs)} ms`} title="网络往返时间" />
|
||||
<Metric label="丢包" value={readableNumber(metrics.packetsLost)} />
|
||||
<Metric
|
||||
label="视频丢包率"
|
||||
value={formatPacketLossPercent(metrics.packetLossPercent)}
|
||||
/>
|
||||
<Metric
|
||||
label={direction === "send" ? "可用上行" : "抖动"}
|
||||
value={
|
||||
@@ -95,12 +99,46 @@ export function StatsGrid({
|
||||
value={`${metrics.localCandidateType ?? "?"} / ${metrics.remoteCandidateType ?? "?"}`}
|
||||
title="本地 / 远端候选类型"
|
||||
/>
|
||||
<Metric label="Codec" value={metrics.codec ?? "未知"} />
|
||||
<Metric label="视频 Codec" value={metrics.codec ?? "未知"} />
|
||||
{metrics.codecProfile && (
|
||||
<Metric label="Codec profile token" value={metrics.codecProfile} />
|
||||
<Metric label="视频 Codec profile token" value={metrics.codecProfile} />
|
||||
)}
|
||||
{metrics.codecParameters && (
|
||||
<Metric label="Codec 协商参数" value={metrics.codecParameters} />
|
||||
<Metric label="视频 Codec 协商参数" value={metrics.codecParameters} />
|
||||
)}
|
||||
<Metric
|
||||
label={direction === "send" ? "音频发送码率" : "音频接收码率"}
|
||||
value={`${readableNumber(metrics.audioBitrateKbps)} kbps`}
|
||||
/>
|
||||
<Metric
|
||||
label="音频丢包率"
|
||||
value={formatPacketLossPercent(metrics.audioPacketLossPercent)}
|
||||
/>
|
||||
<Metric
|
||||
label="音频抖动"
|
||||
value={`${readableNumber(metrics.audioJitterMs, 1)} ms`}
|
||||
/>
|
||||
<Metric label="音频 Codec" value={metrics.audioCodec ?? "未知"} />
|
||||
{metrics.audioCodecClockRate !== null && (
|
||||
<Metric
|
||||
label="音频 RTP 时钟"
|
||||
value={`${metrics.audioCodecClockRate} Hz`}
|
||||
title="Codec 协商时钟,不代表采集源采样率"
|
||||
/>
|
||||
)}
|
||||
{metrics.audioCodecChannels !== null && (
|
||||
<Metric
|
||||
label="音频 Codec 声道"
|
||||
value={String(metrics.audioCodecChannels)}
|
||||
title="Codec 协商声道字段,不证明音源或有效载荷为立体声"
|
||||
/>
|
||||
)}
|
||||
{metrics.audioCodecParameters && (
|
||||
<Metric
|
||||
label="音频 Codec 协商参数"
|
||||
value={metrics.audioCodecParameters}
|
||||
title={`协商参数:${metrics.audioCodecParameters};不证明编码器当前启用了对应模式`}
|
||||
/>
|
||||
)}
|
||||
{direction === "send" ? (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { PeerSnapshot } from "../types";
|
||||
|
||||
export function formatPacketLossPercent(value: number | null): string {
|
||||
return value === null || !Number.isFinite(value)
|
||||
? "未知"
|
||||
: `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function qualityLimitationSummary(
|
||||
snapshots: readonly PeerSnapshot[],
|
||||
): string | null {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type ConnectionMetrics,
|
||||
type PeerSnapshot,
|
||||
} from "../types";
|
||||
import { packetLossPercentFromDeltas } from "../webrtc/stats";
|
||||
|
||||
type ViewerQualityEvidence = Extract<
|
||||
ServerMessage,
|
||||
@@ -236,6 +237,10 @@ export function metricsFromQualityEvidence(
|
||||
packetsLost: metrics.packetsLostDelta,
|
||||
intervalPacketsReceived: metrics.packetsReceivedDelta,
|
||||
intervalPacketsLost: metrics.packetsLostDelta,
|
||||
packetLossPercent: packetLossPercentFromDeltas(
|
||||
metrics.packetsReceivedDelta,
|
||||
metrics.packetsLostDelta,
|
||||
),
|
||||
jitterMs: metrics.jitterMs,
|
||||
framesDropped: metrics.framesDroppedDelta,
|
||||
intervalFramesDecoded: metrics.framesDecodedDelta,
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface ConnectionMetrics {
|
||||
intervalPacketsSent: number | null;
|
||||
intervalPacketsReceived: number | null;
|
||||
intervalPacketsLost: number | null;
|
||||
packetLossPercent: number | null;
|
||||
jitterMs: number | null;
|
||||
framesDropped: number | null;
|
||||
intervalFramesDecoded: number | null;
|
||||
@@ -44,6 +45,13 @@ export interface ConnectionMetrics {
|
||||
codec: string | null;
|
||||
codecProfile: string | null;
|
||||
codecParameters: string | null;
|
||||
audioBitrateKbps: number | null;
|
||||
audioPacketLossPercent: number | null;
|
||||
audioJitterMs: number | null;
|
||||
audioCodec: string | null;
|
||||
audioCodecClockRate: number | null;
|
||||
audioCodecChannels: number | null;
|
||||
audioCodecParameters: string | null;
|
||||
scalabilityMode: string | null;
|
||||
encoderImplementation: string | null;
|
||||
powerEfficientEncoder: boolean | null;
|
||||
@@ -90,6 +98,7 @@ export const EMPTY_METRICS: ConnectionMetrics = {
|
||||
intervalPacketsSent: null,
|
||||
intervalPacketsReceived: null,
|
||||
intervalPacketsLost: null,
|
||||
packetLossPercent: null,
|
||||
jitterMs: null,
|
||||
framesDropped: null,
|
||||
intervalFramesDecoded: null,
|
||||
@@ -101,6 +110,13 @@ export const EMPTY_METRICS: ConnectionMetrics = {
|
||||
codec: null,
|
||||
codecProfile: null,
|
||||
codecParameters: null,
|
||||
audioBitrateKbps: null,
|
||||
audioPacketLossPercent: null,
|
||||
audioJitterMs: null,
|
||||
audioCodec: null,
|
||||
audioCodecClockRate: null,
|
||||
audioCodecChannels: null,
|
||||
audioCodecParameters: null,
|
||||
scalabilityMode: null,
|
||||
encoderImplementation: null,
|
||||
powerEfficientEncoder: null,
|
||||
|
||||
+161
-20
@@ -14,6 +14,7 @@ export interface StatsAccumulator {
|
||||
mediaId: string | null;
|
||||
ssrc: number | null;
|
||||
trackIdentifier: string | null;
|
||||
lossSourceId: string | null;
|
||||
bytes: number | null;
|
||||
frames: number | null;
|
||||
timestamp: number | null;
|
||||
@@ -27,6 +28,14 @@ export interface StatsAccumulator {
|
||||
previousTotalFreezesDuration: number | null;
|
||||
previousRetransmittedPackets: number | null;
|
||||
previousRetransmittedBytes: number | null;
|
||||
audioMediaId: string | null;
|
||||
audioSsrc: number | null;
|
||||
audioTrackIdentifier: string | null;
|
||||
audioLossSourceId: string | null;
|
||||
audioBytes: number | null;
|
||||
audioTimestamp: number | null;
|
||||
previousAudioPacketsReceived: number | null;
|
||||
previousAudioPacketsLost: number | null;
|
||||
}
|
||||
|
||||
export interface StatsMediaSelector {
|
||||
@@ -38,6 +47,7 @@ export function createStatsAccumulator(): StatsAccumulator {
|
||||
mediaId: null,
|
||||
ssrc: null,
|
||||
trackIdentifier: null,
|
||||
lossSourceId: null,
|
||||
bytes: null,
|
||||
frames: null,
|
||||
timestamp: null,
|
||||
@@ -51,6 +61,14 @@ export function createStatsAccumulator(): StatsAccumulator {
|
||||
previousTotalFreezesDuration: null,
|
||||
previousRetransmittedPackets: null,
|
||||
previousRetransmittedBytes: null,
|
||||
audioMediaId: null,
|
||||
audioSsrc: null,
|
||||
audioTrackIdentifier: null,
|
||||
audioLossSourceId: null,
|
||||
audioBytes: null,
|
||||
audioTimestamp: null,
|
||||
previousAudioPacketsReceived: null,
|
||||
previousAudioPacketsLost: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,6 +110,24 @@ function intervalAverageMs(
|
||||
: null;
|
||||
}
|
||||
|
||||
export function packetLossPercentFromDeltas(
|
||||
packetsReceivedDelta: number | null,
|
||||
packetsLostDelta: number | null,
|
||||
): number | null {
|
||||
if (
|
||||
packetsReceivedDelta === null ||
|
||||
packetsLostDelta === null ||
|
||||
!Number.isFinite(packetsReceivedDelta) ||
|
||||
!Number.isFinite(packetsLostDelta) ||
|
||||
packetsReceivedDelta < 0 ||
|
||||
packetsLostDelta < 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const packetDelta = packetsReceivedDelta + packetsLostDelta;
|
||||
return packetDelta > 0 ? (packetsLostDelta / packetDelta) * 100 : null;
|
||||
}
|
||||
|
||||
function numberValue(record: StatsRecord | null, key: string): number | null {
|
||||
const value = record?.[key];
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
@@ -132,11 +168,12 @@ function mediaRecord(
|
||||
report: RTCStatsReport,
|
||||
direction: "send" | "receive",
|
||||
selector: StatsMediaSelector | null,
|
||||
kind: "audio" | "video" = "video",
|
||||
): StatsRecord | null {
|
||||
const expectedType = direction === "send" ? "outbound-rtp" : "inbound-rtp";
|
||||
const isExpectedMedia = (record: StatsRecord): boolean =>
|
||||
record.type === expectedType &&
|
||||
record.kind === "video" &&
|
||||
record.kind === kind &&
|
||||
record.isRemote !== true;
|
||||
|
||||
const candidates: StatsRecord[] = [];
|
||||
@@ -200,17 +237,19 @@ function selectedCandidatePair(
|
||||
function linkedRemoteInbound(
|
||||
report: RTCStatsReport,
|
||||
outbound: StatsRecord | null,
|
||||
kind: "audio" | "video" = "video",
|
||||
): StatsRecord | null {
|
||||
const remote = getRecord(report, stringValue(outbound, "remoteId"));
|
||||
return remote?.type === "remote-inbound-rtp" && remote.kind === "video"
|
||||
return remote?.type === "remote-inbound-rtp" && remote.kind === kind
|
||||
? remote
|
||||
: null;
|
||||
}
|
||||
|
||||
function linkedVideoCodec(
|
||||
function linkedMediaCodec(
|
||||
report: RTCStatsReport,
|
||||
media: StatsRecord | null,
|
||||
transport: StatsRecord | null,
|
||||
kind: "audio" | "video",
|
||||
): StatsRecord | null {
|
||||
const codec = getRecord(report, stringValue(media, "codecId"));
|
||||
const mimeType = stringValue(codec, "mimeType");
|
||||
@@ -218,7 +257,7 @@ function linkedVideoCodec(
|
||||
transport !== null &&
|
||||
stringValue(codec, "transportId") === transport.id &&
|
||||
mimeType !== null &&
|
||||
/^video\/[A-Za-z0-9.+-]{1,32}$/i.test(mimeType)
|
||||
new RegExp(`^${kind}\\/[A-Za-z0-9.+-]{1,32}$`, "i").test(mimeType)
|
||||
? codec
|
||||
: null;
|
||||
}
|
||||
@@ -231,6 +270,21 @@ function deriveCodecEvidence(codec: StatsRecord | null) {
|
||||
);
|
||||
}
|
||||
|
||||
function positiveIntegerValue(
|
||||
record: StatsRecord | null,
|
||||
key: string,
|
||||
): number | null {
|
||||
const value = numberValue(record, key);
|
||||
return value !== null && Number.isInteger(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
function audioCodecParameters(codec: StatsRecord | null): string | null {
|
||||
const value = stringValue(codec, "sdpFmtpLine");
|
||||
return value !== null && value.length <= 512 && /^[\x20-\x7e]+$/.test(value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function scalabilityModeValue(media: StatsRecord | null): string | null {
|
||||
const value = stringValue(media, "scalabilityMode");
|
||||
return value && /^[A-Za-z0-9_-]{1,32}$/.test(value) ? value : null;
|
||||
@@ -276,13 +330,19 @@ export async function collectConnectionMetrics(
|
||||
const trackIdentifier = media
|
||||
? mediaTrackIdentifier(report, media, direction)
|
||||
: null;
|
||||
const remoteInbound =
|
||||
direction === "send" ? linkedRemoteInbound(report, media) : null;
|
||||
const lossSource = direction === "send" ? remoteInbound : media;
|
||||
const lossSourceId = lossSource?.id ?? null;
|
||||
const sameMedia =
|
||||
mediaId !== null &&
|
||||
mediaId === previous.mediaId &&
|
||||
ssrc === previous.ssrc &&
|
||||
trackIdentifier === previous.trackIdentifier;
|
||||
const remoteInbound =
|
||||
direction === "send" ? linkedRemoteInbound(report, media) : null;
|
||||
const sameLossSource =
|
||||
sameMedia &&
|
||||
lossSourceId !== null &&
|
||||
lossSourceId === previous.lossSourceId;
|
||||
const bytesKey = direction === "send" ? "bytesSent" : "bytesReceived";
|
||||
const bytes = numberValue(media, bytesKey);
|
||||
const framesKey = direction === "send" ? "framesEncoded" : "framesDecoded";
|
||||
@@ -291,11 +351,8 @@ export async function collectConnectionMetrics(
|
||||
const framesEncoded = numberValue(media, "framesEncoded");
|
||||
const framesDecoded = numberValue(media, "framesDecoded");
|
||||
const packetsSent = numberValue(media, "packetsSent");
|
||||
const packetsReceived = numberValue(media, "packetsReceived");
|
||||
const packetsLost = numberValue(
|
||||
direction === "send" ? remoteInbound : media,
|
||||
"packetsLost",
|
||||
);
|
||||
const packetsReceived = numberValue(lossSource, "packetsReceived");
|
||||
const packetsLost = numberValue(lossSource, "packetsLost");
|
||||
const totalEncodeTime = numberValue(media, "totalEncodeTime");
|
||||
const totalDecodeTime = numberValue(media, "totalDecodeTime");
|
||||
const framesDropped = numberValue(media, "framesDropped");
|
||||
@@ -354,14 +411,13 @@ export async function collectConnectionMetrics(
|
||||
sampleWindowMs !== null ? previous.frames : null,
|
||||
)
|
||||
: null;
|
||||
const lossPacketsReceivedDelta = intervalDelta(
|
||||
packetsReceived,
|
||||
previous.previousPacketsReceived,
|
||||
sampleWindowMs !== null && sameLossSource,
|
||||
);
|
||||
const intervalPacketsReceived =
|
||||
direction === "receive"
|
||||
? intervalDelta(
|
||||
packetsReceived,
|
||||
previous.previousPacketsReceived,
|
||||
sampleWindowMs !== null,
|
||||
)
|
||||
: null;
|
||||
direction === "receive" ? lossPacketsReceivedDelta : null;
|
||||
const intervalPacketsSent =
|
||||
direction === "send"
|
||||
? intervalDelta(
|
||||
@@ -375,7 +431,7 @@ export async function collectConnectionMetrics(
|
||||
? intervalDelta(
|
||||
packetsLost,
|
||||
previous.previousPacketsLost,
|
||||
sampleWindowMs !== null,
|
||||
sampleWindowMs !== null && sameLossSource,
|
||||
)
|
||||
: null;
|
||||
const intervalFramesDecoded =
|
||||
@@ -414,6 +470,7 @@ export async function collectConnectionMetrics(
|
||||
previous.mediaId = mediaId;
|
||||
previous.ssrc = ssrc;
|
||||
previous.trackIdentifier = trackIdentifier;
|
||||
previous.lossSourceId = lossSourceId;
|
||||
previous.bytes = bytes;
|
||||
previous.frames = frames;
|
||||
previous.timestamp = timestamp;
|
||||
@@ -428,8 +485,75 @@ export async function collectConnectionMetrics(
|
||||
previous.previousRetransmittedPackets = retransmittedPackets;
|
||||
previous.previousRetransmittedBytes = retransmittedBytes;
|
||||
|
||||
const linkedCodec = linkedVideoCodec(report, media, transport);
|
||||
const linkedCodec = linkedMediaCodec(report, media, transport, "video");
|
||||
const codecEvidence = deriveCodecEvidence(linkedCodec);
|
||||
const audio = mediaRecord(report, direction, null, "audio");
|
||||
const audioTransport = transportRecord(report, audio);
|
||||
const audioRemoteInbound =
|
||||
direction === "send"
|
||||
? linkedRemoteInbound(report, audio, "audio")
|
||||
: null;
|
||||
const audioLossSource = direction === "send" ? audioRemoteInbound : audio;
|
||||
const audioMediaId = audio?.id ?? null;
|
||||
const audioSsrc = numberValue(audio, "ssrc");
|
||||
const audioTrackIdentifier = audio
|
||||
? mediaTrackIdentifier(report, audio, direction)
|
||||
: null;
|
||||
const audioLossSourceId = audioLossSource?.id ?? null;
|
||||
const sameAudio =
|
||||
audioMediaId !== null &&
|
||||
audioMediaId === previous.audioMediaId &&
|
||||
audioSsrc === previous.audioSsrc &&
|
||||
audioTrackIdentifier === previous.audioTrackIdentifier;
|
||||
const sameAudioLossSource =
|
||||
sameAudio &&
|
||||
audioLossSourceId !== null &&
|
||||
audioLossSourceId === previous.audioLossSourceId;
|
||||
const audioBytes = numberValue(
|
||||
audio,
|
||||
direction === "send" ? "bytesSent" : "bytesReceived",
|
||||
);
|
||||
const audioTimestamp = numberValue(audio, "timestamp");
|
||||
const audioSampleWindowMs =
|
||||
sameAudio &&
|
||||
audioTimestamp !== null &&
|
||||
previous.audioTimestamp !== null &&
|
||||
audioTimestamp > previous.audioTimestamp
|
||||
? audioTimestamp - previous.audioTimestamp
|
||||
: null;
|
||||
const audioPacketsReceived = numberValue(audioLossSource, "packetsReceived");
|
||||
const audioPacketsLost = numberValue(audioLossSource, "packetsLost");
|
||||
const audioPacketsReceivedDelta = intervalDelta(
|
||||
audioPacketsReceived,
|
||||
previous.previousAudioPacketsReceived,
|
||||
audioSampleWindowMs !== null && sameAudioLossSource,
|
||||
);
|
||||
const audioPacketsLostDelta = intervalDelta(
|
||||
audioPacketsLost,
|
||||
previous.previousAudioPacketsLost,
|
||||
audioSampleWindowMs !== null && sameAudioLossSource,
|
||||
);
|
||||
const audioBitrateKbps =
|
||||
audioSampleWindowMs !== null &&
|
||||
audioBytes !== null &&
|
||||
previous.audioBytes !== null &&
|
||||
audioBytes >= previous.audioBytes
|
||||
? ((audioBytes - previous.audioBytes) * 8) / audioSampleWindowMs
|
||||
: null;
|
||||
previous.audioMediaId = audioMediaId;
|
||||
previous.audioSsrc = audioSsrc;
|
||||
previous.audioTrackIdentifier = audioTrackIdentifier;
|
||||
previous.audioLossSourceId = audioLossSourceId;
|
||||
previous.audioBytes = audioBytes;
|
||||
previous.audioTimestamp = audioTimestamp;
|
||||
previous.previousAudioPacketsReceived = audioPacketsReceived;
|
||||
previous.previousAudioPacketsLost = audioPacketsLost;
|
||||
const linkedAudioCodec = linkedMediaCodec(
|
||||
report,
|
||||
audio,
|
||||
audioTransport,
|
||||
"audio",
|
||||
);
|
||||
const width = numberValue(media, "frameWidth");
|
||||
const height = numberValue(media, "frameHeight");
|
||||
const iceProtocol =
|
||||
@@ -471,6 +595,10 @@ export async function collectConnectionMetrics(
|
||||
intervalPacketsSent,
|
||||
intervalPacketsReceived,
|
||||
intervalPacketsLost,
|
||||
packetLossPercent: packetLossPercentFromDeltas(
|
||||
lossPacketsReceivedDelta,
|
||||
intervalPacketsLost,
|
||||
),
|
||||
jitterMs:
|
||||
numberValue(direction === "send" ? remoteInbound : media, "jitter") !== null
|
||||
? numberValue(direction === "send" ? remoteInbound : media, "jitter")! *
|
||||
@@ -487,6 +615,19 @@ export async function collectConnectionMetrics(
|
||||
codec: codecEvidence.codec,
|
||||
codecProfile: codecEvidence.profile,
|
||||
codecParameters: codecEvidence.parameters,
|
||||
audioBitrateKbps,
|
||||
audioPacketLossPercent: packetLossPercentFromDeltas(
|
||||
audioPacketsReceivedDelta,
|
||||
audioPacketsLostDelta,
|
||||
),
|
||||
audioJitterMs:
|
||||
numberValue(audioLossSource, "jitter") !== null
|
||||
? numberValue(audioLossSource, "jitter")! * 1_000
|
||||
: null,
|
||||
audioCodec: stringValue(linkedAudioCodec, "mimeType"),
|
||||
audioCodecClockRate: positiveIntegerValue(linkedAudioCodec, "clockRate"),
|
||||
audioCodecChannels: positiveIntegerValue(linkedAudioCodec, "channels"),
|
||||
audioCodecParameters: audioCodecParameters(linkedAudioCodec),
|
||||
scalabilityMode:
|
||||
direction === "send" ? scalabilityModeValue(media) : null,
|
||||
encoderImplementation: stringValue(media, "encoderImplementation"),
|
||||
|
||||
+245
-1
@@ -1423,6 +1423,7 @@ describe("WebRTC stats parsing", () => {
|
||||
frameHeight: 1_080,
|
||||
intervalPacketsReceived: 1_500,
|
||||
intervalPacketsLost: 2,
|
||||
packetLossPercent: (2 / 1_502) * 100,
|
||||
intervalFramesDecoded: 120,
|
||||
intervalDecodeMs: 2.5,
|
||||
codec: "video/H264",
|
||||
@@ -1431,6 +1432,249 @@ describe("WebRTC stats parsing", () => {
|
||||
scalabilityMode: null,
|
||||
});
|
||||
expect(JSON.stringify(stable)).not.toContain("sprop-parameter-sets");
|
||||
expect(correctedLoss.intervalPacketsLost).toBeNull();
|
||||
expect(correctedLoss).toMatchObject({
|
||||
intervalPacketsLost: null,
|
||||
packetLossPercent: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports verified inbound audio codec and interval transport facts", async () => {
|
||||
const report = (
|
||||
timestamp: number,
|
||||
videoPacketsReceived: number,
|
||||
videoPacketsLost: number,
|
||||
audioBytesReceived: number,
|
||||
audioPacketsReceived: number,
|
||||
audioPacketsLost: number,
|
||||
audioCodecTransportId = "transport",
|
||||
) =>
|
||||
new Map<string, unknown>([
|
||||
[
|
||||
"transport",
|
||||
{ id: "transport", type: "transport", timestamp },
|
||||
],
|
||||
[
|
||||
"video-codec",
|
||||
{
|
||||
id: "video-codec",
|
||||
type: "codec",
|
||||
timestamp,
|
||||
transportId: "transport",
|
||||
mimeType: "video/VP8",
|
||||
},
|
||||
],
|
||||
[
|
||||
"audio-codec",
|
||||
{
|
||||
id: "audio-codec",
|
||||
type: "codec",
|
||||
timestamp,
|
||||
transportId: audioCodecTransportId,
|
||||
mimeType: "audio/opus",
|
||||
clockRate: 48_000,
|
||||
channels: 2,
|
||||
sdpFmtpLine: "minptime=10;useinbandfec=1",
|
||||
},
|
||||
],
|
||||
[
|
||||
"video-inbound",
|
||||
{
|
||||
id: "video-inbound",
|
||||
type: "inbound-rtp",
|
||||
timestamp,
|
||||
kind: "video",
|
||||
ssrc: 101,
|
||||
transportId: "transport",
|
||||
codecId: "video-codec",
|
||||
bytesReceived: timestamp * 10,
|
||||
packetsReceived: videoPacketsReceived,
|
||||
packetsLost: videoPacketsLost,
|
||||
framesDecoded: timestamp / 20,
|
||||
},
|
||||
],
|
||||
[
|
||||
"audio-inbound",
|
||||
{
|
||||
id: "audio-inbound",
|
||||
type: "inbound-rtp",
|
||||
timestamp,
|
||||
kind: "audio",
|
||||
ssrc: 202,
|
||||
trackIdentifier: "shared-audio",
|
||||
transportId: "transport",
|
||||
codecId: "audio-codec",
|
||||
bytesReceived: audioBytesReceived,
|
||||
packetsReceived: audioPacketsReceived,
|
||||
packetsLost: audioPacketsLost,
|
||||
jitter: 0.003,
|
||||
},
|
||||
],
|
||||
]) as unknown as RTCStatsReport;
|
||||
const reports = [
|
||||
report(1_000, 100, 5, 10_000, 1_000, 5),
|
||||
report(3_000, 190, 15, 50_000, 1_180, 10),
|
||||
report(5_000, 190, 15, 50_000, 1_180, 10, "other-transport"),
|
||||
];
|
||||
const connection = {
|
||||
getStats: async () => reports.shift()!,
|
||||
} as unknown as RTCPeerConnection;
|
||||
const accumulator = createStatsAccumulator();
|
||||
|
||||
const first = await collectConnectionMetrics(
|
||||
connection,
|
||||
"receive",
|
||||
accumulator,
|
||||
);
|
||||
const stable = await collectConnectionMetrics(
|
||||
connection,
|
||||
"receive",
|
||||
accumulator,
|
||||
);
|
||||
const zeroDenominator = await collectConnectionMetrics(
|
||||
connection,
|
||||
"receive",
|
||||
accumulator,
|
||||
);
|
||||
|
||||
expect(first).toMatchObject({
|
||||
packetLossPercent: null,
|
||||
audioBitrateKbps: null,
|
||||
audioPacketLossPercent: null,
|
||||
audioJitterMs: 3,
|
||||
audioCodec: "audio/opus",
|
||||
audioCodecClockRate: 48_000,
|
||||
audioCodecChannels: 2,
|
||||
audioCodecParameters: "minptime=10;useinbandfec=1",
|
||||
});
|
||||
expect(stable.packetLossPercent).toBeCloseTo(10);
|
||||
expect(stable.audioBitrateKbps).toBeCloseTo(160);
|
||||
expect(stable.audioPacketLossPercent).toBeCloseTo((5 / 185) * 100);
|
||||
expect(zeroDenominator).toMatchObject({
|
||||
packetLossPercent: null,
|
||||
audioBitrateKbps: 0,
|
||||
audioPacketLossPercent: null,
|
||||
audioCodec: null,
|
||||
audioCodecClockRate: null,
|
||||
audioCodecChannels: null,
|
||||
audioCodecParameters: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses linked remote inbound counters for outbound audio loss", async () => {
|
||||
const report = (
|
||||
timestamp: number,
|
||||
audioBytesSent: number,
|
||||
packetsReceived: number | undefined,
|
||||
packetsLost: number | undefined,
|
||||
) =>
|
||||
new Map<string, unknown>([
|
||||
[
|
||||
"transport",
|
||||
{ id: "transport", type: "transport", timestamp },
|
||||
],
|
||||
[
|
||||
"video-outbound",
|
||||
{
|
||||
id: "video-outbound",
|
||||
type: "outbound-rtp",
|
||||
timestamp,
|
||||
kind: "video",
|
||||
ssrc: 101,
|
||||
bytesSent: timestamp * 10,
|
||||
packetsSent: timestamp / 10,
|
||||
framesEncoded: timestamp / 20,
|
||||
},
|
||||
],
|
||||
[
|
||||
"audio-codec",
|
||||
{
|
||||
id: "audio-codec",
|
||||
type: "codec",
|
||||
timestamp,
|
||||
transportId: "transport",
|
||||
mimeType: "audio/opus",
|
||||
clockRate: 48_000,
|
||||
channels: 2,
|
||||
},
|
||||
],
|
||||
[
|
||||
"audio-outbound",
|
||||
{
|
||||
id: "audio-outbound",
|
||||
type: "outbound-rtp",
|
||||
timestamp,
|
||||
kind: "audio",
|
||||
ssrc: 202,
|
||||
transportId: "transport",
|
||||
codecId: "audio-codec",
|
||||
remoteId: "audio-remote-inbound",
|
||||
bytesSent: audioBytesSent,
|
||||
},
|
||||
],
|
||||
[
|
||||
"audio-remote-inbound",
|
||||
{
|
||||
id: "audio-remote-inbound",
|
||||
type: "remote-inbound-rtp",
|
||||
timestamp,
|
||||
kind: "audio",
|
||||
packetsReceived,
|
||||
packetsLost,
|
||||
jitter: 0.004,
|
||||
},
|
||||
],
|
||||
]) as unknown as RTCStatsReport;
|
||||
const firstReport = report(1_000, 20_000, 500, 1);
|
||||
(firstReport as unknown as Map<string, unknown>).delete(
|
||||
"audio-remote-inbound",
|
||||
);
|
||||
const reports = [
|
||||
firstReport,
|
||||
report(3_000, 50_000, 500, 1),
|
||||
report(5_000, 80_000, 650, 4),
|
||||
report(7_000, 110_000, 50, 1),
|
||||
report(9_000, 140_000, 200, undefined),
|
||||
];
|
||||
const connection = {
|
||||
getStats: async () => reports.shift()!,
|
||||
} as unknown as RTCPeerConnection;
|
||||
const accumulator = createStatsAccumulator();
|
||||
|
||||
await collectConnectionMetrics(connection, "send", accumulator);
|
||||
const remoteAppeared = await collectConnectionMetrics(
|
||||
connection,
|
||||
"send",
|
||||
accumulator,
|
||||
);
|
||||
const stable = await collectConnectionMetrics(
|
||||
connection,
|
||||
"send",
|
||||
accumulator,
|
||||
);
|
||||
const reset = await collectConnectionMetrics(
|
||||
connection,
|
||||
"send",
|
||||
accumulator,
|
||||
);
|
||||
const missing = await collectConnectionMetrics(
|
||||
connection,
|
||||
"send",
|
||||
accumulator,
|
||||
);
|
||||
|
||||
expect(remoteAppeared).toMatchObject({
|
||||
audioBitrateKbps: 120,
|
||||
audioPacketLossPercent: null,
|
||||
});
|
||||
expect(stable).toMatchObject({
|
||||
audioBitrateKbps: 120,
|
||||
audioJitterMs: 4,
|
||||
audioCodec: "audio/opus",
|
||||
audioCodecClockRate: 48_000,
|
||||
audioCodecChannels: 2,
|
||||
});
|
||||
expect(stable.audioPacketLossPercent).toBeCloseTo((3 / 153) * 100);
|
||||
expect(reset.audioPacketLossPercent).toBeNull();
|
||||
expect(missing.audioPacketLossPercent).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { qualityLimitationSummary } from "../src/client/components/connection-details.ts";
|
||||
import {
|
||||
formatPacketLossPercent,
|
||||
qualityLimitationSummary,
|
||||
} from "../src/client/components/connection-details.ts";
|
||||
import { EMPTY_METRICS, type PeerSnapshot } from "../src/client/types.ts";
|
||||
|
||||
function snapshot(qualityWarning: string | null): PeerSnapshot {
|
||||
@@ -26,4 +29,11 @@ describe("progressive connection details", () => {
|
||||
).toBe("持续受带宽限制,浏览器正在降低画面质量");
|
||||
expect(qualityLimitationSummary([snapshot(null)])).toBeNull();
|
||||
});
|
||||
|
||||
it("formats interval packet loss without inventing unknown values", () => {
|
||||
expect(formatPacketLossPercent(1.25)).toBe("1.3%");
|
||||
expect(formatPacketLossPercent(0)).toBe("0.0%");
|
||||
expect(formatPacketLossPercent(null)).toBe("未知");
|
||||
expect(formatPacketLossPercent(Number.NaN)).toBe("未知");
|
||||
});
|
||||
});
|
||||
@@ -241,6 +241,7 @@ describe("viewer quality evidence", () => {
|
||||
resolution: "1920x1080",
|
||||
bitrateKbps: 7_500,
|
||||
packetsLost: 2,
|
||||
packetLossPercent: (2 / 1_502) * 100,
|
||||
framesDropped: 1,
|
||||
intervalFramesDecoded: 120,
|
||||
intervalFramesDropped: 1,
|
||||
|
||||
Reference in new issue
Block a user