Files
xiaobailing-turn-detection/labels/eot-studio/components/timeline.tsx
T

485 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
} from "react";
import styles from "./timeline.module.css";
import { resizeTranscriptRange, snapTimelineTime, type SnapPoint } from "@/lib/timeline-edit";
import ModelScoreTrack, { ScoreControls, SCORE_HEIGHT, useModelScores, type ScoreThresholds, type ScoreVisibility } from "./model-score-track";
export interface TimelineTranscript {
index: number;
role: "agent" | "user";
start_ms: number;
end_ms: number;
transcript: string;
}
export interface TimelineAnnotation {
id: string;
decision_ms: number;
can_reply_now: "1" | "0" | "";
reason?: string;
}
export interface TimelineProps {
callId: string;
recordingPath: string | null;
durationMs: number;
currentMs: number;
decisionMs: number | null;
transcripts: TimelineTranscript[];
annotations: TimelineAnnotation[];
selectedId: string | null;
editingTranscriptIndex: number | null;
waveform: number[][] | null;
readOnly?: boolean;
onSeek: (ms: number) => void;
onMoveAnnotation: (id: string, ms: number) => void;
onSelectAnnotation: (id: string, seekToPoint?: boolean) => void;
onSelectTranscript: (index: number) => void;
onResizeTranscript: (index: number, startMs: number, endMs: number) => void;
onAddAnnotation: (ms: number) => void;
}
const ZOOM_LEVELS = [1, 2, 4, 8];
const TICK_STEPS = [100, 200, 500, 1_000, 2_000, 5_000, 10_000, 15_000, 30_000, 60_000, 120_000, 300_000, 600_000];
function finite(value: number, fallback = 0) {
return Number.isFinite(value) ? value : fallback;
}
function timeLabel(ms: number, showMilliseconds = false) {
const bounded = Math.max(0, Math.round(ms));
const minutes = Math.floor(bounded / 60_000);
const seconds = Math.floor((bounded % 60_000) / 1_000);
const fraction = String(bounded % 1_000).padStart(3, "0");
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}${showMilliseconds ? `.${fraction}` : ""}`;
}
function annotationLabel(value: TimelineAnnotation["can_reply_now"], reason?: string) {
return value === "1" ? "可以接话" : value === "0" ? "继续等待" : reason ? "待仲裁" : "待标注";
}
export default function Timeline({
callId,
recordingPath,
durationMs,
currentMs,
decisionMs,
transcripts,
annotations,
selectedId,
editingTranscriptIndex,
waveform,
readOnly = false,
onSeek,
onMoveAnnotation,
onSelectAnnotation,
onSelectTranscript,
onResizeTranscript,
onAddAnnotation,
}: TimelineProps) {
const helpId = useId();
const scoreRegionId = useId();
const [scoresOpen, setScoresOpen] = useState(false);
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 visibleScores = useMemo(() => scores.series.filter(row => scoreVisibility[row.model]), [scores.series, scoreVisibility]);
const viewportRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef<
| { kind: "seek"; pointerId: number }
| { kind: "decision"; pointerId: number; id: string; start: number; next: number }
| { kind: "resize"; pointerId: number; index: number; edge: "start" | "end"; start: number; end: number; nextStart: number; nextEnd: number }
| null
>(null);
const zoomAnchorRef = useRef<number | null>(null);
const suppressAnnotationClickRef = useRef<string | null>(null);
const [viewportWidth, setViewportWidth] = useState(800);
const [zoom, setZoom] = useState(1);
const [resizePreview, setResizePreview] = useState<{ index: number; start_ms: number; end_ms: number } | null>(null);
const [snappedPoint, setSnappedPoint] = useState<SnapPoint | null>(null);
const [decisionPreview, setDecisionPreview] = useState<{ id: string; ms: number } | null>(null);
const duration = Math.max(
1_000,
finite(durationMs),
...transcripts.map((item) => finite(item.end_ms)),
...annotations.map((item) => finite(item.decision_ms)),
finite(decisionMs ?? 0),
);
const canvasWidth = Math.max(360, viewportWidth) * zoom;
const clamp = (value: number) => Math.round(Math.max(0, Math.min(duration, finite(value))));
const current = clamp(currentMs);
const decision = decisionPreview?.id === selectedId ? decisionPreview.ms : decisionMs === null ? null : clamp(decisionMs);
const position = (ms: number) => (clamp(ms) / duration) * canvasWidth;
const activeSnap = snappedPoint && (resizePreview || decisionPreview || current === snappedPoint.ms || decision === snappedPoint.ms) ? snappedPoint : null;
const displayedTranscripts = transcripts.map(item => resizePreview?.index === item.index ? { ...item, ...resizePreview } : item);
const snapPoints = useMemo(() => [
...annotations.map(item => ({ ms: item.decision_ms, label: "EOT 判断点", annotationId: item.id })),
...transcripts.flatMap(item => [
{ ms: item.start_ms, label: `${item.role === "agent" ? "机器人" : "用户"}片段起点`, index: item.index },
{ ms: item.end_ms, label: `${item.role === "agent" ? "机器人" : "用户"}片段终点`, index: item.index },
]),
], [annotations, transcripts]);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
const updateSize = () => setViewportWidth(viewport.clientWidth);
updateSize();
const observer = new ResizeObserver(updateSize);
observer.observe(viewport);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (zoomAnchorRef.current === null || !viewportRef.current) return;
viewportRef.current.scrollLeft = zoomAnchorRef.current * canvasWidth - viewportWidth / 2;
zoomAnchorRef.current = null;
}, [canvasWidth, viewportWidth]);
const ticks = useMemo(() => {
const desiredStep = (duration / canvasWidth) * 90;
const step = TICK_STEPS.find((item) => item >= desiredStep) ?? Math.ceil(desiredStep / 600_000) * 600_000;
const result = [];
for (let time = 0; time <= duration; time += step) result.push({ time, label: timeLabel(time, step < 1_000) });
return result;
}, [duration, canvasWidth]);
const waveformPaths = useMemo(() => (waveform || []).map(channel => {
if (!channel.length) return "";
// Each visible bar is the maximum of a real sample bucket. No generated peaks.
const barCount = Math.min(channel.length, Math.ceil(canvasWidth / 3), 3_200);
const bucketSize = channel.length / barCount;
let path = "";
for (let bar = 0; bar < barCount; bar++) {
const start = Math.floor(bar * bucketSize);
const end = Math.min(channel.length, Math.ceil((bar + 1) * bucketSize));
let peak = 0;
for (let index = start; index < end; index++) peak = Math.max(peak, Math.min(1, Math.abs(finite(channel[index]))));
const x = ((bar + 0.5) / barCount) * canvasWidth;
const halfHeight = Math.max(0.75, peak * 28);
path += `M${x.toFixed(2)},${(38 - halfHeight).toFixed(2)}v${(halfHeight * 2).toFixed(2)}`;
}
return path;
}), [waveform, canvasWidth]);
function changeZoom(nextZoom: number) {
const viewport = viewportRef.current;
if (viewport) zoomAnchorRef.current = (viewport.scrollLeft + viewport.clientWidth / 2) / canvasWidth;
setZoom(nextZoom);
}
function locateDecision() {
if (decision === null || !viewportRef.current) return;
viewportRef.current.scrollTo({ left: position(decision) - viewportWidth / 2, behavior: "smooth" });
}
function pointerTime(event: { clientX: number }) {
const bounds = canvasRef.current?.getBoundingClientRect();
if (!bounds) return current;
return clamp(((event.clientX - bounds.left) / bounds.width) * duration);
}
function magneticTime(event: { clientX: number; altKey: boolean }, excludeIndex?: number, excludeAnnotationId?: string) {
const points: SnapPoint[] = snapPoints.filter(point =>
!(excludeIndex !== undefined && "index" in point && point.index === excludeIndex) &&
!(excludeAnnotationId !== undefined && "annotationId" in point && point.annotationId === excludeAnnotationId));
if (excludeIndex !== undefined || excludeAnnotationId !== undefined) points.push({ ms: current, label: "播放位置" });
const result = snapTimelineTime(pointerTime(event), points, duration, canvasWidth, event.altKey);
setSnappedPoint(result.point);
return result.ms;
}
function beginDrag(event: PointerEvent<HTMLElement>) {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
event.currentTarget.setPointerCapture(event.pointerId);
draggingRef.current = { kind: "seek", pointerId: event.pointerId };
onSeek(magneticTime(event));
}
function beginDecisionDrag(event: PointerEvent<HTMLElement>, item: TimelineAnnotation) {
event.stopPropagation();
if (readOnly || event.button !== 0) return;
event.preventDefault();
event.currentTarget.focus({ preventScroll: true });
event.currentTarget.setPointerCapture(event.pointerId);
suppressAnnotationClickRef.current = null;
draggingRef.current = { kind: "decision", pointerId: event.pointerId, id: item.id, start: item.decision_ms, next: item.decision_ms };
setDecisionPreview({ id: item.id, ms: item.decision_ms });
setSnappedPoint(null);
onSelectAnnotation(item.id, false);
}
function beginResize(event: PointerEvent<HTMLElement>, item: TimelineTranscript, edge: "start" | "end") {
if (readOnly || event.button !== 0) return;
event.preventDefault(); event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
event.currentTarget.setPointerCapture(event.pointerId);
draggingRef.current = { kind: "resize", pointerId: event.pointerId, index: item.index, edge, start: item.start_ms, end: item.end_ms, nextStart: item.start_ms, nextEnd: item.end_ms };
setResizePreview({ index: item.index, start_ms: item.start_ms, end_ms: item.end_ms });
}
function moveDrag(event: PointerEvent<HTMLElement>) {
const drag = draggingRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
event.stopPropagation();
if (drag.kind === "resize") {
const ms = magneticTime(event, drag.index);
const range = resizeTranscriptRange(drag.start, drag.end, drag.edge, ms, duration);
drag.nextStart = range.start_ms; drag.nextEnd = range.end_ms;
setResizePreview({ index: drag.index, ...range });
if ((drag.edge === "start" ? range.start_ms : range.end_ms) !== ms) setSnappedPoint(null);
} else if (drag.kind === "seek") onSeek(magneticTime(event));
else {
drag.next = magneticTime(event, undefined, drag.id);
setDecisionPreview({ id: drag.id, ms: drag.next });
}
// Keep the pointer near the visible edge usable while a zoomed track scrolls.
const viewport = viewportRef.current;
if (viewport) {
const bounds = viewport.getBoundingClientRect();
if (event.clientX > bounds.right - 24) viewport.scrollLeft += 14;
else if (event.clientX < bounds.left + 24) viewport.scrollLeft -= 14;
}
}
function endDrag(event: PointerEvent<HTMLElement>) {
const drag = draggingRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
draggingRef.current = null;
if (drag.kind === "resize" && (drag.start !== drag.nextStart || drag.end !== drag.nextEnd)) onResizeTranscript(drag.index, drag.nextStart, drag.nextEnd);
if (drag.kind === "decision" && drag.start !== drag.next) {
suppressAnnotationClickRef.current = drag.id;
onMoveAnnotation(drag.id, drag.next);
}
setResizePreview(null);
setDecisionPreview(null);
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
}
function cancelDrag() { if (!draggingRef.current) return; draggingRef.current = null; setResizePreview(null); setDecisionPreview(null); setSnappedPoint(null); }
function keyboardAnnotation(event: KeyboardEvent<HTMLElement>, item: TimelineAnnotation) {
event.stopPropagation();
if (readOnly || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const step = event.shiftKey ? 1000 : event.altKey ? 10 : 100;
const ms = event.key === "Home" ? 0 : event.key === "End" ? duration : item.decision_ms + (["ArrowLeft", "ArrowDown"].includes(event.key) ? -step : step);
setSnappedPoint(null);
onSelectAnnotation(item.id, false);
onMoveAnnotation(item.id, clamp(ms));
}
function keyboardResize(event: KeyboardEvent<HTMLElement>, item: TimelineTranscript, edge: "start" | "end") {
event.stopPropagation();
if (readOnly || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const step = event.shiftKey ? 1000 : event.altKey ? 10 : 100;
const base = edge === "start" ? item.start_ms : item.end_ms;
const value = event.key === "Home" ? 0 : event.key === "End" ? duration : base + (event.key === "ArrowLeft" ? -step : step);
const range = resizeTranscriptRange(item.start_ms, item.end_ms, edge, value, duration);
if (range.start_ms !== item.start_ms || range.end_ms !== item.end_ms) onResizeTranscript(item.index, range.start_ms, range.end_ms);
}
function keyboardMove(event: KeyboardEvent<HTMLElement>) {
const base = current;
const increment = event.shiftKey ? 1_000 : event.altKey ? 10 : 100;
let next: number | null = null;
if (["ArrowLeft", "ArrowDown", "-"].includes(event.key)) next = base - increment;
if (["ArrowRight", "ArrowUp", "+", "="].includes(event.key)) next = base + increment;
if (event.key === "Home") next = 0;
if (event.key === "End") next = duration;
if (next === null) return;
event.preventDefault();
event.stopPropagation();
onSeek(clamp(next));
}
const channelCount = waveform?.length || 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 + (scoresOpen ? SCORE_HEIGHT : 0) } as CSSProperties;
return (
<section className={styles.timeline} aria-label="对话时间轴">
<div className={styles.toolbar}>
<div className={styles.toolbarTitle}>
<span className={styles.title}>对话时间轴</span>
<span className={styles.duration}>{timeLabel(duration)} <span>总时长{waveform && ` · ${channelCount === 2 ? "双声道" : `${channelCount} 声道`}`}</span></span>
</div>
<div className={styles.tools}>
<button className={styles.locate} type="button" onClick={locateDecision} disabled={decision === null} title="将当前判断点移到视图中央">
<span aria-hidden="true">⌖</span> 定位判断点
</button>
<div className={styles.zoom} aria-label="时间轴缩放">
<button type="button" aria-label="缩小时间轴" disabled={zoom === 1} onClick={() => changeZoom(ZOOM_LEVELS[ZOOM_LEVELS.indexOf(zoom) - 1])}>−</button>
<span aria-live="polite">{zoom}×</span>
<button type="button" aria-label="放大时间轴" disabled={zoom === 8} onClick={() => changeZoom(ZOOM_LEVELS[ZOOM_LEVELS.indexOf(zoom) + 1])}>+</button>
</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}
visibility={scoreVisibility} onVisibility={(model, visible) => setScoreVisibility(previous => ({ ...previous, [model]: visible }))}
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.trackNames} aria-hidden="true">
<div className={styles.rulerName}>时间</div>
{Array.from({ length: channelCount }, (_, index) => <div className={styles.waveName} key={index}><span className={index % 2 ? styles.rightDot : styles.leftDot} />{waveform ? channelLabel(index) : "录音"}</div>)}
<div className={styles.speechName}><span className={styles.agentDot} />机器人</div>
<div className={styles.speechName}><span className={styles.userDot} />用户</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 className={styles.viewport} ref={viewportRef}>
<div
className={styles.canvas}
style={trackStyle}
ref={canvasRef}
tabIndex={0}
role="group"
aria-label={`播放位置 ${timeLabel(current, true)}:点击或拖动时间轴定位,方向键微调,Home 到开头,End 到结尾`}
onPointerDown={(event) => beginDrag(event)}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
onLostPointerCapture={cancelDrag}
onKeyDown={keyboardMove}
>
<div className={styles.ruler} aria-hidden="true">
{ticks.map((tick) => <span key={tick.time} className={styles.tick} style={{ left: position(tick.time) }}>{tick.label}</span>)}
</div>
{Array.from({ length: channelCount }, (_, index) => (
<div key={index} className={`${styles.waveTrack} ${index % 2 ? styles.rightChannel : styles.leftChannel}`}>
{waveformPaths[index] ? (
<svg className={styles.waveform} viewBox={`0 0 ${canvasWidth} 76`} preserveAspectRatio="none" role="img" aria-label={`${channelLabel(index)}真实振幅波形`}>
<line x1="0" y1="38" x2={canvasWidth} y2="38" stroke="currentColor" opacity=".15" />
<path d={waveformPaths[index]} fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
) : (
<div className={styles.waveEmpty} style={{ width: viewportWidth }}><span>载入录音后显示各声道波形</span></div>
)}
</div>
))}
{(["agent", "user"] as const).map((role) => (
<div className={styles.speechTrack} key={role} aria-label={role === "agent" ? "机器人发言" : "用户发言"}
role="group"
onPointerDown={event => event.stopPropagation()}
onClick={event => { event.stopPropagation(); if (event.target === event.currentTarget) onSeek(magneticTime(event)); }}
title="点击片段在右侧编辑;拖动片段左右边界调整时间">
{displayedTranscripts.filter((item) => item.role === role).map((item) => {
const start = position(item.start_ms);
const end = position(item.end_ms);
return <div
key={`${role}-${item.index}`}
className={`${styles.speechBlock} ${role === "agent" ? styles.agentBlock : styles.userBlock} ${resizePreview?.index === item.index ? styles.resizingBlock : ""} ${editingTranscriptIndex === item.index ? styles.editingBlock : ""}`}
style={{ left: start, width: Math.max(3, end - start) }}
>
<button type="button" className={styles.blockBody}
title={`${role === "agent" ? "机器人" : "用户"} · ${timeLabel(item.start_ms, true)}—${timeLabel(item.end_ms, true)}\n${item.transcript}`}
aria-label={`${readOnly ? "查看" : "编辑"}${role === "agent" ? "机器人" : "用户"}片段 ${timeLabel(item.start_ms, true)} ${item.transcript}`}
onPointerDown={event => event.stopPropagation()}
onKeyDown={event => event.stopPropagation()}
onDoubleClick={event => event.stopPropagation()}
onClick={event => { event.stopPropagation(); onSelectTranscript(item.index); }}
><span>{item.transcript}</span></button>
{!readOnly && (["start", "end"] as const).map(edge => <button type="button" key={edge}
className={`${styles.resizeHandle} ${edge === "start" ? styles.startHandle : styles.endHandle}`}
aria-label={`调整${role === "agent" ? "机器人" : "用户"}片段${edge === "start" ? "起点" : "终点"} ${timeLabel(item.start_ms, true)}`}
title="拖动调整边界;方向键微调,按住 Alt 暂停吸附"
onPointerDown={event => beginResize(event, item, edge)} onPointerMove={moveDrag} onPointerUp={endDrag}
onPointerCancel={cancelDrag} onLostPointerCapture={cancelDrag}
onKeyDown={event => keyboardResize(event, item, edge)} onClick={event => event.stopPropagation()}
/>)}
{resizePreview?.index === item.index && <span className={styles.resizeReadout}>{timeLabel(item.start_ms, true)} — {timeLabel(item.end_ms, true)}</span>}
</div>;
})}
</div>
))}
<div className={styles.annotationTrack} aria-label="已保存的 EOT 标注"
role="group" tabIndex={readOnly ? -1 : 0}
title={readOnly ? "点击判断点查看" : "点击空白处新增判断点;Enter 在播放位置新增"}
onPointerDown={event => event.stopPropagation()}
onClick={event => { event.stopPropagation(); if (!readOnly) onAddAnnotation(magneticTime(event)); }}
onKeyDown={event => { event.stopPropagation(); if (!readOnly && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); onAddAnnotation(current); } }}>
{annotations.map((item, index) => {
const ms = decisionPreview?.id === item.id ? decisionPreview.ms : item.decision_ms;
const isSelected = item.id === selectedId;
return <button type="button" key={item.id}
className={isSelected ? styles.selectedLabel : `${styles.annotation} ${item.can_reply_now === "1" ? styles.positive : item.can_reply_now === "0" ? styles.negative : styles.pending}`}
style={{ left: position(ms), cursor: readOnly ? "pointer" : undefined }}
title={`标注 ${index + 1} · ${timeLabel(ms, true)} · ${annotationLabel(item.can_reply_now, item.reason)}${readOnly ? "" : " · 拖动调整时间,Alt 暂停吸附"}`}
aria-label={`${isSelected ? "当前" : "选择"}标注 ${timeLabel(ms, true)},${annotationLabel(item.can_reply_now, item.reason)}${readOnly ? "" : ",可拖动"}`}
aria-describedby={helpId}
onPointerDown={event => beginDecisionDrag(event, item)} onPointerMove={moveDrag} onPointerUp={endDrag}
onPointerCancel={cancelDrag} onLostPointerCapture={cancelDrag}
onClick={event => {
event.stopPropagation();
if (suppressAnnotationClickRef.current === item.id) { suppressAnnotationClickRef.current = null; return; }
onSelectAnnotation(item.id);
}}
onKeyDown={event => keyboardAnnotation(event, item)}
><span className={styles.annotationStem} aria-hidden="true" /><span>{isSelected ? annotationLabel(item.can_reply_now, item.reason) : item.can_reply_now === "" ? "·" : item.can_reply_now}</span></button>;
})}
</div>
{scoresOpen && <div id={scoreRegionId}>
{visibleScores.length ? <ModelScoreTrack 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>}
{activeSnap && <div className={styles.snapGuide} style={{ left: position(activeSnap.ms) }} aria-hidden="true" />}
<div className={styles.playhead} style={{ left: position(current) }}>
<button type="button" className={styles.playheadHandle} aria-label={`拖动播放头 ${timeLabel(current, true)}`}
title="拖动播放头,自动吸附片段边界和判断点;Alt 暂停吸附"
onPointerDown={event => beginDrag(event)} onPointerMove={moveDrag} onPointerUp={endDrag}
onPointerCancel={cancelDrag} onLostPointerCapture={cancelDrag}
onKeyDown={keyboardMove}><span /></button>
</div>
{decision !== null && <div className={styles.decisionMarker} style={{ left: position(decision) }}>
<div className={styles.decisionLine} aria-hidden="true" />
</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.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>
</div>
</section>
);
}