为 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
@@ -0,0 +1,57 @@
"""Export causal, continuous MaAI scores for EOT Studio (no label gating)."""
import csv
import hashlib
import json
from smoke_run import HERE, ROOT, EXPECTED, WEIGHTS, CPC, np, sf, soxr, torch
from evaluate_can_reply import model
def main():
torch.set_num_threads(4)
torch.set_num_interop_threads(1)
for p in (WEIGHTS, CPC):
assert hashlib.sha256(p.read_bytes()).hexdigest() == EXPECTED[p.name]
with (HERE/'results/work-data-can-reply/predictions.csv').open(encoding='utf-8-sig') as f:
checkpoints = list(csv.DictReader(f))
calls = []
for call in sorted({r['call_id'] for r in checkpoints}):
path = ROOT/'data/韩泰机器人抢话/音频'/f'{call}.mp3'
pcm, sr = sf.read(path, dtype='float32', always_2d=True)
assert pcm.shape[1] == 2
maai = model()
maai.CALC_PROCESS_TIME_INTERVAL = 10**9
resampler = soxr.ResampleStream(sr,16000,2,dtype='float32',quality='HQ')
pending = np.empty((0,2),dtype=np.float32)
points = []
# Streaming resampling never reads the next input block. Timestamp each
# result when its audio is actually available, including filter delay.
for start in range(0,len(pcm),sr//10):
end = min(len(pcm),start+sr//10)
block = resampler.resample_chunk(pcm[start:end],last=end==len(pcm))
pending = np.concatenate([pending,block])
score = None
while len(pending)>=1600:
frame,pending = pending[:1600],pending[1600:]
maai.process(np.ascontiguousarray(frame[:,0]),np.ascontiguousarray(frame[:,1]))
result = maai.result_dict_queue.get_nowait()
score = 1-float(result['p_now'][0])
assert np.isfinite(score) and 0<=score<=1
if score is not None:
points.append([round(end*1000/sr,3),round(score,7)])
calls.append(dict(call_id=call,audio_path=f'韩泰机器人抢话/音频/{call}.mp3',
audio_sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
duration_ms=round(len(pcm)*1000/sr,3),points=points,
checkpoints=sorted([[int(r['decision_ms']),round(1-float(r['p_now_user']),7)]
for r in checkpoints if r['call_id']==call])))
del maai
print(f'{call[:8]}: {len(points)} timeline scores',flush=True)
output = dict(schema_version=1,model='maai',name='MaAI',sample_interval_ms=100,
score='1 - p_now[user]',
method='Continuous stereo replay, 16kHz, 10Hz, CPC + ch_kyoto noise-robust VAP, 20s KV cache. Causal streaming resampler; timestamps denote input availability. Checkpoints retain the earlier independent <=20s point evaluation.',
model_sha256=EXPECTED,script_sha256=hashlib.sha256((HERE/'export_timeline.py').read_bytes()).hexdigest(),calls=calls)
(HERE/'results/score-timeline.json').write_text(json.dumps(output,ensure_ascii=False,separators=(',',':'))+'\n')
if __name__=='__main__':
main()
File diff suppressed because one or more lines are too long
@@ -0,0 +1,65 @@
"""Export real Smart Turn scores across complete recordings for EOT Studio."""
import csv
import hashlib
import json
import numpy as np
import soundfile as sf
from loguru import logger
from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3
from evaluate_can_reply import HERE, ROOT, block_origin
def read(p):
with p.open(encoding='utf-8-sig') as f:
return list(csv.DictReader(f))
def main():
logger.remove()
checkpoints = read(HERE/'results/work-data-can-reply/predictions.csv')
history = read(ROOT/'labels/EOT标注/工作数据/asr_history.csv')
events = read(HERE/'results/work-data-pipecat/events.csv')
weight = HERE/'smart-turn-v3.2-cpu.onnx'
analyzer = LocalSmartTurnAnalyzerV3(smart_turn_model_path=str(weight),cpu_count=4)
calls=[]
for call in sorted({r['call_id'] for r in checkpoints}):
path=ROOT/'data/韩泰机器人抢话/音频'/f'{call}.mp3'
pcm,sr=sf.read(path,dtype='int16',always_2d=True)
duration=round(len(pcm)*1000/sr,3)
call_labels=[r for r in checkpoints if r['call_id']==call]
exact={int(r['decision_ms']):float(r['probability']) for r in call_labels}
times=sorted(set(range(200,int(duration)+1,200))|set(exact)|{int(duration)})
call_history=[r for r in history if r['call_id']==call]
starts=sorted(float(e['event_ms']) for e in events if e['call_id']==call and e['event']=='vad_start')
analyzer.set_sample_rate(sr)
points=[]
for decision in times:
origin=block_origin(call_history,decision)
if origin is None:
past=[t for t in starts if t<=decision]
origin=max(0,past[-1]+32-200-500) if past else 0
end=decision*sr//1000
start=max(round(origin*sr/1000),end-sr*8)
assert 0<=start<end<=len(pcm)
# Endpoint resampling can only read the already cropped prefix.
samples=np.ascontiguousarray(pcm[start:end,0].astype(np.float32)/32768.)
score=float(analyzer._predict_endpoint(samples)['probability'])
assert np.isfinite(score) and 0<=score<=1
if decision in exact:
assert abs(score-exact[decision])<1e-4,(call,decision,score,exact[decision])
points.append([decision,round(score,7)])
calls.append(dict(call_id=call,audio_path=f'韩泰机器人抢话/音频/{call}.mp3',
audio_sha256=hashlib.sha256(path.read_bytes()).hexdigest(),duration_ms=duration,
points=points,checkpoints=[[t,round(p,7)] for t,p in sorted(exact.items())]))
print(f'{call[:8]}: {len(points)} timeline scores; checkpoints verified',flush=True)
output=dict(schema_version=1,model='smart_turn',name='Smart Turn',sample_interval_ms=200,
score='completion probability',
method='Direct endpoint call every 200ms and at every original annotation. Same past-ASR block origin, 500ms pre-speech, <=3s continuation and last 8s user-channel audio as the point baseline; past VAD fallback when ASR missing. No EOT/timeout gates. Crop before resampling.',
model_sha256=hashlib.sha256(weight.read_bytes()).hexdigest(),
script_sha256=hashlib.sha256((HERE/'export_timeline.py').read_bytes()).hexdigest(),calls=calls)
(HERE/'results/score-timeline.json').write_text(json.dumps(output,ensure_ascii=False,separators=(',',':'))+'\n')
if __name__=='__main__':
main()
File diff suppressed because one or more lines are too long
+22
View File
@@ -24,6 +24,27 @@ npm run dev
浏览器草稿自动保留,刷新后可恢复。网页保存不覆盖原 XLSX、初稿和录音。初稿转换方法、标签映射与限制见 [转换说明](../EOT标注/韩泰初稿/转换说明.md),原行号及原结论保留在初稿目录的 `source_rows.json` 中。原表没有时间信息,录音中未收录的开场与其他发言需人工补充。 浏览器草稿自动保留,刷新后可恢复。网页保存不覆盖原 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 格式 ## CSV 格式
对话转写 `asr_history.csv`: 对话转写 `asr_history.csv`:
@@ -48,6 +69,7 @@ demo,5200,我明天下午,0,high,mid_turn_pause,A
## 本地接口 ## 本地接口
- `GET /api/workspace`:读取工作数据和项目录音列表。工作数据的两份 CSV 都存在时使用它们,否则载入韩泰初稿;初稿不存在时载入示例;已保存 CSV 格式错误时返回错误,不用示例覆盖。 - `GET /api/workspace`:读取工作数据和项目录音列表。工作数据的两份 CSV 都存在时使用它们,否则载入韩泰初稿;初稿不存在时载入示例;已保存 CSV 格式错误时返回错误,不用示例覆盖。
- `GET /api/model-scores?callId=...&recordingPath=...`:只读固定实验目录的已验证评分,按通话ID和项目录音路径精确匹配,并核对实际音频SHA256。缺少某模型时保留另一模型;未匹配或过期的评分不返回曲线。
- `POST /api/workspace`:接受 `{ transcripts, annotations }`,校验后保存到固定工作数据目录,并返回 `savedAt`。同一进程的读写按顺序执行;文件锁防止不同进程同时操作,请求完成后释放。若进程在保存中被强制终止,请确认没有其他服务运行,检查残留 `.backup` 文件并恢复同一次保存前的两份 CSV,再移除工作数据目录的 `.workspace.lock`。 - `POST /api/workspace`:接受 `{ transcripts, annotations }`,校验后保存到固定工作数据目录,并返回 `savedAt`。同一进程的读写按顺序执行;文件锁防止不同进程同时操作,请求完成后释放。若进程在保存中被强制终止,请确认没有其他服务运行,检查残留 `.backup` 文件并恢复同一次保存前的两份 CSV,再移除工作数据目录的 `.workspace.lock`。
- `GET /api/audio?path=...`:仅提供 `data` 内的 MP3、WAV、M4A、OGG、FLAC 和 WebM 录音,支持 Range 请求和 HEAD,便于跳转播放。路径相对 `data` 目录,不能通过 `..` 或符号链接访问目录之外的文件。 - `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> <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> <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> <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); const row = transcripts[index]; seek(row.start_ms);
audioRef.current?.pause(); setTranscriptDraft({ index, row: { ...row } }); audioRef.current?.pause(); setTranscriptDraft({ index, row: { ...row } });
}} onResizeTranscript={resizeTranscript} editingTranscriptIndex={transcriptDraft?.index ?? null} onAddAnnotation={addAnnotationAt} readOnly={mode === "view"} /> }} 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, .rightDot { flex-shrink: 0; width: 6px; height: 6px; border-radius: 50%; }
.leftDot { background: #6c917c; } .leftDot { background: #6c917c; }
.rightDot { background: #658cab; } .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"; } from "react";
import styles from "./timeline.module.css"; import styles from "./timeline.module.css";
import { resizeTranscriptRange, snapTimelineTime, type SnapPoint } from "@/lib/timeline-edit"; import { resizeTranscriptRange, snapTimelineTime, type SnapPoint } from "@/lib/timeline-edit";
import ModelScoreTrack, { ScoreControls, SCORE_HEIGHT, useModelScores, type ScoreThresholds } from "./model-score-track";
export interface TimelineTranscript { export interface TimelineTranscript {
index: number; index: number;
@@ -29,6 +30,8 @@ export interface TimelineAnnotation {
} }
export interface TimelineProps { export interface TimelineProps {
callId: string;
recordingPath: string | null;
durationMs: number; durationMs: number;
currentMs: number; currentMs: number;
decisionMs: number | null; decisionMs: number | null;
@@ -66,6 +69,8 @@ function annotationLabel(value: TimelineAnnotation["can_reply_now"], reason?: st
} }
export default function Timeline({ export default function Timeline({
callId,
recordingPath,
durationMs, durationMs,
currentMs, currentMs,
decisionMs, decisionMs,
@@ -83,6 +88,10 @@ export default function Timeline({
onAddAnnotation, onAddAnnotation,
}: TimelineProps) { }: TimelineProps) {
const helpId = useId(); 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 viewportRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef< const draggingRef = useRef<
@@ -300,7 +309,7 @@ export default function Timeline({
const channelCount = waveform?.length || 1; const channelCount = waveform?.length || 1;
const channelLabel = (index: number) => channelCount === 1 ? "单声道" : channelCount === 2 ? index === 0 ? "左声道 L" : "右声道 R" : `声道 ${index + 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 ( return (
<section className={styles.timeline} aria-label="对话时间轴"> <section className={styles.timeline} aria-label="对话时间轴">
@@ -321,6 +330,20 @@ export default function Timeline({
</div> </div>
</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.trackLayout}>
<div className={styles.trackNames} aria-hidden="true"> <div className={styles.trackNames} aria-hidden="true">
<div className={styles.rulerName}>时间</div> <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.agentDot} />机器人</div>
<div className={styles.speechName}><span className={styles.userDot} />用户</div> <div className={styles.speechName}><span className={styles.userDot} />用户</div>
<div className={styles.markerName}>判断点</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>
<div className={styles.viewport} ref={viewportRef}> <div className={styles.viewport} ref={viewportRef}>
<div <div
@@ -424,6 +448,12 @@ export default function Timeline({
})} })}
</div> </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" />} {activeSnap && <div className={styles.snapGuide} style={{ left: position(activeSnap.ms) }} aria-hidden="true" />}
<div className={styles.playhead} style={{ left: position(current) }}> <div className={styles.playhead} style={{ left: position(current) }}>
<button type="button" className={styles.playheadHandle} aria-label={`拖动播放头 ${timeLabel(current, true)}`} <button type="button" className={styles.playheadHandle} aria-label={`拖动播放头 ${timeLabel(current, true)}`}
@@ -440,6 +470,8 @@ export default function Timeline({
</div> </div>
</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.bottomBar}>
<div className={styles.timeReadout}><span className={styles.playheadDot} /> 播放 <strong>{timeLabel(current, true)}</strong></div> <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> <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);
}
}
});