接入 LiveKit 与 TurnSense 评分并改进模型曲线选择
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, useId } from "react";
|
||||
import { 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";
|
||||
import styles from "./timeline.module.css";
|
||||
|
||||
const EMPTY: ScoreSeries[] = [];
|
||||
const COLORS: Record<ModelId, string> = { maai: "#8866b2", smart_turn: "#278d95", silero_vad: "#c47b32" };
|
||||
const COLORS = Object.fromEntries(SCORE_MODEL_IDS.map(id => [id, SCORE_MODELS[id].color])) as Record<ModelId, string>;
|
||||
export const SCORE_HEIGHT = 172;
|
||||
export type ScoreThresholds = Record<ModelId, number>;
|
||||
export type ScoreVisibility = Record<ModelId, boolean>;
|
||||
@@ -44,33 +46,125 @@ export function useModelScores(callId: string, recordingPath: string | null, exp
|
||||
};
|
||||
}
|
||||
|
||||
export function ScoreControls({ series, currentMs, thresholds, onThreshold, visibility, onVisibility }: {
|
||||
export function useScorePreferences() {
|
||||
const [preferences, setPreferences] = useState(() => readScorePreferences(null));
|
||||
const [ready, setReady] = useState(false);
|
||||
useEffect(() => {
|
||||
try { setPreferences(readScorePreferences(JSON.parse(localStorage.getItem("eot-score-preferences-v1") || "null"))); } catch { /* Defaults survive unavailable or invalid browser storage. */ }
|
||||
setReady(true);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
try { localStorage.setItem("eot-score-preferences-v1", JSON.stringify({ version: 1, ...preferences })); } catch { /* Preferences are optional. */ }
|
||||
}, [ready, preferences]);
|
||||
return { ...preferences,
|
||||
setVisibility: (visibility: ScoreVisibility) => setPreferences(previous => ({ ...previous, visibility })),
|
||||
setThreshold: (id: ModelId, value: number) => setPreferences(previous => ({ ...previous, thresholds: { ...previous.thresholds, [id]: value } })),
|
||||
};
|
||||
}
|
||||
|
||||
export function ScoreControls({ series, currentMs, thresholds, onThreshold, visibility, onVisibility, onFocus, focusedModel, loading }: {
|
||||
series: ScoreSeries[]; currentMs: number; thresholds: ScoreThresholds;
|
||||
onThreshold: (model: ModelId, value: number) => void;
|
||||
visibility: ScoreVisibility; onVisibility: (model: ModelId, visible: boolean) => void;
|
||||
visibility: ScoreVisibility; onVisibility: (visibility: ScoreVisibility) => void;
|
||||
onFocus: (model: ModelId | null) => void; focusedModel: ModelId | null; loading: boolean;
|
||||
}) {
|
||||
const [panel, setPanel] = useState<ModelId | "choose" | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
const [previousSelection, setPreviousSelection] = useState<ScoreVisibility | null>(null);
|
||||
const anchor = useRef<HTMLButtonElement | null>(null);
|
||||
const popup = useRef<HTMLDivElement>(null);
|
||||
const search = useRef<HTMLInputElement>(null);
|
||||
const panelId = useId();
|
||||
const available = new Set(series.map(row => row.model));
|
||||
const selected = series.filter(row => visibility[row.model]);
|
||||
function close() { setPanel(null); anchor.current?.focus({ preventScroll: true }); }
|
||||
function open(kind: ModelId | "choose", button: HTMLButtonElement) {
|
||||
anchor.current = button;
|
||||
if (panel === kind) { close(); return; }
|
||||
setQuery(""); setPanel(kind);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!panel) return;
|
||||
const place = () => {
|
||||
const r = anchor.current?.getBoundingClientRect(); if (!r) return;
|
||||
const width = Math.min(360, window.innerWidth - 24);
|
||||
const height = Math.min(popup.current?.getBoundingClientRect().height || 460, window.innerHeight - 24);
|
||||
setPosition({ left: Math.max(12, Math.min(r.left, window.innerWidth - width - 12)),
|
||||
top: Math.max(12, Math.min(r.bottom + 8, window.innerHeight - height - 12)) });
|
||||
};
|
||||
place();
|
||||
(panel === "choose" ? search.current : popup.current?.querySelector<HTMLElement>("button,select"))?.focus({ preventScroll: true });
|
||||
const outside = (event: PointerEvent) => {
|
||||
if (!popup.current?.contains(event.target as Node) && !anchor.current?.contains(event.target as Node)) setPanel(null);
|
||||
};
|
||||
const escape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
|
||||
// Keep workbench hotkeys from changing annotations while browsing settings.
|
||||
else if (popup.current?.contains(event.target as Node)) event.stopPropagation();
|
||||
};
|
||||
document.addEventListener("pointerdown", outside); document.addEventListener("keydown", escape, true);
|
||||
window.addEventListener("resize", place); window.addEventListener("scroll", place, true);
|
||||
return () => { document.removeEventListener("pointerdown", outside); document.removeEventListener("keydown", escape, true);
|
||||
window.removeEventListener("resize", place); window.removeEventListener("scroll", place, true); };
|
||||
}, [panel]);
|
||||
const manualSelection = (next: ScoreVisibility) => { setPreviousSelection(null); onVisibility(next); };
|
||||
const metadata = panel && panel !== "choose" ? SCORE_MODELS[panel] : null;
|
||||
return <div className={styles.scoreControls}>
|
||||
{series.map(row => {
|
||||
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] }}>
|
||||
<button type="button" role="switch" aria-checked={visible} aria-label={`显示 ${row.name} 分数`}
|
||||
className={styles.modelVisibility} onClick={() => onVisibility(row.model, !visible)}>
|
||||
<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))}>
|
||||
{(row.model === "maai" ? [.11, .5, .8] : [.5, .8]).map(value => <option key={value} value={value}>{value.toFixed(2)}</option>)}
|
||||
<div className={styles.modelPickerBar}>
|
||||
<button type="button" className={styles.modelPickerButton} aria-expanded={panel === "choose"} aria-controls={panel === "choose" ? panelId : undefined}
|
||||
aria-haspopup="dialog" onClick={event => open("choose", event.currentTarget)}>选择模型 <span>{selected.length}/{SCORE_MODEL_IDS.length}</span><span aria-hidden="true">⌄</span></button>
|
||||
{previousSelection && <button type="button" className={styles.restoreSelection} onClick={() => { onVisibility(previousSelection); setPreviousSelection(null); }}>恢复之前选择</button>}
|
||||
</div>
|
||||
<div className={styles.modelLegend} aria-label="已显示模型图例">
|
||||
{selected.map(row => {
|
||||
const model = SCORE_MODELS[row.model];
|
||||
const point = scoreAt(row.points, currentMs, row.sample_interval_ms * 2);
|
||||
return <div key={row.model} className={styles.legendItem} style={{ color: model.color, opacity: focusedModel && focusedModel !== row.model ? .45 : 1 }}
|
||||
onPointerEnter={() => onFocus(row.model)} onPointerLeave={() => onFocus(null)}>
|
||||
<button type="button" className={styles.legendName} aria-label={`${model.name} 曲线设置`} aria-haspopup="dialog" aria-expanded={panel === row.model}
|
||||
onFocus={() => onFocus(row.model)} onBlur={() => onFocus(null)} onClick={event => open(row.model, event.currentTarget)}>
|
||||
<i className={styles.modelColor} /><span>{model.name}</span><output title={point ? `评分时刻 ${time(point[0])}` : "当前位置暂无评分"}>{point ? point[1].toFixed(3) : "—"}</output><span className={styles.legendMore} aria-hidden="true">⋯</span>
|
||||
</button>
|
||||
</div>;
|
||||
})}
|
||||
{!selected.length && <span className={styles.noModels}>{loading ? "正在载入评分…" : "尚未选择可用模型"}</span>}
|
||||
</div>
|
||||
{panel && createPortal(<div className={styles.modelPopover} ref={popup} id={panelId} role="dialog" aria-label={panel === "choose" ? "选择对比模型" : `${metadata!.name} 曲线设置`}
|
||||
style={{ top: position.top, left: position.left }}>
|
||||
<div className={styles.popoverHeading}><strong>{panel === "choose" ? "选择对比模型" : metadata!.name}</strong><button type="button" aria-label="关闭模型面板" onClick={close}>×</button></div>
|
||||
{panel === "choose" ? <>
|
||||
<input ref={search} className={styles.modelSearch} aria-label="搜索模型" placeholder="搜索模型名称或类型" value={query} onChange={event => setQuery(event.target.value)} />
|
||||
<div className={styles.pickerActions}><span>已显示 {selected.length} 个</span><button type="button" disabled={!series.length} onClick={() => manualSelection({ ...visibility, ...Object.fromEntries(series.map(row => [row.model, true])) })}>全选可用</button><button type="button" onClick={() => manualSelection(Object.fromEntries(SCORE_MODEL_IDS.map(id => [id, false])) as ScoreVisibility)}>清空</button></div>
|
||||
<div className={styles.modelOptions}>
|
||||
{Array.from(new Set(SCORE_MODEL_IDS.map(id => SCORE_MODELS[id].family))).reverse().map(family => {
|
||||
const models = SCORE_MODEL_IDS.filter(id => SCORE_MODELS[id].family === family && `${SCORE_MODELS[id].name} ${family}`.toLowerCase().includes(query.trim().toLowerCase()));
|
||||
return models.length ? <fieldset key={family}><legend>{family}</legend>{models.map(id => <label key={id} className={styles.modelOption}>
|
||||
<input type="checkbox" aria-label={`显示 ${SCORE_MODELS[id].name} 分数`} checked={visibility[id]} disabled={!available.has(id)} onChange={event => manualSelection({ ...visibility, [id]: event.target.checked })} />
|
||||
<i className={styles.modelColor} style={{ background: SCORE_MODELS[id].color }} /><span><strong>{SCORE_MODELS[id].name}</strong><small>{available.has(id) ? SCORE_MODELS[id].description.split(";")[0] : loading ? "正在读取评分…" : "当前录音暂无评分"}</small></span>
|
||||
</label>)}</fieldset> : null;
|
||||
})}
|
||||
{!SCORE_MODEL_IDS.some(id => `${SCORE_MODELS[id].name} ${SCORE_MODELS[id].family}`.toLowerCase().includes(query.trim().toLowerCase())) && <p className={styles.noSearchResults}>没有匹配的模型</p>}
|
||||
</div><p className={styles.pickerHint}>选择会自动记住。建议同时比较 2–3 条曲线。</p>
|
||||
</> : <>
|
||||
<p className={styles.modelExplanation}>{metadata!.description}</p>
|
||||
<label className={styles.thresholdSetting}>参考阈值<select aria-label={`${metadata!.name} 参考阈值`} value={thresholds[panel]} onChange={event => onThreshold(panel, Number(event.target.value))}>
|
||||
{metadata!.thresholds.map(value => <option key={value} value={value}>{value === .355 ? "0.355(中文)" : value.toFixed(2)}</option>)}
|
||||
</select></label>
|
||||
</div>;
|
||||
})}
|
||||
<button type="button" className={styles.modelMenuAction} onClick={() => {
|
||||
if (!previousSelection) setPreviousSelection(visibility);
|
||||
onVisibility(Object.fromEntries(SCORE_MODEL_IDS.map(id => [id, id === panel])) as ScoreVisibility); close();
|
||||
}}>仅显示此模型</button>
|
||||
<button type="button" className={styles.modelMenuAction} onClick={() => { manualSelection({ ...visibility, [panel]: false }); close(); }}>隐藏此模型</button>
|
||||
</>}
|
||||
</div>, document.body)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function ModelScoreTrack({ series, width, durationMs, currentMs, thresholds, annotations, onSeek, onSelectAnnotation }: {
|
||||
export default function ModelScoreTrack({ series, width, durationMs, currentMs, thresholds, annotations, onSeek, onSelectAnnotation, focusedModel }: {
|
||||
series: ScoreSeries[]; width: number; durationMs: number; currentMs: number;
|
||||
thresholds: ScoreThresholds; annotations: TimelineAnnotation[];
|
||||
thresholds: ScoreThresholds; annotations: TimelineAnnotation[]; focusedModel: ModelId | null;
|
||||
onSeek: (ms: number) => void; onSelectAnnotation: (id: string, seekToPoint?: boolean) => void;
|
||||
}) {
|
||||
const [hoverMs, setHoverMs] = useState<number | null>(null);
|
||||
@@ -86,7 +180,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 分数对比,纵轴 0 到 1,横轴与录音时间一致"
|
||||
role="group" aria-label="VAP、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)));
|
||||
@@ -94,10 +188,10 @@ export default function ModelScoreTrack({ series, width, durationMs, currentMs,
|
||||
{[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}>
|
||||
{paths.map(row => <g key={row.model} data-model={row.model} opacity={focusedModel && focusedModel !== row.model ? .18 : 1}>
|
||||
<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" />
|
||||
<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)},点击定位`}
|
||||
|
||||
@@ -124,19 +124,7 @@
|
||||
.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; }
|
||||
.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; }
|
||||
.scoreControls { width: 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }
|
||||
.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; }
|
||||
@@ -150,10 +138,51 @@
|
||||
.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, .modelVisibility:focus-visible, .modelControl select:focus-visible { outline: 2px solid #758962; outline-offset: 3px; border-radius: 3px; }
|
||||
.scoreToggle:focus-visible, .modelPickerButton:focus-visible, .legendName: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; }
|
||||
}
|
||||
|
||||
.modelPickerBar { display: flex; align-items: center; gap: 9px; }
|
||||
.modelPickerButton { display: inline-flex; align-items: center; gap: 7px; padding: 6px 9px; border: 1px solid #dfe5d7; background: #f8faf3; border-radius: 6px; font: inherit; font-size: 11px; color: #526448; cursor: pointer; white-space: nowrap; }
|
||||
.modelPickerButton > span:first-child { padding: 1px 5px; border-radius: 4px; background: #e9efdf; color: #69775f; font: 10px monospace; }
|
||||
.restoreSelection { border: 0; background: none; color: #647d50; font: inherit; font-size: 10px; cursor: pointer; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.modelLegend { display: flex; align-items: center; flex-wrap: wrap; gap: 3px 10px; min-width: 0; }
|
||||
.legendItem { transition: opacity .12s; }
|
||||
.legendName { display: flex; align-items: center; gap: 5px; border: 0; border-radius: 5px; padding: 5px 2px; background: none; color: inherit; font-size: 10px; cursor: pointer; }
|
||||
.legendName:hover, .legendName[aria-expanded="true"] { background: #f0f3e9; }
|
||||
.legendName output { font: 10px monospace; font-variant-numeric: tabular-nums; min-width: 32px; }
|
||||
.legendMore { color: #9aa18e; font-size: 15px; line-height: 10px; }
|
||||
.modelColor { display: inline-block; width: 7px; height: 7px; flex-shrink: 0; background: currentColor; border-radius: 50%; }
|
||||
.noModels { color: #929b88; font-size: 10px; }
|
||||
.modelPopover { position: fixed; z-index: 100; width: min(360px, calc(100vw - 24px)); max-height: calc(100vh - 24px); overflow-y: auto; padding: 14px; border: 1px solid #dfe4d6; border-radius: 12px; background: #fffefb; box-shadow: 0 12px 40px #33402a26, 0 2px 8px #33402a0d; color: #46563e; font-family: inherit; font-size: 12px; }
|
||||
.popoverHeading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||||
.popoverHeading strong { font-size: 13px; font-weight: 600; }
|
||||
.popoverHeading button { display: grid; place-items: center; width: 26px; height: 26px; border: 0; border-radius: 5px; background: #f4f6ee; font-size: 19px; color: #7c8872; cursor: pointer; }
|
||||
.modelSearch { box-sizing: border-box; width: 100%; padding: 8px 10px; border: 1px solid #dfe5d7; border-radius: 7px; color: #526448; background: #fbfcf7; font: inherit; font-size: 12px; }
|
||||
.modelSearch:focus-visible { outline: 2px solid #9aac87; outline-offset: 1px; }
|
||||
.pickerActions { display: flex; align-items: center; gap: 9px; padding: 11px 0 7px; font-size: 10px; color: #8b9580; }
|
||||
.pickerActions > span { margin-right: auto; }
|
||||
.pickerActions button { padding: 2px 0; border: 0; background: none; color: #637e4d; cursor: pointer; font: inherit; }
|
||||
.pickerActions button:disabled { opacity: .4; cursor: default; }
|
||||
.modelOptions { max-height: min(360px, 50vh); overflow-y: auto; scrollbar-width: thin; }
|
||||
.modelOptions fieldset { border: 0; margin: 6px 0 0; padding: 0; min-width: 0; }
|
||||
.modelOptions legend { padding: 5px 0; color: #8c9781; font-size: 10px; }
|
||||
.modelOption { display: flex; align-items: center; gap: 9px; padding: 8px 6px; border-radius: 6px; cursor: pointer; }
|
||||
.modelOption:hover { background: #f4f7ed; }
|
||||
.modelOption:has(input:disabled) { opacity: .55; cursor: default; }
|
||||
.modelOption input { accent-color: #748c5f; margin: 0; width: 14px; height: 14px; }
|
||||
.modelOption > span { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.modelOption strong { font-size: 11px; font-weight: 550; }
|
||||
.modelOption small { font-size: 10px; color: #8c9781; }
|
||||
.pickerHint { margin: 12px 0 0; border-top: 1px solid #ecede5; padding-top: 10px; color: #929b88; font-size: 10px; }
|
||||
.noSearchResults { padding: 18px 0; text-align: center; color: #929b88; }
|
||||
.modelExplanation { margin: 0 0 14px; line-height: 1.8; color: #839175; font-size: 11px; }
|
||||
.thresholdSetting { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 8px 0 14px; color: #66775a; font-size: 11px; }
|
||||
.thresholdSetting select { border: 1px solid #dfe5d7; border-radius: 6px; padding: 6px 8px; background: #f9fbf4; color: #526448; font: inherit; }
|
||||
.modelMenuAction { display: block; width: 100%; padding: 9px 6px; text-align: left; border: 0; border-top: 1px solid #eef0e6; color: #637a50; background: none; cursor: pointer; font: inherit; font-size: 11px; }
|
||||
.modelMenuAction:hover { background: #f4f7ed; }
|
||||
.modelPopover button:focus-visible, .modelPopover select:focus-visible { outline: 2px solid #91a67c; outline-offset: 2px; }
|
||||
@media (prefers-reduced-motion: reduce) { .legendItem { transition: none; } }
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
type PointerEvent,
|
||||
} from "react";
|
||||
import styles from "./timeline.module.css";
|
||||
import type { ModelId } from "@/lib/model-scores";
|
||||
import { resizeTranscriptRange, snapTimelineTime, type SnapPoint } from "@/lib/timeline-edit";
|
||||
import ModelScoreTrack, { ScoreControls, SCORE_HEIGHT, useModelScores, type ScoreThresholds, type ScoreVisibility } from "./model-score-track";
|
||||
import ModelScoreTrack, { ScoreControls, SCORE_HEIGHT, useModelScores, useScorePreferences } from "./model-score-track";
|
||||
|
||||
export interface TimelineTranscript {
|
||||
index: number;
|
||||
@@ -90,8 +91,8 @@ export default function Timeline({
|
||||
const helpId = useId();
|
||||
const scoreRegionId = useId();
|
||||
const [scoresOpen, setScoresOpen] = useState(false);
|
||||
const [thresholds, setThresholds] = useState<ScoreThresholds>({ maai: .5, smart_turn: .5, silero_vad: .5 });
|
||||
const [scoreVisibility, setScoreVisibility] = useState<ScoreVisibility>({ maai: true, smart_turn: true, silero_vad: true });
|
||||
const { thresholds, visibility: scoreVisibility, setVisibility, setThreshold } = useScorePreferences();
|
||||
const [focusedModel, setFocusedModel] = useState<ModelId | null>(null);
|
||||
const scores = useModelScores(callId, recordingPath, scoresOpen);
|
||||
const visibleScores = useMemo(() => scores.series.filter(row => scoreVisibility[row.model]), [scores.series, scoreVisibility]);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
@@ -336,15 +337,15 @@ export default function Timeline({
|
||||
<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>模型分数对比</strong><span>{scoresOpen ? "收起" : "展开 VAP / Smart Turn / Silero VAD 对比"}</span>
|
||||
<strong>模型分数对比</strong><span>{scoresOpen ? "收起" : "展开评分曲线"}</span>
|
||||
</button>
|
||||
{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 }))} />}
|
||||
visibility={scoreVisibility} onVisibility={setVisibility}
|
||||
thresholds={thresholds} onThreshold={setThreshold} focusedModel={focusedModel} onFocus={setFocusedModel} loading={scores.status === "loading"} />}
|
||||
</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 表示完成倾向;VAP 使用 1 − 用户近期发声分数,Silero 使用 1 − 当前发声分数,仅为非发声基线,不是语义 EOT 概率。{scores.messages.join(" ")}</>}
|
||||
: <>实线:连续回放评分 · 空心点:原标注点评分 · 虚线:参考阈值。Smart Turn、LiveKit 表示完成倾向,TurnSense 为 P(complete);VAP 使用 1 − 用户近期发声分数,Silero 使用 1 − 当前发声分数,仅为非发声基线,不是语义 EOT 概率。{scores.messages.join(" ")}</>}
|
||||
</div>}
|
||||
|
||||
<div className={styles.trackLayout}>
|
||||
@@ -452,9 +453,9 @@ export default function Timeline({
|
||||
</div>
|
||||
|
||||
{scoresOpen && <div id={scoreRegionId}>
|
||||
{visibleScores.length ? <ModelScoreTrack series={visibleScores} width={canvasWidth} durationMs={duration}
|
||||
{visibleScores.length ? <ModelScoreTrack focusedModel={focusedModel && visibleScores.some(row => row.model === focusedModel) ? focusedModel : null} series={visibleScores} 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" ? "正在载入评分曲线…" : scores.series.length ? "已隐藏全部模型分数,请打开上方开关查看" : "当前录音暂无可显示的模型分数"}</div>}
|
||||
: <div className={styles.scoreEmpty} style={{ height: SCORE_HEIGHT, width: viewportWidth }}>{scores.status === "loading" ? "正在载入评分曲线…" : scores.series.length ? "已隐藏全部模型分数,请在「选择模型」中添加" : "当前录音暂无可显示的模型分数"}</div>}
|
||||
</div>}
|
||||
|
||||
{activeSnap && <div className={styles.snapGuide} style={{ left: position(activeSnap.ms) }} aria-hidden="true" />}
|
||||
@@ -473,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 更新。Silero 空心点取判断前最后完整帧;其他模型原标注点评分使用独立历史窗口,与连续曲线可能不同;编辑标注或转写不会重算已有分数。</p>}
|
||||
{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>}
|
||||
|
||||
<div className={styles.bottomBar}>
|
||||
<div className={styles.timeReadout}><span className={styles.playheadDot} /> 播放 <strong>{timeLabel(current, true)}</strong></div>
|
||||
|
||||
Reference in New Issue
Block a user