io.lotus.call_summary gains an optional `details`: the other Matrix users seen and the peak in the call; how long your connection, each other person's, and everyone at once (likely the server) was poor/lost; RTT median/p95, outbound and inbound packet loss and jitter (sampled every 5 s from LiveKit sender/receiver stats); and your talk, mute and screenshare time. People already present when you connect are recorded on Connected (LiveKit sends no ParticipantConnected for them), and joins are idempotent per device. Older hosts ignore the field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
471 lines
16 KiB
TypeScript
471 lines
16 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 {
|
|
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();
|
|
};
|
|
}
|