From 1e34923f6abdc149f5a1678626d09a26438117b1 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sun, 20 Sep 2026 13:39:18 -0400 Subject: [PATCH] =?UTF-8?q?feat(lotus):=20io.lotus.call=5Fsummary=20?= =?UTF-8?q?=E2=80=94=20per-call=20quality=20readout=20at=20hangup=20(#143)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracks the local participant's LiveKit ConnectionQuality (time spent poor/lost) and Reconnecting events for the life of the in-call view and sends one io.lotus.call_summary { durationMs, reconnects, poorMs, verdict } to the host on the first SFU disconnect or on teardown. Nothing is stored or sent anywhere else. CallQualityTracker is pure and unit-tested. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/lotus/lotusActions.ts | 6 ++ src/lotus/lotusCallSummary.test.ts | 93 +++++++++++++++++++ src/lotus/lotusCallSummary.ts | 144 +++++++++++++++++++++++++++++ src/room/InCallView.tsx | 2 + 4 files changed, 245 insertions(+) create mode 100644 src/lotus/lotusCallSummary.test.ts create mode 100644 src/lotus/lotusCallSummary.ts diff --git a/src/lotus/lotusActions.ts b/src/lotus/lotusActions.ts index f2fdf7f2..3e504664 100644 --- a/src/lotus/lotusActions.ts +++ b/src/lotus/lotusActions.ts @@ -46,6 +46,12 @@ export enum LotusWidgetActions { * toggle can reflect reality rather than the requested state. */ DenoiseState = "io.lotus.denoise_state", + /** + * fromWidget: one-shot end-of-call readout for the local participant — + * `{ durationMs, reconnects, poorMs, verdict }` (#143) — sent on SFU + * disconnect or in-call teardown, whichever comes first. + */ + CallSummary = "io.lotus.call_summary", } /** toWidget Lotus actions that must be allow-listed in `initializeWidget`. */ diff --git a/src/lotus/lotusCallSummary.test.ts b/src/lotus/lotusCallSummary.test.ts new file mode 100644 index 00000000..02621528 --- /dev/null +++ b/src/lotus/lotusCallSummary.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 { ConnectionQuality } from "livekit-client"; +import { describe, expect, it } from "vitest"; + +import { CallQualityTracker } from "./lotusCallSummary"; + +const clock = (): { now: () => number; tick: (ms: number) => void } => { + let t = 1_000_000; + return { + now: () => t, + tick: (ms) => { + t += ms; + }, + }; +}; + +describe("CallQualityTracker", () => { + it("reports unknown when nothing was sampled", () => { + const c = clock(); + const tr = new CallQualityTracker(c.now); + tr.start(); + c.tick(60_000); + expect(tr.summary()).toEqual({ + durationMs: 60_000, + reconnects: 0, + poorMs: 0, + verdict: "unknown", + }); + }); + + it("is good when quality stayed fine", () => { + const c = clock(); + const tr = new CallQualityTracker(c.now); + tr.start(); + tr.setQuality(ConnectionQuality.Excellent); + c.tick(30 * 60_000); + expect(tr.summary().verdict).toBe("good"); + }); + + it("accumulates poor time across episodes, including an open one", () => { + const c = clock(); + const tr = new CallQualityTracker(c.now); + tr.start(); + tr.setQuality(ConnectionQuality.Good); + c.tick(60_000); + tr.setQuality(ConnectionQuality.Poor); + c.tick(10_000); + tr.setQuality(ConnectionQuality.Good); + c.tick(60_000); + tr.setQuality(ConnectionQuality.Lost); + c.tick(5_000); + const s = tr.summary(); + expect(s.poorMs).toBe(15_000); + expect(s.durationMs).toBe(135_000); + expect(s.verdict).toBe("fair"); + }); + + it("is poor with many reconnects or mostly-poor quality", () => { + const c = clock(); + const tr = new CallQualityTracker(c.now); + tr.start(); + tr.setQuality(ConnectionQuality.Good); + for (let i = 0; i < 4; i += 1) tr.reconnect(); + c.tick(60_000); + expect(tr.summary()).toMatchObject({ reconnects: 4, verdict: "poor" }); + + const tr2 = new CallQualityTracker(c.now); + tr2.start(); + tr2.setQuality(ConnectionQuality.Poor); + c.tick(60_000); + expect(tr2.summary().verdict).toBe("poor"); + }); + + it("ignores unknown samples and only starts once", () => { + const c = clock(); + const tr = new CallQualityTracker(c.now); + tr.start(); + c.tick(1_000); + tr.start(); + tr.setQuality(ConnectionQuality.Unknown); + c.tick(1_000); + expect(tr.summary()).toMatchObject({ + durationMs: 2_000, + verdict: "unknown", + }); + }); +}); diff --git a/src/lotus/lotusCallSummary.ts b/src/lotus/lotusCallSummary.ts new file mode 100644 index 00000000..13e89c97 --- /dev/null +++ b/src/lotus/lotusCallSummary.ts @@ -0,0 +1,144 @@ +/* +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 { + ConnectionQuality, + type Participant, + type Room as LivekitRoom, + RoomEvent, +} from "livekit-client"; + +import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; +import { widget } from "../widget"; +import { LotusWidgetActions } from "./lotusActions"; +import { lotusSendToHost } from "./lotusWidget"; + +export type CallQualityVerdict = "good" | "fair" | "poor" | "unknown"; + +export interface CallSummary { + /** Wall-clock time connected to the SFU, ms. */ + durationMs: number; + /** LiveKit reconnect attempts during the call. */ + reconnects: number; + /** Time the local connection quality was reported poor/lost, ms. */ + poorMs: number; + verdict: CallQualityVerdict; +} + +/** + * [Gitea #143] Per-call connection quality, kept in memory for the local + * participant only and summarised once at hangup. Nothing is stored or sent + * anywhere but to the host at the end. + */ +export class CallQualityTracker { + private startedAt: number | undefined; + + private poorSince: number | undefined; + + private poorMs = 0; + + private reconnects = 0; + + private sampled = false; + + public constructor(private readonly now: () => number = () => Date.now()) {} + + public start(): void { + if (this.startedAt === undefined) this.startedAt = this.now(); + } + + public setQuality(quality: ConnectionQuality): void { + if (quality === ConnectionQuality.Unknown) return; + this.sampled = true; + const bad = + quality === ConnectionQuality.Poor || quality === ConnectionQuality.Lost; + if (bad && this.poorSince === undefined) this.poorSince = this.now(); + if (!bad && this.poorSince !== undefined) { + this.poorMs += this.now() - this.poorSince; + this.poorSince = undefined; + } + } + + public reconnect(): void { + this.reconnects += 1; + } + + public summary(): CallSummary { + const end = this.now(); + const durationMs = + this.startedAt === undefined ? 0 : Math.max(0, end - this.startedAt); + const poorMs = + this.poorMs + (this.poorSince === undefined ? 0 : end - this.poorSince); + let verdict: CallQualityVerdict = "unknown"; + if (this.sampled && durationMs > 0) { + const poorShare = poorMs / durationMs; + if (poorShare < 0.05 && this.reconnects <= 1) verdict = "good"; + else if (poorShare < 0.25 && this.reconnects <= 3) verdict = "fair"; + else verdict = "poor"; + } + return { durationMs, reconnects: this.reconnects, poorMs, verdict }; + } +} + +/** + * Track the local participant's LiveKit connection quality and reconnects + * for the life of the in-call view, and send `io.lotus.call_summary` to the + * host once — on the first SFU disconnect or on teardown, whichever comes + * first — so the host can show "41 min · connection was good" at hangup. + */ +export function startLotusCallSummary(vm: CallViewModel): () => void { + if (!widget) return () => undefined; + const tracker = new CallQualityTracker(); + let sent = false; + const send = (): void => { + if (sent) return; + sent = true; + lotusSendToHost(LotusWidgetActions.CallSummary, tracker.summary()); + }; + + const listeners = new Map void>(); + const attach = (room: LivekitRoom): void => { + const onQuality = ( + quality: ConnectionQuality, + participant: Participant, + ): void => { + if (participant.isLocal) tracker.setQuality(quality); + }; + const onConnected = (): void => tracker.start(); + const onReconnecting = (): void => tracker.reconnect(); + const onDisconnected = (): void => send(); + room.on(RoomEvent.ConnectionQualityChanged, onQuality); + room.on(RoomEvent.Connected, onConnected); + room.on(RoomEvent.Reconnecting, onReconnecting); + room.on(RoomEvent.Disconnected, onDisconnected); + if (room.state === "connected") tracker.start(); + listeners.set(room, () => { + room.off(RoomEvent.ConnectionQualityChanged, onQuality); + room.off(RoomEvent.Connected, onConnected); + room.off(RoomEvent.Reconnecting, onReconnecting); + room.off(RoomEvent.Disconnected, onDisconnected); + }); + }; + + const sub = vm.allConnections$.subscribe((data) => { + const next = data.getConnections().map((c) => c.livekitRoom); + for (const [room, off] of listeners) { + if (!next.includes(room)) { + off(); + listeners.delete(room); + } + } + for (const room of next) if (!listeners.has(room)) attach(room); + }); + + return () => { + sub.unsubscribe(); + for (const off of listeners.values()) off(); + listeners.clear(); + send(); + }; +} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index ac987193..9d0d96c3 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -35,6 +35,7 @@ import { startLotusAudioInject } from "../lotus/lotusAudioInject"; import { startLotusQuality } from "../lotus/lotusQuality"; import { startLotusDecorations } from "../lotus/lotusDecorations"; import { startLotusDenoise } from "../lotus/lotusDenoise"; +import { startLotusCallSummary } from "../lotus/lotusCallSummary"; import { startLotusDeafen } from "../lotus/lotusDeafen"; import styles from "./InCallView.module.css"; import { GridTile } from "../tile/GridTile"; @@ -309,6 +310,7 @@ export const InCallView: FC = ({ // [lotus] Apply ML denoise to the mic as a first-class audio processor that // survives reconnects (#1 / A7). No-op unless lotusDenoiseSource=1. useEffect(() => startLotusDenoise(vm), [vm]); + useEffect(() => startLotusCallSummary(vm), [vm]); // [lotus] Handle the host's io.lotus.set_deafen action to silence remote // audio (and optionally screenshare audio) at the LiveKit source. No-op // unless the host sends the action.