From c962fe758bdbacfcaf8fd3865fb64e69c06de0c7 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:10:49 +0800 Subject: [PATCH] feat(media): apply live audio ceilings --- src/client/components/StatsGrid.tsx | 17 +- src/client/media/host-sfu-route.ts | 5 + src/client/media/quality.ts | 65 +++- src/client/pages/HostPage.tsx | 88 ++--- src/client/pages/ViewerPage.tsx | 1 + src/client/pages/host-page-notices.ts | 6 - src/client/sfu/publisher.ts | 500 ++++++++++++++++++++++---- src/client/types.ts | 3 + src/client/webrtc/host-peer.ts | 201 ++++++++--- src/client/webrtc/viewer-relay.ts | 12 +- src/shared/protocol.ts | 11 +- tests/host-page-notice.test.ts | 15 - tests/host-peer-tracks.test.ts | 99 ++++- tests/protocol.test.ts | 6 + tests/quality.test.ts | 80 ++++- tests/sfu-client.test.ts | 163 ++++++++- 16 files changed, 1063 insertions(+), 209 deletions(-) diff --git a/src/client/components/StatsGrid.tsx b/src/client/components/StatsGrid.tsx index 1bf0162..2bd8c53 100644 --- a/src/client/components/StatsGrid.tsx +++ b/src/client/components/StatsGrid.tsx @@ -2,7 +2,10 @@ import { ChevronDown } from "lucide-react"; import { useId, useState } from "react"; import type { ConnectionMetrics } from "../types"; -import type { VideoSenderParameterReadback } from "../media/quality"; +import type { + AudioSenderParameterReadback, + VideoSenderParameterReadback, +} from "../media/quality"; import { formatPacketLossPercent } from "./connection-details"; function readableNumber(value: number | null, digits = 0): string { @@ -81,11 +84,13 @@ export function StatsGrid({ metrics, direction, senderParameters, + audioSenderParameters, progressive = false, }: { metrics: ConnectionMetrics; direction: "send" | "receive"; senderParameters?: VideoSenderParameterReadback | null; + audioSenderParameters?: AudioSenderParameterReadback | null; progressive?: boolean; }) { const [secondaryExpanded, setSecondaryExpanded] = useState(false); @@ -232,6 +237,16 @@ export function StatsGrid({ )} + {direction === "send" && audioSenderParameters && ( + `${Math.round(Number(value) / 1_000)} kbps`, + )} + /> + )} {metrics.audioCodecClockRate !== null && ( ; } @@ -45,6 +47,7 @@ interface HostPublisherSlot { export interface HostSfuPublisherSnapshot { metrics: ConnectionMetrics; senderParameters: VideoSenderParameterReadback | null; + audioSenderParameters: AudioSenderParameterReadback | null; } interface HostSfuRouteEvents { @@ -610,6 +613,8 @@ export class HostSfuRoute { this.events.onPublisherUpdate?.({ metrics: { ...metrics }, senderParameters: slot.publisher.getSenderParameters?.() ?? null, + audioSenderParameters: + slot.publisher.getAudioSenderParameters?.() ?? null, }); } } diff --git a/src/client/media/quality.ts b/src/client/media/quality.ts index dd8b58b..9645d60 100644 --- a/src/client/media/quality.ts +++ b/src/client/media/quality.ts @@ -21,15 +21,15 @@ export type { export type QualityProfile = QualitySettings; export const QUALITY_PROFILES = { - "1080p60": DEFAULT_QUALITY_SETTINGS, - "1080p30": { + "1080p60": { resolution: "1080p", - maxFramerate: 30, - maxBitrate: 5_000_000, + maxFramerate: 60, + maxBitrate: 8_000_000, degradationPreference: "balanced", videoCodec: "automatic", screenAudioQuality: "music", }, + "1080p30": DEFAULT_QUALITY_SETTINGS, "720p30": { resolution: "720p", maxFramerate: 30, @@ -47,6 +47,7 @@ export const QUALITY_PROFILE_LABELS = { } as const satisfies Record; export const QUALITY_RESOLUTIONS = { + "480p": { width: 854, height: 480, label: "480p" }, "720p": { width: 1280, height: 720, label: "720p" }, "1080p": { width: 1920, height: 1080, label: "1080p" }, "1440p": { width: 2560, height: 1440, label: "1440p" }, @@ -101,6 +102,12 @@ export interface TwoLayerVideoSenderParameterReadback { high: VideoSenderParameterReadback; } +export interface AudioSenderParameterReadback { + requestedMaxBitrate: number; + appliedMaxBitrate: number | null; + mismatch: boolean; +} + export const SCREEN_SHARE_LOW_SCALE = 2; export function resolveScreenAudioQuality( @@ -122,13 +129,31 @@ export function screenShareLowBitrate(profile: QualityProfile): number { export function qualitySettingsEqual( left: QualitySettings, right: QualitySettings, +): boolean { + return ( + videoQualitySettingsEqual(left, right) && + screenAudioQualityEqual(left, right) + ); +} + +export function videoQualitySettingsEqual( + left: QualitySettings, + right: QualitySettings, ): boolean { return ( left.resolution === right.resolution && left.maxFramerate === right.maxFramerate && left.maxBitrate === right.maxBitrate && left.degradationPreference === right.degradationPreference && - (left.videoCodec ?? "automatic") === (right.videoCodec ?? "automatic") && + (left.videoCodec ?? "automatic") === (right.videoCodec ?? "automatic") + ); +} + +export function screenAudioQualityEqual( + left: QualitySettings, + right: QualitySettings, +): boolean { + return ( resolveScreenAudioQuality(left.screenAudioQuality) === resolveScreenAudioQuality(right.screenAudioQuality) ); @@ -325,14 +350,36 @@ export async function configureVideoSender( export async function configureScreenAudioSender( sender: RTCRtpSender, quality?: ScreenAudioQuality, -): Promise { - const parameters = sender.getParameters(); +): Promise { + const current = sender.getParameters(); + const parameters = { + ...current, + encodings: current.encodings.map((encoding) => ({ ...encoding })), + }; if (parameters.encodings.length === 0) { parameters.encodings = [{}]; } - parameters.encodings[0]!.maxBitrate = screenAudioBitrate(quality); + const requestedMaxBitrate = screenAudioBitrate(quality); + parameters.encodings[0]!.maxBitrate = requestedMaxBitrate; await sender.setParameters(parameters); - return sender.getParameters().encodings[0]?.maxBitrate ?? null; + const appliedMaxBitrate = + sender.getParameters().encodings[0]?.maxBitrate ?? null; + return { + requestedMaxBitrate, + appliedMaxBitrate, + mismatch: appliedMaxBitrate !== requestedMaxBitrate, + }; +} + +export function audioSenderParameterWarning( + readback: AudioSenderParameterReadback, +): string | null { + if (!readback.mismatch) { + return null; + } + return readback.appliedMaxBitrate === null + ? "浏览器未读回音频码率上限" + : `浏览器将音频码率上限改写为 ${Math.round(readback.appliedMaxBitrate / 1_000)} kbps`; } export async function configureTwoLayerVideoSender( diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index 7c46335..cc0fbeb 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -82,6 +82,7 @@ import { resolveScreenAudioQuality, SCREEN_AUDIO_QUALITY_LABELS, setMediaPaused, + videoQualitySettingsEqual, type DegradationPreference, type QualityProfileId, type QualitySettings, @@ -120,7 +121,6 @@ import { reconcileBoundedMediaChildren, } from "../webrtc/media-assignment"; import { - screenAudioQualityLockNotice, shouldPauseLocalPreview, sourceSwitchNotice, videoCodecLockNotice, @@ -722,10 +722,17 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { setSwitchingSource(false); } - function commitQuality(settings: QualitySettings): void { + function commitQuality( + settings: QualitySettings, + preserveAdvancedDraft = false, + ): void { qualitySettingsRef.current = settings; setQualitySettings(settings); - setAdvancedQuality(settings); + setAdvancedQuality((current) => + preserveAdvancedDraft + ? { ...current, screenAudioQuality: settings.screenAudioQuality } + : settings, + ); } function changeVideoCodec(videoCodec: VideoCodecPreference): void { @@ -741,29 +748,27 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { function changeScreenAudioQuality( screenAudioQuality: ScreenAudioQuality, ): void { - if (phase === "starting" || phase === "live") { + if (phase === "starting") { return; } const next = { ...qualitySettingsRef.current, screenAudioQuality }; - qualitySettingsRef.current = next; - setQualitySettings(next); - setAdvancedQuality((current) => ({ ...current, screenAudioQuality })); + void changeQuality(next, true); } - async function changeQuality(nextProfile: QualitySettings): Promise { + async function changeQuality( + nextProfile: QualitySettings, + preserveAdvancedDraft = false, + ): Promise { + const previousProfile = qualitySettingsRef.current; if ( phase === "live" && - ((nextProfile.videoCodec ?? "automatic") !== - (qualitySettingsRef.current.videoCodec ?? "automatic") || - resolveScreenAudioQuality(nextProfile.screenAudioQuality) !== - resolveScreenAudioQuality( - qualitySettingsRef.current.screenAudioQuality, - )) + (nextProfile.videoCodec ?? "automatic") !== + (previousProfile.videoCodec ?? "automatic") ) { return; } if (phase !== "live") { - commitQuality(nextProfile); + commitQuality(nextProfile, preserveAdvancedDraft); return; } @@ -783,9 +788,18 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { qualityChangeRef.current = token; setChangingQuality(true); setNotice(null); + const videoChanged = !videoQualitySettingsEqual( + previousProfile, + nextProfile, + ); + const audioChanged = + resolveScreenAudioQuality(previousProfile.screenAudioQuality) !== + resolveScreenAudioQuality(nextProfile.screenAudioQuality); try { - await applyCaptureProfile(activeStream, nextProfile); + if (videoChanged) { + await applyCaptureProfile(activeStream, nextProfile); + } if ( !isCurrentGeneration(generation) || qualityChangeRef.current !== token || @@ -794,8 +808,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { return; } - commitQuality(nextProfile); - setDetails(captureDetails(activeStream)); + commitQuality(nextProfile, preserveAdvancedDraft); + if (videoChanged) { + setDetails(captureDetails(activeStream)); + } const roomSettingsSent = !peerAssistedRef.current || signalRef.current?.send({ @@ -828,7 +844,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { : null; const connectionWarning = failed > 0 || !sfuUpdated - ? "画质已切换,但部分观看连接未能应用新参数" + ? "分享设置已更新,但部分观看连接未能应用新参数" : null; const syncWarning = roomSettingsSent ? null @@ -836,9 +852,13 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const warning = [sfuWarning ?? connectionWarning, syncWarning] .filter((message): message is string => message !== null) .join(";"); - setNotice( - warning || `画质已切换为 ${qualitySettingsLabel(nextProfile)}`, - ); + const successNotice = + audioChanged && !videoChanged + ? `音频质量已切换为 ${SCREEN_AUDIO_QUALITY_LABELS[resolveScreenAudioQuality(nextProfile.screenAudioQuality)]}` + : videoChanged && audioChanged + ? "分享设置已应用" + : `画质已切换为 ${qualitySettingsLabel(nextProfile)}`; + setNotice(warning || successNotice); } } catch (error) { if ( @@ -1864,7 +1884,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const activeCodeEntryPolicy = room?.codeEntryPolicy ?? creationProfile.codeEntryPolicy; const codecLockNotice = videoCodecLockNotice(phase); - const audioQualityLockNotice = screenAudioQualityLockNotice(phase); return (
@@ -2316,14 +2335,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {

)} -
+
音频质量
{( @@ -2348,7 +2360,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { } disabled={ phase === "starting" || - phase === "live" || + switchingSource || changingQuality } onClick={() => @@ -2359,14 +2371,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { ))}
- {audioQualityLockNotice && ( -

- {audioQualityLockNotice} -

- )}