Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9d0ac7cac | ||
|
|
1e34923f6a |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lotusguild/element-call-embedded",
|
||||
"version": "0.25.0-lotus.9",
|
||||
"version": "0.25.0-lotus.10",
|
||||
"files": [
|
||||
"README.md",
|
||||
"LICENSE-AGPL-3.0",
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<LivekitRoom, () => 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();
|
||||
};
|
||||
}
|
||||
@@ -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<InCallViewProps> = ({
|
||||
// [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.
|
||||
|
||||
Reference in New Issue
Block a user