添加 MaAI 和 Smart Turn 分数独立显示开关

This commit is contained in:
Xin Wang
2026-10-14 11:01:39 +08:00
parent 22556c67a4
commit 2219a11f44
4 changed files with 26 additions and 10 deletions
+1
View File
@@ -32,6 +32,7 @@ npm run dev
- MaAI使用双声道连续回放、20秒KV缓存,分数为 `1 - p_now[user]`,是接话候选分数,不是校准的EOT概率。Smart Turn沿用原回测的用户声道、过去ASR定位、500ms前置音频、3秒内续说及最多8秒窗口;没有额外的结束信号、VAD停止门槛或超时放行。 - MaAI使用双声道连续回放、20秒KV缓存,分数为 `1 - p_now[user]`,是接话候选分数,不是校准的EOT概率。Smart Turn沿用原回测的用户声道、过去ASR定位、500ms前置音频、3秒内续说及最多8秒窗口;没有额外的结束信号、VAD停止门槛或超时放行。
- 空心点保留原44点的独立窗口回测分数。尤其MaAI的连续状态与独立窗口不同,空心点可能不落在连续曲线上。原始44点指标不会被这次时间序列覆盖。 - 空心点保留原44点的独立窗口回测分数。尤其MaAI的连续状态与独立窗口不同,空心点可能不落在连续曲线上。原始44点指标不会被这次时间序列覆盖。
- 参考阈值只改变虚线位置,不修改人工标签。MaAI可选0.11、0.5、0.8,Smart Turn可选0.5、0.8。保存的分数不会随标注或转写编辑而重新计算。 - 参考阈值只改变虚线位置,不修改人工标签。MaAI可选0.11、0.5、0.8,Smart Turn可选0.5、0.8。保存的分数不会随标注或转写编辑而重新计算。
- 两个模型名称旁各有独立显示开关,默认都打开。关闭后隐藏该模型的曲线、原标注点评分、参考线及分数读数;重新打开保留原阈值。两个都关闭时显示提示,切换通话或收起再展开会保留当前显示选择。
- 仅在展开时读取当前项目录音的评分,并核对音频SHA256。更换为本地新录音、音频变化或没有对应推理数据时,不显示旧曲线。切换通话会取消旧请求,避免前一段数据覆盖当前段。 - 仅在展开时读取当前项目录音的评分,并核对音频SHA256。更换为本地新录音、音频变化或没有对应推理数据时,不显示旧曲线。切换通话会取消旧请求,避免前一段数据覆盖当前段。
评分数据位于各模型实验目录的 `results/score-timeline.json`。更新数据时,在两个各自的本机uv环境中运行: 评分数据位于各模型实验目录的 `results/score-timeline.json`。更新数据时,在两个各自的本机uv环境中运行:
@@ -9,6 +9,7 @@ const EMPTY: ScoreSeries[] = [];
const COLORS: Record<ModelId, string> = { maai: "#8866b2", smart_turn: "#278d95" }; const COLORS: Record<ModelId, string> = { maai: "#8866b2", smart_turn: "#278d95" };
export const SCORE_HEIGHT = 172; export const SCORE_HEIGHT = 172;
export type ScoreThresholds = Record<ModelId, number>; export type ScoreThresholds = Record<ModelId, number>;
export type ScoreVisibility = Record<ModelId, boolean>;
const time = (ms: number) => `${Math.floor(ms / 60000).toString().padStart(2, "0")}:${(ms % 60000 / 1000).toFixed(3).padStart(6, "0")}`; 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) { export function useModelScores(callId: string, recordingPath: string | null, expanded: boolean) {
@@ -43,17 +44,22 @@ export function useModelScores(callId: string, recordingPath: string | null, exp
}; };
} }
export function ScoreControls({ series, currentMs, thresholds, onThreshold }: { export function ScoreControls({ series, currentMs, thresholds, onThreshold, visibility, onVisibility }: {
series: ScoreSeries[]; currentMs: number; thresholds: ScoreThresholds; series: ScoreSeries[]; currentMs: number; thresholds: ScoreThresholds;
onThreshold: (model: ModelId, value: number) => void; onThreshold: (model: ModelId, value: number) => void;
visibility: ScoreVisibility; onVisibility: (model: ModelId, visible: boolean) => void;
}) { }) {
return <div className={styles.scoreControls}> return <div className={styles.scoreControls}>
{series.map(row => { {series.map(row => {
const point = scoreAt(row.points, currentMs, row.sample_interval_ms * 2); const visible = visibility[row.model];
const point = visible ? scoreAt(row.points, currentMs, row.sample_interval_ms * 2) : null;
return <div className={styles.modelControl} key={row.model} style={{ color: COLORS[row.model] }}> return <div className={styles.modelControl} key={row.model} style={{ color: COLORS[row.model] }}>
<span className={styles.modelSwatch} /><strong>{row.name}</strong> <button type="button" role="switch" aria-checked={visible} aria-label={`显示 ${row.name} 分数`}
<output aria-live="off" title={point ? `评分时刻 ${time(point[0])}` : "当前位置暂无评分"}>{point ? point[1].toFixed(3) : "—"}</output> className={styles.modelVisibility} onClick={() => onVisibility(row.model, !visible)}>
<label>参考线<select aria-label={`${row.name} 参考阈值`} value={thresholds[row.model]} <span className={styles.modelSwitch} aria-hidden="true" /><strong>{row.name}</strong>
</button>
<output aria-live="off" title={!visible ? "已隐藏" : point ? `评分时刻 ${time(point[0])}` : "当前位置暂无评分"}>{point ? point[1].toFixed(3) : "—"}</output>
<label>参考线<select aria-label={`${row.name} 参考阈值`} value={thresholds[row.model]} disabled={!visible}
onChange={event => onThreshold(row.model, Number(event.target.value))}> 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>)} {(row.model === "maai" ? [.11, .5, .8] : [.5, .8]).map(value => <option key={value} value={value}>{value.toFixed(2)}</option>)}
</select></label> </select></label>
@@ -130,7 +130,13 @@
.modelControl output { width: 37px; font-family: monospace; font-variant-numeric: tabular-nums; text-align: center; } .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 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; } .modelControl select { padding: 1px 0; border: 0; background: transparent; font-size: 10px; color: #637258; cursor: pointer; }
.modelSwatch { width: 14px; height: 2px; background: currentColor; } .modelVisibility { display: inline-flex; align-items: center; gap: 6px; padding: 3px 0; border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; }
.modelSwitch { display: inline-block; position: relative; width: 26px; height: 15px; border-radius: 20px; background: currentColor; }
.modelSwitch::after { content: ""; position: absolute; top: 3px; left: 14px; width: 9px; height: 9px; border-radius: 50%; background: white; transition: left .15s ease; }
.modelVisibility[aria-checked="false"] { color: #92998c; }
.modelVisibility[aria-checked="false"] .modelSwitch { background: #c6cbbe; }
.modelVisibility[aria-checked="false"] .modelSwitch::after { left: 3px; }
.modelControl select:disabled { opacity: .45; cursor: default; }
.scoreDescription { padding: 0 22px 10px; font-size: 10px; line-height: 1.7; color: #88927f; } .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; } .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; } .trackNames > .scoreAxis { padding: 12px 8px; flex-direction: column; justify-content: space-between; font-size: 10px; color: #929a87; border-top: 1px solid #e7e9e0; }
@@ -144,7 +150,7 @@
.scoreInspection b { font-weight: 500; } .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; } .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; } .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; } .scoreToggle:focus-visible, .modelVisibility:focus-visible, .modelControl select:focus-visible { outline: 2px solid #758962; outline-offset: 3px; border-radius: 3px; }
@media (max-width: 640px) { @media (max-width: 640px) {
.scorePanelHeader { padding-inline: 12px; gap: 5px; } .scorePanelHeader { padding-inline: 12px; gap: 5px; }
.scoreDescription, .scoreFootnote { padding-inline: 12px; } .scoreDescription, .scoreFootnote { padding-inline: 12px; }
+6 -3
View File
@@ -12,7 +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"; import ModelScoreTrack, { ScoreControls, SCORE_HEIGHT, useModelScores, type ScoreThresholds, type ScoreVisibility } from "./model-score-track";
export interface TimelineTranscript { export interface TimelineTranscript {
index: number; index: number;
@@ -91,7 +91,9 @@ export default function Timeline({
const scoreRegionId = useId(); const scoreRegionId = useId();
const [scoresOpen, setScoresOpen] = useState(false); const [scoresOpen, setScoresOpen] = useState(false);
const [thresholds, setThresholds] = useState<ScoreThresholds>({ maai: .5, smart_turn: .5 }); const [thresholds, setThresholds] = useState<ScoreThresholds>({ maai: .5, smart_turn: .5 });
const [scoreVisibility, setScoreVisibility] = useState<ScoreVisibility>({ maai: true, smart_turn: true });
const scores = useModelScores(callId, recordingPath, scoresOpen); const scores = useModelScores(callId, recordingPath, scoresOpen);
const visibleScores = useMemo(() => scores.series.filter(row => scoreVisibility[row.model]), [scores.series, scoreVisibility]);
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<
@@ -337,6 +339,7 @@ export default function Timeline({
<strong>模型 EOT 分数</strong><span>{scoresOpen ? "收起" : "展开 MaAI / Smart Turn 对比"}</span> <strong>模型 EOT 分数</strong><span>{scoresOpen ? "收起" : "展开 MaAI / Smart Turn 对比"}</span>
</button> </button>
{scoresOpen && <ScoreControls series={scores.series} currentMs={current} {scoresOpen && <ScoreControls series={scores.series} currentMs={current}
visibility={scoreVisibility} onVisibility={(model, visible) => setScoreVisibility(previous => ({ ...previous, [model]: visible }))}
thresholds={thresholds} onThreshold={(model, value) => setThresholds(previous => ({ ...previous, [model]: value }))} />} thresholds={thresholds} onThreshold={(model, value) => setThresholds(previous => ({ ...previous, [model]: value }))} />}
</div> </div>
{scoresOpen && <div className={styles.scoreDescription} role="status"> {scoresOpen && <div className={styles.scoreDescription} role="status">
@@ -449,9 +452,9 @@ export default function Timeline({
</div> </div>
{scoresOpen && <div id={scoreRegionId}> {scoresOpen && <div id={scoreRegionId}>
{scores.series.length ? <ModelScoreTrack series={scores.series} width={canvasWidth} durationMs={duration} {visibleScores.length ? <ModelScoreTrack series={visibleScores} width={canvasWidth} durationMs={duration}
currentMs={current} thresholds={thresholds} annotations={annotations} onSeek={onSeek} onSelectAnnotation={onSelectAnnotation} /> 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 className={styles.scoreEmpty} style={{ height: SCORE_HEIGHT, width: viewportWidth }}>{scores.status === "loading" ? "正在载入评分曲线…" : scores.series.length ? "已隐藏全部模型分数,请打开上方开关查看" : "当前录音暂无可显示的模型分数"}</div>}
</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" />}