feat(media): apply live audio ceilings

This commit is contained in:
AAA committed 2026-08-24 05:58:15 +08:00
1 parent 41b8056e04
commit c962fe758b
16 files changed
+1063 -209

No files matched your search

+16 -1
View File
@@ -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({
</>
)}
<Metric label="音频 Codec" value={metrics.audioCodec ?? "未知"} />
{direction === "send" && audioSenderParameters && (
<Metric
label="音频上限 请求 / 读回"
value={requestedApplied(
audioSenderParameters.requestedMaxBitrate,
audioSenderParameters.appliedMaxBitrate,
(value) => `${Math.round(Number(value) / 1_000)} kbps`,
)}
/>
)}
{metrics.audioCodecClockRate !== null && (
<Metric
label="音频 RTP 时钟"
+5
View File
@@ -11,6 +11,7 @@ import {
} from "../sfu/publisher";
import type { ConnectionMetrics } from "../types";
import type {
AudioSenderParameterReadback,
QualityProfile,
VideoSenderParameterReadback,
} from "./quality";
@@ -30,6 +31,7 @@ interface HostPublisherTransport {
getQualityWarning?(): string | null;
getFailureStage?(): SfuPublisherFailureStage | null;
getSenderParameters?(): VideoSenderParameterReadback | null;
getAudioSenderParameters?(): AudioSenderParameterReadback | null;
disconnect(): Promise<void>;
}
@@ -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,
});
}
}
+56 -9
View File
@@ -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<QualityProfileId, string>;
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<number | null> {
const parameters = sender.getParameters();
): Promise<AudioSenderParameterReadback> {
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(
+48 -40
View File
@@ -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<void> {
async function changeQuality(
nextProfile: QualitySettings,
preserveAdvancedDraft = false,
): Promise<void> {
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 (
<div className="app-shell">
@@ -2316,14 +2335,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
</p>
)}
</fieldset>
<fieldset
className="control-group quality-priority"
aria-describedby={
audioQualityLockNotice
? "screen-audio-quality-lock-notice"
: undefined
}
>
<fieldset className="control-group quality-priority">
<legend>音频质量</legend>
<div className="segmented-control">
{(
@@ -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 = {}) {
</button>
))}
</div>
{audioQualityLockNotice && (
<p
id="screen-audio-quality-lock-notice"
className="control-note"
>
{audioQualityLockNotice}
</p>
)}
</fieldset>
<button
className="button button-secondary"
@@ -2565,6 +2569,9 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
metrics={sfuPublisherSnapshot.metrics}
direction="send"
senderParameters={sfuPublisherSnapshot.senderParameters}
audioSenderParameters={
sfuPublisherSnapshot.audioSenderParameters
}
/>
</article>
)}
@@ -2624,6 +2631,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
metrics={snapshot.metrics}
direction="send"
senderParameters={snapshot.senderParameters}
audioSenderParameters={snapshot.audioSenderParameters}
progressive
/>
)}
+1
View File
@@ -1728,6 +1728,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
metrics={relaySnapshot.metrics}
direction="send"
senderParameters={relaySnapshot.senderParameters}
audioSenderParameters={relaySnapshot.audioSenderParameters}
/>
</section>
)}
-6
View File
@@ -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;
}
+438 -62
View File
@@ -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<boolean> {
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<boolean> {
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<void> {
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<PublishedAudioConfiguration | null> {
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>): string | null {
const present = warnings.filter(
(warning): warning is string => warning !== null,
+3
View File
@@ -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;
}
+152 -49
View File
@@ -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<void> = 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<boolean> {
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<boolean> {
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 {
+2 -10
View File
@@ -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<boolean> {
if (
this.disposed ||
(this.stream !== null &&
resolveScreenAudioQuality(profile.screenAudioQuality) !==
resolveScreenAudioQuality(this.desiredProfile.screenAudioQuality))
) {
if (this.disposed) {
return Promise.resolve(false);
}
this.desiredProfile = profile;
+8 -3
View File
@@ -157,7 +157,12 @@ export const qualityProfileIdSchema = z.enum([
]);
export type QualityProfileId = z.infer<typeof qualityProfileIdSchema>;
export const qualityResolutionSchema = z.enum(["720p", "1080p", "1440p"]);
export const qualityResolutionSchema = z.enum([
"480p",
"720p",
"1080p",
"1440p",
]);
export type QualityResolution = z.infer<typeof qualityResolutionSchema>;
export const degradationPreferenceSchema = z.enum([
@@ -198,8 +203,8 @@ export const qualitySettingsSchema = z
export type QualitySettings = z.infer<typeof qualitySettingsSchema>;
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",
-15
View File
@@ -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();
}
});
});
+90 -9
View File
@@ -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,
]),
);
});
+6
View File
@@ -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",
+76 -4
View File
@@ -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 () => {
+162 -1
View File
@@ -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<void> => {
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<void>((resolve) => {
this.releaseSetParameters = resolve;
});
}
this.parameters = parameters;
},
);
@@ -61,6 +74,11 @@ const livekit = vi.hoisted(() => {
async (): Promise<RTCStatsReport> =>
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<string, unknown>;
}> = [];
@@ -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 () => {