export type ModelId = "maai" | "maai_user_only" | "maai_mimi_standard" | "maai_mimi_mc" | "smart_turn" | "silero_vad" | "livekit_mini" | "turnsense"; export type ScorePoint = [timeMs: number, score: number]; export interface ScoreCall { call_id: string; audio_path: string; audio_sha256: string; duration_ms: number; points: ScorePoint[]; checkpoints: ScorePoint[]; checkpoint_sources?: [decisionMs: number, sourceTimeMs: number][]; } export interface ScoreExport { schema_version: 1; model: ModelId; name: string; score: string; sample_interval_ms: number; checkpoint_mode?: "streaming" | "independent_window"; calls: ScoreCall[]; } export interface ScoreSeries extends ScoreCall { model: ModelId; name: string; score: string; sample_interval_ms: number; checkpoint_mode?: "streaming" | "independent_window"; } export interface ScoreResponse { callId: string; recordingPath: string; series: ScoreSeries[]; messages: string[]; } function validPoints(value: unknown, duration: number): value is ScorePoint[] { if (!Array.isArray(value)) return false; let previous = -1; return value.every(point => { if (!Array.isArray(point) || point.length !== 2) return false; const [time, score] = point; if (!Number.isFinite(time) || time < 0 || time > duration || time <= previous || !Number.isFinite(score) || score < 0 || score > 1) return false; previous = time; return true; }); } /** Validate inference exports without accepting null as a zero score. */ export function parseScoreExport(value: unknown, model: ModelId): ScoreExport { const data = value as ScoreExport; if (!data || data.schema_version !== 1 || data.model !== model || typeof data.name !== "string" || typeof data.score !== "string" || (data.checkpoint_mode !== undefined && !["streaming", "independent_window"].includes(data.checkpoint_mode)) || !Number.isFinite(data.sample_interval_ms) || data.sample_interval_ms <= 0 || !Array.isArray(data.calls)) throw new Error("模型评分文件格式不正确。"); const calls = new Set(); for (const call of data.calls) { if (!call || typeof call.call_id !== "string" || !call.call_id || calls.has(call.call_id) || typeof call.audio_path !== "string" || !/^[a-f0-9]{64}$/.test(call.audio_sha256) || !Number.isFinite(call.duration_ms) || call.duration_ms <= 0 || !validPoints(call.points, call.duration_ms) || !validPoints(call.checkpoints, call.duration_ms)) { throw new Error("模型评分包含重复时间、越界分数或缺失的录音信息。"); } if (data.checkpoint_mode === "streaming") { if (!Array.isArray(call.checkpoint_sources) || call.checkpoint_sources.length !== call.checkpoints.length) { throw new Error("流式判断点缺少实际评分时刻。"); } call.checkpoints.forEach(([decision, score], index) => { const point = scoreAt(call.points, decision, data.sample_interval_ms * 2); const source = call.checkpoint_sources![index]; if (!point || !Array.isArray(source) || source.length !== 2 || source[0] !== decision || source[1] !== point[0] || score !== point[1]) { throw new Error("流式判断点必须取判断前最后可用的曲线分数。"); } }); } calls.add(call.call_id); } return data; } /** Streaming markers follow current annotations, using the exact hover rule. */ export function checkpointsForDisplay(series: ScoreSeries, decisionTimes: readonly number[]): ScorePoint[] { if (series.checkpoint_mode !== "streaming") return series.checkpoints; return [...new Set(decisionTimes)].sort((a, b) => a - b).flatMap(ms => { if (!Number.isFinite(ms) || ms < 0 || ms > series.duration_ms) return []; const point = scoreAt(series.points, ms, series.sample_interval_ms * 2); return point ? [[ms, point[1]] as ScorePoint] : []; }); } /** Only an already available sample may describe a playhead/hover position. */ export function scoreAt(points: readonly ScorePoint[], ms: number, maxAgeMs: number): ScorePoint | null { let low = 0, high = points.length - 1, found = -1; while (low <= high) { const mid = (low + high) >>> 1; if (points[mid][0] <= ms) { found = mid; low = mid + 1; } else high = mid - 1; } return found >= 0 && ms - points[found][0] <= maxAgeMs ? points[found] : null; } /** Step curves show measured updates; a missing interval starts a new segment. */ export function scorePath(points: readonly ScorePoint[], duration: number, width: number, height: number, interval: number): string { const x = (t: number) => (t / duration * width).toFixed(2); const y = (p: number) => (12 + (1 - p) * (height - 24)).toFixed(2); return points.map((point, index) => { const previous = points[index - 1]; const start = previous && point[0] - previous[0] <= interval * 2 ? `V${y(point[1])}` : `M${x(point[0])},${y(point[1])}`; // Match scoreAt's validity period, including the final sample and the // valid beginning of a gap. Never draw across unavailable/stale time. const until = Math.min(points[index + 1]?.[0] ?? duration, duration, point[0] + interval * 2); return start + (until > point[0] ? `H${x(until)}` : ""); }).join(""); }