perf(lotus): call_state field-wise dedupe, 500 ms trailing throttle (max 2/s)

Fixes #20

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-13 01:22:20 -04:00
co-authored by Claude Opus 5
parent 501e3fb5ac
commit 1c1394b6ef
2 changed files with 164 additions and 5 deletions
+36 -5
View File
@@ -27,6 +27,29 @@ interface ParticipantState {
videoEnabled: boolean;
}
/**
* [lotus #20] Field-wise equality for `ParticipantState[]`, used in place of
* `JSON.stringify` comparison: cheaper (no serialisation of every
* participant on every emission) and just as correct, since array order here
* is stable (it mirrors `members` from `userMedia$`).
*/
function participantsEqual(
a: ParticipantState[],
b: ParticipantState[],
): boolean {
return (
a.length === b.length &&
a.every(
(p, i) =>
p.id === b[i].id &&
p.userId === b[i].userId &&
p.speaking === b[i].speaking &&
p.audioEnabled === b[i].audioEnabled &&
p.videoEnabled === b[i].videoEnabled,
)
);
}
/**
* Stream per-participant speaking / mute / camera state to the Lotus host
* (cinny) over the widget API, so the host can drive speaking rings, mute
@@ -73,11 +96,19 @@ export function startLotusCallState(vm: CallViewModel): () => void {
),
),
// `speaking` flips rapidly; drop no-op repeats BEFORE throttling so
// the throttle window isn't spent re-emitting an unchanged value, then
// cap the send rate. 250ms is plenty for speaking rings / mute badges
// and keeps the request/response widget traffic modest.
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
throttleTime(250, undefined, { leading: true, trailing: true }),
// the throttle window isn't spent re-emitting an unchanged value.
// Field-wise (cheaper than JSON.stringify, and correct: array order is
// stable since it mirrors `members` from userMedia$).
distinctUntilChanged(participantsEqual),
// [lotus #20] `speaking` is the field that flips constantly in an active
// conversation; mute/camera toggles are rare and user-intentional and
// would ideally stay prompt, but a single combined stream is far
// simpler than splitting it, and the leading+trailing 250ms window
// previously allowed ~8 sends/sec (each re-serialising every
// participant) in a busy call. Trailing-only + a longer window caps
// that to 2/sec while still reflecting mute/camera changes within
// 500ms.
throttleTime(500, undefined, { leading: false, trailing: true }),
)
.subscribe((participants) => {
lotusSendToHost(LotusWidgetActions.CallState, { participants });