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
284 lines
12 KiB
TypeScript
284 lines
12 KiB
TypeScript
/*
|
||
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);
|
||
}
|
||
}
|