添加 MaAI 和 Smart Turn 分数独立显示开关
This commit is contained in:
@@ -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; }
|
||||||
|
|||||||
@@ -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" />}
|
||||||
|
|||||||
Reference in New Issue
Block a user