485 lines
27 KiB
TypeScript
485 lines
27 KiB
TypeScript
"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, silero_vad: .5 });
|
||
const [scoreVisibility, setScoreVisibility] = useState<ScoreVisibility>({ maai: true, smart_turn: true, silero_vad: 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>模型分数对比</strong><span>{scoresOpen ? "收起" : "展开 VAP / Smart Turn / Silero VAD 对比"}</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></>
|
||
: <>实线:连续回放评分 · 空心点:原标注点评分 · 虚线:参考阈值。Smart Turn 表示完成倾向;VAP 使用 1 − 用户近期发声分数,Silero 使用 1 − 当前发声分数,仅为非发声基线,不是语义 EOT 概率。{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}>点击曲线定位录音;悬停查看分数。VAP 约每 100 ms、Smart Turn 每 200 ms、Silero 每 32 ms 更新。Silero 空心点取判断前最后完整帧;其他模型原标注点评分使用独立历史窗口,与连续曲线可能不同;编辑标注或转写不会重算已有分数。</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>
|
||
);
|
||
}
|