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

360 lines
16 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";
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>
);
}