为 EOT Studio 增加可展开的模型分数时间轴
This commit is contained in:
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user