From b78e25b4fb0787201b7342f8a3659407b3413af8 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:12:13 +0800 Subject: [PATCH] feat(web): add audio and loss diagnostics (#120) --- docs/project-memory.md | 2 +- docs/research/browser-screen-audio-quality.md | 45 +++- docs/status.md | 4 +- docs/方案设计.md | 3 +- docs/需求理解.md | 2 +- src/client/components/StatsGrid.tsx | 46 +++- src/client/components/connection-details.ts | 6 + src/client/media/viewer-quality-evidence.ts | 5 + src/client/types.ts | 16 ++ src/client/webrtc/stats.ts | 181 +++++++++++-- tests/client-state.test.ts | 246 +++++++++++++++++- tests/connection-details.test.ts | 12 +- tests/viewer-quality-evidence.test.ts | 1 + 13 files changed, 528 insertions(+), 41 deletions(-) diff --git a/docs/project-memory.md b/docs/project-memory.md index 1102112..01e899f 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -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 diff --git a/docs/research/browser-screen-audio-quality.md b/docs/research/browser-screen-audio-quality.md index 2c41566..1cf8c61 100644 --- a/docs/research/browser-screen-audio-quality.md +++ b/docs/research/browser-screen-audio-quality.md @@ -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) diff --git a/docs/status.md b/docs/status.md index 8fbe390..ed51469 100644 --- a/docs/status.md +++ b/docs/status.md @@ -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 diff --git a/docs/方案设计.md b/docs/方案设计.md index 2693a74..da72811 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -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 只标为网络往返时间,不能冒充玻璃到玻璃延迟。 ## 界面结构 diff --git a/docs/需求理解.md b/docs/需求理解.md index 9271b2f..3b9fae2 100644 --- a/docs/需求理解.md +++ b/docs/需求理解.md @@ -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 后才能生成新邀请。 diff --git a/src/client/components/StatsGrid.tsx b/src/client/components/StatsGrid.tsx index 775ae0d..ebee2b0 100644 --- a/src/client/components/StatsGrid.tsx +++ b/src/client/components/StatsGrid.tsx @@ -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({ - + - + {metrics.codecProfile && ( - + )} {metrics.codecParameters && ( - + + )} + + + + + {metrics.audioCodecClockRate !== null && ( + + )} + {metrics.audioCodecChannels !== null && ( + + )} + {metrics.audioCodecParameters && ( + )} {direction === "send" ? ( <> diff --git a/src/client/components/connection-details.ts b/src/client/components/connection-details.ts index 507c07e..92f739d 100644 --- a/src/client/components/connection-details.ts +++ b/src/client/components/connection-details.ts @@ -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 { diff --git a/src/client/media/viewer-quality-evidence.ts b/src/client/media/viewer-quality-evidence.ts index 2f7973d..14cf9eb 100644 --- a/src/client/media/viewer-quality-evidence.ts +++ b/src/client/media/viewer-quality-evidence.ts @@ -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, diff --git a/src/client/types.ts b/src/client/types.ts index ad29fac..feab37b 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -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, diff --git a/src/client/webrtc/stats.ts b/src/client/webrtc/stats.ts index dab2a05..f0c3e41 100644 --- a/src/client/webrtc/stats.ts +++ b/src/client/webrtc/stats.ts @@ -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"), diff --git a/tests/client-state.test.ts b/tests/client-state.test.ts index 9bd0c39..882a00a 100644 --- a/tests/client-state.test.ts +++ b/tests/client-state.test.ts @@ -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([ + [ + "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([ + [ + "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).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(); }); }); diff --git a/tests/connection-details.test.ts b/tests/connection-details.test.ts index 260f5dd..53d4891 100644 --- a/tests/connection-details.test.ts +++ b/tests/connection-details.test.ts @@ -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("未知"); + }); }); diff --git a/tests/viewer-quality-evidence.test.ts b/tests/viewer-quality-evidence.test.ts index dd56ff8..808224c 100644 --- a/tests/viewer-quality-evidence.test.ts +++ b/tests/viewer-quality-evidence.test.ts @@ -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,