Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/olive-pears-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'livekit-client': patch
---

Apply the resolved degradation preference to the backup codec's sender

Degradation preference is a property of the sender, not of the track, and a backup codec publishes over its own sender. Previously only the primary sender was configured, so the backup encoder resolved a preference implicitly and could adapt along a different axis than the primary.
2 changes: 1 addition & 1 deletion src/room/RTCEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1095,7 +1095,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
if (!opts.videoCodec) {
return;
}
track.setSimulcastTrackSender(opts.videoCodec, transceiver.sender);
await track.setSimulcastTrackSender(opts.videoCodec, transceiver.sender);
return transceiver.sender;
}

Expand Down
107 changes: 105 additions & 2 deletions src/room/track/LocalVideoTrack.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest';
import { videoLayersFromEncodings } from './LocalVideoTrack';
import { describe, expect, it, vi } from 'vitest';
import LocalVideoTrack, { videoLayersFromEncodings } from './LocalVideoTrack';
import type { SimulcastTrackInfo } from './LocalVideoTrack';
import { VideoQuality } from './Track';
import type { VideoCodec } from './options';

describe('videoLayersFromEncodings', () => {
it('returns single layer for no encoding', () => {
Expand Down Expand Up @@ -131,3 +133,104 @@ describe('videoLayersFromEncodings', () => {
expect(layers[2].width).toBe(720);
});
});

function makeSender(label = 'sender', events: string[] = []) {
let params: RTCRtpSendParameters = {
encodings: [],
transactionId: '',
codecs: [],
headerExtensions: [],
rtcp: {},
};
return {
getParameters: () => params,
// resolves on a later task, like the real setParameters, so that a caller
// which fails to await it never observes the update
setParameters: vi.fn(async (next: RTCRtpSendParameters) => {
events.push(`${label}:start`);
await new Promise((resolve) => setTimeout(resolve, 0));
params = next;
events.push(`${label}:done`);
}),
get degradationPreference() {
return params.degradationPreference;
},
};
}

function makeTrack() {
const track = Object.create(LocalVideoTrack.prototype) as LocalVideoTrack;
Object.assign(track, {
log: { debug: vi.fn(), warn: vi.fn() },
simulcastCodecs: new Map<VideoCodec, SimulcastTrackInfo>(),
subscribedCodecs: undefined,
});
// logContext and mediaStreamTrack are getters we don't set up state for here
Object.defineProperty(track, 'logContext', { get: () => ({}) });
Object.defineProperty(track, 'mediaStreamTrack', { get: () => ({ clone: () => ({}) }) });
return track;
}

describe('setDegradationPreference', () => {
it('applies the preference to the primary sender', async () => {
const track = makeTrack();
const sender = makeSender();
Object.assign(track, { _sender: sender });

await track.setDegradationPreference('maintain-resolution');

expect(sender.degradationPreference).toBe('maintain-resolution');
});

it('applies the resolved preference to a backup codec sender', async () => {
const track = makeTrack();
const primary = makeSender();
Object.assign(track, { _sender: primary });

await track.setDegradationPreference('maintain-resolution');

// the backup codec transceiver is created later, when the server asks for it
const backupInfo = track.addSimulcastTrack('vp8', [])!;
const backup = makeSender();
await track.setSimulcastTrackSender('vp8', backup as unknown as RTCRtpSender);

expect(backupInfo.sender).toBe(backup);
expect(backup.degradationPreference).toBe('maintain-resolution');
expect(primary.degradationPreference).toBe('maintain-resolution');
});

it('updates every sender when the preference changes after the backup is published', async () => {
const track = makeTrack();
const primary = makeSender();
Object.assign(track, { _sender: primary });
await track.setDegradationPreference('maintain-framerate');

track.addSimulcastTrack('vp8', []);
const backup = makeSender();
await track.setSimulcastTrackSender('vp8', backup as unknown as RTCRtpSender);

await track.setDegradationPreference('balanced');

expect(primary.degradationPreference).toBe('balanced');
expect(backup.degradationPreference).toBe('balanced');
});

it('applies to senders one at a time', async () => {
const events: string[] = [];
const track = makeTrack();
const primary = makeSender('primary', events);
Object.assign(track, { _sender: primary });
track.addSimulcastTrack('vp8', []);
await track.setSimulcastTrackSender(
'vp8',
makeSender('backup', events) as unknown as RTCRtpSender,
);
events.length = 0;

await track.setDegradationPreference('balanced');

// setParameters is only valid against the most recent getParameters, so the
// writes must not overlap
expect(events).toEqual(['primary:start', 'primary:done', 'backup:start', 'backup:done']);
});
});
46 changes: 36 additions & 10 deletions src/room/track/LocalVideoTrack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,15 +406,37 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {

async setDegradationPreference(preference: RTCDegradationPreference) {
this.degradationPreference = preference;
if (this.sender) {
try {
this.log.debug(`setting degradationPreference to ${preference}`, this.logContext);
const params = this.sender.getParameters();
params.degradationPreference = preference;
this.sender.setParameters(params);
} catch (e: any) {
this.log.warn(`failed to set degradationPreference`, { error: e, ...this.logContext });
}
// applied one sender at a time on purpose, see applyDegradationPreference
await this.applyDegradationPreference(this.sender);
for (const sc of this.simulcastCodecs.values()) {
await this.applyDegradationPreference(sc.sender);
}
}

/**
* Degradation preference is a property of the sender, not of the track, so every sender
* publishing this track needs it applied separately. A backup codec publishes over its
* own sender, which would otherwise let the browser resolve a preference implicitly and
* diverge from the primary encoder.
*
* Callers apply this sequentially rather than concurrently: `setParameters` is only valid
* against the parameters most recently returned by `getParameters`, which is why this file
* serializes other sender parameter updates through `senderLock`.
*/
private async applyDegradationPreference(sender?: RTCRtpSender) {
if (!sender) {
return;
}
try {
this.log.debug(
`setting degradationPreference to ${this.degradationPreference}`,
this.logContext,
);
const params = sender.getParameters();
params.degradationPreference = this.degradationPreference;
await sender.setParameters(params);
} catch (e: any) {
this.log.warn(`failed to set degradationPreference`, { error: e, ...this.logContext });
}
}

Expand All @@ -436,13 +458,17 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
return simulcastCodecInfo;
}

setSimulcastTrackSender(codec: VideoCodec, sender: RTCRtpSender) {
async setSimulcastTrackSender(codec: VideoCodec, sender: RTCRtpSender) {
const simulcastCodecInfo = this.simulcastCodecs.get(codec);
if (!simulcastCodecInfo) {
return;
}
simulcastCodecInfo.sender = sender;

// the backup codec publishes over its own sender, so it needs the same degradation
// preference the primary sender resolved to.
await this.applyDegradationPreference(sender);

// browser will reenable disabled codec/layers after new codec has been published,
// so refresh subscribedCodecs after publish a new codec
setTimeout(() => {
Expand Down
Loading