"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({ maai: .5, smart_turn: .5, silero_vad: .5 }); const [scoreVisibility, setScoreVisibility] = useState({ 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(null); const canvasRef = useRef(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(null); const suppressAnnotationClickRef = useRef(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(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) { 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, 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, 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) { 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) { 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, 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, 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) { 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 (
对话时间轴 {timeLabel(duration)} 总时长{waveform && ` · ${channelCount === 2 ? "双声道" : `${channelCount} 声道`}`}
{zoom}×
{scoresOpen && setScoreVisibility(previous => ({ ...previous, [model]: visible }))} thresholds={thresholds} onThreshold={(model, value) => setThresholds(previous => ({ ...previous, [model]: value }))} />}
{scoresOpen &&
{scores.status === "loading" ? "正在读取模型分数…" : scores.status === "error" ? <>{scores.messages.join(" ")} : <>实线:连续回放评分 · 空心点:原标注点评分 · 虚线:参考阈值。Smart Turn 表示完成倾向;VAP 使用 1 − 用户近期发声分数,Silero 使用 1 − 当前发声分数,仅为非发声基线,不是语义 EOT 概率。{scores.messages.join(" ")}}
}
beginDrag(event)} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={cancelDrag} onLostPointerCapture={cancelDrag} onKeyDown={keyboardMove} > {Array.from({ length: channelCount }, (_, index) => (
{waveformPaths[index] ? ( ) : (
载入录音后显示各声道波形
)}
))} {(["agent", "user"] as const).map((role) => (
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
{!readOnly && (["start", "end"] as const).map(edge =>
; })}
))}
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 ; })}
{scoresOpen &&
{visibleScores.length ? :
{scores.status === "loading" ? "正在载入评分曲线…" : scores.series.length ? "已隐藏全部模型分数,请打开上方开关查看" : "当前录音暂无可显示的模型分数"}
}
} {activeSnap &&
{scoresOpen && scores.series.length > 0 &&

点击曲线定位录音;悬停查看分数。VAP 约每 100 ms、Smart Turn 每 200 ms、Silero 每 32 ms 更新。Silero 空心点取判断前最后完整帧;其他模型原标注点评分使用独立历史窗口,与连续曲线可能不同;编辑标注或转写不会重算已有分数。

}
播放 {timeLabel(current, true)}
{resizePreview ? "拖动边界调整片段 · 松开保存 · Alt 暂停吸附" : activeSnap ? `已吸附:${activeSnap.label} ${timeLabel(activeSnap.ms, true)}` : "拖动判断点或播放头 · 自动吸附 · Alt 暂停吸附"}
); }