为 EOT Studio 增加可展开的模型分数时间轴

This commit is contained in:
Xin Wang
2026-10-14 10:51:29 +08:00
parent 05f67739ce
commit 22556c67a4
12 changed files with 532 additions and 2 deletions
+22
View File
@@ -24,6 +24,27 @@ npm run dev
浏览器草稿自动保留,刷新后可恢复。网页保存不覆盖原 XLSX、初稿和录音。初稿转换方法、标签映射与限制见 [转换说明](../EOT标注/韩泰初稿/转换说明.md),原行号及原结论保留在初稿目录的 `source_rows.json` 中。原表没有时间信息,录音中未收录的开场与其他发言需人工补充。
## 模型 EOT 分数
点击时间轴上方的“模型 EOT 分数”展开对比,默认收起。MaAI(紫色)和 Smart Turn(青色)共用录音的时间刻度、缩放、播放头及判断点参考线。点击或拖动曲线区域定位播放,悬停查看当时已产生的分数;点击空心评分点定位到原标注时刻。顶部显示播放位置最近的有效评分,暂无评分时显示“—”,不将缺失数据当作 0。
- 实线阶梯曲线来自对六通完整录音的真实推理,不是用44个点插值。MaAI每100ms输入一块音频,因流式重采样缓冲,部分评分间隔为200ms;Smart Turn每200ms计算一次,并包含原标注的精确时刻。
- MaAI使用双声道连续回放、20秒KV缓存,分数为 `1 - p_now[user]`,是接话候选分数,不是校准的EOT概率。Smart Turn沿用原回测的用户声道、过去ASR定位、500ms前置音频、3秒内续说及最多8秒窗口;没有额外的结束信号、VAD停止门槛或超时放行。
- 空心点保留原44点的独立窗口回测分数。尤其MaAI的连续状态与独立窗口不同,空心点可能不落在连续曲线上。原始44点指标不会被这次时间序列覆盖。
- 参考阈值只改变虚线位置,不修改人工标签。MaAI可选0.11、0.5、0.8,Smart Turn可选0.5、0.8。保存的分数不会随标注或转写编辑而重新计算。
- 仅在展开时读取当前项目录音的评分,并核对音频SHA256。更换为本地新录音、音频变化或没有对应推理数据时,不显示旧曲线。切换通话会取消旧请求,避免前一段数据覆盖当前段。
评分数据位于各模型实验目录的 `results/score-timeline.json`。更新数据时,在两个各自的本机uv环境中运行:
```bash
cd experiments/maai-ch-kyoto
uv run --locked python export_timeline.py
cd ../smart-turn-v3.2
uv run --locked python export_timeline.py
```
上述命令从项目根目录开始;生成脚本只读取录音及已有回测资料,不改人工标注。时间戳表示该评分输入已收到的时刻,MaAI使用流式重采样并计入其缓冲;Smart Turn先裁取过去音频再重采样,均不读取判断时刻之后的音频。
## CSV 格式
对话转写 `asr_history.csv`:
@@ -48,6 +69,7 @@ demo,5200,我明天下午,0,high,mid_turn_pause,A
## 本地接口
- `GET /api/workspace`:读取工作数据和项目录音列表。工作数据的两份 CSV 都存在时使用它们,否则载入韩泰初稿;初稿不存在时载入示例;已保存 CSV 格式错误时返回错误,不用示例覆盖。
- `GET /api/model-scores?callId=...&recordingPath=...`:只读固定实验目录的已验证评分,按通话ID和项目录音路径精确匹配,并核对实际音频SHA256。缺少某模型时保留另一模型;未匹配或过期的评分不返回曲线。
- `POST /api/workspace`:接受 `{ transcripts, annotations }`,校验后保存到固定工作数据目录,并返回 `savedAt`。同一进程的读写按顺序执行;文件锁防止不同进程同时操作,请求完成后释放。若进程在保存中被强制终止,请确认没有其他服务运行,检查残留 `.backup` 文件并恢复同一次保存前的两份 CSV,再移除工作数据目录的 `.workspace.lock`。
- `GET /api/audio?path=...`:仅提供 `data` 内的 MP3、WAV、M4A、OGG、FLAC 和 WebM 录音,支持 Range 请求和 HEAD,便于跳转播放。路径相对 `data` 目录,不能通过 `..` 或符号链接访问目录之外的文件。
@@ -0,0 +1,59 @@
import { promises as fs } from "node:fs";
import { createHash } from "node:crypto";
import path from "node:path";
import { parseScoreExport, type ModelId, type ScoreResponse } from "@/lib/model-scores";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const root = path.resolve(process.cwd(), "..", "..");
const sources: { model: ModelId; directory: string; name: string }[] = [
{ model: "maai", directory: "maai-ch-kyoto", name: "MaAI" },
{ model: "smart_turn", directory: "smart-turn-v3.2", name: "Smart Turn" },
];
const json = (data: unknown, status = 200) => Response.json(data, { status, headers: { "Cache-Control": "no-store" } });
export async function GET(request: Request) {
const params = new URL(request.url).searchParams;
const callId = params.get("callId") || "";
const recordingPath = params.get("recordingPath") || "";
if (!callId || callId.length > 200 || !recordingPath || recordingPath.length > 1000) {
return json({ error: "请选择已关联项目录音的对话。" }, 400);
}
const payload: ScoreResponse = { callId, recordingPath, series: [], messages: [] };
try {
for (const source of sources) {
try {
// Fixed paths only: no request value is used to locate an export.
const file = path.join(root, "experiments", source.directory, "results", "score-timeline.json");
const data = parseScoreExport(JSON.parse(await fs.readFile(file, "utf8")), source.model);
const call = data.calls.find(item => item.call_id === callId && item.audio_path === recordingPath);
if (call) payload.series.push({ ...call, model: data.model, name: data.name,
score: data.score, sample_interval_ms: data.sample_interval_ms });
else payload.messages.push(`${source.name} 暂无这段录音的评分。`);
} catch (error) {
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) {
console.error(`Cannot read ${source.name} scores`, error);
}
payload.messages.push(`${source.name} 评分暂时不可用。`);
}
}
if (payload.series.length) {
const dataRoot = await fs.realpath(path.join(root, "data"));
const audio = await fs.realpath(path.resolve(dataRoot, payload.series[0].audio_path));
const relative = path.relative(dataRoot, audio);
if (relative.startsWith(`..${path.sep}`) || relative === ".." || path.isAbsolute(relative)) {
return json({ error: "评分对应的录音不在项目目录内。" }, 422);
}
const hash = createHash("sha256").update(await fs.readFile(audio)).digest("hex");
payload.series = payload.series.filter(series => {
if (series.audio_sha256 === hash) return true;
payload.messages.push(`${series.name} 的评分对应旧录音,请重新生成评分。`);
return false;
});
}
return json(payload);
} catch (error) {
console.error("Cannot verify score recording", error);
return json({ error: "无法核对评分对应的录音,请检查项目文件。" }, 500);
}
}
+1 -1
View File
@@ -357,7 +357,7 @@ export default function Workbench() {
<main className="main-workspace"><div className="page-heading"><div><div className="breadcrumb">工作空间 <span>/</span> {callName}</div><h1>对话标注<span className="offline-badge">离线转写</span></h1><p title={callId}>{callId === "demo" ? "从一段对话开始,判断用户是否已经说完。" : callId}</p></div><div className="view-switch" aria-label="工作模式"><button className={mode === "edit" ? "active" : ""} onClick={() => setMode("edit")}><Icon name="edit" size={15} />标注</button><button className={mode === "view" ? "active" : ""} onClick={() => { setMode("view"); setTranscriptDraft(null); }}>查看</button></div></div>
<section className="audio-card panel"><div className="panel-heading"><div><h2><Icon name="headphones" />录音与时间轴</h2><p className="audio-filename" title={recording?.name}>{recording?.name || "示例尚未关联录音,可先查看转写与标注"}</p></div><button className="button small ghost" onClick={() => audioImportRef.current?.click()}><Icon name="upload" size={15} />{recording ? "更换录音" : "关联录音"}</button></div>
<div className="player"><div className="player-main"><button className="icon-button" aria-label="后退 3 秒" onClick={() => seek(currentMs - 3000)}><Icon name="back" /></button><button className="play-button" aria-label={playing ? "暂停录音" : "播放录音"} onClick={() => void togglePlay()} disabled={!recording}><Icon name={playing ? "pause" : "play"} size={19} /></button><button className="icon-button" aria-label="前进 3 秒" onClick={() => seek(currentMs + 3000)}><Icon name="forward" /></button><span className="time-display">{formatTime(currentMs)}<span> / {formatTime(durationMs)}</span></span></div><div className="player-secondary"><label className="speed-label">播放速度<select aria-label="播放速度" value={rate} onChange={event => setRate(Number(event.target.value))}>{[.75, 1, 1.25, 1.5, 2].map(value => <option key={value} value={value}>{value}×</option>)}</select></label>{mode === "edit" && <div className="menu-wrap"><button className="button small subtle" onClick={() => setMenu(menu === "create" ? null : "create")} disabled={loading} aria-expanded={menu === "create"} aria-haspopup="menu"><Icon name="plus" size={16} />在播放位置新增<Icon name="chevron" size={13} /></button>{menu === "create" && <div className="dropdown" role="menu" aria-label="新增项目"><button role="menuitem" onClick={addAnnotation}>EOT 判断点</button><button role="menuitem" onClick={() => addTranscriptAt("agent")}>机器人转写</button><button role="menuitem" onClick={() => addTranscriptAt("user")}>用户转写</button></div>}</div>}</div></div>
<Timeline durationMs={durationMs} currentMs={currentMs} decisionMs={selected?.decision_ms ?? null} transcripts={callTranscripts.map(({ row, index }) => ({ ...row, index }))} annotations={callAnnotations} selectedId={selectedId} waveform={waveform} onSeek={seek} onMoveAnnotation={moveAnnotation} onSelectAnnotation={selectAnnotation} onSelectTranscript={index => {
<Timeline callId={callId} recordingPath={recording?.url.startsWith("/api/audio?") ? recording.path : null} durationMs={durationMs} currentMs={currentMs} decisionMs={selected?.decision_ms ?? null} transcripts={callTranscripts.map(({ row, index }) => ({ ...row, index }))} annotations={callAnnotations} selectedId={selectedId} waveform={waveform} onSeek={seek} onMoveAnnotation={moveAnnotation} onSelectAnnotation={selectAnnotation} onSelectTranscript={index => {
const row = transcripts[index]; seek(row.start_ms);
audioRef.current?.pause(); setTranscriptDraft({ index, row: { ...row } });
}} onResizeTranscript={resizeTranscript} editingTranscriptIndex={transcriptDraft?.index ?? null} onAddAnnotation={addAnnotationAt} readOnly={mode === "view"} />
@@ -0,0 +1,114 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { scoreAt, scorePath, type ModelId, type ScoreResponse, type ScoreSeries } from "@/lib/model-scores";
import type { TimelineAnnotation } from "./timeline";
import styles from "./timeline.module.css";
const EMPTY: ScoreSeries[] = [];
const COLORS: Record<ModelId, string> = { maai: "#8866b2", smart_turn: "#278d95" };
export const SCORE_HEIGHT = 172;
export type ScoreThresholds = Record<ModelId, number>;
const time = (ms: number) => `${Math.floor(ms / 60000).toString().padStart(2, "0")}:${(ms % 60000 / 1000).toFixed(3).padStart(6, "0")}`;
export function useModelScores(callId: string, recordingPath: string | null, expanded: boolean) {
const key = `${callId}:${recordingPath}`;
const [retry, setRetry] = useState(0);
const [state, setState] = useState<{ key: string; status: "loading" | "ready" | "error"; data?: ScoreResponse; error?: string } | null>(null);
useEffect(() => {
if (!expanded || !recordingPath) return;
const controller = new AbortController();
setState({ key, status: "loading" });
void (async () => {
try {
const query = new URLSearchParams({ callId, recordingPath });
const response = await fetch(`/api/model-scores?${query}`, { cache: "no-store", signal: controller.signal });
const data = await response.json();
if (!response.ok) throw new Error(data.error || "评分读取失败。");
if (data.callId !== callId || data.recordingPath !== recordingPath || !Array.isArray(data.series)) throw new Error("评分与当前录音不匹配。");
if (!controller.signal.aborted) setState({ key, status: "ready", data });
} catch (error) {
if (!controller.signal.aborted) setState({ key, status: "error", error: error instanceof Error ? error.message : "评分读取失败。" });
}
})();
return () => controller.abort();
}, [callId, recordingPath, expanded, key, retry]);
const active = state?.key === key ? state : null;
return {
series: recordingPath && active?.status === "ready" ? active.data?.series || EMPTY : EMPTY,
status: !recordingPath ? "empty" : active?.status || "loading",
messages: !recordingPath ? ["请使用项目原录音查看模型评分;新关联的录音需先生成对应评分。"]
: active?.status === "error" ? [active.error || "评分读取失败。"] : active?.data?.messages || [],
retry: () => setRetry(value => value + 1),
};
}
export function ScoreControls({ series, currentMs, thresholds, onThreshold }: {
series: ScoreSeries[]; currentMs: number; thresholds: ScoreThresholds;
onThreshold: (model: ModelId, value: number) => void;
}) {
return <div className={styles.scoreControls}>
{series.map(row => {
const point = scoreAt(row.points, currentMs, row.sample_interval_ms * 2);
return <div className={styles.modelControl} key={row.model} style={{ color: COLORS[row.model] }}>
<span className={styles.modelSwatch} /><strong>{row.name}</strong>
<output aria-live="off" title={point ? `评分时刻 ${time(point[0])}` : "当前位置暂无评分"}>{point ? point[1].toFixed(3) : "—"}</output>
<label>参考线<select aria-label={`${row.name} 参考阈值`} value={thresholds[row.model]}
onChange={event => onThreshold(row.model, Number(event.target.value))}>
{(row.model === "maai" ? [.11, .5, .8] : [.5, .8]).map(value => <option key={value} value={value}>{value.toFixed(2)}</option>)}
</select></label>
</div>;
})}
</div>;
}
export default function ModelScoreTrack({ series, width, durationMs, currentMs, thresholds, annotations, onSeek, onSelectAnnotation }: {
series: ScoreSeries[]; width: number; durationMs: number; currentMs: number;
thresholds: ScoreThresholds; annotations: TimelineAnnotation[];
onSeek: (ms: number) => void; onSelectAnnotation: (id: string, seekToPoint?: boolean) => void;
}) {
const [hoverMs, setHoverMs] = useState<number | null>(null);
const paths = useMemo(() => series.map(row => ({ ...row,
path: scorePath(row.points, durationMs, width, SCORE_HEIGHT, row.sample_interval_ms) })), [series, durationMs, width]);
const y = (p: number) => 12 + (1 - p) * (SCORE_HEIGHT - 24);
const x = (ms: number) => ms / durationMs * width;
const inspect = hoverMs ?? currentMs;
const selectPoint = (ms: number) => {
const annotation = annotations.find(row => row.decision_ms === ms);
if (annotation) onSelectAnnotation(annotation.id, false);
onSeek(ms);
};
return <div className={styles.scoreTrack} onPointerLeave={() => setHoverMs(null)}>
<svg width={width} height={SCORE_HEIGHT} viewBox={`0 0 ${width} ${SCORE_HEIGHT}`}
role="group" aria-label="MaAI 与 Smart Turn 的 EOT 分数,纵轴 0 到 1,横轴与录音时间一致"
onPointerMove={event => {
const bounds = event.currentTarget.getBoundingClientRect();
setHoverMs(Math.max(0, Math.min(durationMs, (event.clientX - bounds.left) / bounds.width * durationMs)));
}}>
{[0, .25, .5, .75, 1].map(p => <line key={p} x1={0} x2={width} y1={y(p)} y2={y(p)} stroke="#e8e9e4" strokeDasharray={p === 0 || p === 1 ? undefined : "2 4"} />)}
{annotations.map(row => <line key={row.id} x1={x(row.decision_ms)} x2={x(row.decision_ms)} y1={0} y2={SCORE_HEIGHT}
stroke={row.can_reply_now === "0" ? "#c68c6b" : "#96a781"} strokeDasharray="2 5" opacity=".4" />)}
{paths.map(row => <g key={row.model}>
<line x1={0} x2={width} y1={y(thresholds[row.model])} y2={y(thresholds[row.model])}
stroke={COLORS[row.model]} strokeDasharray={row.model === "maai" ? "6 5" : "2 4"} opacity=".55" />
<path d={row.path} fill="none" stroke={COLORS[row.model]} strokeWidth={1.6} strokeLinejoin="round" pointerEvents="none" />
{row.checkpoints.map(([ms, score]) => <circle key={ms} cx={x(ms)} cy={y(score)} r={3.8}
fill="#fffefb" stroke={COLORS[row.model]} strokeWidth={1.8} tabIndex={0} role="button"
aria-label={`${row.name} 原标注点评分 ${time(ms)},${score.toFixed(3)},点击定位`}
onPointerDown={event => event.stopPropagation()}
onClick={event => { event.stopPropagation(); selectPoint(ms); }}
onKeyDown={event => {
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); event.stopPropagation(); selectPoint(ms); }
}}><title>{row.name} · 原标注点评分 {score.toFixed(3)} · {time(ms)}</title></circle>)}
</g>)}
{hoverMs !== null && <line x1={x(hoverMs)} x2={x(hoverMs)} y1={0} y2={SCORE_HEIGHT} stroke="#77816c" opacity=".5" pointerEvents="none" />}
</svg>
<div className={styles.scoreInspection} aria-hidden="true">
<span>{time(inspect)}</span>
{series.map(row => {
const point = scoreAt(row.points, inspect, row.sample_interval_ms * 2);
return <span key={row.model} style={{ color: COLORS[row.model] }}>{row.name} <b>{point ? point[1].toFixed(3) : "—"}</b></span>;
})}
</div>
</div>;
}
@@ -117,3 +117,37 @@
.leftDot, .rightDot { flex-shrink: 0; width: 6px; height: 6px; border-radius: 50%; }
.leftDot { background: #6c917c; }
.rightDot { background: #658cab; }
.scorePanelHeader { padding: 0 20px 12px; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.scoreToggle { display: inline-flex; align-items: center; gap: 8px; padding: 6px 0; background: none; border: 0; color: #67775b; cursor: pointer; font: inherit; font-size: 11px; }
.scoreToggle strong { font-size: 12px; font-weight: 600; color: #45553e; }
.scoreToggle > span:last-child { color: #929b89; font-size: 10px; }
.chevron, .chevronOpen { display: inline-block; width: 12px; font-size: 23px; line-height: 16px; text-align: center; }
.chevronOpen { transform: rotate(90deg); }
.scoreControls { display: flex; flex-wrap: wrap; align-items: center; gap: 9px; }
.modelControl { display: flex; align-items: center; gap: 6px; padding: 5px 8px; border: 1px solid #e9e9e2; border-radius: 7px; background: #fafbf7; font-size: 10px; }
.modelControl strong { font-weight: 600; }
.modelControl output { width: 37px; font-family: monospace; font-variant-numeric: tabular-nums; text-align: center; }
.modelControl label { display: flex; align-items: center; gap: 4px; color: #88927e; padding-left: 4px; border-left: 1px solid #e2e5db; }
.modelControl select { padding: 1px 0; border: 0; background: transparent; font-size: 10px; color: #637258; cursor: pointer; }
.modelSwatch { width: 14px; height: 2px; background: currentColor; }
.scoreDescription { padding: 0 22px 10px; font-size: 10px; line-height: 1.7; color: #88927f; }
.scoreDescription button { border: 0; background: none; color: #536f40; cursor: pointer; text-decoration: underline; font-size: inherit; }
.trackNames > .scoreAxis { padding: 12px 8px; flex-direction: column; justify-content: space-between; font-size: 10px; color: #929a87; border-top: 1px solid #e7e9e0; }
.scoreAxis span:first-child, .scoreAxis span:last-child { font-family: monospace; align-self: flex-end; }
.scoreAxis span:nth-child(2) { color: #647759; }
.scoreTrack { position: relative; height: 172px; background: #fdfefb; border-top: 1px solid #e7e9e0; }
.scoreTrack > svg { display: block; overflow: hidden; }
.scoreTrack circle { cursor: pointer; }
.scoreTrack circle:hover, .scoreTrack circle:focus-visible { stroke-width: 3; outline: none; r: 5; }
.scoreInspection { position: sticky; left: 10px; margin-top: -26px; width: max-content; max-width: 100%; display: flex; gap: 12px; padding: 4px 7px; border-radius: 4px; color: #8c9682; background: #fffefbe8; font: 10px monospace; pointer-events: none; }
.scoreInspection b { font-weight: 500; }
.scoreEmpty { position: sticky; left: 0; display: grid; place-items: center; color: #929b88; font-size: 11px; background: #fdfefb; border-top: 1px solid #e7e9e0; }
.scoreFootnote { margin: 0; padding: 9px 22px 0; color: #8c9583; font-size: 10px; line-height: 1.7; }
.scoreToggle:focus-visible, .modelControl select:focus-visible { outline: 2px solid #758962; outline-offset: 3px; border-radius: 3px; }
@media (max-width: 640px) {
.scorePanelHeader { padding-inline: 12px; gap: 5px; }
.scoreDescription, .scoreFootnote { padding-inline: 12px; }
.scoreControls { gap: 5px; }
.modelControl { padding: 4px 5px; }
}
+33 -1
View File
@@ -12,6 +12,7 @@ import {
} from "react";
import styles from "./timeline.module.css";
import { resizeTranscriptRange, snapTimelineTime, type SnapPoint } from "@/lib/timeline-edit";
import ModelScoreTrack, { ScoreControls, SCORE_HEIGHT, useModelScores, type ScoreThresholds } from "./model-score-track";
export interface TimelineTranscript {
index: number;
@@ -29,6 +30,8 @@ export interface TimelineAnnotation {
}
export interface TimelineProps {
callId: string;
recordingPath: string | null;
durationMs: number;
currentMs: number;
decisionMs: number | null;
@@ -66,6 +69,8 @@ function annotationLabel(value: TimelineAnnotation["can_reply_now"], reason?: st
}
export default function Timeline({
callId,
recordingPath,
durationMs,
currentMs,
decisionMs,
@@ -83,6 +88,10 @@ export default function Timeline({
onAddAnnotation,
}: TimelineProps) {
const helpId = useId();
const scoreRegionId = useId();
const [scoresOpen, setScoresOpen] = useState(false);
const [thresholds, setThresholds] = useState<ScoreThresholds>({ maai: .5, smart_turn: .5 });
const scores = useModelScores(callId, recordingPath, scoresOpen);
const viewportRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef<
@@ -300,7 +309,7 @@ export default function Timeline({
const channelCount = waveform?.length || 1;
const channelLabel = (index: number) => channelCount === 1 ? "单声道" : channelCount === 2 ? index === 0 ? "左声道 L" : "右声道 R" : `声道 ${index + 1}`;
const trackStyle = { "--timeline-width": `${canvasWidth}px`, height: 204 + 76 * channelCount } as CSSProperties;
const trackStyle = { "--timeline-width": `${canvasWidth}px`, height: 204 + 76 * channelCount + (scoresOpen ? SCORE_HEIGHT : 0) } as CSSProperties;
return (
<section className={styles.timeline} aria-label="对话时间轴">
@@ -321,6 +330,20 @@ export default function Timeline({
</div>
</div>
<div className={styles.scorePanelHeader}>
<button type="button" className={styles.scoreToggle} aria-expanded={scoresOpen} aria-controls={scoreRegionId}
onClick={() => setScoresOpen(value => !value)}>
<span aria-hidden="true" className={scoresOpen ? styles.chevronOpen : styles.chevron}>›</span>
<strong>模型 EOT 分数</strong><span>{scoresOpen ? "收起" : "展开 MaAI / Smart Turn 对比"}</span>
</button>
{scoresOpen && <ScoreControls series={scores.series} currentMs={current}
thresholds={thresholds} onThreshold={(model, value) => setThresholds(previous => ({ ...previous, [model]: value }))} />}
</div>
{scoresOpen && <div className={styles.scoreDescription} role="status">
{scores.status === "loading" ? "正在读取模型分数…" : scores.status === "error" ? <>{scores.messages.join(" ")} <button type="button" onClick={scores.retry}>重新读取</button></>
: <>实线:连续回放评分 · 空心点:原标注点评分 · 虚线:参考阈值。分数越高越倾向接话,MaAI 使用 1 − 用户近期发声分数。{scores.messages.join(" ")}</>}
</div>}
<div className={styles.trackLayout}>
<div className={styles.trackNames} aria-hidden="true">
<div className={styles.rulerName}>时间</div>
@@ -328,6 +351,7 @@ export default function Timeline({
<div className={styles.speechName}><span className={styles.agentDot} />机器人</div>
<div className={styles.speechName}><span className={styles.userDot} />用户</div>
<div className={styles.markerName}>判断点</div>
{scoresOpen && <div className={styles.scoreAxis} style={{ height: SCORE_HEIGHT }}><span>1.0</span><span>接话分数</span><span>0.0</span></div>}
</div>
<div className={styles.viewport} ref={viewportRef}>
<div
@@ -424,6 +448,12 @@ export default function Timeline({
})}
</div>
{scoresOpen && <div id={scoreRegionId}>
{scores.series.length ? <ModelScoreTrack series={scores.series} width={canvasWidth} durationMs={duration}
currentMs={current} thresholds={thresholds} annotations={annotations} onSeek={onSeek} onSelectAnnotation={onSelectAnnotation} />
: <div className={styles.scoreEmpty} style={{ height: SCORE_HEIGHT, width: viewportWidth }}>{scores.status === "loading" ? "正在载入评分曲线…" : "当前录音暂无可显示的模型分数"}</div>}
</div>}
{activeSnap && <div className={styles.snapGuide} style={{ left: position(activeSnap.ms) }} aria-hidden="true" />}
<div className={styles.playhead} style={{ left: position(current) }}>
<button type="button" className={styles.playheadHandle} aria-label={`拖动播放头 ${timeLabel(current, true)}`}
@@ -440,6 +470,8 @@ export default function Timeline({
</div>
</div>
{scoresOpen && scores.series.length > 0 && <p className={styles.scoreFootnote}>点击曲线定位录音;悬停查看分数。MaAI 约每 100 ms、Smart Turn 每 200 ms 更新。原标注点评分使用独立历史窗口,与连续曲线可能不同;编辑标注或转写不会重算已有分数。</p>}
<div className={styles.bottomBar}>
<div className={styles.timeReadout}><span className={styles.playheadDot} /> 播放 <strong>{timeLabel(current, true)}</strong></div>
<span id={helpId} className={styles.help}>{resizePreview ? "拖动边界调整片段 · 松开保存 · Alt 暂停吸附" : activeSnap ? `已吸附:${activeSnap.label} ${timeLabel(activeSnap.ms, true)}` : "拖动判断点或播放头 · 自动吸附 · Alt 暂停吸附"}</span>
+91
View File
@@ -0,0 +1,91 @@
export type ModelId = "maai" | "smart_turn";
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("");
}
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFile } from "node:fs/promises";
// @ts-expect-error Node's native TypeScript runner resolves explicit .ts imports.
import { parseScoreExport, scoreAt, scorePath } from "../lib/model-scores.ts";
const fixture = () => ({ schema_version: 1, model: "maai", name: "MaAI", score: "1-p_now[user]", sample_interval_ms: 100,
calls: [{ call_id: "demo", audio_path: "demo.mp3", audio_sha256: "a".repeat(64), duration_ms: 1000,
points: [[100, .2], [200, .9], [900, .3]], checkpoints: [[150, .8]] }] });
test("model scores accept real ordered samples and independent checkpoint times", () => {
assert.equal(parseScoreExport(fixture(), "maai").calls[0].checkpoints[0][0], 150);
});
test("invalid exports cannot masquerade as zero scores or another model", () => {
for (const points of [[[100, null]], [[100, 1.1]], [[100, -1]], [[100, .5], [100, .6]], [[200, .5], [100, .6]], [[1001, .5]]]) {
const data = fixture(); data.calls[0].points = points as number[][];
assert.throws(() => parseScoreExport(data, "maai"));
}
assert.throws(() => parseScoreExport(fixture(), "smart_turn"));
const duplicate = fixture(); duplicate.calls.push(duplicate.calls[0]);
assert.throws(() => parseScoreExport(duplicate, "maai"));
});
test("playhead values never use a future sample or carry through missing data", () => {
const points: [number, number][] = [[100, .2], [200, .9], [900, .3]];
assert.equal(scoreAt(points, 99, 200), null);
assert.deepEqual(scoreAt(points, 150, 200), [100, .2]);
assert.deepEqual(scoreAt(points, 200, 200), [200, .9]);
assert.equal(scoreAt(points, 600, 200), null);
assert.equal(scoreAt([], 100, 200), null);
});
test("curve gaps are not connected and steps remain on the audio clock", () => {
const path = scorePath([[100, .2], [200, .9], [900, .3]], 1000, 500, 100, 100);
assert.equal(path, "M50.00,72.80H100.00V19.60M450.00,65.20");
});
test("recorded inference exports agree on audio identity and retain all 44 checkpoints", async () => {
const files = [
["maai", "maai-ch-kyoto"], ["smart_turn", "smart-turn-v3.2"],
] as const;
const exports = await Promise.all(files.map(async ([model, directory]) => parseScoreExport(
JSON.parse(await readFile(new URL(`../../../experiments/${directory}/results/score-timeline.json`, import.meta.url), "utf8")), model)));
const identity = (data: typeof exports[number]) => data.calls.map(call => [call.call_id, call.audio_path, call.audio_sha256, call.duration_ms]);
assert.deepEqual(identity(exports[0]), identity(exports[1]));
for (const data of exports) {
assert.equal(data.calls.reduce((n, call) => n + call.checkpoints.length, 0), 44);
for (const call of data.calls) {
assert.ok(call.points.length > 100);
assert.ok(call.points.at(-1)![0] <= call.duration_ms);
}
}
});