diff --git a/.changeset/olive-pears-repeat.md b/.changeset/olive-pears-repeat.md new file mode 100644 index 0000000000..f0c594b2d5 --- /dev/null +++ b/.changeset/olive-pears-repeat.md @@ -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. diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index ab0a7bb4ae..dd8a514d99 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -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; } diff --git a/src/room/track/LocalVideoTrack.test.ts b/src/room/track/LocalVideoTrack.test.ts index 4b7df6f948..03c2e63afb 100644 --- a/src/room/track/LocalVideoTrack.test.ts +++ b/src/room/track/LocalVideoTrack.test.ts @@ -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', () => { @@ -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(), + 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']); + }); +}); diff --git a/src/room/track/LocalVideoTrack.ts b/src/room/track/LocalVideoTrack.ts index 34f5daba10..5c99fc9b0c 100644 --- a/src/room/track/LocalVideoTrack.ts +++ b/src/room/track/LocalVideoTrack.ts @@ -406,15 +406,37 @@ export default class LocalVideoTrack extends LocalTrack { 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 }); } } @@ -436,13 +458,17 @@ export default class LocalVideoTrack extends LocalTrack { 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(() => {