Files
element-call/src/lotus/lotusCallSummary.ts
T

471 lines
16 KiB
TypeScript
Raw Normal View History

/*
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 LocalAudioTrack,
type LocalTrackPublication,
type Participant,
ParticipantEvent,
type RemoteAudioTrack,
type RemoteParticipant,
type Room as LivekitRoom,
RoomEvent,
Track,
type TrackPublication,
} 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;
/** [cinny #253] The breakdown behind the host's "Details" card. */
details?: CallSummaryDetails;
}
/** [cinny #253] Everything the host's post-call Details card shows. */
export interface CallSummaryDetails {
/** Other Matrix users seen in the call (`@user:server`), first-seen order. */
participants: string[];
/** Most people in the call at once, you included. */
peakParticipants: number;
quality: {
/** Your connection reported poor/lost, ms. */
youPoorMs: number;
/** Each other person's connection reported poor/lost, ms (only > 0). */
othersPoorMs: Record<string, number>;
/** Everyone, you included, poor at the same time (points at the server). */
everyonePoorMs: number;
};
network: {
rttMedianMs?: number;
rttP95Ms?: number;
/** Your microphone's packets the SFU reported lost, %. */
outboundLossPct?: number;
/** Packets lost on the audio you received, %. */
inboundLossPct?: number;
jitterMs?: number;
};
activity: { speakingMs: number; mutedMs: number; screenshareMs: number };
}
/** Time spent in an on/off state, e.g. muted. */
class Stopwatch {
private since: number | undefined;
private total = 0;
public set(on: boolean, now: number): void {
if (on && this.since === undefined) this.since = now;
if (!on && this.since !== undefined) {
this.total += now - this.since;
this.since = undefined;
}
}
public get on(): boolean {
return this.since !== undefined;
}
public ms(now: number): number {
return this.total + (this.since === undefined ? 0 : now - this.since);
}
}
/** `@user:server:DEVICE` (a LiveKit identity) → `@user:server`. */
export function matrixUserOf(identity: string): string {
if (!identity.startsWith("@")) return identity;
const first = identity.indexOf(":");
const second = first === -1 ? -1 : identity.indexOf(":", first + 1);
return second === -1 ? identity : identity.slice(0, second);
}
const percentile = (sorted: number[], p: number): number =>
sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
/**
* [cinny #253] Who was in the call, whose connection was poor and when,
* network numbers, and your own activity. Fed by LiveKit events and periodic
* stats samples; nothing leaves the frame except the final summary, sent to
* the host only.
*/
export class CallDetailsTracker {
// user -> identities (devices) currently in the call
private readonly devices = new Map<string, Set<string>>();
private readonly seen: string[] = [];
private peak = 1;
private readonly poor = new Map<string, Stopwatch>();
private readonly youPoor = new Stopwatch();
private readonly everyonePoor = new Stopwatch();
private readonly rtts: number[] = [];
private readonly jitters: number[] = [];
private readonly sent = new Map<string, { packets: number; lost: number }>();
private readonly received = new Map<
string,
{ packets: number; lost: number }
>();
private readonly speaking = new Stopwatch();
private readonly muted = new Stopwatch();
private readonly sharing = new Stopwatch();
public constructor(private readonly now: () => number = () => Date.now()) {}
/** Idempotent per device: safe to call for everyone already present too. */
public remoteJoined(identity: string): void {
const user = matrixUserOf(identity);
let ids = this.devices.get(user);
if (!ids) this.devices.set(user, (ids = new Set()));
ids.add(identity);
if (!this.seen.includes(user)) this.seen.push(user);
this.peak = Math.max(this.peak, this.devices.size + 1);
this.updateEveryone();
}
public remoteLeft(identity: string): void {
const user = matrixUserOf(identity);
const ids = this.devices.get(user);
ids?.delete(identity);
if (!ids || ids.size === 0) {
this.devices.delete(user);
this.poor.get(user)?.set(false, this.now());
}
this.updateEveryone();
}
/** `identity` undefined = the local participant. */
public setQuality(
identity: string | undefined,
quality: ConnectionQuality,
): void {
if (quality === ConnectionQuality.Unknown) return;
const bad =
quality === ConnectionQuality.Poor || quality === ConnectionQuality.Lost;
if (identity === undefined) this.youPoor.set(bad, this.now());
else {
const user = matrixUserOf(identity);
let sw = this.poor.get(user);
if (!sw) this.poor.set(user, (sw = new Stopwatch()));
sw.set(bad, this.now());
}
this.updateEveryone();
}
private updateEveryone(): void {
const others = [...this.devices.keys()];
const all =
this.youPoor.on &&
others.length > 0 &&
others.every((u) => this.poor.get(u)?.on);
this.everyonePoor.set(all, this.now());
}
public addRtt(seconds: number | undefined): void {
if (typeof seconds === "number" && Number.isFinite(seconds) && seconds >= 0)
this.rtts.push(seconds * 1000);
}
public addJitter(seconds: number | undefined): void {
if (typeof seconds === "number" && Number.isFinite(seconds) && seconds >= 0)
this.jitters.push(seconds * 1000);
}
/** Cumulative counters for one sent/received track (latest value wins). */
public setSent(key: string, packets?: number, lost?: number): void {
if (typeof packets === "number")
this.sent.set(key, { packets, lost: Math.max(0, lost ?? 0) });
}
public setReceived(key: string, packets?: number, lost?: number): void {
if (typeof packets === "number")
this.received.set(key, { packets, lost: Math.max(0, lost ?? 0) });
}
public setSpeaking(on: boolean): void {
this.speaking.set(on, this.now());
}
public setMuted(on: boolean): void {
this.muted.set(on, this.now());
}
public setScreensharing(on: boolean): void {
this.sharing.set(on, this.now());
}
public details(): CallSummaryDetails {
const now = this.now();
const othersPoorMs: Record<string, number> = {};
for (const [user, sw] of this.poor) {
const ms = sw.ms(now);
if (ms > 0) othersPoorMs[user] = ms;
}
const lossPct = (
m: Map<string, { packets: number; lost: number }>,
): number | undefined => {
let packets = 0;
let lost = 0;
for (const v of m.values()) {
packets += v.packets;
lost += v.lost;
}
return packets + lost > 0
? Math.round((lost / (packets + lost)) * 1000) / 10
: undefined;
};
const rtts = [...this.rtts].sort((a, b) => a - b);
const network: CallSummaryDetails["network"] = {};
if (rtts.length) {
network.rttMedianMs = Math.round(percentile(rtts, 0.5));
network.rttP95Ms = Math.round(percentile(rtts, 0.95));
}
const out = lossPct(this.sent);
if (out !== undefined) network.outboundLossPct = out;
const inn = lossPct(this.received);
if (inn !== undefined) network.inboundLossPct = inn;
if (this.jitters.length)
network.jitterMs = Math.round(
this.jitters.reduce((a, b) => a + b, 0) / this.jitters.length,
);
return {
participants: [...this.seen],
peakParticipants: this.peak,
quality: {
youPoorMs: this.youPoor.ms(now),
othersPoorMs,
everyonePoorMs: this.everyonePoor.ms(now),
},
network,
activity: {
speakingMs: this.speaking.ms(now),
mutedMs: this.muted.ms(now),
screenshareMs: this.sharing.ms(now),
},
};
}
}
/**
* [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();
const details = new CallDetailsTracker();
let sent = false;
const send = (): void => {
if (sent) return;
sent = true;
clearInterval(sampler);
lotusSendToHost(LotusWidgetActions.CallSummary, {
...tracker.summary(),
details: details.details(),
});
};
// [cinny #253] Network numbers: sample sender/receiver stats every 5 s.
const sample = async (room: LivekitRoom): Promise<void> => {
const mic = room.localParticipant.getTrackPublication(
Track.Source.Microphone,
)?.track as LocalAudioTrack | undefined;
const s = await mic?.getSenderStats?.().catch(() => undefined);
if (s) {
details.addRtt(s.roundTripTime);
details.setSent(`${room.name}:mic`, s.packetsSent, s.packetsLost);
}
for (const p of room.remoteParticipants.values()) {
for (const pub of p.audioTrackPublications.values()) {
const t = pub.track as RemoteAudioTrack | undefined;
const r = await t?.getReceiverStats?.().catch(() => undefined);
if (r) {
details.addJitter(r.jitter);
details.setReceived(
`${room.name}:${p.identity}:${pub.trackSid}`,
r.packetsReceived,
r.packetsLost,
);
}
}
}
};
const sampler = setInterval(() => {
for (const room of listeners.keys())
void sample(room).catch(() => undefined);
}, 5_000);
const listeners = new Map<LivekitRoom, () => void>();
const attach = (room: LivekitRoom): void => {
const onQuality = (
quality: ConnectionQuality,
participant: Participant,
): void => {
if (participant.isLocal) tracker.setQuality(quality);
details.setQuality(
participant.isLocal ? undefined : participant.identity,
quality,
);
};
// LiveKit doesn't send ParticipantConnected for people already in the
// room when we connect, and the mic isn't published yet at attach time:
// record both once the connection is up.
const onConnected = (): void => {
tracker.start();
for (const p of room.remoteParticipants.values())
details.remoteJoined(p.identity);
details.setMuted(!room.localParticipant.isMicrophoneEnabled);
};
const onReconnecting = (): void => tracker.reconnect();
const onDisconnected = (): void => send();
const onJoined = (p: RemoteParticipant): void =>
details.remoteJoined(p.identity);
const onLeft = (p: RemoteParticipant): void =>
details.remoteLeft(p.identity);
const isLocalMic = (pub: TrackPublication, p: Participant): boolean =>
p.isLocal && pub.source === Track.Source.Microphone;
const onMuted = (pub: TrackPublication, p: Participant): void => {
if (isLocalMic(pub, p)) details.setMuted(true);
};
const onUnmuted = (pub: TrackPublication, p: Participant): void => {
if (isLocalMic(pub, p)) details.setMuted(false);
};
const onLocalPublished = (pub: LocalTrackPublication): void => {
if (pub.source === Track.Source.ScreenShare)
details.setScreensharing(true);
if (pub.source === Track.Source.Microphone) details.setMuted(pub.isMuted);
};
const onLocalUnpublished = (pub: LocalTrackPublication): void => {
if (pub.source === Track.Source.ScreenShare)
details.setScreensharing(false);
};
const onSpeaking = (speaking: boolean): void =>
details.setSpeaking(speaking);
room.on(RoomEvent.ConnectionQualityChanged, onQuality);
room.on(RoomEvent.Connected, onConnected);
room.on(RoomEvent.Reconnecting, onReconnecting);
room.on(RoomEvent.Disconnected, onDisconnected);
room.on(RoomEvent.ParticipantConnected, onJoined);
room.on(RoomEvent.ParticipantDisconnected, onLeft);
room.on(RoomEvent.TrackMuted, onMuted);
room.on(RoomEvent.TrackUnmuted, onUnmuted);
room.on(RoomEvent.LocalTrackPublished, onLocalPublished);
room.on(RoomEvent.LocalTrackUnpublished, onLocalUnpublished);
room.localParticipant.on(ParticipantEvent.IsSpeakingChanged, onSpeaking);
if (room.state === "connected") onConnected();
listeners.set(room, () => {
room.off(RoomEvent.ConnectionQualityChanged, onQuality);
room.off(RoomEvent.Connected, onConnected);
room.off(RoomEvent.Reconnecting, onReconnecting);
room.off(RoomEvent.Disconnected, onDisconnected);
room.off(RoomEvent.ParticipantConnected, onJoined);
room.off(RoomEvent.ParticipantDisconnected, onLeft);
room.off(RoomEvent.TrackMuted, onMuted);
room.off(RoomEvent.TrackUnmuted, onUnmuted);
room.off(RoomEvent.LocalTrackPublished, onLocalPublished);
room.off(RoomEvent.LocalTrackUnpublished, onLocalUnpublished);
room.localParticipant.off(ParticipantEvent.IsSpeakingChanged, onSpeaking);
});
};
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();
};
}