fix(lotus): set_quality null actually clears a cap on the sender
A null cap only removed the key from the sticky settings and applyToRoom then skipped that sender, leaving the previously written maxBitrate / maxFramerate live on the RTCRtpSender — contrary to the host contract documented in cinny's CallControl. Track which keys this module wrote per sender and write them back as undefined on clear. Unit-tested. Fixes #11 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
co-authored by
Claude Opus 5
parent
fd957badff
commit
cbd42bf975
@@ -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<keyof RTCRtpEncodingParameters>
|
||||||
|
>();
|
||||||
|
|
||||||
|
// 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<keyof RTCRtpEncodingParameters>
|
||||||
|
>();
|
||||||
|
|
||||||
|
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<keyof RTCRtpEncodingParameters>
|
||||||
|
>();
|
||||||
|
expect(buildPatch(undefined, { maxBitrate: 1000 }, writtenKeys)).toEqual(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+62
-16
@@ -45,6 +45,14 @@ export function startLotusQuality(vm: CallViewModel): () => void {
|
|||||||
if (!w) return () => undefined;
|
if (!w) return () => undefined;
|
||||||
|
|
||||||
const settings: QualitySettings = {};
|
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<keyof RTCRtpEncodingParameters>
|
||||||
|
>();
|
||||||
// Per-room LocalTrackPublished listeners, so sticky settings re-apply on
|
// Per-room LocalTrackPublished listeners, so sticky settings re-apply on
|
||||||
// every (re)publish.
|
// every (re)publish.
|
||||||
const roomListeners = new Map<LivekitRoom, () => void>();
|
const roomListeners = new Map<LivekitRoom, () => void>();
|
||||||
@@ -57,24 +65,27 @@ export function startLotusQuality(vm: CallViewModel): () => void {
|
|||||||
const applyToRoom = (room: LivekitRoom): void => {
|
const applyToRoom = (room: LivekitRoom): void => {
|
||||||
const lp = room.localParticipant;
|
const lp = room.localParticipant;
|
||||||
|
|
||||||
if (settings.audioMaxBitrate !== undefined) {
|
const mic = lp.getTrackPublication(Track.Source.Microphone)?.track as
|
||||||
const mic = lp.getTrackPublication(Track.Source.Microphone)?.track as
|
| LocalTrack
|
||||||
| LocalTrack
|
| undefined;
|
||||||
| undefined;
|
const micDesired: Partial<RTCRtpEncodingParameters> = {};
|
||||||
void patchSender(mic?.sender, { maxBitrate: settings.audioMaxBitrate });
|
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<RTCRtpEncodingParameters> = {};
|
const ssDesired: Partial<RTCRtpEncodingParameters> = {};
|
||||||
if (settings.screenshareMaxBitrate !== undefined)
|
if (settings.screenshareMaxBitrate !== undefined)
|
||||||
ssPatch.maxBitrate = settings.screenshareMaxBitrate;
|
ssDesired.maxBitrate = settings.screenshareMaxBitrate;
|
||||||
if (settings.screenshareMaxFramerate !== undefined)
|
if (settings.screenshareMaxFramerate !== undefined)
|
||||||
ssPatch.maxFramerate = settings.screenshareMaxFramerate;
|
ssDesired.maxFramerate = settings.screenshareMaxFramerate;
|
||||||
if (Object.keys(ssPatch).length > 0) {
|
const ss = lp.getTrackPublication(Track.Source.ScreenShare)?.track as
|
||||||
const ss = lp.getTrackPublication(Track.Source.ScreenShare)?.track as
|
| LocalTrack
|
||||||
| LocalTrack
|
| undefined;
|
||||||
| undefined;
|
const ssPatch = buildPatch(ss?.sender, ssDesired, writtenKeys);
|
||||||
void patchSender(ss?.sender, ssPatch);
|
if (Object.keys(ssPatch).length > 0)
|
||||||
}
|
void patchSender(ss?.sender, ssPatch, writtenKeys);
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyToAll = (): void => rooms.forEach(applyToRoom);
|
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<RTCRtpEncodingParameters>,
|
||||||
|
writtenKeys: WeakMap<RTCRtpSender, Set<keyof RTCRtpEncodingParameters>>,
|
||||||
|
): Partial<RTCRtpEncodingParameters> {
|
||||||
|
if (!sender) return {};
|
||||||
|
const patch: Partial<RTCRtpEncodingParameters> = { ...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,
|
sender: RTCRtpSender | undefined,
|
||||||
patch: Partial<RTCRtpEncodingParameters>,
|
patch: Partial<RTCRtpEncodingParameters>,
|
||||||
|
writtenKeys: WeakMap<RTCRtpSender, Set<keyof RTCRtpEncodingParameters>>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!sender) return;
|
if (!sender) return;
|
||||||
try {
|
try {
|
||||||
@@ -178,6 +215,15 @@ async function patchSender(
|
|||||||
// full-resolution layer — the real bandwidth hog — is a later encoding.
|
// full-resolution layer — the real bandwidth hog — is a later encoding.
|
||||||
for (const enc of params.encodings) Object.assign(enc, patch);
|
for (const enc of params.encodings) Object.assign(enc, patch);
|
||||||
await sender.setParameters(params);
|
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<keyof RTCRtpEncodingParameters>();
|
||||||
|
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) {
|
} catch (e) {
|
||||||
logger.warn("[lotus] set_quality: setParameters failed", e);
|
logger.warn("[lotus] set_quality: setParameters failed", e);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user