新增 Mimi 与仅用户 VAP 对比并统一流式评测
This commit is contained in:
@@ -26,17 +26,30 @@ npm run dev
|
||||
|
||||
## 模型分数对比
|
||||
|
||||
点击时间轴上方的“模型分数对比”展开对比,默认收起。VAP / MaAI(紫色)、Smart Turn(青色)、Silero VAD(橙色)、LiveKit v1-mini(蓝色)和 TurnSense 1.0(玫红色)共用录音的时间刻度、缩放、播放头及判断点参考线。点击或拖动曲线区域定位播放,悬停查看当时已产生的分数;点击空心评分点定位到原标注时刻。顶部显示播放位置最近的有效评分,暂无评分时显示“—”,不将缺失数据当作 0。
|
||||
点击时间轴上方的“模型分数对比”展开对比,默认收起。VAP / MaAI(紫色)、Smart Turn(青色)、Silero VAD(橙色)、LiveKit v1-mini(蓝色)和 TurnSense 1.0(玫红色)共用录音的时间刻度、缩放、播放头及判断点参考线。点击或拖动曲线区域定位播放,悬停查看当时已产生的分数;点击空心评分点定位到判断时刻。顶部显示播放位置最近的有效评分,暂无评分时显示“—”,不将缺失数据当作 0。
|
||||
|
||||
新增橄榄绿色 **VAP / MaAI(仅用户)**,在“选择模型”中勾选。它复用相同 MaAI `ch_kyoto` 权重,保留用户声道,将机器人输入全程置零;连续曲线约每 100 ms 更新,空心点取当前判断时刻之前最后可用的连续流评分。分数仍为 `1 - p_now[user]`,可独立切换 0.11 / 0.5 / 0.8 参考线。原双声道版及页面播放录音保留;用户声道内已有的机器人串音不因此消失。当前流式结果位于 `experiments/maai-ch-kyoto/results/streaming/cpc-user-only/`,原始独立窗口结果保留在 `results/user-only/`,复现方式和同点双声道对照见[仅用户评测说明](../../experiments/maai-ch-kyoto/仅用户评测说明.md)。
|
||||
|
||||
另有 **VAP / MaAI Mimi(标准双声道)**(蓝灰色)和 **VAP / MaAI Mimi(降噪双声道)**(金棕色),均输入用户与机器人原双声道,使用 FP32 Mimi 编码器、12.5 Hz(80 ms)和 20 秒上下文;两版分别使用标准/降噪 VAP 权重。分数映射、独立开关及参考阈值与 CPC 版一致。当前流式结果分别位于 MaAI 目录的 `results/streaming/mimi-standard/` 和 `results/streaming/mimi-mc/`;原独立窗口结果保留在 `results/mimi-standard/` 和 `results/mimi-mc/`;复现、权重校验及比较边界见 [Mimi 评测说明](../../experiments/maai-ch-kyoto/Mimi评测说明.md)。
|
||||
|
||||
- 实线阶梯曲线来自对六通完整录音的真实推理,不是用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点指标不会被这次时间序列覆盖。
|
||||
- VAP 的四个配置统一按流式场景评估:连续处理完整录音并维护模型状态;判断点、悬停分数和准确率均取判断时刻之前最后可用的输出,不在判断点重置或插值。时间戳包含流式重采样缓冲;尚未输出或分数年龄超过两倍标称更新间隔时显示不可用,并单独计数,不补成 0。空心点应落在阶梯线上。Smart Turn 的原判断点仍保留独立窗口结果。
|
||||
- Silero VAD 为用户声道逐个完整 32 ms 帧的真实 ONNX 推理,使用 `1 - speech_probability`,仅为非发声基线,不是语义 EOT 概率。时间戳为帧末;空心点取原判断时刻最后可用帧,不读取未来音频。独立环境和复现方法见 [Silero 实验说明](../../experiments/silero-vad/README.md)。
|
||||
- 参考阈值只改变虚线位置,不修改人工标签。MaAI可选0.11、0.5、0.8,Smart Turn和Silero可选0.5、0.8。保存的分数不会随标注或转写编辑而重新计算。
|
||||
- 参考阈值只改变虚线位置,不修改人工标签。MaAI可选0.11、0.5、0.8,Smart Turn和Silero可选0.5、0.8。编辑不会重跑模型;新增或移动 VAP 判断点时,页面立即从已有连续曲线重新取点。磁盘上的统计需保存标注后运行下述流式评估命令更新。
|
||||
- “选择模型”打开可搜索、按类型分组的多选面板,支持全选可用 / 清空;无评分模型保留在列表并说明状态。首次显示 VAP、Smart Turn、TurnSense 三条,之后记住显示选择和参考阈值,切换通话、收起和刷新均保留。紧凑图例只显示已选模型与当前分数,悬停或键盘聚焦会突出对应曲线。点击图例打开阈值和模型说明,可单独查看一个模型并恢复原选择。
|
||||
- 仅在展开时读取当前项目录音的评分,并核对音频SHA256。更换为本地新录音、音频变化或没有对应推理数据时,不显示旧曲线。切换通话会取消旧请求,避免前一段数据覆盖当前段。
|
||||
|
||||
评分数据位于各模型实验目录的 `results/score-timeline.json`。更新数据时,在两个各自的本机uv环境中运行:
|
||||
VAP 前端读取 MaAI 目录的 `results/streaming/{cpc-stereo,cpc-user-only,mimi-standard,mimi-mc}/score-timeline.json`;其他模型沿用各自的 `results/score-timeline.json`。仅更新当前标注对应的 VAP 流式取点和统计,无需重跑模型:
|
||||
|
||||
```bash
|
||||
cd experiments/maai-ch-kyoto
|
||||
uv run --locked python evaluate_streaming.py all
|
||||
```
|
||||
|
||||
每点的 `decision_ms`、实际 `score_time_ms` 和 `score_age_ms` 记录在各配置 `predictions.csv`,四版汇总见 `results/streaming/comparison-summary.json` 和 `对比分析.txt`。MaAI 的 `export_timeline.py`、`run_user_only.py all`、`run_mimi.py all` 在重新生成连续推理后也会刷新对应配置的流式结果。原独立窗口文件保留作为另一评估口径的历史记录。
|
||||
|
||||
如需重新生成连续推理,在两个各自的本机 uv 环境中运行:
|
||||
|
||||
```bash
|
||||
cd experiments/maai-ch-kyoto
|
||||
|
||||
@@ -23,11 +23,11 @@ export async function GET(request: Request) {
|
||||
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 file = path.join(root, "experiments", source.directory, "results", source.scoreFile ?? "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 });
|
||||
score: data.score, sample_interval_ms: data.sample_interval_ms, checkpoint_mode: data.checkpoint_mode });
|
||||
else payload.messages.push(`${source.name} 暂无这段录音的评分。`);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, useId } from "react";
|
||||
import { scoreAt, scorePath, type ModelId, type ScoreResponse, type ScoreSeries } from "@/lib/model-scores";
|
||||
import { checkpointsForDisplay, scoreAt, scorePath, type ModelId, type ScoreResponse, type ScoreSeries } from "@/lib/model-scores";
|
||||
import { createPortal } from "react-dom";
|
||||
import { SCORE_MODELS, SCORE_MODEL_IDS, readScorePreferences } from "@/lib/score-models";
|
||||
import type { TimelineAnnotation } from "./timeline";
|
||||
@@ -169,7 +169,8 @@ export default function ModelScoreTrack({ series, width, durationMs, currentMs,
|
||||
}) {
|
||||
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]);
|
||||
checkpoints: checkpointsForDisplay(row, annotations.map(annotation => annotation.decision_ms)),
|
||||
path: scorePath(row.points, durationMs, width, SCORE_HEIGHT, row.sample_interval_ms) })), [series, annotations, durationMs, width]);
|
||||
const y = (p: number) => 12 + (1 - p) * (SCORE_HEIGHT - 24);
|
||||
const x = (ms: number) => ms / durationMs * width;
|
||||
const inspect = hoverMs ?? currentMs;
|
||||
@@ -180,7 +181,7 @@ export default function ModelScoreTrack({ series, width, durationMs, currentMs,
|
||||
};
|
||||
return <div className={styles.scoreTrack} onPointerLeave={() => setHoverMs(null)}>
|
||||
<svg width={width} height={SCORE_HEIGHT} viewBox={`0 0 ${width} ${SCORE_HEIGHT}`}
|
||||
role="group" aria-label="VAP、Smart Turn、Silero VAD、LiveKit v1-mini 与 TurnSense 分数对比,纵轴 0 到 1,横轴与录音时间一致"
|
||||
role="group" aria-label="VAP CPC 双声道与仅用户版本、Mimi 标准与降噪双声道、Smart Turn、Silero VAD、LiveKit v1-mini 与 TurnSense 分数对比,纵轴 0 到 1,横轴与录音时间一致"
|
||||
onPointerMove={event => {
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
setHoverMs(Math.max(0, Math.min(durationMs, (event.clientX - bounds.left) / bounds.width * durationMs)));
|
||||
@@ -194,12 +195,12 @@ export default function ModelScoreTrack({ series, width, durationMs, currentMs,
|
||||
<path d={row.path} fill="none" stroke={COLORS[row.model]} strokeWidth={focusedModel === row.model ? 2.8 : 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)},点击定位`}
|
||||
aria-label={`${row.name} ${row.checkpoint_mode === "streaming" ? "流式判断点" : "原标注点评分"} ${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>)}
|
||||
}}><title>{row.name} · {row.checkpoint_mode === "streaming" ? "流式判断点" : "原标注点评分"} {score.toFixed(3)} · {time(ms)}{row.checkpoint_mode === "streaming" ? ` · 最近更新 ${time(scoreAt(row.points, ms, row.sample_interval_ms * 2)![0])}` : ""}</title></circle>)}
|
||||
</g>)}
|
||||
{hoverMs !== null && <line x1={x(hoverMs)} x2={x(hoverMs)} y1={0} y2={SCORE_HEIGHT} stroke="#77816c" opacity=".5" pointerEvents="none" />}
|
||||
</svg>
|
||||
|
||||
@@ -345,7 +345,7 @@ export default function Timeline({
|
||||
</div>
|
||||
{scoresOpen && <div className={styles.scoreDescription} role="status">
|
||||
{scores.status === "loading" ? "正在读取模型分数…" : scores.status === "error" ? <>{scores.messages.join(" ")} <button type="button" onClick={scores.retry}>重新读取</button></>
|
||||
: <>实线:连续回放评分 · 空心点:原标注点评分 · 虚线:参考阈值。Smart Turn、LiveKit 表示完成倾向,TurnSense 为 P(complete);VAP 使用 1 − 用户近期发声分数,Silero 使用 1 − 当前发声分数,仅为非发声基线,不是语义 EOT 概率。{scores.messages.join(" ")}</>}
|
||||
: <>实线:连续回放评分 · 空心点:判断时评分(VAP 为流式取点) · 虚线:参考阈值。Smart Turn、LiveKit 表示完成倾向,TurnSense 为 P(complete);VAP 使用 1 − 用户近期发声分数,Silero 使用 1 − 当前发声分数,仅为非发声基线,不是语义 EOT 概率。{scores.messages.join(" ")}</>}
|
||||
</div>}
|
||||
|
||||
<div className={styles.trackLayout}>
|
||||
@@ -474,7 +474,7 @@ export default function Timeline({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scoresOpen && scores.series.length > 0 && <p className={styles.scoreFootnote}>点击曲线定位录音;悬停查看分数。VAP 约每 100 ms、Smart Turn 每 200 ms、Silero 每 32 ms、LiveKit、TurnSense 每 200 ms 更新。TurnSense 使用过去 8 秒用户音频;LiveKit 使用过去 1.2 秒用户音频,中文参考阈值 0.355;Silero 空心点取判断前最后完整帧;VAP 和 Smart Turn 原标注点评分使用独立历史窗口,与连续曲线可能不同;TurnSense 的 0.5 参考线不等同于原生三分类取最大值规则;编辑标注或转写不会重算已有分数。</p>}
|
||||
{scoresOpen && scores.series.length > 0 && <p className={styles.scoreFootnote}>点击曲线定位录音;悬停查看分数。VAP CPC 约每 100 ms,Mimi 标准与降噪版约每 80 ms;Smart Turn 每 200 ms、Silero 每 32 ms、LiveKit、TurnSense 每 200 ms 更新。原 VAP 为 CPC 降噪双声道,“仅用户”版将机器人输入全程置为静音;Mimi 两版均输入用户和机器人双声道,20 秒上下文。回放仍为原录音。TurnSense 使用过去 8 秒用户音频;LiveKit 使用过去 1.2 秒用户音频,中文参考阈值 0.355;Silero 空心点取判断前最后完整帧;VAP 各版本圆点与悬停分数均取判断前最后可用的连续流评分,圆点应落在对应阶梯曲线上;Smart Turn 的原判断点仍为独立窗口结果;TurnSense 的 0.5 参考线不等同于原生三分类取最大值规则;编辑标注或转写不会重跑模型;VAP 圆点随当前判断位置从连续曲线重新取点。</p>}
|
||||
|
||||
<div className={styles.bottomBar}>
|
||||
<div className={styles.timeReadout}><span className={styles.playheadDot} /> 播放 <strong>{timeLabel(current, true)}</strong></div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ModelId = "maai" | "smart_turn" | "silero_vad" | "livekit_mini" | "turnsense";
|
||||
export type ModelId = "maai" | "maai_user_only" | "maai_mimi_standard" | "maai_mimi_mc" | "smart_turn" | "silero_vad" | "livekit_mini" | "turnsense";
|
||||
export type ScorePoint = [timeMs: number, score: number];
|
||||
|
||||
export interface ScoreCall {
|
||||
@@ -8,6 +8,7 @@ export interface ScoreCall {
|
||||
duration_ms: number;
|
||||
points: ScorePoint[];
|
||||
checkpoints: ScorePoint[];
|
||||
checkpoint_sources?: [decisionMs: number, sourceTimeMs: number][];
|
||||
}
|
||||
|
||||
export interface ScoreExport {
|
||||
@@ -16,6 +17,7 @@ export interface ScoreExport {
|
||||
name: string;
|
||||
score: string;
|
||||
sample_interval_ms: number;
|
||||
checkpoint_mode?: "streaming" | "independent_window";
|
||||
calls: ScoreCall[];
|
||||
}
|
||||
|
||||
@@ -24,6 +26,7 @@ export interface ScoreSeries extends ScoreCall {
|
||||
name: string;
|
||||
score: string;
|
||||
sample_interval_ms: number;
|
||||
checkpoint_mode?: "streaming" | "independent_window";
|
||||
}
|
||||
|
||||
export interface ScoreResponse {
|
||||
@@ -51,6 +54,7 @@ export function parseScoreExport(value: unknown, model: ModelId): ScoreExport {
|
||||
const data = value as ScoreExport;
|
||||
if (!data || data.schema_version !== 1 || data.model !== model
|
||||
|| typeof data.name !== "string" || typeof data.score !== "string"
|
||||
|| (data.checkpoint_mode !== undefined && !["streaming", "independent_window"].includes(data.checkpoint_mode))
|
||||
|| !Number.isFinite(data.sample_interval_ms) || data.sample_interval_ms <= 0
|
||||
|| !Array.isArray(data.calls)) throw new Error("模型评分文件格式不正确。");
|
||||
const calls = new Set<string>();
|
||||
@@ -61,11 +65,34 @@ export function parseScoreExport(value: unknown, model: ModelId): ScoreExport {
|
||||
|| !validPoints(call.points, call.duration_ms) || !validPoints(call.checkpoints, call.duration_ms)) {
|
||||
throw new Error("模型评分包含重复时间、越界分数或缺失的录音信息。");
|
||||
}
|
||||
if (data.checkpoint_mode === "streaming") {
|
||||
if (!Array.isArray(call.checkpoint_sources) || call.checkpoint_sources.length !== call.checkpoints.length) {
|
||||
throw new Error("流式判断点缺少实际评分时刻。");
|
||||
}
|
||||
call.checkpoints.forEach(([decision, score], index) => {
|
||||
const point = scoreAt(call.points, decision, data.sample_interval_ms * 2);
|
||||
const source = call.checkpoint_sources![index];
|
||||
if (!point || !Array.isArray(source) || source.length !== 2 || source[0] !== decision
|
||||
|| source[1] !== point[0] || score !== point[1]) {
|
||||
throw new Error("流式判断点必须取判断前最后可用的曲线分数。");
|
||||
}
|
||||
});
|
||||
}
|
||||
calls.add(call.call_id);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Streaming markers follow current annotations, using the exact hover rule. */
|
||||
export function checkpointsForDisplay(series: ScoreSeries, decisionTimes: readonly number[]): ScorePoint[] {
|
||||
if (series.checkpoint_mode !== "streaming") return series.checkpoints;
|
||||
return [...new Set(decisionTimes)].sort((a, b) => a - b).flatMap(ms => {
|
||||
if (!Number.isFinite(ms) || ms < 0 || ms > series.duration_ms) return [];
|
||||
const point = scoreAt(series.points, ms, series.sample_interval_ms * 2);
|
||||
return point ? [[ms, point[1]] as ScorePoint] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Only an already available sample may describe a playhead/hover position. */
|
||||
export function scoreAt(points: readonly ScorePoint[], ms: number, maxAgeMs: number): ScorePoint | null {
|
||||
let low = 0, high = points.length - 1, found = -1;
|
||||
@@ -81,11 +108,13 @@ export function scorePath(points: readonly ScorePoint[], duration: number, width
|
||||
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;
|
||||
return points.map((point, index) => {
|
||||
const previous = points[index - 1];
|
||||
const start = previous && point[0] - previous[0] <= interval * 2
|
||||
? `V${y(point[1])}` : `M${x(point[0])},${y(point[1])}`;
|
||||
// Match scoreAt's validity period, including the final sample and the
|
||||
// valid beginning of a gap. Never draw across unavailable/stale time.
|
||||
const until = Math.min(points[index + 1]?.[0] ?? duration, duration, point[0] + interval * 2);
|
||||
return start + (until > point[0] ? `H${x(until)}` : "");
|
||||
}).join("");
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { ModelId } from "./model-scores";
|
||||
|
||||
export const SCORE_MODELS: Record<ModelId, {
|
||||
name: string; family: string; color: string; directory: string;
|
||||
name: string; family: string; color: string; directory: string; scoreFile?: string;
|
||||
thresholds: readonly number[]; defaultThreshold: number; defaultVisible: boolean; description: string;
|
||||
}> = {
|
||||
maai: { name: "VAP / MaAI", family: "活动预测", color: "#8866b2", directory: "maai-ch-kyoto", thresholds: [.11, .5, .8], defaultThreshold: .5, defaultVisible: true, description: "1 − 用户近期发声分数;约每 100 ms 更新,不是校准的 EOT 概率。" },
|
||||
maai: { name: "VAP / MaAI", family: "活动预测", color: "#8866b2", directory: "maai-ch-kyoto", scoreFile: "streaming/cpc-stereo/score-timeline.json", thresholds: [.11, .5, .8], defaultThreshold: .5, defaultVisible: true, description: "CPC 编码器、降噪版,用户与机器人双声道;流式评分,圆点取判断前最后可用分数。1 − 用户近期发声分数,约每 100 ms 更新,不是校准的 EOT 概率。" },
|
||||
maai_user_only: { name: "VAP / MaAI(仅用户)", family: "活动预测", color: "#6c8746", directory: "maai-ch-kyoto", scoreFile: "streaming/cpc-user-only/score-timeline.json", thresholds: [.11, .5, .8], defaultThreshold: .5, defaultVisible: false, description: "保留用户语音,机器人输入全程静音;流式评分,圆点取判断前最后可用分数。1 − 用户近期发声分数,约每 100 ms 更新,与双声道版使用相同权重。仅改变模型输入,回放仍为原录音;圆点取判断前最后可用的流式分数,不是校准的 EOT 概率。" },
|
||||
maai_mimi_standard: { name: "VAP / MaAI Mimi(标准双声道)", family: "活动预测", color: "#4b859f", directory: "maai-ch-kyoto", scoreFile: "streaming/mimi-standard/score-timeline.json", thresholds: [.11, .5, .8], defaultThreshold: .5, defaultVisible: false, description: "Mimi 编码器、标准版,用户与机器人双声道;流式评分,圆点取判断前最后可用分数。1 − 用户近期发声分数,每 80 ms 更新,20 秒上下文,不是校准的 EOT 概率。" },
|
||||
maai_mimi_mc: { name: "VAP / MaAI Mimi(降噪双声道)", family: "活动预测", color: "#a58537", directory: "maai-ch-kyoto", scoreFile: "streaming/mimi-mc/score-timeline.json", thresholds: [.11, .5, .8], defaultThreshold: .5, defaultVisible: false, description: "Mimi 编码器、降噪版,用户与机器人双声道;流式评分,圆点取判断前最后可用分数。1 − 用户近期发声分数,每 80 ms 更新,20 秒上下文,不是校准的 EOT 概率。" },
|
||||
smart_turn: { name: "Smart Turn", family: "话轮检测", color: "#278d95", directory: "smart-turn-v3.2", thresholds: [.5, .8], defaultThreshold: .5, defaultVisible: true, description: "完成倾向;最近最多 8 秒用户音频,每 200 ms 更新。" },
|
||||
silero_vad: { name: "Silero VAD", family: "活动预测", color: "#c47b32", directory: "silero-vad", thresholds: [.5, .8], defaultThreshold: .5, defaultVisible: false, description: "1 − 发声概率;每 32 ms 更新,仅为非发声基线,不是语义 EOT 概率。" },
|
||||
livekit_mini: { name: "LiveKit v1-mini", family: "话轮检测", color: "#5378c4", directory: "livekit-v1-mini", thresholds: [.355, .5, .8], defaultThreshold: .355, defaultVisible: false, description: "P(turn ended);最近 1.2 秒用户音频,每 200 ms 更新。0.355 为中文参考阈值。" },
|
||||
|
||||
@@ -2,7 +2,7 @@ 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";
|
||||
import { checkpointsForDisplay, parseScoreExport, scoreAt, scorePath, type ScoreSeries } 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,
|
||||
@@ -33,20 +33,39 @@ test("playhead values never use a future sample or carry through missing data",
|
||||
|
||||
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");
|
||||
assert.equal(path, "M50.00,72.80H100.00V19.60H200.00M450.00,65.20H500.00");
|
||||
});
|
||||
|
||||
test("streaming markers use current decision times, never future or stale scores", () => {
|
||||
const data = parseScoreExport(fixture(), "maai");
|
||||
const series = { ...data.calls[0], model: data.model, name: data.name, score: data.score,
|
||||
sample_interval_ms: data.sample_interval_ms, checkpoint_mode: "streaming" as const };
|
||||
assert.deepEqual(checkpointsForDisplay(series, [99, 150, 150, 200, 400, 401, 1000]),
|
||||
[[150, .2], [200, .9], [400, .9], [1000, .3]]);
|
||||
assert.deepEqual(checkpointsForDisplay({ ...series, checkpoint_mode: undefined }, [150]), [[150, .8]]);
|
||||
assert.equal(scorePath([[100, .2]], 1000, 1000, 100, 100), "M100.00,72.80H300.00");
|
||||
});
|
||||
|
||||
test("streaming exports reject independent, future or missing checkpoint sources", () => {
|
||||
const data = { ...fixture(), checkpoint_mode: "streaming" };
|
||||
assert.throws(() => parseScoreExport(data, "maai"), /实际评分时刻/);
|
||||
const valid = { ...data, calls: [{ ...data.calls[0], checkpoints: [[150, .2]], checkpoint_sources: [[150, 100]] }] };
|
||||
assert.equal(parseScoreExport(valid, "maai").calls[0].checkpoints[0][1], .2);
|
||||
assert.throws(() => parseScoreExport({ ...valid, calls: [{ ...valid.calls[0], checkpoints: [[150, .8]] }] }, "maai"), /最后可用/);
|
||||
assert.throws(() => parseScoreExport({ ...valid, calls: [{ ...valid.calls[0], checkpoint_sources: [[150, 200]] }] }, "maai"), /最后可用/);
|
||||
});
|
||||
|
||||
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"], ["silero_vad", "silero-vad"], ["livekit_mini", "livekit-v1-mini"], ["turnsense", "turnsense-1.0"],
|
||||
["maai", "maai-ch-kyoto", "streaming/cpc-stereo/score-timeline.json"], ["maai_user_only", "maai-ch-kyoto", "streaming/cpc-user-only/score-timeline.json"],
|
||||
["maai_mimi_standard", "maai-ch-kyoto", "streaming/mimi-standard/score-timeline.json"], ["maai_mimi_mc", "maai-ch-kyoto", "streaming/mimi-mc/score-timeline.json"],
|
||||
["smart_turn", "smart-turn-v3.2", "score-timeline.json"], ["silero_vad", "silero-vad", "score-timeline.json"],
|
||||
["livekit_mini", "livekit-v1-mini", "score-timeline.json"], ["turnsense", "turnsense-1.0", "score-timeline.json"],
|
||||
] 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 exports = await Promise.all(files.map(async ([model, directory, file]) => parseScoreExport(
|
||||
JSON.parse(await readFile(new URL(`../../../experiments/${directory}/results/${file}`, 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]));
|
||||
assert.deepEqual(identity(exports[0]), identity(exports[2]));
|
||||
assert.deepEqual(identity(exports[0]), identity(exports[3]));
|
||||
assert.deepEqual(identity(exports[0]), identity(exports[4]));
|
||||
for (const data of exports.slice(1)) assert.deepEqual(identity(exports[0]), identity(data));
|
||||
for (const data of exports) {
|
||||
assert.equal(data.calls.reduce((n, call) => n + call.checkpoints.length, 0), 44);
|
||||
for (const call of data.calls) {
|
||||
@@ -56,6 +75,54 @@ test("recorded inference exports agree on audio identity and retain all 44 check
|
||||
}
|
||||
});
|
||||
|
||||
test("all four VAP streaming exports, displayed circles and accuracy use the same past-only scores", async () => {
|
||||
const sources = [["maai", "cpc-stereo"], ["maai_user_only", "cpc-user-only"],
|
||||
["maai_mimi_standard", "mimi-standard"], ["maai_mimi_mc", "mimi-mc"]] as const;
|
||||
const annotationCsv = await readFile(new URL("../../EOT标注/工作数据/eot_labels.csv", import.meta.url), "utf8");
|
||||
// @ts-expect-error Node's native TypeScript runner resolves explicit .ts imports.
|
||||
const { parseAnnotations } = await import("../lib/annotations.ts");
|
||||
const annotations = parseAnnotations(annotationCsv);
|
||||
for (const [model, directory] of sources) {
|
||||
const url = new URL(`../../../experiments/maai-ch-kyoto/results/streaming/${directory}/`, import.meta.url);
|
||||
const data = parseScoreExport(JSON.parse(await readFile(new URL("score-timeline.json", url), "utf8")), model);
|
||||
const summary = JSON.parse(await readFile(new URL("summary.json", url), "utf8"));
|
||||
assert.equal(data.checkpoint_mode, "streaming");
|
||||
let correct = 0, count = 0;
|
||||
for (const call of data.calls) {
|
||||
const labels = annotations.filter(row => row.call_id === call.call_id);
|
||||
const series: ScoreSeries = { ...call, model, name: data.name, score: data.score,
|
||||
sample_interval_ms: data.sample_interval_ms, checkpoint_mode: data.checkpoint_mode };
|
||||
assert.deepEqual(checkpointsForDisplay(series, labels.map(row => row.decision_ms)), call.checkpoints);
|
||||
for (const [ms, score] of call.checkpoints) {
|
||||
assert.equal(scoreAt(call.points, ms, data.sample_interval_ms * 2)![1], score);
|
||||
const label = labels.find(row => row.decision_ms === ms)!;
|
||||
if (label.can_reply_now !== "") { count += 1; correct += Number(Number(score > .5) === Number(label.can_reply_now)); }
|
||||
}
|
||||
}
|
||||
assert.equal(summary.main_metrics.n, count);
|
||||
assert.equal(summary.main_metrics.correct, correct);
|
||||
assert.equal(summary.main_metrics.accuracy, correct / count);
|
||||
}
|
||||
});
|
||||
|
||||
test("user-only VAP checkpoints use the current labels and the silent-robot experiment", async () => {
|
||||
const load = async (file: string) => JSON.parse(await readFile(new URL(
|
||||
`../../../experiments/maai-ch-kyoto/results/user-only/${file}`, import.meta.url), "utf8"));
|
||||
const data = parseScoreExport(await load("score-timeline.json"), "maai_user_only");
|
||||
const summary = await load("summary.json");
|
||||
const annotationCsv = await readFile(new URL("../../EOT标注/工作数据/eot_labels.csv", import.meta.url), "utf8");
|
||||
// @ts-expect-error Node's native TypeScript runner resolves explicit .ts imports.
|
||||
const { parseAnnotations } = await import("../lib/annotations.ts");
|
||||
const annotations = parseAnnotations(annotationCsv).filter(row => row.can_reply_now !== "");
|
||||
assert.equal(summary.witness.robot_and_future_invariance, true);
|
||||
assert.equal(summary.witness.repeat_identical, true);
|
||||
assert.equal(data.sample_interval_ms, 100);
|
||||
const checkpoints = data.calls.flatMap(call => call.checkpoints.map(([ms]) => `${call.call_id}:${ms}`)).sort();
|
||||
assert.deepEqual(checkpoints, annotations.map(row => `${row.call_id}:${row.decision_ms}`).sort());
|
||||
assert.equal(summary.main_metrics.n, annotations.length);
|
||||
assert.equal(summary.same_point_stereo_reference.n, annotations.length);
|
||||
});
|
||||
|
||||
test("Silero checkpoints use only the last available frame, and speech is inverted once", async () => {
|
||||
const data = parseScoreExport(JSON.parse(await readFile(new URL(
|
||||
"../../../experiments/silero-vad/results/score-timeline.json", import.meta.url), "utf8")), "silero_vad");
|
||||
@@ -72,6 +139,31 @@ test("Silero checkpoints use only the last available frame, and speech is invert
|
||||
}
|
||||
});
|
||||
|
||||
test("Mimi standard and MC use verified distinct checkpoints with the same causal stereo inputs", async () => {
|
||||
const load = async (directory: string, file: string) => JSON.parse(await readFile(new URL(
|
||||
`../../../experiments/maai-ch-kyoto/results/${directory}/${file}`, import.meta.url), "utf8"));
|
||||
const standard = await load("mimi-standard", "summary.json");
|
||||
const mc = await load("mimi-mc", "summary.json");
|
||||
const checkpointKeys = [];
|
||||
for (const [id, dir, summary] of [["maai_mimi_standard", "mimi-standard", standard], ["maai_mimi_mc", "mimi-mc", mc]] as const) {
|
||||
const data = parseScoreExport(await load(dir, "score-timeline.json"), id);
|
||||
assert.equal(data.sample_interval_ms, 80);
|
||||
assert.equal(summary.witness.repeat_identical, true);
|
||||
assert.equal(summary.witness.future_invariance, true);
|
||||
assert.equal(summary.witness.both_channels_nonzero, true);
|
||||
assert.deepEqual(summary.witness.loading_audit.missing_trainable_parameters, []);
|
||||
assert.deepEqual(summary.witness.loading_audit.mismatched_parameters, []);
|
||||
assert.equal(summary.provenance.channels["1"], "recorded robot");
|
||||
checkpointKeys.push(data.calls.flatMap(call => call.checkpoints.map(([ms]) => `${call.call_id}:${ms}`)).sort());
|
||||
}
|
||||
assert.equal(standard.provenance.mc, false);
|
||||
assert.equal(mc.provenance.mc, true);
|
||||
assert.notEqual(standard.provenance.checkpoint_filename, mc.provenance.checkpoint_filename);
|
||||
assert.equal(standard.provenance.label_sha256, mc.provenance.label_sha256);
|
||||
assert.deepEqual(standard.provenance.audio_sha256, mc.provenance.audio_sha256);
|
||||
assert.deepEqual(checkpointKeys[0], checkpointKeys[1]);
|
||||
});
|
||||
|
||||
|
||||
test("LiveKit original checkpoints are real exact-time curve scores", async () => {
|
||||
const data = parseScoreExport(JSON.parse(await readFile(new URL(
|
||||
|
||||
@@ -7,6 +7,8 @@ test("first visit starts with three comparison curves and each model's reference
|
||||
const defaults = readScorePreferences(null);
|
||||
assert.deepEqual(Object.entries(defaults.visibility).filter(([, value]) => value).map(([id]) => id), ["maai", "smart_turn", "turnsense"]);
|
||||
assert.equal(defaults.thresholds.livekit_mini, .355);
|
||||
assert.equal(defaults.visibility.maai_user_only, false);
|
||||
assert.equal(defaults.thresholds.maai_user_only, .5);
|
||||
});
|
||||
|
||||
test("saved choices preserve clear-all and accept only supported reference thresholds", () => {
|
||||
@@ -26,3 +28,22 @@ test("partial and invalid preferences leave future models on their defaults", ()
|
||||
assert.equal("unknown" in saved.visibility, false);
|
||||
assert.deepEqual(readScorePreferences({ version: 999, visibility: { maai: false } }), readScorePreferences(null));
|
||||
});
|
||||
|
||||
test("user-only VAP has independent visibility and thresholds alongside stereo VAP", () => {
|
||||
const saved = readScorePreferences({ version: 1, visibility: { maai: false, maai_user_only: true },
|
||||
thresholds: { maai: .8, maai_user_only: .11 } });
|
||||
assert.equal(saved.visibility.maai, false);
|
||||
assert.equal(saved.visibility.maai_user_only, true);
|
||||
assert.equal(saved.thresholds.maai, .8);
|
||||
assert.equal(saved.thresholds.maai_user_only, .11);
|
||||
});
|
||||
|
||||
test("Mimi standard and noise-robust comparison settings are independent", () => {
|
||||
const saved = readScorePreferences({ version: 1,
|
||||
visibility: { maai_mimi_standard: true, maai_mimi_mc: false },
|
||||
thresholds: { maai_mimi_standard: .8, maai_mimi_mc: .11 } });
|
||||
assert.equal(saved.visibility.maai_mimi_standard, true);
|
||||
assert.equal(saved.visibility.maai_mimi_mc, false);
|
||||
assert.equal(saved.thresholds.maai_mimi_standard, .8);
|
||||
assert.equal(saved.thresholds.maai_mimi_mc, .11);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user