feat(media): apply live audio ceilings
This commit is contained in:
1 parent
41b8056e04
commit
c962fe758b
16 files changed
+1063
-209
No files matched your search
@@ -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 时钟"
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1728,6 +1728,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
metrics={relaySnapshot.metrics}
|
||||
direction="send"
|
||||
senderParameters={relaySnapshot.senderParameters}
|
||||
audioSenderParameters={relaySnapshot.audioSenderParameters}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user