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} - - )} )} @@ -2624,6 +2631,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { metrics={snapshot.metrics} direction="send" senderParameters={snapshot.senderParameters} + audioSenderParameters={snapshot.audioSenderParameters} progressive /> )} diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index a09bf1a..a7472a2 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -1728,6 +1728,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { metrics={relaySnapshot.metrics} direction="send" senderParameters={relaySnapshot.senderParameters} + audioSenderParameters={relaySnapshot.audioSenderParameters} /> )} diff --git a/src/client/pages/host-page-notices.ts b/src/client/pages/host-page-notices.ts index 295dda0..463f60b 100644 --- a/src/client/pages/host-page-notices.ts +++ b/src/client/pages/host-page-notices.ts @@ -39,9 +39,3 @@ export function videoCodecLockNotice(phase: string): string | null { ? "本次分享的编码已锁定,停止分享后可修改" : null; } - -export function screenAudioQualityLockNotice(phase: string): string | null { - return phase === "starting" || phase === "live" - ? "本次分享的音频质量已锁定,停止分享后可修改" - : null; -} diff --git a/src/client/sfu/publisher.ts b/src/client/sfu/publisher.ts index 645a081..2ac0e72 100644 --- a/src/client/sfu/publisher.ts +++ b/src/client/sfu/publisher.ts @@ -6,13 +6,18 @@ import type { } from "livekit-client"; import { + audioSenderParameterWarning, + configureScreenAudioSender, configureTwoLayerVideoSender, QUALITY_RESOLUTIONS, resolveScreenAudioQuality, + screenAudioQualityEqual, SCREEN_SHARE_LOW_SCALE, screenAudioBitrate, screenShareLowBitrate, senderParameterWarning, + videoQualitySettingsEqual, + type AudioSenderParameterReadback, type QualityProfile, type ScreenAudioQuality, type VideoSenderParameterReadback, @@ -53,6 +58,12 @@ interface PublishedVideoConfiguration { warning: string | null; } +interface PublishedAudioConfiguration { + sender: RTCRtpSender; + readback: AudioSenderParameterReadback; + warning: string | null; +} + interface PublisherStatsIdentity { video: PublishedTrack; sender: RTCRtpSender; @@ -76,9 +87,14 @@ export class SfuPublisher { private sdk: LiveKit | null = null; private video: PublishedTrack | null = null; private audio: PublishedTrack | null = null; - private profile: QualityProfile | null = null; + private desiredProfile: QualityProfile | null = null; + private appliedVideoProfile: QualityProfile | null = null; + private profileRevision = 0; private senderParameters: VideoSenderParameterReadback | null = null; - private qualityWarning: string | null = null; + private audioSenderParameters: AudioSenderParameterReadback | null = null; + private audioParametersSender: RTCRtpSender | null = null; + private videoQualityWarning: string | null = null; + private audioQualityWarning: string | null = null; private failureStage: SfuPublisherFailureStage | null = null; private state: PublisherState = "idle"; private generation = 0; @@ -119,6 +135,11 @@ export class SfuPublisher { } } }); + room.on(sdk.RoomEvent.Reconnected, () => { + if (this.owns(room, generation) && this.state === "active") { + this.reapplyAudioAfterReconnect(room, generation); + } + }); await room.connect(config.url, config.token, { autoSubscribe: false, @@ -144,6 +165,8 @@ export class SfuPublisher { } activate(stream: MediaStream, profile: QualityProfile): Promise { + this.desiredProfile = profile; + ++this.profileRevision; const generation = this.generation; return this.enqueue(async () => { const room = this.requireOwnedRoom(generation, "prepared"); @@ -182,6 +205,8 @@ export class SfuPublisher { } let audio: PublishedTrack | null = null; + let audioConfiguration: PublishedAudioConfiguration | null = null; + let audioWarning: string | null = null; if (audioTrack) { failureStage = "audio-publish"; audio = await publishTrack( @@ -193,12 +218,36 @@ export class SfuPublisher { if (!this.owns(room, generation)) { return false; } + retainPublishedAudioOptions(audio, profile, sdk); + try { + const audioSender = publishedAudioSender(audio); + audioConfiguration = await configurePublishedAudio( + audio, + audioSender, + profile, + () => this.owns(room, generation), + ); + if (!audioConfiguration || !this.owns(room, generation)) { + return false; + } + audioWarning = audioConfiguration.warning; + } catch (error) { + if (!this.owns(room, generation)) { + return false; + } + audioWarning = audioSenderFailureWarning(error); + } } this.video = video; this.audio = audio; - this.profile = profile; - this.retainSenderParameters(videoConfiguration); + this.appliedVideoProfile = profile; + this.retainSenderParameters( + videoConfiguration, + audioConfiguration?.readback ?? null, + audioWarning, + audioConfiguration?.sender ?? null, + ); this.failureStage = null; this.state = "active"; this.startStats(video); @@ -249,9 +298,14 @@ export class SfuPublisher { this.video = null; this.audio = null; - this.profile = null; + this.desiredProfile = null; + this.appliedVideoProfile = null; + ++this.profileRevision; this.senderParameters = null; - this.qualityWarning = null; + this.audioSenderParameters = null; + this.audioParametersSender = null; + this.videoQualityWarning = null; + this.audioQualityWarning = null; this.state = "prepared"; return true; }); @@ -266,8 +320,9 @@ export class SfuPublisher { } const sdk = this.sdk; const previousVideo = this.video; - const profile = this.profile; - if (!sdk || !previousVideo || !profile) { + const profile = this.desiredProfile; + const previousVideoProfile = this.appliedVideoProfile; + if (!sdk || !previousVideo || !profile || !previousVideoProfile) { throw new Error("SFU publisher has no active video publication"); } @@ -276,6 +331,8 @@ export class SfuPublisher { const previousAudio = this.audio; let videoReplaceAttempted = false; let audioReplaceAttempted = false; + let audioConfiguration: PublishedAudioConfiguration | null = null; + let audioWarning: string | null = null; this.stopStats(); try { @@ -310,6 +367,29 @@ export class SfuPublisher { this.audio = nextAudio; } + const currentAudio = this.audio; + if (currentAudio && nextAudioTrack) { + retainPublishedAudioOptions(currentAudio, profile, sdk); + try { + const audioSender = publishedAudioSender(currentAudio); + audioConfiguration = await configurePublishedAudio( + currentAudio, + audioSender, + profile, + () => this.owns(room, generation), + ); + if (!audioConfiguration || !this.owns(room, generation)) { + return false; + } + audioWarning = audioConfiguration.warning; + } catch (error) { + if (!this.owns(room, generation)) { + return false; + } + audioWarning = audioSenderFailureWarning(error); + } + } + const videoConfiguration = await configurePublishedVideo( previousVideo, profile, @@ -323,7 +403,17 @@ export class SfuPublisher { if (previousAudio && nextAudioTrack) { previousAudio.rawTrack = nextAudioTrack; } - this.retainSenderParameters(videoConfiguration); + this.appliedVideoProfile = profile; + this.retainSenderParameters( + videoConfiguration, + nextAudioTrack + ? (audioConfiguration?.readback ?? this.audioSenderParameters) + : null, + nextAudioTrack ? audioWarning : null, + nextAudioTrack + ? (audioConfiguration?.sender ?? this.audioParametersSender) + : null, + ); this.startStats(previousVideo); return true; } catch (error) { @@ -359,15 +449,16 @@ export class SfuPublisher { } const videoConfiguration = await configurePublishedVideo( previousVideo, - profile, + previousVideoProfile, sdk, () => this.owns(room, generation), ); if (!videoConfiguration || !this.owns(room, generation)) { return false; } + this.appliedVideoProfile = previousVideoProfile; this.senderParameters = videoConfiguration.readback; - this.qualityWarning = mergeQualityWarnings( + this.videoQualityWarning = mergeQualityWarnings( failureWarning, videoConfiguration.warning, ); @@ -385,77 +476,226 @@ export class SfuPublisher { } updateProfile(profile: QualityProfile): Promise { + const previousDesiredProfile = this.desiredProfile; + const requestedVideo = + previousDesiredProfile === null || + !videoQualitySettingsEqual(previousDesiredProfile, profile) || + this.appliedVideoProfile === null || + !videoQualitySettingsEqual(this.appliedVideoProfile, profile); + const requestedAudio = + previousDesiredProfile === null || + !screenAudioQualityEqual(previousDesiredProfile, profile) || + (this.audio !== null && + (this.audio.publication.audioTrack?.sender !== + this.audioParametersSender || + this.audioSenderParameters?.requestedMaxBitrate !== + screenAudioBitrate(profile.screenAudioQuality))); + this.desiredProfile = profile; + const requestedRevision = ++this.profileRevision; + if (this.state === "active" && this.audio && this.sdk) { + retainPublishedAudioOptions(this.audio, profile, this.sdk); + } const generation = this.generation; return this.enqueue(async () => { const room = this.requireOwnedRoom(generation, "active"); - if (!room) { + if (!room || requestedRevision !== this.profileRevision) { return false; } const video = this.video; - const previousProfile = this.profile; + const audio = this.audio; + const previousVideoProfile = this.appliedVideoProfile; const sdk = this.sdk; - if (!video || !previousProfile || !sdk) { + if (!video || !previousVideoProfile || !sdk) { throw new Error("SFU publisher has no active video publication"); } - if ( - resolveScreenAudioQuality(profile.screenAudioQuality) !== - resolveScreenAudioQuality(previousProfile.screenAudioQuality) - ) { - return false; - } - this.stopStats(); - - try { - const videoConfiguration = await configurePublishedVideo( - video, - profile, - sdk, - () => this.owns(room, generation), - ); - if (!videoConfiguration || !this.owns(room, generation)) { - return false; - } - this.profile = profile; - this.retainSenderParameters(videoConfiguration); - this.startStats(video); + const updateVideo = + requestedVideo || + !videoQualitySettingsEqual(previousVideoProfile, profile); + const currentAudioSender = audio?.publication.audioTrack?.sender ?? null; + const updateAudio = + audio !== null && + (requestedAudio || + currentAudioSender !== this.audioParametersSender || + this.audioSenderParameters?.requestedMaxBitrate !== + screenAudioBitrate(profile.screenAudioQuality)); + if (!updateVideo && !updateAudio) { return true; - } catch (error) { - if (!this.owns(room, generation)) { - return false; - } - const failureWarning = - error instanceof Error && error.message - ? `应用 SFU 发送参数失败:${error.message}` - : "应用 SFU 发送参数失败"; + } + if (audio) { + retainPublishedAudioOptions(audio, profile, sdk); + } + if (updateVideo) { + this.stopStats(); + } + + let senderParameters = this.senderParameters; + let videoWarning = this.videoQualityWarning; + let appliedVideoProfile = previousVideoProfile; + let videoSucceeded = true; + if (updateVideo) { try { - const videoConfiguration = await configurePublishedVideo( + const configured = await configurePublishedVideo( video, - previousProfile, + profile, sdk, - () => this.owns(room, generation), + () => + this.owns(room, generation) && + requestedRevision === this.profileRevision && + this.video === video, ); - if (!videoConfiguration || !this.owns(room, generation)) { + if (!configured) { + if (this.video === video) { + this.startStats(video); + } return false; } - this.senderParameters = videoConfiguration.readback; - this.qualityWarning = mergeQualityWarnings( - failureWarning, - videoConfiguration.warning, - ); - this.startStats(video); - return false; - } catch (rollbackError) { - if (this.owns(room, generation)) { - await this.failClosed(room, generation); + senderParameters = configured.readback; + videoWarning = configured.warning; + appliedVideoProfile = profile; + } catch (error) { + if ( + !this.owns(room, generation) || + requestedRevision !== this.profileRevision || + this.video !== video + ) { + if (this.video === video) { + this.startStats(video); + } + return false; + } + videoSucceeded = false; + const failureWarning = + error instanceof Error && error.message + ? `应用 SFU 发送参数失败:${error.message}` + : "应用 SFU 发送参数失败"; + try { + const rolledBack = await configurePublishedVideo( + video, + previousVideoProfile, + sdk, + () => + this.owns(room, generation) && + requestedRevision === this.profileRevision && + this.video === video, + ); + if (!rolledBack) { + if (this.video === video) { + this.startStats(video); + } + return false; + } + senderParameters = rolledBack.readback; + videoWarning = mergeQualityWarnings( + failureWarning, + rolledBack.warning, + ); + } catch (rollbackError) { + if ( + this.owns(room, generation) && + requestedRevision === this.profileRevision && + this.video === video + ) { + await this.failClosed(room, generation); + throw rollbackError; + } + if (this.video === video) { + this.startStats(video); + } + return false; } - throw rollbackError; } } + + if ( + !this.owns(room, generation) || + requestedRevision !== this.profileRevision || + this.video !== video || + this.audio !== audio + ) { + if (updateVideo && this.video === video) { + this.startStats(video); + } + return false; + } + + let audioSenderParameters = this.audioSenderParameters; + let audioParametersSender = this.audioParametersSender; + let audioWarning = this.audioQualityWarning; + let audioSucceeded = true; + if (updateAudio && audio) { + let audioSender: RTCRtpSender | null = null; + try { + audioSender = publishedAudioSender(audio); + const ownedAudioSender = audioSender; + const configured = await configurePublishedAudio( + audio, + ownedAudioSender, + profile, + () => + this.ownsAudioMutation( + room, + generation, + requestedRevision, + audio, + ownedAudioSender, + ), + ); + if (!configured) { + if (updateVideo && this.video === video) { + this.startStats(video); + } + return false; + } + audioSenderParameters = configured.readback; + audioParametersSender = configured.sender; + audioWarning = configured.warning; + } catch (error) { + if ( + !this.owns(room, generation) || + requestedRevision !== this.profileRevision || + this.audio !== audio || + (audioSender !== null && + audio.publication.audioTrack?.sender !== audioSender) + ) { + if (updateVideo && this.video === video) { + this.startStats(video); + } + return false; + } + audioSucceeded = false; + audioWarning = audioSenderFailureWarning(error); + } + } + + if ( + !this.owns(room, generation) || + requestedRevision !== this.profileRevision || + this.video !== video || + this.audio !== audio + ) { + if (updateVideo && this.video === video) { + this.startStats(video); + } + return false; + } + this.senderParameters = senderParameters; + this.audioSenderParameters = audioSenderParameters; + this.audioParametersSender = audioParametersSender; + this.appliedVideoProfile = appliedVideoProfile; + this.videoQualityWarning = videoWarning; + this.audioQualityWarning = audioWarning; + if (updateVideo) { + this.startStats(video); + } + return videoSucceeded && audioSucceeded; }); } getQualityWarning(): string | null { - return this.qualityWarning; + return mergeQualityWarnings( + this.videoQualityWarning, + this.audioQualityWarning, + ); } getFailureStage(): SfuPublisherFailureStage | null { @@ -466,6 +706,64 @@ export class SfuPublisher { return this.senderParameters; } + getAudioSenderParameters(): AudioSenderParameterReadback | null { + return this.audioSenderParameters; + } + + private reapplyAudioAfterReconnect(room: Room, generation: number): void { + void this.enqueue(async () => { + if (!this.owns(room, generation) || this.state !== "active") { + return false; + } + const audio = this.audio; + const profile = this.desiredProfile; + const sdk = this.sdk; + const profileRevision = this.profileRevision; + if (!audio || !profile || !sdk) { + return true; + } + retainPublishedAudioOptions(audio, profile, sdk); + + let audioSender: RTCRtpSender | null = null; + try { + audioSender = publishedAudioSender(audio); + const ownedAudioSender = audioSender; + const configured = await configurePublishedAudio( + audio, + ownedAudioSender, + profile, + () => + this.ownsAudioMutation( + room, + generation, + profileRevision, + audio, + ownedAudioSender, + ), + ); + if (!configured) { + return false; + } + this.audioSenderParameters = configured.readback; + this.audioParametersSender = configured.sender; + this.audioQualityWarning = configured.warning; + return true; + } catch (error) { + if ( + !this.owns(room, generation) || + profileRevision !== this.profileRevision || + this.audio !== audio || + (audioSender !== null && + audio.publication.audioTrack?.sender !== audioSender) + ) { + return false; + } + this.audioQualityWarning = audioSenderFailureWarning(error); + return false; + } + }); + } + async disconnect(): Promise { const room = this.invalidate(); if (room) { @@ -509,6 +807,22 @@ export class SfuPublisher { return this.room === room && this.ownsGeneration(generation); } + private ownsAudioMutation( + room: Room, + generation: number, + profileRevision: number, + audio: PublishedTrack, + sender: RTCRtpSender, + ): boolean { + return ( + this.owns(room, generation) && + this.state === "active" && + this.profileRevision === profileRevision && + this.audio === audio && + audio.publication.audioTrack?.sender === sender + ); + } + private invalidate(): Room | null { if (this.state === "disconnected") { return null; @@ -521,9 +835,14 @@ export class SfuPublisher { this.sdk = null; this.video = null; this.audio = null; - this.profile = null; + this.desiredProfile = null; + this.appliedVideoProfile = null; + ++this.profileRevision; this.senderParameters = null; - this.qualityWarning = null; + this.audioSenderParameters = null; + this.audioParametersSender = null; + this.videoQualityWarning = null; + this.audioQualityWarning = null; return room; } @@ -546,9 +865,15 @@ export class SfuPublisher { private retainSenderParameters( configuration: PublishedVideoConfiguration, + audioSenderParameters: AudioSenderParameterReadback | null, + audioWarning: string | null, + audioParametersSender: RTCRtpSender | null, ): void { this.senderParameters = configuration.readback; - this.qualityWarning = configuration.warning; + this.audioSenderParameters = audioSenderParameters; + this.audioParametersSender = audioParametersSender; + this.videoQualityWarning = configuration.warning; + this.audioQualityWarning = audioWarning; } private startStats(video: PublishedTrack): void { @@ -721,6 +1046,57 @@ async function configurePublishedVideo( }; } +async function configurePublishedAudio( + published: PublishedTrack, + sender: RTCRtpSender, + profile: QualityProfile, + ownsPublication: () => boolean, +): Promise { + if (published.publication.audioTrack?.sender !== sender) { + return null; + } + const readback = await configureScreenAudioSender( + sender, + profile.screenAudioQuality, + ); + if ( + !ownsPublication() || + published.publication.audioTrack?.sender !== sender + ) { + return null; + } + return { + sender, + readback, + warning: audioSenderParameterWarning(readback), + }; +} + +function publishedAudioSender(published: PublishedTrack): RTCRtpSender { + const sender = published.publication.audioTrack?.sender; + if (!sender) { + throw new Error("SFU audio publication has no RTP sender"); + } + return sender; +} + +function retainPublishedAudioOptions( + published: PublishedTrack, + profile: QualityProfile, + sdk: LiveKit, +): void { + published.publication.options = { + ...published.publication.options, + ...audioPublishOptions(sdk, profile.screenAudioQuality), + }; +} + +function audioSenderFailureWarning(error: unknown): string { + return error instanceof Error && error.message + ? `应用 SFU 音频发送参数失败:${error.message}` + : "应用 SFU 音频发送参数失败"; +} + function mergeQualityWarnings(...warnings: Array): string | null { const present = warnings.filter( (warning): warning is string => warning !== null, diff --git a/src/client/types.ts b/src/client/types.ts index 26b608a..a9c8c7c 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -83,6 +83,9 @@ export interface PeerSnapshot { metrics: ConnectionMetrics; error: string | null; senderParameters?: import("./media/quality").VideoSenderParameterReadback | null; + audioSenderParameters?: + | import("./media/quality").AudioSenderParameterReadback + | null; qualityWarning?: string | null; } diff --git a/src/client/webrtc/host-peer.ts b/src/client/webrtc/host-peer.ts index 1f9f9bf..d508f46 100644 --- a/src/client/webrtc/host-peer.ts +++ b/src/client/webrtc/host-peer.ts @@ -1,11 +1,16 @@ import type { IceConfig, SignalPayload } from "../../shared/protocol"; import { createOpaqueId } from "../lib/opaque-id"; -import type { QualityProfile } from "../media/quality"; import { + audioSenderParameterWarning, configureScreenAudioSender, configureVideoSender, resolveScreenAudioQuality, + screenAudioQualityEqual, senderParameterWarning, + videoQualitySettingsEqual, + type AudioSenderParameterReadback, + type QualityProfile, + type ScreenAudioQuality, } from "../media/quality"; import { EMPTY_METRICS, @@ -41,11 +46,18 @@ export class HostPeer { private statsTimer: number | null = null; private statsInFlight = false; private statsSamplingBlocked = false; - private senderWarning: string | null = null; + private videoSenderWarning: string | null = null; + private audioSenderWarning: string | null = null; + private appliedVideoProfile: QualityProfile | null = null; + private appliedAudioQuality: ScreenAudioQuality | null = null; + private appliedAudioSenderParameters: + | AudioSenderParameterReadback + | null = null; private limitationReason: string | null = null; private limitationSamples = 0; private disposed = false; private negotiating = false; + private profileRevision = 0; private senderMutationTail: Promise = Promise.resolve(); private snapshot: PeerSnapshot; @@ -69,6 +81,7 @@ export class HostPeer { metrics: { ...EMPTY_METRICS }, error: null, senderParameters: null, + audioSenderParameters: null, qualityWarning: null, }; this.bindConnectionEvents(); @@ -161,28 +174,59 @@ export class HostPeer { } updateProfile(profile: QualityProfile): Promise { - if ( - this.disposed || - resolveScreenAudioQuality(profile.screenAudioQuality) !== - resolveScreenAudioQuality(this.desiredProfile.screenAudioQuality) - ) { + if (this.disposed) { return Promise.resolve(false); } + const requestedVideo = + !videoQualitySettingsEqual(this.desiredProfile, profile) || + this.appliedVideoProfile === null || + !videoQualitySettingsEqual(this.appliedVideoProfile, profile); + const requestedAudio = + !screenAudioQualityEqual(this.desiredProfile, profile) || + this.appliedAudioQuality !== + resolveScreenAudioQuality(profile.screenAudioQuality); this.desiredProfile = profile; + const requestedRevision = ++this.profileRevision; return this.enqueueSenderMutation(async () => { const videoSender = this.videoSender; - if (this.disposed || !videoSender) { + const audioSender = this.audioSender; + if ( + this.disposed || + !videoSender || + !audioSender || + requestedRevision !== this.profileRevision + ) { return false; } - if (!(await this.configureSender(videoSender))) { + const updateVideo = + requestedVideo || + this.appliedVideoProfile === null || + !videoQualitySettingsEqual(this.appliedVideoProfile, profile); + const updateAudio = + requestedAudio || + this.appliedAudioQuality !== + resolveScreenAudioQuality(profile.screenAudioQuality); + if (!updateVideo && !updateAudio) { + return true; + } + if ( + !(await this.configureSender(videoSender, audioSender, { + profile, + profileRevision: requestedRevision, + video: updateVideo, + audio: updateAudio, + })) + ) { return false; } - if (this.disposed) { + if (this.disposed || requestedRevision !== this.profileRevision) { return false; } - this.statsAccumulator = createStatsAccumulator(); - this.limitationReason = null; - this.limitationSamples = 0; + if (updateVideo) { + this.statsAccumulator = createStatsAccumulator(); + this.limitationReason = null; + this.limitationSamples = 0; + } this.snapshot = { ...this.snapshot, error: null }; this.emit(); return true; @@ -372,7 +416,7 @@ export class HostPeer { ...this.snapshot, metrics, qualityWarning: - this.senderWarning ?? this.persistentLimitationWarning(), + this.combinedSenderWarning() ?? this.persistentLimitationWarning(), }; this.emit(); } catch { @@ -391,45 +435,104 @@ export class HostPeer { private async configureSender( sender: RTCRtpSender, audioSender?: RTCRtpSender, + mutation: { + profile: QualityProfile; + profileRevision: number; + video: boolean; + audio: boolean; + } = { + profile: this.desiredProfile, + profileRevision: this.profileRevision, + video: true, + audio: true, + }, ): Promise { - try { - const senderParameters = await configureVideoSender( - sender, - this.desiredProfile, - ); - if (audioSender?.track) { - await configureScreenAudioSender( - audioSender, - this.desiredProfile.screenAudioQuality, - ); + const { profile, profileRevision } = mutation; + let senderParameters = this.snapshot.senderParameters ?? null; + let audioSenderParameters = this.appliedAudioSenderParameters; + let videoWarning = this.videoSenderWarning; + let audioWarning = this.audioSenderWarning; + let videoSucceeded = true; + let audioSucceeded = true; + + if (mutation.video) { + try { + senderParameters = await configureVideoSender(sender, profile); + videoWarning = senderParameterWarning(senderParameters); + } catch (error) { + videoSucceeded = false; + videoWarning = + error instanceof Error && error.message + ? `应用发送参数失败:${error.message}` + : "应用发送参数失败"; } - if (this.disposed) { - return false; - } - this.senderWarning = senderParameterWarning(senderParameters); - this.snapshot = { - ...this.snapshot, - senderParameters, - qualityWarning: - this.senderWarning ?? this.persistentLimitationWarning(), - }; - this.emit(); - return true; - } catch (error) { - if (this.disposed) { - return false; - } - this.senderWarning = - error instanceof Error && error.message - ? `应用发送参数失败:${error.message}` - : "应用发送参数失败"; - this.snapshot = { - ...this.snapshot, - qualityWarning: this.senderWarning, - }; - this.emit(); + } + + if ( + this.disposed || + profileRevision !== this.profileRevision || + (mutation.video && this.videoSender !== sender) + ) { return false; } + + if (mutation.audio && audioSender) { + if (audioSender.track) { + try { + audioSenderParameters = await configureScreenAudioSender( + audioSender, + profile.screenAudioQuality, + ); + audioWarning = audioSenderParameterWarning(audioSenderParameters); + } catch (error) { + audioSucceeded = false; + audioWarning = + error instanceof Error && error.message + ? `应用音频发送参数失败:${error.message}` + : "应用音频发送参数失败"; + } + } else { + audioSenderParameters = null; + audioWarning = null; + } + } + + if ( + this.disposed || + profileRevision !== this.profileRevision || + (mutation.video && this.videoSender !== sender) || + (mutation.audio && this.audioSender !== audioSender) + ) { + return false; + } + this.videoSenderWarning = videoWarning; + this.audioSenderWarning = audioWarning; + if (mutation.video && videoSucceeded) { + this.appliedVideoProfile = profile; + } + if (mutation.audio && audioSucceeded) { + this.appliedAudioQuality = resolveScreenAudioQuality( + profile.screenAudioQuality, + ); + this.appliedAudioSenderParameters = audioSenderParameters; + } + this.snapshot = { + ...this.snapshot, + senderParameters, + audioSenderParameters: this.appliedAudioSenderParameters, + qualityWarning: + this.combinedSenderWarning() ?? this.persistentLimitationWarning(), + }; + this.emit(); + return videoSucceeded && audioSucceeded; + } + + private combinedSenderWarning(): string | null { + const warnings = [ + this.videoSenderWarning, + this.audioSenderWarning, + ].filter((warning): warning is string => warning !== null); + return warnings.length > 0 ? warnings.join(";") : null; } private updateLimitationWarning(reason: string | null): void { diff --git a/src/client/webrtc/viewer-relay.ts b/src/client/webrtc/viewer-relay.ts index 7072889..b156df9 100644 --- a/src/client/webrtc/viewer-relay.ts +++ b/src/client/webrtc/viewer-relay.ts @@ -3,10 +3,7 @@ import type { PreparedRouteCandidate, SignalPayload, } from "../../shared/protocol"; -import { - resolveScreenAudioQuality, - type QualityProfile, -} from "../media/quality"; +import type { QualityProfile } from "../media/quality"; import type { PeerSnapshot } from "../types"; import { HostPeer } from "./host-peer"; import { MAX_ENDPOINT_MEDIA_CHILDREN } from "./media-assignment"; @@ -183,12 +180,7 @@ export class ViewerRelay { } updateProfile(profile: QualityProfile): Promise { - if ( - this.disposed || - (this.stream !== null && - resolveScreenAudioQuality(profile.screenAudioQuality) !== - resolveScreenAudioQuality(this.desiredProfile.screenAudioQuality)) - ) { + if (this.disposed) { return Promise.resolve(false); } this.desiredProfile = profile; diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index e3cd6b4..b26b69e 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -157,7 +157,12 @@ export const qualityProfileIdSchema = z.enum([ ]); export type QualityProfileId = z.infer; -export const qualityResolutionSchema = z.enum(["720p", "1080p", "1440p"]); +export const qualityResolutionSchema = z.enum([ + "480p", + "720p", + "1080p", + "1440p", +]); export type QualityResolution = z.infer; export const degradationPreferenceSchema = z.enum([ @@ -198,8 +203,8 @@ export const qualitySettingsSchema = z export type QualitySettings = z.infer; export const DEFAULT_QUALITY_SETTINGS = { resolution: "1080p", - maxFramerate: 60, - maxBitrate: 8_000_000, + maxFramerate: 30, + maxBitrate: 5_000_000, degradationPreference: "balanced", videoCodec: "automatic", screenAudioQuality: "music", diff --git a/tests/host-page-notice.test.ts b/tests/host-page-notice.test.ts index 2fc2995..fbaa947 100644 --- a/tests/host-page-notice.test.ts +++ b/tests/host-page-notice.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { - screenAudioQualityLockNotice, shouldPauseLocalPreview, sourceSwitchNotice, videoCodecLockNotice, @@ -60,17 +59,3 @@ describe("videoCodecLockNotice", () => { } }); }); - -describe("screenAudioQualityLockNotice", () => { - it("explains why audio quality is locked during a share", () => { - expect(screenAudioQualityLockNotice("starting")).toBe( - "本次分享的音频质量已锁定,停止分享后可修改", - ); - expect(screenAudioQualityLockNotice("live")).toBe( - "本次分享的音频质量已锁定,停止分享后可修改", - ); - for (const phase of ["idle", "ended", "error"]) { - expect(screenAudioQualityLockNotice(phase)).toBeNull(); - } - }); -}); diff --git a/tests/host-peer-tracks.test.ts b/tests/host-peer-tracks.test.ts index 4871093..6a41642 100644 --- a/tests/host-peer-tracks.test.ts +++ b/tests/host-peer-tracks.test.ts @@ -537,9 +537,87 @@ describe("HostPeer source replacement", () => { ...QUALITY_PROFILES["1080p60"], screenAudioQuality: "very-high", }), - ).resolves.toBe(false); + ).resolves.toBe(true); expect(connection.senders[0]?.setParameters).toHaveBeenCalledTimes(2); - expect(connection.senders[1]?.setParameters).toHaveBeenCalledOnce(); + expect(connection.senders[1]?.setParameters).toHaveBeenCalledTimes(2); + expect(connection.senders[1]?.appliedMaxBitrates).toEqual([ + 128_000, + 256_000, + ]); + expect(peer.getSnapshot().audioSenderParameters).toEqual({ + requestedMaxBitrate: 256_000, + appliedMaxBitrate: 256_000, + mismatch: false, + }); + }); + + it("keeps media and prior audio readback when a live ceiling fails", async () => { + const video = createTrack("video", "video"); + const audio = createTrack("audio", "audio"); + const peer = createPeer(createStream(video, audio)); + + await expect(peer.start()).resolves.toBe(true); + const connection = FakePeerConnection.latest!; + const audioSender = connection.senders[1]!; + audioSender.failNextSetParameters = true; + + await expect( + peer.updateProfile({ + ...QUALITY_PROFILES["720p30"], + screenAudioQuality: "very-high", + }), + ).resolves.toBe(false); + + expect(audioSender.track).toBe(audio); + expect(audioSender.getParameters().encodings[0]?.maxBitrate).toBe(128_000); + expect(peer.getSnapshot().audioSenderParameters?.appliedMaxBitrate).toBe( + 128_000, + ); + expect(peer.getSnapshot().qualityWarning).toContain( + "应用音频发送参数失败", + ); + + await expect( + peer.updateProfile({ + ...QUALITY_PROFILES["720p30"], + screenAudioQuality: "very-high", + }), + ).resolves.toBe(true); + expect(audioSender.getParameters().encodings[0]?.maxBitrate).toBe(256_000); + expect(peer.getSnapshot().qualityWarning).toBeNull(); + }); + + it("keeps rapid audio ceiling changes last-wins", async () => { + const peer = createPeer( + createStream(createTrack("video", "video"), createTrack("audio", "audio")), + ); + await expect(peer.start()).resolves.toBe(true); + const audioSender = FakePeerConnection.latest!.senders[1]!; + audioSender.deferNextSetParameters = true; + + const saver = peer.updateProfile({ + ...QUALITY_PROFILES["720p30"], + screenAudioQuality: "saver", + }); + await vi.waitFor(() => + expect(audioSender.setParameters).toHaveBeenCalledTimes(2), + ); + const veryHigh = peer.updateProfile({ + ...QUALITY_PROFILES["720p30"], + screenAudioQuality: "very-high", + }); + audioSender.releaseDeferredSetParameters(); + + await expect(saver).resolves.toBe(false); + await expect(veryHigh).resolves.toBe(true); + expect(audioSender.appliedMaxBitrates).toEqual([ + 128_000, + 64_000, + 256_000, + ]); + expect(peer.getSnapshot().audioSenderParameters?.appliedMaxBitrate).toBe( + 256_000, + ); }); it("accepts an answer without reapplying the selected profile", async () => { @@ -1603,14 +1681,14 @@ describe("ViewerRelay downstream ownership", () => { }); }); - it("retains the locked audio preset for current and future children", async () => { - const lockedProfile = { + it("applies the latest audio ceiling to current and future children", async () => { + const initialProfile = { ...QUALITY_PROFILES["1080p60"], screenAudioQuality: "saver", } as const; const relay = new ViewerRelay( { iceServers: [] }, - lockedProfile, + initialProfile, { sendSignal: () => true }, ); relay.setChildren(["first-audio-child"]); @@ -1629,11 +1707,14 @@ describe("ViewerRelay downstream ownership", () => { await expect( relay.updateProfile({ - ...lockedProfile, + ...initialProfile, screenAudioQuality: "very-high", }), - ).resolves.toBe(false); - expect(firstConnection.senders[1]?.appliedMaxBitrates).toEqual([64_000]); + ).resolves.toBe(true); + expect(firstConnection.senders[1]?.appliedMaxBitrates).toEqual([ + 64_000, + 256_000, + ]); relay.setChildren(["second-audio-child"]); await vi.waitFor(() => @@ -1641,7 +1722,7 @@ describe("ViewerRelay downstream ownership", () => { ); await vi.waitFor(() => expect(FakePeerConnection.latest?.senders[1]?.appliedMaxBitrates).toEqual([ - 64_000, + 256_000, ]), ); }); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 21ae805..0686610 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -513,6 +513,12 @@ describe("client signaling protocol", () => { qualitySettings: qualitySettingsWithCodec, }).success, ).toBe(true); + expect( + clientMessageSchema.safeParse({ + type: "set-quality-settings", + qualitySettings: { ...qualitySettings, resolution: "480p" }, + }).success, + ).toBe(true); for (const screenAudioQuality of [ "saver", "music", diff --git a/tests/quality.test.ts b/tests/quality.test.ts index 199f04a..61cb5ad 100644 --- a/tests/quality.test.ts +++ b/tests/quality.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + audioSenderParameterWarning, applyCaptureProfile, captureDisplay, configureScreenAudioSender, @@ -8,6 +9,7 @@ import { configureVideoSender, matchingQualityProfileId, QUALITY_PROFILES, + QUALITY_RESOLUTIONS, qualitySettingsEqual, SCREEN_AUDIO_BITRATES, screenShareLowBitrate, @@ -39,6 +41,30 @@ afterEach(() => { }); describe("realtime quality controls", () => { + it("keeps exactly three recommended profiles with 1080p30 as the default", () => { + expect(Object.keys(QUALITY_PROFILES)).toEqual([ + "1080p60", + "1080p30", + "720p30", + ]); + expect(QUALITY_PROFILES["1080p30"]).toMatchObject({ + resolution: "1080p", + maxFramerate: 30, + maxBitrate: 5_000_000, + }); + }); + + it("offers 854x480 only through advanced resolution settings", () => { + expect(QUALITY_RESOLUTIONS["480p"]).toEqual({ + width: 854, + height: 480, + label: "480p", + }); + expect( + Object.values(QUALITY_PROFILES).map((profile) => profile.resolution), + ).not.toContain("480p"); + }); + it("bounds initial capture to the selected profile", async () => { const { stream, videoTrack, audioTrack } = createVideoStream(); const getDisplayMedia = vi.fn(async () => stream); @@ -169,9 +195,11 @@ describe("realtime quality controls", () => { }), } as unknown as RTCRtpSender; - await expect(configureScreenAudioSender(sender, quality)).resolves.toBe( - bitrate, - ); + await expect(configureScreenAudioSender(sender, quality)).resolves.toEqual({ + requestedMaxBitrate: bitrate, + appliedMaxBitrate: bitrate, + mismatch: false, + }); expect(applied.encodings).toEqual([{ maxBitrate: bitrate }]); expect(SCREEN_AUDIO_BITRATES[quality]).toBe(bitrate); }); @@ -185,7 +213,51 @@ describe("realtime quality controls", () => { }), } as unknown as RTCRtpSender; - await expect(configureScreenAudioSender(sender)).resolves.toBe(128_000); + await expect(configureScreenAudioSender(sender)).resolves.toEqual({ + requestedMaxBitrate: 128_000, + appliedMaxBitrate: 128_000, + mismatch: false, + }); + }); + + it("reports an audio ceiling the browser rewrites", async () => { + const sender = { + getParameters: vi + .fn<() => RTCRtpSendParameters>() + .mockReturnValueOnce({ encodings: [{}] } as RTCRtpSendParameters) + .mockReturnValue({ + encodings: [{ maxBitrate: 96_000 }], + } as RTCRtpSendParameters), + setParameters: vi.fn(async () => undefined), + } as unknown as RTCRtpSender; + + const readback = await configureScreenAudioSender(sender, "music"); + + expect(readback).toEqual({ + requestedMaxBitrate: 128_000, + appliedMaxBitrate: 96_000, + mismatch: true, + }); + expect(audioSenderParameterWarning(readback)).toBe( + "浏览器将音频码率上限改写为 96 kbps", + ); + }); + + it("does not mutate prior audio parameters when setParameters fails", async () => { + const applied = { + encodings: [{ maxBitrate: 64_000 }], + } as RTCRtpSendParameters; + const sender = { + getParameters: () => applied, + setParameters: vi.fn(async () => { + throw new Error("rejected"); + }), + } as unknown as RTCRtpSender; + + await expect( + configureScreenAudioSender(sender, "very-high"), + ).rejects.toThrow("rejected"); + expect(applied.encodings[0]?.maxBitrate).toBe(64_000); }); it("reports fields the browser does not retain", async () => { diff --git a/tests/sfu-client.test.ts b/tests/sfu-client.test.ts index 9befce4..b59f6bd 100644 --- a/tests/sfu-client.test.ts +++ b/tests/sfu-client.test.ts @@ -34,6 +34,9 @@ const livekit = vi.hoisted(() => { class FakeSender { track: MediaStreamTrack; + failNextSetParameters = false; + deferNextSetParameters = false; + private releaseSetParameters: (() => void) | null = null; parameters: RTCRtpSendParameters = { codecs: [], encodings: [{}], @@ -49,11 +52,21 @@ const livekit = vi.hoisted(() => { readonly getParameters = vi.fn(() => this.parameters); readonly setParameters = vi.fn( async (parameters: RTCRtpSendParameters): Promise => { + if (this.failNextSetParameters) { + this.failNextSetParameters = false; + throw new Error("audio parameters rejected"); + } const failure = state.nextSenderParameterError; if (failure) { state.nextSenderParameterError = null; throw failure; } + if (this.deferNextSetParameters) { + this.deferNextSetParameters = false; + await new Promise((resolve) => { + this.releaseSetParameters = resolve; + }); + } this.parameters = parameters; }, ); @@ -61,6 +74,11 @@ const livekit = vi.hoisted(() => { async (): Promise => new Map() as unknown as RTCStatsReport, ); + + releaseDeferredSetParameters(): void { + this.releaseSetParameters?.(); + this.releaseSetParameters = null; + } } class FakeLocalTrack { @@ -97,6 +115,7 @@ const livekit = vi.hoisted(() => { readonly publications: Array<{ track: FakeLocalTrack; videoTrack?: FakeLocalTrack; + audioTrack?: FakeLocalTrack; rawTrack: MediaStreamTrack; options: Record; }> = []; @@ -141,6 +160,7 @@ const livekit = vi.hoisted(() => { const publication = { track: localTrack, videoTrack: rawTrack.kind === "video" ? localTrack : undefined, + audioTrack: rawTrack.kind === "audio" ? localTrack : undefined, rawTrack, options, }; @@ -763,7 +783,7 @@ describe("SfuPublisher", () => { expect(publisher.getQualityWarning()).toBeNull(); }); - it("rejects an audio preset change while the share is active", async () => { + it("updates an active audio sender and retained options without republishing", async () => { const publisher = new SfuPublisher(); await publisher.connect(connection); await publisher.activate( @@ -772,6 +792,51 @@ describe("SfuPublisher", () => { ); const room = livekit.state.rooms[0]; const videoPublication = room.localParticipant.publications[0]; + const audioPublication = room.localParticipant.publications[1]; + + await expect( + publisher.updateProfile({ + ...qualityProfile, + screenAudioQuality: "very-high", + }), + ).resolves.toBe(true); + + expect(videoPublication.track.sender.setParameters).toHaveBeenCalledOnce(); + expect(audioPublication.track.sender.setParameters).toHaveBeenCalledTimes(2); + expect(audioPublication.track.sender.parameters.encodings[0]?.maxBitrate).toBe( + 256_000, + ); + expect(audioPublication.options).toMatchObject({ + audioPreset: { maxBitrate: 256_000 }, + forceStereo: true, + dtx: false, + }); + expect(publisher.getAudioSenderParameters()).toEqual({ + requestedMaxBitrate: 256_000, + appliedMaxBitrate: 256_000, + mismatch: false, + }); + expect(room.localParticipant.publishTrack).toHaveBeenCalledTimes(2); + + await room.localParticipant.republishAllTracks(); + expect(room.localParticipant.republishedOptions[1]).toMatchObject({ + audioPreset: { maxBitrate: 256_000 }, + forceStereo: true, + dtx: false, + }); + }); + + it("keeps media and prior audio readback when a live update fails", async () => { + const publisher = new SfuPublisher(); + await publisher.connect(connection); + await publisher.activate( + stream(track("video", "video-1"), track("audio", "audio-1")), + { ...qualityProfile, screenAudioQuality: "music" }, + ); + const room = livekit.state.rooms[0]; + const videoPublication = room.localParticipant.publications[0]; + const audioPublication = room.localParticipant.publications[1]; + audioPublication.track.sender.failNextSetParameters = true; await expect( publisher.updateProfile({ @@ -780,8 +845,104 @@ describe("SfuPublisher", () => { }), ).resolves.toBe(false); + expect(publisher.getAudioSenderParameters()?.appliedMaxBitrate).toBe( + 128_000, + ); + expect(publisher.getQualityWarning()).toContain( + "应用 SFU 音频发送参数失败", + ); + expect(audioPublication.track.sender.parameters.encodings[0]?.maxBitrate).toBe( + 128_000, + ); + expect(audioPublication.options).toMatchObject({ + audioPreset: { maxBitrate: 256_000 }, + }); expect(videoPublication.track.sender.setParameters).toHaveBeenCalledOnce(); + expect(room.disconnect).not.toHaveBeenCalled(); expect(room.localParticipant.publishTrack).toHaveBeenCalledTimes(2); + + await expect( + publisher.updateProfile({ + ...qualityProfile, + screenAudioQuality: "very-high", + }), + ).resolves.toBe(true); + expect(publisher.getAudioSenderParameters()?.appliedMaxBitrate).toBe( + 256_000, + ); + expect(publisher.getQualityWarning()).toBeNull(); + }); + + it("keeps rapid SFU audio updates last-wins", async () => { + const publisher = new SfuPublisher(); + await publisher.connect(connection); + await publisher.activate( + stream(track("video", "video-1"), track("audio", "audio-1")), + { ...qualityProfile, screenAudioQuality: "music" }, + ); + const room = livekit.state.rooms[0]; + const audioPublication = room.localParticipant.publications[1]; + const audioSender = audioPublication.track.sender; + audioSender.deferNextSetParameters = true; + + const saver = publisher.updateProfile({ + ...qualityProfile, + screenAudioQuality: "saver", + }); + await vi.waitFor(() => + expect(audioSender.setParameters).toHaveBeenCalledTimes(2), + ); + const music = publisher.updateProfile({ + ...qualityProfile, + screenAudioQuality: "music", + }); + expect(audioPublication.options).toMatchObject({ + audioPreset: { maxBitrate: 128_000 }, + }); + audioSender.releaseDeferredSetParameters(); + + await expect(saver).resolves.toBe(false); + await expect(music).resolves.toBe(true); + expect(audioSender.parameters.encodings[0]?.maxBitrate).toBe(128_000); + expect(publisher.getAudioSenderParameters()?.appliedMaxBitrate).toBe( + 128_000, + ); + expect(room.localParticipant.republishAllTracks).not.toHaveBeenCalled(); + }); + + it("reapplies the latest audio ceiling to a replacement sender after reconnect", async () => { + const publisher = new SfuPublisher(); + await publisher.connect(connection); + await publisher.activate( + stream(track("video", "video-1"), track("audio", "audio-1")), + { ...qualityProfile, screenAudioQuality: "music" }, + ); + const room = livekit.state.rooms[0]; + const audioPublication = room.localParticipant.publications[1]; + const oldSender = audioPublication.track.sender; + oldSender.deferNextSetParameters = true; + + const updating = publisher.updateProfile({ + ...qualityProfile, + screenAudioQuality: "very-high", + }); + await vi.waitFor(() => + expect(oldSender.setParameters).toHaveBeenCalledTimes(2), + ); + const replacementSender = audioPublication.track.replaceSenderForTest(); + + room.emit(RoomEvent.Reconnected); + oldSender.releaseDeferredSetParameters(); + + await expect(updating).resolves.toBe(false); + await vi.waitFor(() => + expect(replacementSender.setParameters).toHaveBeenCalledOnce(), + ); + expect(replacementSender.parameters.encodings[0]?.maxBitrate).toBe(256_000); + expect(publisher.getAudioSenderParameters()?.appliedMaxBitrate).toBe( + 256_000, + ); + expect(room.localParticipant.republishAllTracks).not.toHaveBeenCalled(); }); it("retains the active profile for LiveKit track restart and republish", async () => {
- {audioQualityLockNotice} -