92 lines
3.3 KiB
TypeScript
92 lines
3.3 KiB
TypeScript
export type ModelId = "maai" | "smart_turn" | "silero_vad";
|
|
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[];
|
|
}
|
|
|
|
export interface ScoreExport {
|
|
schema_version: 1;
|
|
model: ModelId;
|
|
name: string;
|
|
score: string;
|
|
sample_interval_ms: number;
|
|
calls: ScoreCall[];
|
|
}
|
|
|
|
export interface ScoreSeries extends ScoreCall {
|
|
model: ModelId;
|
|
name: string;
|
|
score: string;
|
|
sample_interval_ms: number;
|
|
}
|
|
|
|
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"
|
|
|| !Number.isFinite(data.sample_interval_ms) || data.sample_interval_ms <= 0
|
|
|| !Array.isArray(data.calls)) throw new Error("模型评分文件格式不正确。");
|
|
const calls = new Set<string>();
|
|
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("模型评分包含重复时间、越界分数或缺失的录音信息。");
|
|
}
|
|
calls.add(call.call_id);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
/** 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);
|
|
let previous: ScorePoint | null = null;
|
|
return points.map(point => {
|
|
const step = previous && point[0] - previous[0] <= interval * 2
|
|
? `H${x(point[0])}V${y(point[1])}` : `M${x(point[0])},${y(point[1])}`;
|
|
previous = point;
|
|
return step;
|
|
}).join("");
|
|
}
|