360 lines
16 KiB
TypeScript
360 lines
16 KiB
TypeScript
"use client";
|
||||
|
|
|
|||
|
|
import {
|
|||
|
|
useEffect,
|
|||
|
|
useId,
|
|||
|
|
useMemo,
|
|||
|
|
useRef,
|
|||
|
|
useState,
|
|||
|
|
type CSSProperties,
|
|||
|
|
type KeyboardEvent,
|
|||
|
|
type PointerEvent,
|
|||
|
|
} from "react";
|
|||
|
|
import styles from "./timeline.module.css";
|
|||
|
|
|
|||
|
|
export interface TimelineTranscript {
|
|||
|
|
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 {
|
|||
|
|
durationMs: number;
|
|||
|
|
currentMs: number;
|
|||
|
|
decisionMs: number | null;
|
|||
|
|
transcripts: TimelineTranscript[];
|
|||
|
|
annotations: TimelineAnnotation[];
|
|||
|
|
selectedId: string | null;
|
|||
|
|
waveform: number[] | null;
|
|||
|
|
readOnly?: boolean;
|
|||
|
|
onSeek: (ms: number) => void;
|
|||
|
|
onDecisionChange: (ms: number) => void;
|
|||
|
|
onSelectAnnotation: (id: string) => 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({
|
|||
|
|
durationMs,
|
|||
|
|
currentMs,
|
|||
|
|
decisionMs,
|
|||
|
|
transcripts,
|
|||
|
|
annotations,
|
|||
|
|
selectedId,
|
|||
|
|
waveform,
|
|||
|
|
readOnly = false,
|
|||
|
|
onSeek,
|
|||
|
|
onDecisionChange,
|
|||
|
|
onSelectAnnotation,
|
|||
|
|
}: TimelineProps) {
|
|||
|
|
const helpId = useId();
|
|||
|
|
const rangeId = useId();
|
|||
|
|
const viewportRef = useRef<HTMLDivElement>(null);
|
|||
|
|
const canvasRef = useRef<HTMLDivElement>(null);
|
|||
|
|
const draggingRef = useRef<{ kind: "seek" | "decision"; pointerId: number } | null>(null);
|
|||
|
|
const zoomAnchorRef = useRef<number | null>(null);
|
|||
|
|
const [viewportWidth, setViewportWidth] = useState(800);
|
|||
|
|
const [zoom, setZoom] = useState(1);
|
|||
|
|
|
|||
|
|
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 = decisionMs === null ? null : clamp(decisionMs);
|
|||
|
|
const position = (ms: number) => (clamp(ms) / duration) * canvasWidth;
|
|||
|
|
const selectedAnnotation = annotations.find((item) => item.id === selectedId);
|
|||
|
|
|
|||
|
|
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 waveformPath = useMemo(() => {
|
|||
|
|
if (!waveform?.length) return "";
|
|||
|
|
// Each visible bar is the maximum of a real sample bucket. No generated peaks.
|
|||
|
|
const barCount = Math.min(waveform.length, Math.ceil(canvasWidth / 3), 3_200);
|
|||
|
|
const bucketSize = waveform.length / barCount;
|
|||
|
|
let path = "";
|
|||
|
|
for (let bar = 0; bar < barCount; bar++) {
|
|||
|
|
const start = Math.floor(bar * bucketSize);
|
|||
|
|
const end = Math.min(waveform.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(waveform[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: PointerEvent<HTMLElement>) {
|
|||
|
|
const bounds = canvasRef.current?.getBoundingClientRect();
|
|||
|
|
if (!bounds) return current;
|
|||
|
|
return clamp(((event.clientX - bounds.left) / bounds.width) * duration);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function beginDrag(event: PointerEvent<HTMLElement>, kind: "seek" | "decision") {
|
|||
|
|
if (event.button !== 0 || (kind === "decision" && (decision === null || readOnly))) return;
|
|||
|
|
event.preventDefault();
|
|||
|
|
event.stopPropagation();
|
|||
|
|
event.currentTarget.focus({ preventScroll: true });
|
|||
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|||
|
|
draggingRef.current = { kind, pointerId: event.pointerId };
|
|||
|
|
if (kind === "seek") onSeek(pointerTime(event));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function moveDrag(event: PointerEvent<HTMLElement>) {
|
|||
|
|
const drag = draggingRef.current;
|
|||
|
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
|||
|
|
event.stopPropagation();
|
|||
|
|
const ms = pointerTime(event);
|
|||
|
|
if (drag.kind === "seek") onSeek(ms);
|
|||
|
|
else onDecisionChange(ms);
|
|||
|
|
// 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>) {
|
|||
|
|
if (draggingRef.current?.pointerId !== event.pointerId) return;
|
|||
|
|
draggingRef.current = null;
|
|||
|
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function keyboardMove(event: KeyboardEvent<HTMLElement>, kind: "seek" | "decision") {
|
|||
|
|
if (kind === "decision" && readOnly) return;
|
|||
|
|
const base = kind === "seek" ? current : decision;
|
|||
|
|
if (base === null) return;
|
|||
|
|
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();
|
|||
|
|
if (kind === "seek") onSeek(clamp(next));
|
|||
|
|
else onDecisionChange(clamp(next));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const trackStyle = { "--timeline-width": `${canvasWidth}px` } 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>总时长</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.trackLayout}>
|
|||
|
|
<div className={styles.trackNames} aria-hidden="true">
|
|||
|
|
<div className={styles.rulerName}>时间</div>
|
|||
|
|
<div className={styles.waveName}>录音</div>
|
|||
|
|
<div className={styles.speechName}><span className={styles.agentDot} />机器人</div>
|
|||
|
|
<div className={styles.speechName}><span className={styles.userDot} />用户</div>
|
|||
|
|
<div className={styles.markerName}>判断点</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, "seek")}
|
|||
|
|
onPointerMove={moveDrag}
|
|||
|
|
onPointerUp={endDrag}
|
|||
|
|
onPointerCancel={endDrag}
|
|||
|
|
onLostPointerCapture={() => { draggingRef.current = null; }}
|
|||
|
|
onKeyDown={(event) => keyboardMove(event, "seek")}
|
|||
|
|
>
|
|||
|
|
<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>
|
|||
|
|
|
|||
|
|
<div className={styles.waveTrack}>
|
|||
|
|
{waveformPath ? (
|
|||
|
|
<svg className={styles.waveform} viewBox={`0 0 ${canvasWidth} 76`} preserveAspectRatio="none" role="img" aria-label="录音真实振幅波形">
|
|||
|
|
<path d={waveformPath} fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
|
|||
|
|
</svg>
|
|||
|
|
) : (
|
|||
|
|
<div className={styles.waveEmpty} style={{ width: viewportWidth }}>
|
|||
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 10v4m4-7v10m4-13v16m4-13v10m4-7v4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>
|
|||
|
|
<span>载入录音后显示波形</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{(["agent", "user"] as const).map((role) => (
|
|||
|
|
<div className={styles.speechTrack} key={role} aria-label={role === "agent" ? "机器人发言" : "用户发言"}>
|
|||
|
|
{transcripts.filter((item) => item.role === role).map((item, index) => {
|
|||
|
|
const start = position(item.start_ms);
|
|||
|
|
const end = position(item.end_ms);
|
|||
|
|
return <span
|
|||
|
|
key={`${role}-${item.start_ms}-${index}`}
|
|||
|
|
className={`${styles.speechBlock} ${role === "agent" ? styles.agentBlock : styles.userBlock}`}
|
|||
|
|
style={{ left: start, width: Math.max(3, end - start) }}
|
|||
|
|
title={`${role === "agent" ? "机器人" : "用户"} · ${timeLabel(item.start_ms, true)}—${timeLabel(item.end_ms, true)}\n${item.transcript}`}
|
|||
|
|
><span>{item.transcript}</span></span>;
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
|
|||
|
|
<div className={styles.annotationTrack} aria-label="已保存的 EOT 标注">
|
|||
|
|
{annotations.filter((item) => item.id !== selectedId).map((item, index) => (
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
key={item.id}
|
|||
|
|
className={`${styles.annotation} ${item.can_reply_now === "1" ? styles.positive : item.can_reply_now === "0" ? styles.negative : styles.pending}`}
|
|||
|
|
style={{ left: position(item.decision_ms) }}
|
|||
|
|
title={`标注 ${index + 1} · ${timeLabel(item.decision_ms, true)} · ${annotationLabel(item.can_reply_now, item.reason)}`}
|
|||
|
|
aria-label={`选择 ${timeLabel(item.decision_ms, true)} 的标注,${annotationLabel(item.can_reply_now, item.reason)}`}
|
|||
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|||
|
|
onClick={(event) => { event.stopPropagation(); onSelectAnnotation(item.id); }}
|
|||
|
|
onKeyDown={(event) => event.stopPropagation()}
|
|||
|
|
><span className={styles.annotationStem} aria-hidden="true" /><span>{item.can_reply_now === "" ? "·" : item.can_reply_now}</span></button>
|
|||
|
|
))}
|
|||
|
|
{decision !== null && <span className={styles.selectedLabel} style={{ left: position(decision) }}>
|
|||
|
|
{selectedAnnotation ? annotationLabel(selectedAnnotation.can_reply_now, selectedAnnotation.reason) : "当前判断"}
|
|||
|
|
</span>}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className={styles.playhead} style={{ left: position(current) }} aria-hidden="true"><span /></div>
|
|||
|
|
|
|||
|
|
{decision !== null && <div className={styles.decisionMarker} style={{ left: position(decision) }}>
|
|||
|
|
<div className={styles.decisionLine} aria-hidden="true" />
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
className={styles.decisionHandle}
|
|||
|
|
title={readOnly ? "当前判断时间(查看模式)" : "拖动调整判断时间;方向键微调 100 ms,Shift 微调 1 秒"}
|
|||
|
|
aria-label={`当前判断点 ${timeLabel(decision, true)}${readOnly ? ",查看模式" : ",拖动或使用方向键调整"}`}
|
|||
|
|
aria-describedby={helpId}
|
|||
|
|
aria-disabled={readOnly}
|
|||
|
|
style={readOnly ? { cursor: "default" } : undefined}
|
|||
|
|
onPointerDown={(event) => { event.stopPropagation(); if (!readOnly) beginDrag(event, "decision"); }}
|
|||
|
|
onPointerMove={moveDrag}
|
|||
|
|
onPointerUp={endDrag}
|
|||
|
|
onPointerCancel={endDrag}
|
|||
|
|
onLostPointerCapture={(event) => { event.stopPropagation(); draggingRef.current = null; }}
|
|||
|
|
onKeyDown={(event) => { event.stopPropagation(); keyboardMove(event, "decision"); }}
|
|||
|
|
><span aria-hidden="true" /></button>
|
|||
|
|
</div>}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className={styles.bottomBar}>
|
|||
|
|
<div className={styles.timeReadout}><span className={styles.playheadDot} /> 播放 <strong>{timeLabel(current, true)}</strong></div>
|
|||
|
|
<span id={helpId} className={styles.help}>{readOnly ? "点击定位播放 · 选择标记查看详情" : "点击定位播放 · 拖动橙色标记调整判断点"}</span>
|
|||
|
|
</div>
|
|||
|
|
<div className={styles.decisionControls}>
|
|||
|
|
<label htmlFor={rangeId}><span className={styles.decisionDot} />判断时间</label>
|
|||
|
|
<input
|
|||
|
|
id={rangeId}
|
|||
|
|
type="range"
|
|||
|
|
min={0}
|
|||
|
|
max={duration}
|
|||
|
|
step={1}
|
|||
|
|
value={decision ?? 0}
|
|||
|
|
disabled={decision === null || readOnly}
|
|||
|
|
aria-valuetext={decision === null ? "未选择判断点" : timeLabel(decision, true)}
|
|||
|
|
onChange={(event) => onDecisionChange(clamp(Number(event.currentTarget.value)))}
|
|||
|
|
/>
|
|||
|
|
<label className={styles.numberInput}>
|
|||
|
|
<input
|
|||
|
|
type="number"
|
|||
|
|
aria-label="判断时间,单位毫秒"
|
|||
|
|
min={0}
|
|||
|
|
max={duration}
|
|||
|
|
step={1}
|
|||
|
|
value={decision ?? ""}
|
|||
|
|
placeholder="—"
|
|||
|
|
disabled={decision === null || readOnly}
|
|||
|
|
onChange={(event) => { if (event.currentTarget.value !== "") onDecisionChange(clamp(Number(event.currentTarget.value))); }}
|
|||
|
|
/>
|
|||
|
|
<span>ms</span>
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
);
|
|||
|
|
}
|