import { getLogger } from "../util/logger.js"; const log = getLogger("live-voice-metrics"); export type LiveVoiceMetricsClock = () => number; export type LiveVoiceMetricsEvent = | "session_started" | "session_ready" | "turn_started" | "first_audio" | "assistant_dispatch" | "first_partial" | "vad_speech_start" | "ptt_release" | "utterance_end" | "endpoint_decision" | "endpoint_commit" | "barge_in" | "final_transcript" | "first_assistant_delta" | "progress_spoken" | "first_tts_audio" | "turn_completed" | "turn_cancelled" | "session_ended"; // The endpoint outcome recorded for a silence boundary: the speculative // front-door leg either holds the utterance open or releases it to the turn. export type VoiceEndpointAction = "release" | "hold"; // Which decider produced the endpoint outcome: the speculative front-door // leg, or the STT provider's own turn-detection stream. export type VoiceEndpointSource = "front-door" | "provider"; const DEFAULT_ENDPOINT_SOURCE: VoiceEndpointSource = "front-door"; // Semantic-endpointing decision on a silence boundary. interface LiveVoiceEndpointDecisionMark { action: VoiceEndpointAction; latencyMs: number; source?: VoiceEndpointSource; } type LiveVoiceTurnStatus = "active" | "completed" | "cancelled"; interface LiveVoiceMetricsCollectorOptions { sessionId: string; conversationId?: string; clock?: LiveVoiceMetricsClock; emit?: (frame: LiveVoiceMetricsFrame) => void; recentTurnLimit?: number; } interface LiveVoiceSessionMetrics { sessionId: string; conversationId?: string; startedAtMs: number; readyAtMs: number | null; startToReadyMs: number | null; } // Marks captured before a turn opened in the collector (server-VAD overlap); // passed to startTurn to backfill the new turn's timestamps. export interface LiveVoiceTurnSeedMarks { firstAudioAtMs?: number; firstPartialAtMs?: number; speechStartAtMs?: number; utteranceEndAtMs?: number; finalTranscriptAtMs?: number; } const SEEDABLE_MARK_FIELDS = [ "firstAudioAtMs", "firstPartialAtMs", "speechStartAtMs", "utteranceEndAtMs", "finalTranscriptAtMs", ] as const satisfies ReadonlyArray; interface LiveVoiceTurnTimestamps { startedAtMs: number; firstAudioAtMs: number | null; firstPartialAtMs: number | null; speechStartAtMs: number | null; pttReleaseAtMs: number | null; utteranceEndAtMs: number | null; bargeInAtMs: number | null; finalTranscriptAtMs: number | null; // First assistant-leg dispatch of the turn (first-wins across hold // replays): the moment the felt-latency clock starts, unlike // finalTranscriptAtMs which can predate the boundary by the caller's // whole multi-segment utterance. assistantDispatchAtMs: number | null; firstAssistantDeltaAtMs: number | null; firstTtsAudioAtMs: number | null; completedAtMs: number | null; cancelledAtMs: number | null; } interface LiveVoiceTurnDurations { firstAudioToFirstPartialMs: number | null; pttReleaseToFinalTranscriptMs: number | null; utteranceEndToFinalTranscriptMs: number | null; finalTranscriptToFirstAssistantDeltaMs: number | null; // Dispatch-anchored versions of the two numbers above: what the leg (and // the caller's ear) actually waited, immune to final-transcript anchor // inflation on multi-segment utterances. dispatchToFirstAssistantDeltaMs: number | null; dispatchToFirstTtsAudioMs: number | null; firstAssistantDeltaToFirstTtsAudioMs: number | null; // End-of-speech (utterance_end, or ptt_release in manual mode) to first // TTS audio: the server-side turn round trip. roundTripMs: number | null; totalTurnDurationMs: number | null; } interface LiveVoiceTurnMetrics { turnId: string; status: LiveVoiceTurnStatus; cancellationReason: string | null; timestamps: LiveVoiceTurnTimestamps; durations: LiveVoiceTurnDurations; // End of turn measured from the local VAD speech-stop mark to the commit, // recorded on every committed turn whichever decider owned the boundary. // Absent on a turn that never committed, and in push-to-talk mode, where // there is no local speech-stop mark to anchor to. endpointCommitLatencyMs?: number; // Present only when endpointing or progress narration engaged, so // turns that never touch the features carry no trace of them. endpointHoldCount?: number; endpointDecisionMaxLatencyMs?: number; endpointDecisionSource?: VoiceEndpointSource; progressUpdatesSpoken?: number; } interface LiveVoiceDurationSummary { count: number; p50Ms: number | null; p95Ms: number | null; } interface LiveVoiceMetricsSummary { retainedTurnCount: number; completedTurnCount: number; cancelledTurnCount: number; durations: { firstAudioToFirstPartialMs: LiveVoiceDurationSummary; pttReleaseToFinalTranscriptMs: LiveVoiceDurationSummary; utteranceEndToFinalTranscriptMs: LiveVoiceDurationSummary; finalTranscriptToFirstAssistantDeltaMs: LiveVoiceDurationSummary; firstAssistantDeltaToFirstTtsAudioMs: LiveVoiceDurationSummary; roundTripMs: LiveVoiceDurationSummary; totalTurnDurationMs: LiveVoiceDurationSummary; }; } interface LiveVoiceMetricsSnapshot { session: LiveVoiceSessionMetrics; activeTurn: LiveVoiceTurnMetrics | null; recentTurns: LiveVoiceTurnMetrics[]; summary: LiveVoiceMetricsSummary; } interface LiveVoiceMetricsAggregateFields { sttMs: number | null; llmFirstDeltaMs: number | null; // Dispatch-anchored felt latency: leg dispatch to first delta / first TTS // audio, immune to the final-transcript anchor inflation llmFirstDeltaMs // suffers on multi-segment utterances. dispatchToFirstDeltaMs: number | null; dispatchToFirstAudioMs: number | null; ttsFirstAudioMs: number | null; roundTripMs: number | null; totalMs: number | null; // Optional so metrics frames stay byte-identical when the turn never // committed and when the front-model features never engaged (see the // matching fields on LiveVoiceTurnMetrics). endpointCommitLatencyMs?: number; endpointHoldCount?: number; endpointDecisionMaxLatencyMs?: number; endpointDecisionSource?: VoiceEndpointSource; progressUpdatesSpoken?: number; } export interface LiveVoiceMetricsFrame { type: "metrics"; event: LiveVoiceMetricsEvent; sessionId: string; conversationId?: string; turnId?: string; metrics: LiveVoiceMetricsSnapshot; } interface MutableTurn { turnId: string; status: LiveVoiceTurnStatus; cancellationReason: string | null; timestamps: LiveVoiceTurnTimestamps; // Null means the turn never committed (see LiveVoiceTurnMetrics). endpointCommitLatencyMs: number | null; endpointHoldCount: number; // Doubles as the "decider was consulted" latch: null means no endpoint // decision was ever recorded for the turn. endpointDecisionMaxLatencyMs: number | null; // The decider behind the turn's most recent endpoint decision. endpointDecisionSource: VoiceEndpointSource | null; progressUpdatesSpoken: number; } const DEFAULT_RECENT_TURN_LIMIT = 50; export class LiveVoiceMetricsCollector { private readonly sessionId: string; private readonly conversationId?: string; private readonly clock: LiveVoiceMetricsClock; private readonly emitFrame?: (frame: LiveVoiceMetricsFrame) => void; private readonly recentTurnLimit: number; private readonly sessionStartedAtMs: number; private readyAtMs: number | null = null; private lastTimestampMs = Number.NEGATIVE_INFINITY; private nextTurnNumber = 1; private activeTurn: MutableTurn | null = null; private readonly recentTurns: MutableTurn[] = []; constructor(options: LiveVoiceMetricsCollectorOptions) { this.sessionId = options.sessionId; this.conversationId = options.conversationId; this.clock = options.clock ?? Date.now; this.emitFrame = options.emit; this.recentTurnLimit = normalizeRecentTurnLimit(options.recentTurnLimit); this.sessionStartedAtMs = this.timestamp(); this.emit("session_started"); } markReady(): LiveVoiceMetricsFrame { if (this.readyAtMs === null) { this.readyAtMs = this.timestamp(); } return this.emit("session_ready"); } startTurn( turnId = this.createTurnId(), seedMarks: LiveVoiceTurnSeedMarks = {}, ): LiveVoiceTurnMetrics { if (this.activeTurn !== null) { this.cancelTurn("superseded"); } this.activeTurn = { turnId, status: "active", cancellationReason: null, timestamps: { startedAtMs: this.timestamp(), firstAudioAtMs: null, firstPartialAtMs: null, speechStartAtMs: null, pttReleaseAtMs: null, utteranceEndAtMs: null, bargeInAtMs: null, finalTranscriptAtMs: null, assistantDispatchAtMs: null, firstAssistantDeltaAtMs: null, firstTtsAudioAtMs: null, completedAtMs: null, cancelledAtMs: null, }, endpointCommitLatencyMs: null, endpointHoldCount: 0, endpointDecisionMaxLatencyMs: null, endpointDecisionSource: null, progressUpdatesSpoken: 0, }; this.applySeedMarks(this.activeTurn, seedMarks); this.emit("turn_started", turnId); return snapshotTurn(this.activeTurn); } // Backfills marks stashed before this turn opened. Seeds never overwrite // an existing mark (first timestamp wins) and are clamped to the turn's // start timestamp; the earliest seed becomes the turn start so total // durations cover the stashed span. private applySeedMarks( turn: MutableTurn, seeds: LiveVoiceTurnSeedMarks, ): void { const startedAtMs = turn.timestamps.startedAtMs; let earliestMs = startedAtMs; for (const field of SEEDABLE_MARK_FIELDS) { const value = seeds[field]; if (value === undefined || !Number.isFinite(value)) { continue; } if (turn.timestamps[field] !== null) { continue; } const seededMs = Math.min(value, startedAtMs); turn.timestamps[field] = seededMs; earliestMs = Math.min(earliestMs, seededMs); } turn.timestamps.startedAtMs = earliestMs; } markFirstAudio(turnId?: string): LiveVoiceMetricsFrame { const turn = this.ensureActiveTurn(turnId); if (turn.timestamps.firstAudioAtMs === null) { turn.timestamps.firstAudioAtMs = this.timestamp(); } return this.emit("first_audio", turn.turnId); } markFirstPartial(turnId?: string): LiveVoiceMetricsFrame { const turn = this.ensureActiveTurn(turnId); if (turn.timestamps.firstPartialAtMs === null) { turn.timestamps.firstPartialAtMs = this.timestamp(); } return this.emit("first_partial", turn.turnId); } markSpeechStart(turnId?: string): LiveVoiceMetricsFrame { const turn = this.ensureActiveTurn(turnId); if (turn.timestamps.speechStartAtMs === null) { turn.timestamps.speechStartAtMs = this.timestamp(); } return this.emit("vad_speech_start", turn.turnId); } markPushToTalkRelease(turnId?: string): LiveVoiceMetricsFrame { const turn = this.ensureActiveTurn(turnId); if (turn.timestamps.pttReleaseAtMs === null) { turn.timestamps.pttReleaseAtMs = this.timestamp(); } return this.emit("ptt_release", turn.turnId); } markUtteranceEnd(turnId?: string): LiveVoiceMetricsFrame { const turn = this.ensureActiveTurn(turnId); if (turn.timestamps.utteranceEndAtMs === null) { turn.timestamps.utteranceEndAtMs = this.timestamp(); } return this.emit("utterance_end", turn.turnId); } // Unlike the first-wins timestamp marks, every decision accumulates: holds // bump the per-turn count and both outcomes feed the worst-latency figure. markEndpointDecision( turnId: string | undefined, decision: LiveVoiceEndpointDecisionMark, ): LiveVoiceMetricsFrame { const turn = this.ensureActiveTurn(turnId); if (decision.action === "hold") { turn.endpointHoldCount += 1; } turn.endpointDecisionMaxLatencyMs = Math.max( turn.endpointDecisionMaxLatencyMs ?? 0, normalizeLatencyMs(decision.latencyMs), ); turn.endpointDecisionSource = decision.source ?? DEFAULT_ENDPOINT_SOURCE; return this.emit("endpoint_decision", turn.turnId); } // First-wins, since an utterance commits once. markEndpointCommit( turnId: string | undefined, latencyMs: number, ): LiveVoiceMetricsFrame { const turn = this.ensureActiveTurn(turnId); if (turn.endpointCommitLatencyMs === null) { turn.endpointCommitLatencyMs = normalizeLatencyMs(latencyMs); } return this.emit("endpoint_commit", turn.turnId); } // A counter, not a first-wins mark: every spoken progress narration bumps // the per-turn count. markProgressSpoken(turnId?: string): LiveVoiceMetricsFrame { const turn = this.ensureActiveTurn(turnId); turn.progressUpdatesSpoken += 1; return this.emit("progress_spoken", turn.turnId); } markBargeIn(turnId?: string): LiveVoiceMetricsFrame { const turn = this.ensureActiveTurn(turnId); if (turn.timestamps.bargeInAtMs === null) { turn.timestamps.bargeInAtMs = this.timestamp(); } return this.emit("barge_in", turn.turnId); } markFinalTranscript(turnId?: string): LiveVoiceMetricsFrame { const turn = this.ensureActiveTurn(turnId); if (turn.timestamps.finalTranscriptAtMs === null) { turn.timestamps.finalTranscriptAtMs = this.timestamp(); } return this.emit("final_transcript", turn.turnId); } markAssistantDispatch(turnId?: string): LiveVoiceMetricsFrame { const turn = this.ensureActiveTurn(turnId); if (turn.timestamps.assistantDispatchAtMs === null) { turn.timestamps.assistantDispatchAtMs = this.timestamp(); } return this.emit("assistant_dispatch", turn.turnId); } markFirstAssistantDelta(turnId?: string): LiveVoiceMetricsFrame { const turn = this.ensureActiveTurn(turnId); if (turn.timestamps.firstAssistantDeltaAtMs === null) { turn.timestamps.firstAssistantDeltaAtMs = this.timestamp(); } return this.emit("first_assistant_delta", turn.turnId); } markFirstTtsAudio(turnId?: string): LiveVoiceMetricsFrame { const turn = this.ensureActiveTurn(turnId); if (turn.timestamps.firstTtsAudioAtMs === null) { turn.timestamps.firstTtsAudioAtMs = this.timestamp(); } return this.emit("first_tts_audio", turn.turnId); } completeTurn(turnId?: string): LiveVoiceTurnMetrics { const turn = this.ensureActiveTurn(turnId); if (turn.status === "active") { turn.status = "completed"; turn.timestamps.completedAtMs = this.timestamp(); this.finishTurn(turn); } this.emit("turn_completed", turn.turnId); return snapshotTurn(turn); } cancelTurn(reason = "cancelled", turnId?: string): LiveVoiceTurnMetrics { const turn = this.ensureActiveTurn(turnId); if (turn.status === "active") { turn.status = "cancelled"; turn.cancellationReason = reason; turn.timestamps.cancelledAtMs = this.timestamp(); this.finishTurn(turn); } this.emit("turn_cancelled", turn.turnId); return snapshotTurn(turn); } getSnapshot(): LiveVoiceMetricsSnapshot { return { session: this.getSessionMetrics(), activeTurn: this.activeTurn ? snapshotTurn(this.activeTurn) : null, recentTurns: this.recentTurns.map(snapshotTurn), summary: summarizeTurns(this.recentTurns), }; } private getSessionMetrics(): LiveVoiceSessionMetrics { return { sessionId: this.sessionId, conversationId: this.conversationId, startedAtMs: this.sessionStartedAtMs, readyAtMs: this.readyAtMs, startToReadyMs: duration(this.sessionStartedAtMs, this.readyAtMs), }; } private ensureActiveTurn(turnId?: string): MutableTurn { if (this.activeTurn === null) { return this.mutableStartTurn(turnId ?? this.createTurnId()); } if (turnId !== undefined && this.activeTurn.turnId !== turnId) { this.cancelTurn("superseded"); return this.mutableStartTurn(turnId); } return this.activeTurn; } private mutableStartTurn(turnId: string): MutableTurn { this.startTurn(turnId); if (this.activeTurn === null) { throw new Error("Live voice metrics failed to start a turn."); } return this.activeTurn; } private finishTurn(turn: MutableTurn): void { if (this.activeTurn === turn) { this.activeTurn = null; } this.recentTurns.push(cloneMutableTurn(turn)); while (this.recentTurns.length > this.recentTurnLimit) { this.recentTurns.shift(); } this.logFinishedTurn(turn); } // One structured line per finished turn so latency is greppable from // daemon logs without a connected client. private logFinishedTurn(turn: MutableTurn): void { const finishReason = turn.status === "completed" ? "completed" : (turn.cancellationReason ?? "cancelled"); log.info( { sessionId: this.sessionId, conversationId: this.conversationId, turnId: turn.turnId, finishReason, ...aggregateFieldsForTurn(snapshotTurn(turn)), }, "Live voice turn latency", ); } private timestamp(): number { const raw = this.clock(); if (!Number.isFinite(raw)) { throw new Error( `Live voice metrics clock returned a non-finite value: ${raw}`, ); } const normalized = Math.max(this.lastTimestampMs, raw); this.lastTimestampMs = normalized; return normalized; } private emit( event: LiveVoiceMetricsEvent, turnId?: string, ): LiveVoiceMetricsFrame { const frame: LiveVoiceMetricsFrame = { type: "metrics", event, sessionId: this.sessionId, conversationId: this.conversationId, turnId, metrics: this.getSnapshot(), }; this.emitFrame?.(frame); return frame; } private createTurnId(): string { const turnId = `turn-${this.nextTurnNumber}`; this.nextTurnNumber += 1; return turnId; } } export function getLiveVoiceMetricsAggregateFields( snapshot: LiveVoiceMetricsSnapshot, turnId?: string, ): LiveVoiceMetricsAggregateFields { const turn = selectTurnForAggregate(snapshot, turnId); if (!turn) { return { sttMs: null, llmFirstDeltaMs: null, dispatchToFirstDeltaMs: null, dispatchToFirstAudioMs: null, ttsFirstAudioMs: null, roundTripMs: null, totalMs: null, }; } return aggregateFieldsForTurn(turn); } function aggregateFieldsForTurn( turn: LiveVoiceTurnMetrics, ): LiveVoiceMetricsAggregateFields { return { // Manual mode stamps ptt_release; server-VAD sessions stamp utterance_end // instead, so the VAD boundary plays the sttMs role there. sttMs: turn.durations.pttReleaseToFinalTranscriptMs ?? turn.durations.utteranceEndToFinalTranscriptMs, llmFirstDeltaMs: turn.durations.finalTranscriptToFirstAssistantDeltaMs, dispatchToFirstDeltaMs: turn.durations.dispatchToFirstAssistantDeltaMs, dispatchToFirstAudioMs: turn.durations.dispatchToFirstTtsAudioMs, ttsFirstAudioMs: turn.durations.firstAssistantDeltaToFirstTtsAudioMs, roundTripMs: turn.durations.roundTripMs, totalMs: turn.durations.totalTurnDurationMs, ...optionalTurnFields(turn), }; } // Shared optional fields for turn snapshots and aggregate frame fields: the // commit latency is absent unless the turn committed against a local // speech-stop mark, and the front-model fields are absent unless the endpoint // decider was consulted or a progress narration spoke, so turns // that never touch the features are unchanged. function optionalTurnFields( // Accepts both MutableTurn (null = unset) and snapshot (absent = unset). turn: { endpointCommitLatencyMs?: number | null; endpointHoldCount?: number | null; endpointDecisionMaxLatencyMs?: number | null; endpointDecisionSource?: VoiceEndpointSource | null; progressUpdatesSpoken?: number | null; }, ): Pick< LiveVoiceTurnMetrics, | "endpointCommitLatencyMs" | "endpointHoldCount" | "endpointDecisionMaxLatencyMs" | "endpointDecisionSource" | "progressUpdatesSpoken" > { const progressUpdatesSpoken = turn.progressUpdatesSpoken ?? 0; return { ...(turn.endpointCommitLatencyMs != null ? { endpointCommitLatencyMs: turn.endpointCommitLatencyMs } : {}), ...(turn.endpointDecisionMaxLatencyMs != null ? { endpointHoldCount: turn.endpointHoldCount ?? 0, endpointDecisionMaxLatencyMs: turn.endpointDecisionMaxLatencyMs, endpointDecisionSource: turn.endpointDecisionSource ?? DEFAULT_ENDPOINT_SOURCE, } : {}), ...(progressUpdatesSpoken > 0 ? { progressUpdatesSpoken } : {}), }; } function normalizeRecentTurnLimit(limit: number | undefined): number { if (limit === undefined) { return DEFAULT_RECENT_TURN_LIMIT; } if (!Number.isFinite(limit) || limit < 1) { return DEFAULT_RECENT_TURN_LIMIT; } return Math.floor(limit); } function selectTurnForAggregate( snapshot: LiveVoiceMetricsSnapshot, turnId: string | undefined, ): LiveVoiceTurnMetrics | null { if (turnId !== undefined) { if (snapshot.activeTurn?.turnId === turnId) { return snapshot.activeTurn; } const matchingRecentTurn = snapshot.recentTurns.find( (turn) => turn.turnId === turnId, ); if (matchingRecentTurn) { return matchingRecentTurn; } } return ( snapshot.activeTurn ?? snapshot.recentTurns[snapshot.recentTurns.length - 1] ?? null ); } function cloneMutableTurn(turn: MutableTurn): MutableTurn { return { turnId: turn.turnId, status: turn.status, cancellationReason: turn.cancellationReason, timestamps: { ...turn.timestamps }, endpointCommitLatencyMs: turn.endpointCommitLatencyMs, endpointHoldCount: turn.endpointHoldCount, endpointDecisionMaxLatencyMs: turn.endpointDecisionMaxLatencyMs, endpointDecisionSource: turn.endpointDecisionSource, progressUpdatesSpoken: turn.progressUpdatesSpoken, }; } function snapshotTurn(turn: MutableTurn): LiveVoiceTurnMetrics { const timestamps = { ...turn.timestamps }; return { turnId: turn.turnId, status: turn.status, cancellationReason: turn.cancellationReason, timestamps, ...optionalTurnFields(turn), durations: { firstAudioToFirstPartialMs: duration( timestamps.firstAudioAtMs, timestamps.firstPartialAtMs, ), pttReleaseToFinalTranscriptMs: duration( timestamps.pttReleaseAtMs, timestamps.finalTranscriptAtMs, ), utteranceEndToFinalTranscriptMs: duration( timestamps.utteranceEndAtMs, timestamps.finalTranscriptAtMs, ), finalTranscriptToFirstAssistantDeltaMs: duration( timestamps.finalTranscriptAtMs, timestamps.firstAssistantDeltaAtMs, ), dispatchToFirstAssistantDeltaMs: duration( timestamps.assistantDispatchAtMs, timestamps.firstAssistantDeltaAtMs, ), dispatchToFirstTtsAudioMs: duration( timestamps.assistantDispatchAtMs, timestamps.firstTtsAudioAtMs, ), firstAssistantDeltaToFirstTtsAudioMs: duration( timestamps.firstAssistantDeltaAtMs, timestamps.firstTtsAudioAtMs, ), roundTripMs: duration( timestamps.utteranceEndAtMs ?? timestamps.pttReleaseAtMs, timestamps.firstTtsAudioAtMs, ), totalTurnDurationMs: duration( timestamps.startedAtMs, timestamps.completedAtMs ?? timestamps.cancelledAtMs, ), }, }; } function summarizeTurns(turns: MutableTurn[]): LiveVoiceMetricsSummary { const snapshots = turns.map(snapshotTurn); const durations = snapshots.map((turn) => turn.durations); return { retainedTurnCount: snapshots.length, completedTurnCount: snapshots.filter((turn) => turn.status === "completed") .length, cancelledTurnCount: snapshots.filter((turn) => turn.status === "cancelled") .length, durations: { firstAudioToFirstPartialMs: summarizeDuration( durations.map((value) => value.firstAudioToFirstPartialMs), ), pttReleaseToFinalTranscriptMs: summarizeDuration( durations.map((value) => value.pttReleaseToFinalTranscriptMs), ), utteranceEndToFinalTranscriptMs: summarizeDuration( durations.map((value) => value.utteranceEndToFinalTranscriptMs), ), finalTranscriptToFirstAssistantDeltaMs: summarizeDuration( durations.map((value) => value.finalTranscriptToFirstAssistantDeltaMs), ), firstAssistantDeltaToFirstTtsAudioMs: summarizeDuration( durations.map((value) => value.firstAssistantDeltaToFirstTtsAudioMs), ), roundTripMs: summarizeDuration( durations.map((value) => value.roundTripMs), ), totalTurnDurationMs: summarizeDuration( durations.map((value) => value.totalTurnDurationMs), ), }, }; } function summarizeDuration( values: Array, ): LiveVoiceDurationSummary { const sorted = values .filter((value): value is number => value !== null) .sort((left, right) => left - right); return { count: sorted.length, p50Ms: percentile(sorted, 0.5), p95Ms: percentile(sorted, 0.95), }; } function percentile( sortedValues: number[], percentileValue: number, ): number | null { if (sortedValues.length === 0) { return null; } const index = Math.ceil(sortedValues.length * percentileValue) - 1; return sortedValues[Math.min(Math.max(index, 0), sortedValues.length - 1)]; } function normalizeLatencyMs(latencyMs: number): number { return Number.isFinite(latencyMs) ? Math.max(0, latencyMs) : 0; } function duration(startMs: number | null, endMs: number | null): number | null { if (startMs === null || endMs === null) { return null; } return Math.max(0, endMs - startMs); }