Files
element-call/src/lotus/lotusQuality.ts
T
Lotus CIandClaude Opus 5 dcba5b6b7e fix(lotus): screenshare bitrate cap is a budget across simulcast layers
The same maxBitrate was written into every encoding, so a 1.5 Mbps cap
could mean 4.5 Mbps aggregate. Distribute proportionally to the layers'
existing ratios (floor, so never over). Tested with a 3-layer case.

Fixes #12

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-13 01:22:20 -04:00

284 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
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 {
type LocalTrack,
ParticipantEvent,
type Room as LivekitRoom,
Track,
} from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import { type IWidgetApiRequest } from "matrix-widget-api";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { widget } from "../widget";
import { LotusWidgetActions } from "./lotusActions";
interface QualitySettings {
/** Max audio (mic) bitrate in bits/sec. */
audioMaxBitrate?: number;
/** Max screenshare video bitrate in bits/sec. */
screenshareMaxBitrate?: number;
/** Max screenshare framerate in fps. */
screenshareMaxFramerate?: number;
}
/**
* Handle the host's `io.lotus.set_quality` toWidget action (#7): apply
* audio/screenshare encoding limits (bitrate, framerate) to the local
* published tracks via `RTCRtpSender.setParameters` — no republish needed.
* These controls live in EC's module scope and were unreachable from the host
* against the prebuilt bundle.
*
* Settings are sticky and re-applied whenever a matching local track is
* (re)published, so they survive mute/unmute and reconnects. The server-side
* voice-limit-guard remains the enforcement backstop.
*
* No effect unless the host sends the action. Returns a teardown function.
*/
export function startLotusQuality(vm: CallViewModel): () => void {
const w = widget;
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<keyof RTCRtpEncodingParameters>
>();
// Per-room LocalTrackPublished listeners, so sticky settings re-apply on
// every (re)publish.
const roomListeners = new Map<LivekitRoom, () => void>();
// Per-room settle re-apply timers, so we can cancel a pending 500ms re-apply
// when a room is removed or on teardown — otherwise it would fire against a
// torn-down room.
const settleTimers = new Map<LivekitRoom, ReturnType<typeof setTimeout>>();
let rooms: LivekitRoom[] = [];
const applyToRoom = (room: LivekitRoom): void => {
const lp = room.localParticipant;
const mic = lp.getTrackPublication(Track.Source.Microphone)?.track as
| LocalTrack
| undefined;
const micDesired: Partial<RTCRtpEncodingParameters> = {};
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 ssDesired: Partial<RTCRtpEncodingParameters> = {};
if (settings.screenshareMaxBitrate !== undefined)
ssDesired.maxBitrate = settings.screenshareMaxBitrate;
if (settings.screenshareMaxFramerate !== undefined)
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);
// Keep the LocalTrackPublished listeners in sync with the connected rooms.
// Drive off the LOCAL participant's connection(s), not `livekitRoomItems$` —
// that stream omits rooms with no remote members (returns null for isLocal),
// so caps wouldn't apply to the local senders while you're alone. Map the
// connections to their livekit rooms exactly like `lotusDenoise.ts` does.
const sub = vm.allConnections$.subscribe((data) => {
const next = data.getConnections().map((c) => c.livekitRoom);
rooms = next;
// Remove listeners for rooms that went away.
for (const [room, off] of roomListeners) {
if (!next.includes(room)) {
off();
roomListeners.delete(room);
}
}
// Add listeners for new rooms + apply current settings to them.
for (const room of next) {
if (!roomListeners.has(room)) {
// Re-apply on (re)publish AND unmute/track-restart: LiveKit's
// refreshSenderEncodings() overwrites maxBitrate/maxFramerate from the
// publish presets on replaceTrack (device/source switch, processor
// toggle, restart-on-unmute), and those paths don't emit
// LocalTrackPublished. The settle re-apply lands after LiveKit's async
// recompute so our cap wins.
const reapply = (): void => {
applyToRoom(room);
// Store the settle timer per room and cancel any pending one, so it
// can be cleared on removal/teardown and never fires against a
// torn-down room.
const prev = settleTimers.get(room);
if (prev !== undefined) clearTimeout(prev);
settleTimers.set(
room,
setTimeout(() => {
settleTimers.delete(room);
applyToRoom(room);
}, 500),
);
};
const events = [
ParticipantEvent.LocalTrackPublished,
ParticipantEvent.TrackUnmuted,
] as const;
for (const e of events) room.localParticipant.on(e, reapply);
roomListeners.set(room, () => {
for (const e of events) room.localParticipant.off(e, reapply);
const t = settleTimers.get(room);
if (t !== undefined) clearTimeout(t);
settleTimers.delete(room);
});
applyToRoom(room);
}
}
});
const handler = (ev: CustomEvent<IWidgetApiRequest>): void => {
w.api.transport.reply(ev.detail, {});
const data = ev.detail.data as Record<string, unknown> | undefined;
if (!data) return;
// Clamp to sane ranges so a typo can't brick the encoder (e.g. a 1 bps mic).
const ranges: Record<keyof QualitySettings, [number, number]> = {
audioMaxBitrate: [6_000, 510_000],
screenshareMaxBitrate: [50_000, 20_000_000],
screenshareMaxFramerate: [1, 60],
};
for (const key of Object.keys(ranges) as (keyof QualitySettings)[]) {
const v = data[key];
if (v === null) settings[key] = undefined;
else if (typeof v === "number" && Number.isFinite(v)) {
const [lo, hi] = ranges[key];
settings[key] = Math.min(hi, Math.max(lo, v));
}
}
applyToAll();
};
w.lazyActions.on(LotusWidgetActions.SetQuality, handler);
return () => {
sub.unsubscribe();
for (const off of roomListeners.values()) off();
roomListeners.clear();
w.lazyActions.off(LotusWidgetActions.SetQuality, handler);
};
}
// [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;
}
// [lotus] Treat `cap` as a budget for the WHOLE simulcast publication rather
// than a per-encoding value, and distribute it across encodings in
// proportion to their existing (pre-patch) ratios — instead of writing `cap`
// into every encoding (#12), which let the aggregate reach up to N× the
// requested cap (and also raised the low layers far above their presets).
// With a single encoding this reduces to the previous behaviour: that
// encoding simply gets `cap`.
// Exported for unit testing only (see lotusQuality.test.ts) — not part of
// the module's public surface used by callers.
export function applyProportionalMaxBitrate(
encodings: RTCRtpEncodingParameters[],
cap: number,
): void {
if (encodings.length <= 1) {
for (const enc of encodings) enc.maxBitrate = cap;
return;
}
const total = encodings.reduce(
(sum, enc) =>
sum + (typeof enc.maxBitrate === "number" ? enc.maxBitrate : 0),
0,
);
if (total <= 0) {
// No existing ratios to scale from: fall back to capping only the
// highest layer (LiveKit orders simulcast encodings low-to-high, so
// that's the last one) and leave the others alone rather than guess.
encodings[encodings.length - 1].maxBitrate = cap;
return;
}
// Math.floor (not round) so rounding error can only push the aggregate
// under the cap, never over it.
for (const enc of encodings) {
const prev = typeof enc.maxBitrate === "number" ? enc.maxBitrate : 0;
enc.maxBitrate = Math.floor((prev / total) * cap);
}
}
// 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<RTCRtpEncodingParameters>,
writtenKeys: WeakMap<RTCRtpSender, Set<keyof RTCRtpEncodingParameters>>,
): Promise<void> {
if (!sender) return;
try {
const params = sender.getParameters();
if (!params.encodings || params.encodings.length === 0)
params.encodings = [{}];
// Screenshare publishes with simulcast (VP8): encodings[0] is the small
// layer and the full-resolution layer — the real bandwidth hog — is a
// later encoding. maxFramerate (and an `undefined` clear) still apply to
// every encoding, but maxBitrate is special-cased (#12): writing the same
// value into every encoding let the aggregate reach N× the requested cap
// and also raised the low layers far above their presets, so instead we
// treat the requested value as a budget for the whole publication and
// distribute it across encodings proportionally to their existing
// ratios.
const { maxBitrate, ...rest } = patch;
for (const enc of params.encodings) Object.assign(enc, rest);
if ("maxBitrate" in patch) {
if (maxBitrate === undefined) {
// Clearing the cap (#11): drop it from every encoding rather than
// scaling — there's no budget to distribute.
for (const enc of params.encodings) enc.maxBitrate = undefined;
} else {
applyProportionalMaxBitrate(params.encodings, maxBitrate);
}
}
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) {
logger.warn("[lotus] set_quality: setParameters failed", e);
}
}