diff --git a/src/lotus/lotusQuality.test.ts b/src/lotus/lotusQuality.test.ts new file mode 100644 index 00000000..e8dbce03 --- /dev/null +++ b/src/lotus/lotusQuality.test.ts @@ -0,0 +1,93 @@ +/* +Copyright 2026 Lotus Guild + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { describe, expect, test, vi } from "vitest"; + +import { buildPatch, patchSender } from "./lotusQuality"; + +function makeSender( + initialEncodings: RTCRtpEncodingParameters[] = [{}], +): RTCRtpSender { + let encodings = initialEncodings; + return { + getParameters: vi.fn(() => ({ encodings })), + setParameters: vi.fn(async (params: RTCRtpSendParameters) => { + await Promise.resolve(); + encodings = params.encodings ?? []; + }), + } as unknown as RTCRtpSender; +} + +describe("lotusQuality set_quality -> clear (#11)", () => { + test("clearing a previously-set cap actively unsets it on the sender", async () => { + const sender = makeSender(); + const writtenKeys = new WeakMap< + RTCRtpSender, + Set + >(); + + // Set: audioMaxBitrate = 64000. + const setPatch = buildPatch(sender, { maxBitrate: 64_000 }, writtenKeys); + expect(setPatch).toEqual({ maxBitrate: 64_000 }); + await patchSender(sender, setPatch, writtenKeys); + + expect(sender.getParameters().encodings[0].maxBitrate).toBe(64_000); + expect(writtenKeys.get(sender)).toEqual(new Set(["maxBitrate"])); + + // Clear: host sends `null`, so the caller now wants an empty desired + // patch. buildPatch must still emit an explicit `undefined` for the key + // it previously wrote, instead of an empty patch that leaves the stale + // cap on the sender. + const clearPatch = buildPatch(sender, {}, writtenKeys); + expect(clearPatch).toEqual({ maxBitrate: undefined }); + await patchSender(sender, clearPatch, writtenKeys); + + const finalEncoding = sender.getParameters().encodings[0]; + expect(finalEncoding.maxBitrate).toBeUndefined(); + expect("maxBitrate" in finalEncoding).toBe(true); // explicitly cleared, not merely absent + expect(writtenKeys.has(sender)).toBe(false); + }); + + test("clearing one of several caps leaves the others (and their tracking) intact", async () => { + const sender = makeSender(); + const writtenKeys = new WeakMap< + RTCRtpSender, + Set + >(); + + const setPatch = buildPatch( + sender, + { maxBitrate: 500_000, maxFramerate: 24 }, + writtenKeys, + ); + await patchSender(sender, setPatch, writtenKeys); + expect(writtenKeys.get(sender)).toEqual( + new Set(["maxBitrate", "maxFramerate"]), + ); + + // Only maxFramerate is still desired; maxBitrate should be actively + // cleared. + const clearPatch = buildPatch(sender, { maxFramerate: 24 }, writtenKeys); + expect(clearPatch).toEqual({ maxFramerate: 24, maxBitrate: undefined }); + await patchSender(sender, clearPatch, writtenKeys); + + const finalEncoding = sender.getParameters().encodings[0]; + expect(finalEncoding.maxBitrate).toBeUndefined(); + expect(finalEncoding.maxFramerate).toBe(24); + expect(writtenKeys.get(sender)).toEqual(new Set(["maxFramerate"])); + }); + + test("no sender means an empty patch and no call", () => { + const writtenKeys = new WeakMap< + RTCRtpSender, + Set + >(); + expect(buildPatch(undefined, { maxBitrate: 1000 }, writtenKeys)).toEqual( + {}, + ); + }); +}); diff --git a/src/lotus/lotusQuality.ts b/src/lotus/lotusQuality.ts index 8c182358..4917fd6f 100644 --- a/src/lotus/lotusQuality.ts +++ b/src/lotus/lotusQuality.ts @@ -45,6 +45,14 @@ export function startLotusQuality(vm: CallViewModel): () => void { if (!w) return () => undefined; const settings: QualitySettings = {}; + // [lotus] Tracks which RTCRtpEncodingParameters keys this module has + // actively written on each sender, so a later `null` (clear) can write + // `undefined` into those same keys instead of just dropping the sticky + // setting and leaving the stale cap live on the sender (#11). + const writtenKeys = new WeakMap< + RTCRtpSender, + Set + >(); // Per-room LocalTrackPublished listeners, so sticky settings re-apply on // every (re)publish. const roomListeners = new Map void>(); @@ -57,24 +65,27 @@ export function startLotusQuality(vm: CallViewModel): () => void { const applyToRoom = (room: LivekitRoom): void => { const lp = room.localParticipant; - if (settings.audioMaxBitrate !== undefined) { - const mic = lp.getTrackPublication(Track.Source.Microphone)?.track as - | LocalTrack - | undefined; - void patchSender(mic?.sender, { maxBitrate: settings.audioMaxBitrate }); - } + const mic = lp.getTrackPublication(Track.Source.Microphone)?.track as + | LocalTrack + | undefined; + const micDesired: Partial = {}; + if (settings.audioMaxBitrate !== undefined) + micDesired.maxBitrate = settings.audioMaxBitrate; + const micPatch = buildPatch(mic?.sender, micDesired, writtenKeys); + if (Object.keys(micPatch).length > 0) + void patchSender(mic?.sender, micPatch, writtenKeys); - const ssPatch: Partial = {}; + const ssDesired: Partial = {}; if (settings.screenshareMaxBitrate !== undefined) - ssPatch.maxBitrate = settings.screenshareMaxBitrate; + ssDesired.maxBitrate = settings.screenshareMaxBitrate; if (settings.screenshareMaxFramerate !== undefined) - ssPatch.maxFramerate = settings.screenshareMaxFramerate; - if (Object.keys(ssPatch).length > 0) { - const ss = lp.getTrackPublication(Track.Source.ScreenShare)?.track as - | LocalTrack - | undefined; - void patchSender(ss?.sender, ssPatch); - } + ssDesired.maxFramerate = settings.screenshareMaxFramerate; + const ss = lp.getTrackPublication(Track.Source.ScreenShare)?.track as + | LocalTrack + | undefined; + const ssPatch = buildPatch(ss?.sender, ssDesired, writtenKeys); + if (Object.keys(ssPatch).length > 0) + void patchSender(ss?.sender, ssPatch, writtenKeys); }; const applyToAll = (): void => rooms.forEach(applyToRoom); @@ -164,9 +175,35 @@ export function startLotusQuality(vm: CallViewModel): () => void { }; } -async function patchSender( +// [lotus] Build the patch to actually send to a sender: the desired caps, +// plus an explicit `undefined` for any key this module previously wrote to +// this sender but no longer wants (see writtenKeys / #11) — otherwise a +// cleared sticky setting would simply be skipped here and the stale +// maxBitrate/maxFramerate would stay live on the RTCRtpSender. +// Exported for unit testing only (see lotusQuality.test.ts) — not part of +// the module's public surface used by callers. +export function buildPatch( + sender: RTCRtpSender | undefined, + desired: Partial, + writtenKeys: WeakMap>, +): Partial { + if (!sender) return {}; + const patch: Partial = { ...desired }; + const prev = writtenKeys.get(sender); + if (prev) { + for (const key of prev) { + if (!(key in patch)) patch[key] = undefined; + } + } + return patch; +} + +// Exported for unit testing only (see lotusQuality.test.ts) — not part of +// the module's public surface used by callers. +export async function patchSender( sender: RTCRtpSender | undefined, patch: Partial, + writtenKeys: WeakMap>, ): Promise { if (!sender) return; try { @@ -178,6 +215,15 @@ async function patchSender( // full-resolution layer — the real bandwidth hog — is a later encoding. for (const enc of params.encodings) Object.assign(enc, patch); await sender.setParameters(params); + // Remember only the caps that are still active (defined) after this + // write, so a later clear knows exactly which keys to unset. + const active = new Set(); + for (const [key, value] of Object.entries(patch)) { + if (value !== undefined) + active.add(key as keyof RTCRtpEncodingParameters); + } + if (active.size > 0) writtenKeys.set(sender, active); + else writtenKeys.delete(sender); } catch (e) { logger.warn("[lotus] set_quality: setParameters failed", e); }