feat(dub): timeline segment editor — drag, snap-to-onset, keyboard a11y (#280) (#348)

* feat(dub): full-track speech-onset detection + GET /dub/onsets/{job_id} (#280)

detect_speech_onsets() lists every speech rise across the track (frame RMS,
adaptive threshold, 150ms hysteresis) — powers the timeline editor's
snap-to-onset ticks. Route prefers the Demucs vocals stem, falls back to the
mix, and caches onsets.json per job (mtime-invalidated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(dub): timeline editor math core — windowing, snap, clamp, fingerprint-safe commit (#280)

Pure helpers for the segment track: binary-search windowing, snapTime with
deterministic ties, neighbour/min-duration clamps with Alt-overlap (<=200ms),
commitMoveResize with fingerprint parity (move touches only start/end; resize
sets speed exactly like the old Regions handler and DELETES the key at 1.0 so
_canon_value's missing-vs-1.0 hashing can't mark untouched segments stale),
and overlap detection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(dub): SegmentTrack editing lane replaces the Regions plugin (#280)

Custom DOM segment boxes (6px edge handles, body-drag move, speaker colors,
stale/fresh tint, hatched overlap warning) virtualized by time over a single
{pxPerSec, scrollLeft} alignment source read off WaveSurfer's wrapper.
Snap-to-onset ticks on a viewport-sized canvas light up in snap range;
Ctrl/Cmd-wheel zooms centered on the cursor; double-click plays the slot via
playRange (timeupdate watcher pauses at slot end). Roving-tabindex listbox
keyboard model (arrows / Enter / Shift / Alt / Delete / S) with polite
aria-live announcements. WebKit fallback keeps a self-scrolling lane at a
fixed px/sec. timeline.* strings translated in all 21 locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(dub): wire timeline editor — per-gesture undo, id fix, table selection sync (#280)

segmentMoveResize() pushes undo ONCE per gesture (drag commits on pointerup;
keyboard nudges coalesce per focus session) and matches by String(id) — the
old parseInt('seg-3_a') path edited the wrong segment after a split. Commits
go through commitMoveResize for fingerprint parity, and the existing
recomputeIncremental effect picks up every commit. Clicking a timeline box
scrolls + highlights its row in DubSegmentTable; 'preview dub here' parks
the player at the slot start, then synthesizes the line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dub): inline the onsets-cache containment guard — CodeQL can't track helpers

Same lesson as #328/#329: the realpath+startswith sanitizer must sit at
the sink, not behind a function return. Unused helper removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-11 16:21:07 +05:30
committed by GitHub
co-authored by Claude Fable 5 mergetest
parent 4b21f82619
commit 65fc5245dc
38 changed files with 2269 additions and 117 deletions
+69 -11
View File
@@ -26,17 +26,6 @@ def _unique_stamp() -> str:
_SAFE_LANG = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
def _safe_job_path(job_id: str, *parts: str) -> str:
"""Join path components under DUB_DIR/<job_id>/ with a realpath
containment guard — request-supplied ids/names must never traverse out
of the job's directory (same pattern as the per-segment export below)."""
base = os.path.realpath(DUB_DIR)
cand = os.path.realpath(os.path.join(DUB_DIR, job_id, *parts))
if cand != base and not cand.startswith(base + os.sep):
raise HTTPException(status_code=400, detail="Invalid path component")
return cand
def _native_save(source: str, destination: str, display_name: str, media_type: str):
"""Copy a generated export file to a user-chosen destination and return JSON."""
import shutil
@@ -751,6 +740,75 @@ async def dub_preview_video(
)
def _compute_onsets_sync(src_path: str) -> list[float]:
"""Blocking part of onset analysis — runs in a worker thread."""
import soundfile as sf
from services.onset_align import detect_speech_onsets
audio, sr = sf.read(src_path, dtype="float32")
return detect_speech_onsets(audio, sr)
@router.get("/dub/onsets/{job_id}")
async def dub_get_onsets(job_id: str):
"""Speech-onset times for the timeline editor's snap-to-onset ticks (#280).
Prefers the Demucs-isolated vocals track (clean speech energy); falls
back to the mixed audio. Computed once per job and cached as
``onsets.json`` in the job directory; recomputed if the source audio is
newer than the cache (e.g. re-ingest into the same job dir).
"""
import json
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
vocals = job.get("vocals_path")
mix = job.get("audio_path")
if vocals and os.path.exists(vocals):
src_path, source = vocals, "vocals"
elif mix and os.path.exists(mix):
src_path, source = mix, "mix"
else:
raise HTTPException(status_code=404, detail="No audio track available for onset analysis")
# Containment inlined (not via _safe_job_path): CodeQL can't track the
# sanitizer through a helper's return — the file's established idiom.
base = os.path.realpath(DUB_DIR)
cache_path = os.path.realpath(os.path.join(base, job_id, "onsets.json"))
if not cache_path.startswith(base + os.sep):
raise HTTPException(status_code=400, detail="Invalid job id")
try:
if (
os.path.exists(cache_path)
and os.path.getmtime(cache_path) >= os.path.getmtime(src_path)
):
with open(cache_path, "r", encoding="utf-8") as f:
cached = json.load(f)
if isinstance(cached, dict) and isinstance(cached.get("onsets"), list):
return cached
except (OSError, ValueError):
pass # unreadable/corrupt cache → recompute below
try:
onsets = await asyncio.to_thread(_compute_onsets_sync, src_path)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Onset analysis failed: {str(e)[:200]}",
)
payload = {"onsets": onsets, "source": source}
try:
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
tmp_path = cache_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(payload, f)
os.replace(tmp_path, cache_path)
except OSError as e:
logger.warning("onsets cache write failed for %s: %s", job_id, e)
return payload
@router.get("/dub/thumb/{job_id}")
async def dub_get_thumb(job_id: str):
"""Serve the extracted dub video thumbnail (jpg). 404 if not generated."""
+46
View File
@@ -92,6 +92,52 @@ def detect_speech_onset(
return start_s + float(above[0]) * (frame_len / sr)
# Hysteresis for full-track onset listing: after a frame crosses the
# threshold, the energy must stay *below* it for at least this long before
# the next rise counts as a new onset. Stops syllable-internal dips from
# spamming the timeline with ticks.
MIN_ONSET_GAP_S = 0.15
def detect_speech_onsets(audio: np.ndarray, sr: int) -> list[float]:
"""Return the times (s) of every speech-like onset across the whole track.
Powers the timeline editor's snap-to-onset ticks (issue #280, item 3):
frame RMS over the full track, single adaptive threshold
``max(RELATIVE_THRESHOLD × peak, ABS_RMS_FLOOR)``, and hysteresis — a
new onset registers only when the energy rises above the threshold
after at least ``MIN_ONSET_GAP_S`` below it.
Pure NumPy, identical behaviour on every platform. Returns ``[]`` for
empty/silent audio.
"""
if sr <= 0 or audio is None or len(audio) == 0:
return []
if audio.ndim > 1:
audio = audio.mean(axis=1)
frame_len = max(1, int(FRAME_S * sr))
rms = _frame_rms(audio, frame_len)
if rms.size == 0:
return []
peak = float(rms.max())
if peak < ABS_RMS_FLOOR:
return [] # whole track is effectively silent
threshold = max(RELATIVE_THRESHOLD * peak, ABS_RMS_FLOOR)
gap_frames = max(1, int(round(MIN_ONSET_GAP_S / FRAME_S)))
frame_s = frame_len / sr
onsets: list[float] = []
below_run = gap_frames # armed, so speech at t=0 still counts
for i, v in enumerate(rms):
if v >= threshold:
if below_run >= gap_frames:
onsets.append(round(i * frame_s, 3))
below_run = 0
else:
below_run += 1
return onsets
def snap_segment_starts(
segments: Sequence[dict],
audio: np.ndarray,
+4 -1
View File
@@ -305,7 +305,8 @@ function App() {
const {
undo, redo, editSegments,
segmentEditField, segmentDelete, segmentRestoreOriginal,
segmentSplit, segmentMerge,
segmentSplit, segmentMerge, segmentMoveResize,
timelineSelSegId, setTimelineSelSegId,
selectedSegIds, setSelectedSegIds,
toggleSegSelect, selectAllSegs, clearSegSelection,
bulkApplyToSelected, bulkDeleteSelected,
@@ -1091,6 +1092,8 @@ function App() {
segmentEditField={segmentEditField} segmentDelete={segmentDelete}
segmentRestoreOriginal={segmentRestoreOriginal}
segmentSplit={segmentSplit} segmentMerge={segmentMerge}
segmentMoveResize={segmentMoveResize}
timelineSelSegId={timelineSelSegId} setTimelineSelSegId={setTimelineSelSegId}
toggleSegSelect={toggleSegSelect}
selectAllSegs={selectAllSegs} clearSegSelection={clearSegSelection}
bulkApplyToSelected={bulkApplyToSelected}
@@ -139,3 +139,10 @@
display: inline-flex; align-items: center; justify-content: center;
}
/* Row selected from the waveform timeline (#280, item 3) — distinct from
multi-select (checkbox) and from the playhead row. */
.segment-row.segment-timeline-selected {
background: rgba(250, 189, 47, 0.08) !important;
box-shadow: inset 2px 0 0 #fabd2f;
}
+5 -4
View File
@@ -13,8 +13,8 @@ import './DubSegmentRow.css';
const CHAR_BUDGET_RATIO = 1.3;
const SENTENCE_END = /[.!?。!?]/;
function rowClass(isActive, isDone, selected, isPlaying) {
return `segment-row${isActive ? ' segment-active' : ''}${isDone ? ' segment-done' : ''}${selected ? ' segment-selected' : ''}${isPlaying ? ' segment-playing' : ''}`;
function rowClass(isActive, isDone, selected, isPlaying, timelineSelected) {
return `segment-row${isActive ? ' segment-active' : ''}${isDone ? ' segment-done' : ''}${selected ? ' segment-selected' : ''}${isPlaying ? ' segment-playing' : ''}${timelineSelected ? ' segment-timeline-selected' : ''}`;
}
// Best split point for the Scissors menu when the user hasn't placed a cursor —
@@ -49,7 +49,7 @@ function parseTime(s) {
function DubSegmentRow({
seg, idx, style, disabled, isActive, isDone, isPlaying, previewLoading, selected,
profiles, speakerClones, onEditField, onDelete, onRestore, onPreview, onSelect, onSplit, onMerge, canMerge,
onDirect, onSeek,
onDirect, onSeek, timelineSelected,
}) {
const { t } = useTranslation();
const textInputRef = useRef(null);
@@ -133,7 +133,7 @@ function DubSegmentRow({
};
return (
<div style={style} className={rowClass(isActive, isDone, selected, isPlaying)} onClick={handleRowClick}>
<div style={style} className={rowClass(isActive, isDone, selected, isPlaying, timelineSelected)} onClick={handleRowClick}>
<input
type="checkbox"
checked={!!selected}
@@ -388,6 +388,7 @@ export default memo(DubSegmentRow, (prev, next) => (
prev.isActive === next.isActive &&
prev.isDone === next.isDone &&
prev.isPlaying === next.isPlaying &&
prev.timelineSelected === next.timelineSelected &&
prev.previewLoading === next.previewLoading &&
prev.onDirect === next.onDirect &&
prev.onSeek === next.onSeek &&
+19 -3
View File
@@ -23,6 +23,7 @@ export default function DubSegmentTable({
segments, profiles, speakerClones, dubStep, dubProgress, previewLoadingId,
selectedIds, onSelect, onSelectAll, onClearSelection,
onEditField, onDelete, onRestore, onPreview, onSplit, onMerge, onDirect, onSeek,
timelineSelectedId = null,
}) {
const { t } = useTranslation();
const disabled = dubStep === 'generating' || dubStep === 'stopping';
@@ -88,6 +89,19 @@ export default function DubSegmentTable({
} catch (_) { /* react-window may not be ready yet */ }
}, [currentSegId, filtered]);
// Timeline → table sync (#280, item 3): clicking a segment box on the
// waveform timeline scrolls its row into view and highlights it.
useEffect(() => {
if (timelineSelectedId == null || !listRef.current) return;
const filteredIdx = filtered.findIndex(s => String(s.id) === String(timelineSelectedId));
if (filteredIdx < 0) return;
try {
listRef.current.scrollToRow({
index: filteredIdx, align: 'smart', behavior: 'smooth',
});
} catch (_) { /* react-window may not be ready yet */ }
}, [timelineSelectedId, filtered]);
const rowHeight = useCallback((index) => {
const s = filtered[index];
if (!s) return BASE_ROW_HEIGHT;
@@ -97,23 +111,25 @@ export default function DubSegmentTable({
const rowProps = useMemo(() => ({
filtered, profiles, speakerClones, disabled, dubStep, dubProgress, previewLoadingId,
selectedIds, onSelect, onEditField, onDelete, onRestore, onPreview, onSplit, onMerge, onDirect, onSeek,
segments, currentSegId,
segments, currentSegId, timelineSelectedId,
}), [filtered, profiles, speakerClones, disabled, dubStep, dubProgress, previewLoadingId,
selectedIds, onSelect, onEditField, onDelete, onRestore, onPreview, onSplit, onMerge, onDirect, onSeek, segments, currentSegId]);
selectedIds, onSelect, onEditField, onDelete, onRestore, onPreview, onSplit, onMerge, onDirect, onSeek, segments, currentSegId, timelineSelectedId]);
const Row = useCallback(({ index, style, filtered: fl, profiles: profs, speakerClones: clones, disabled: dis, dubProgress: prog, dubStep: step, previewLoadingId: previewId, selectedIds: sel, onSelect: pick, onEditField: edit, onDelete: del, onRestore: rest, onPreview: prev, onSplit: split, onMerge: merge, onDirect: direct, onSeek: seek, segments: segs, currentSegId: curId }) => {
const Row = useCallback(({ index, style, filtered: fl, profiles: profs, speakerClones: clones, disabled: dis, dubProgress: prog, dubStep: step, previewLoadingId: previewId, selectedIds: sel, onSelect: pick, onEditField: edit, onDelete: del, onRestore: rest, onPreview: prev, onSplit: split, onMerge: merge, onDirect: direct, onSeek: seek, segments: segs, currentSegId: curId, timelineSelectedId: tlSel }) => {
const seg = fl[index];
if (!seg) return null;
const absoluteIndex = segs.indexOf(seg);
const isActive = (step === 'generating' || step === 'stopping') && prog.current === absoluteIndex + 1;
const isDone = (step === 'generating' || step === 'stopping') && prog.current > absoluteIndex + 1;
const isPlaying = curId === seg.id;
const timelineSelected = tlSel != null && String(tlSel) === String(seg.id);
const canMerge = index < fl.length - 1;
return (
<DubSegmentRow
seg={seg} idx={index} style={style}
disabled={dis} isActive={isActive} isDone={isDone}
isPlaying={isPlaying}
timelineSelected={timelineSelected}
previewLoading={previewId === seg.id}
selected={sel && sel.has(seg.id)}
canMerge={canMerge}
+167
View File
@@ -0,0 +1,167 @@
/* SegmentTrack — custom dub-timeline segment lane (#280, item 3). */
.seg-track {
position: relative;
width: 100%;
user-select: none;
-webkit-user-select: none;
margin-top: 2px;
}
.seg-track__onsets {
display: block;
width: 100%;
pointer-events: none;
}
.seg-track__viewport {
position: relative;
width: 100%;
height: 40px;
overflow: hidden;
}
/* WebKit fallback: the lane scrolls itself (no WaveSurfer wrapper to sync). */
.seg-track__viewport--scroll {
overflow-x: auto;
overflow-y: hidden;
}
.seg-track__lane {
position: relative;
height: 100%;
will-change: transform;
}
.seg-track__box {
position: absolute;
top: 3px;
height: 34px;
border: 1px solid rgba(168, 153, 132, 0.35);
border-radius: 4px;
box-sizing: border-box;
display: flex;
align-items: center;
overflow: hidden;
cursor: grab;
touch-action: none;
outline: none;
}
.seg-track__box.is-dragging { cursor: grabbing; }
.seg-track__box:focus-visible {
border-color: #d3869b;
box-shadow: 0 0 0 1px #d3869b;
}
.seg-track__box.is-selected {
border-color: #d3869b;
box-shadow: 0 0 0 1px rgba(211, 134, 155, 0.7);
}
.seg-track__box.is-editing {
border-style: dashed;
border-color: #fabd2f;
}
/* Cache state from the incremental plan. */
.seg-track__box.is-stale { border-bottom: 2px solid #fabd2f; }
.seg-track__box.is-fresh { border-bottom: 2px solid rgba(142, 192, 124, 0.8); }
/* Hatched warning when Alt-drag created an overlap (≤200 ms allowed). */
.seg-track__box.is-overlap {
background-image: repeating-linear-gradient(
45deg,
rgba(251, 73, 52, 0.28) 0,
rgba(251, 73, 52, 0.28) 4px,
transparent 4px,
transparent 8px
) !important;
border-color: #fb4934;
}
.seg-track__label {
flex: 1;
min-width: 0;
padding: 0 8px;
font-size: 9px;
line-height: 1.2;
color: #ebdbb2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.seg-track__handle {
position: absolute;
top: 0;
bottom: 0;
width: 6px;
cursor: ew-resize;
z-index: 2;
}
.seg-track__handle--l { left: 0; border-left: 2px solid rgba(235, 219, 178, 0.45); }
.seg-track__handle--r { right: 0; border-right: 2px solid rgba(235, 219, 178, 0.45); }
.seg-track__box:hover .seg-track__handle--l,
.seg-track__box.is-selected .seg-track__handle--l { border-left-color: #d3869b; }
.seg-track__box:hover .seg-track__handle--r,
.seg-track__box.is-selected .seg-track__handle--r { border-right-color: #d3869b; }
.seg-track__actions {
display: inline-flex;
gap: 2px;
margin-right: 8px;
z-index: 3;
}
.seg-track__action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: 1px solid rgba(168, 153, 132, 0.4);
border-radius: 3px;
background: rgba(40, 40, 40, 0.85);
color: #ebdbb2;
cursor: pointer;
}
.seg-track__action-btn:hover {
border-color: #d3869b;
color: #d3869b;
}
.seg-track__playhead {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: #d3869b;
opacity: 0.8;
pointer-events: none;
}
.seg-track.is-disabled .seg-track__box {
cursor: default;
opacity: 0.6;
}
/* Visually hidden, still announced by screen readers. */
.seg-track__sr-announce {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
clip: rect(0 0 0 0);
clip-path: inset(50%);
overflow: hidden;
white-space: nowrap;
}
+517
View File
@@ -0,0 +1,517 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Play, Headphones } from 'lucide-react';
import {
REGION_COLORS, SNAP_PX,
visibleSegmentRange, snapTime, snapCandidates, clampSegmentEdit,
detectOverlaps, nearestOnset,
} from '../utils/timeline';
import './SegmentTrack.css';
const ONSET_STRIP_H = 8; // px — non-interactive onset tick strip
const KB_STEP_S = 0.01; // ←/→ nudge
const KB_STEP_BIG_S = 0.1; // Ctrl+←/→ nudge
const DRAG_DEADZONE_PX = 3;
const fmt = (t) => {
const m = Math.floor(t / 60);
const s = (t % 60).toFixed(2);
return `${m}:${s.padStart(5, '0')}`;
};
/**
* SegmentTrack — custom DOM segment editor lane for the dub timeline (#280).
*
* Replaces the WaveSurfer Regions plugin in the editing path. Renders one
* absolutely-positioned box per segment inside a lane whose horizontal
* position is derived from a single {pxPerSec, scrollLeft} source (read off
* WaveSurfer's wrapper by the parent), so boxes stay pixel-aligned with the
* waveform across zoom/scroll/resize. Virtualized by TIME — only the boxes
* inside the visible window (+ buffer) are mounted.
*
* Props:
* segments sorted-by-start segment array (store shape)
* pxPerSec px per second (single alignment source)
* scrollLeft px (ignored when selfScroll — WebKit fallback)
* duration track duration (s)
* currentTime playhead (s)
* onsets speech onset times (s) for snap + tick strip
* disabled locks all editing (generation in flight)
* selectedId selected segment id (String), selection syncs the table
* onSelectSeg (id|null) => void
* incrementalPlan { stale: [ids], fresh: [ids] } | null
* onCommit (id, {start,end}, {undo}) => void — ONE per gesture
* onDelete (id) => void
* onPlayRange (start, end) => void — play the slot on the main player
* onPreviewSegment(seg) => void — synthesize-and-play this segment's dub
* onEnsureVisible (timeS) => void — ask parent to scroll a time into view
* selfScroll WebKit fallback: lane scrolls itself (fixed pxPerSec)
*/
export default function SegmentTrack({
segments = [],
pxPerSec = 0,
scrollLeft = 0,
duration = 0,
currentTime = 0,
onsets = [],
disabled = false,
selectedId = null,
onSelectSeg,
incrementalPlan = null,
onCommit,
onDelete,
onPlayRange,
onPreviewSegment,
onEnsureVisible,
selfScroll = false,
}) {
const { t } = useTranslation();
const hostRef = useRef(null);
const viewportRef = useRef(null);
const canvasRef = useRef(null);
const boxRefs = useRef(new Map());
const gestureRef = useRef(null); // live pointer gesture
const kbGestureRef = useRef(false); // first nudge of a focus session pushed undo?
const [viewWidth, setViewWidth] = useState(0);
const [innerScroll, setInnerScroll] = useState(0); // selfScroll mode only
const [live, setLive] = useState(null); // {id,start,end} during drag
const [activeEdge, setActiveEdge] = useState(null); // dragged edge time (onset highlight)
const [focusId, setFocusId] = useState(null);
const [editMode, setEditMode] = useState(false);
const [announceMsg, setAnnounceMsg] = useState('');
const effScroll = selfScroll ? innerScroll : scrollLeft;
const announce = useCallback((msg) => setAnnounceMsg(msg), []);
// ── Geometry ────────────────────────────────────────────────────────────
useLayoutEffect(() => {
const el = hostRef.current;
if (!el) return undefined;
const measure = () => setViewWidth(el.clientWidth || 0);
measure();
if (typeof ResizeObserver === 'undefined') return undefined;
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
const indexById = useMemo(() => {
const m = new Map();
segments.forEach((s, i) => m.set(String(s.id), i));
return m;
}, [segments]);
// Segments with the in-flight drag override applied (render + overlap).
const effSegments = useMemo(() => {
if (!live) return segments;
return segments.map(s => (String(s.id) === live.id ? { ...s, start: live.start, end: live.end } : s));
}, [segments, live]);
const overlaps = useMemo(() => detectOverlaps(effSegments), [effSegments]);
const staleSet = useMemo(
() => new Set((incrementalPlan?.stale || []).map(String)),
[incrementalPlan],
);
const freshSet = useMemo(
() => new Set((incrementalPlan?.fresh || []).map(String)),
[incrementalPlan],
);
const viewStart = pxPerSec > 0 ? effScroll / pxPerSec : 0;
const viewEnd = pxPerSec > 0 ? (effScroll + viewWidth) / pxPerSec : 0;
const [lo, hi] = useMemo(
() => visibleSegmentRange(effSegments, viewStart, viewEnd, 2),
[effSegments, viewStart, viewEnd],
);
const speakerColor = useMemo(() => {
const speakers = [...new Set(segments.map(s => s.speaker_id).filter(Boolean))];
const bySpeaker = new Map(speakers.map((sp, i) => [sp, REGION_COLORS[i % REGION_COLORS.length]]));
return (seg, idx) => bySpeaker.get(seg.speaker_id) || REGION_COLORS[idx % REGION_COLORS.length];
}, [segments]);
// ── Onset tick strip (one viewport-sized canvas, non-interactive) ───────
useEffect(() => {
const cv = canvasRef.current;
if (!cv || viewWidth <= 0) return;
const dpr = window.devicePixelRatio || 1;
const w = Math.round(viewWidth * dpr);
const h = Math.round(ONSET_STRIP_H * dpr);
if (cv.width !== w) cv.width = w;
if (cv.height !== h) cv.height = h;
const ctx = cv.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, w, h);
if (pxPerSec <= 0) return;
const snapS = SNAP_PX / pxPerSec;
const t0 = effScroll / pxPerSec - 1;
const t1 = (effScroll + viewWidth) / pxPerSec + 1;
for (const o of onsets) {
if (o < t0 || o > t1) continue;
const x = Math.round((o * pxPerSec - effScroll) * dpr) + 0.5;
const hot = activeEdge != null && Math.abs(o - activeEdge) <= snapS;
ctx.strokeStyle = hot ? '#fabd2f' : 'rgba(168,153,132,0.45)';
ctx.lineWidth = hot ? 2 * dpr : 1 * dpr;
ctx.beginPath();
ctx.moveTo(x, hot ? 0 : h * 0.35);
ctx.lineTo(x, h);
ctx.stroke();
}
}, [onsets, pxPerSec, effScroll, viewWidth, activeEdge]);
// ── Selection / focus plumbing ──────────────────────────────────────────
const ensureVisible = useCallback((timeS) => {
if (selfScroll) {
const vp = viewportRef.current;
if (vp && pxPerSec > 0) {
const x = timeS * pxPerSec;
if (x < vp.scrollLeft || x > vp.scrollLeft + vp.clientWidth) {
vp.scrollLeft = Math.max(0, x - vp.clientWidth * 0.3);
}
}
return;
}
onEnsureVisible?.(timeS);
}, [selfScroll, pxPerSec, onEnsureVisible]);
const selectAndFocus = useCallback((sid) => {
setFocusId(sid);
onSelectSeg?.(sid);
}, [onSelectSeg]);
// Keep DOM focus on the roving-focus box after re-renders, but only when
// focus already lives inside the track (never steal it from elsewhere).
useEffect(() => {
if (focusId == null) return;
const el = boxRefs.current.get(String(focusId));
const host = hostRef.current;
if (el && host && host.contains(document.activeElement) && document.activeElement !== el) {
el.focus({ preventScroll: true });
}
}, [focusId, lo, hi]);
// ── Pointer gestures (drag = move, handles = resize) ────────────────────
const onBoxPointerDown = useCallback((e) => {
if (e.button !== 0) return;
const sid = e.currentTarget.dataset.segid;
selectAndFocus(sid);
if (disabled) return;
const idx = indexById.get(sid);
const s = segments[idx];
if (!s || pxPerSec <= 0) return;
const handle = e.target?.dataset?.handle || null;
gestureRef.current = {
sid, idx,
mode: handle || 'move',
startX: e.clientX,
origStart: s.start, origEnd: s.end,
moved: false, last: null,
};
try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* jsdom */ }
}, [disabled, indexById, segments, pxPerSec, selectAndFocus]);
const onBoxPointerMove = useCallback((e) => {
const g = gestureRef.current;
if (!g || pxPerSec <= 0) return;
const dx = e.clientX - g.startX;
if (!g.moved && Math.abs(dx) < DRAG_DEADZONE_PX) return;
g.moved = true;
const dt = dx / pxPerSec;
let proposed;
if (g.mode === 'move') proposed = { start: g.origStart + dt, end: g.origEnd + dt };
else if (g.mode === 'start') proposed = { start: g.origStart + dt, end: g.origEnd };
else proposed = { start: g.origStart, end: g.origEnd + dt };
// Snap (unless Alt): onsets + adjacent edges + playhead + low-zoom grid.
const alt = e.altKey;
if (!alt) {
const thresholdS = SNAP_PX / pxPerSec;
const edge = g.mode === 'end' ? proposed.end : proposed.start;
const cands = snapCandidates({
onsets,
prevEnd: segments[g.idx - 1]?.end,
nextStart: segments[g.idx + 1]?.start,
playhead: currentTime,
pxPerSec,
t: edge,
});
const r = snapTime(edge, cands, thresholdS);
if (r.candidate != null) {
if (g.mode === 'move') {
const dur = g.origEnd - g.origStart;
proposed = { start: r.time, end: r.time + dur };
} else if (g.mode === 'start') {
proposed = { ...proposed, start: r.time };
} else {
proposed = { ...proposed, end: r.time };
}
}
}
const clamped = clampSegmentEdit(segments, g.idx, g.mode, proposed, {
allowOverlap: alt,
duration: duration || Infinity,
});
g.last = clamped;
setLive({ id: String(g.sid), ...clamped });
setActiveEdge(g.mode === 'end' ? clamped.end : clamped.start);
}, [pxPerSec, onsets, segments, currentTime, duration]);
const onBoxPointerUp = useCallback(() => {
const g = gestureRef.current;
gestureRef.current = null;
setActiveEdge(null);
setLive(null);
if (!g || !g.moved || !g.last) return;
if (g.last.start === g.origStart && g.last.end === g.origEnd) return;
// ONE undo entry per drag gesture — live positions never hit the store.
onCommit?.(g.sid, g.last, { undo: true });
const idx = g.idx;
if (g.mode === 'move') {
announce(t('timeline.moved_announce', { index: idx + 1, start: fmt(g.last.start) }));
} else {
announce(t('timeline.resized_announce', { index: idx + 1, start: fmt(g.last.start), end: fmt(g.last.end) }));
}
}, [onCommit, announce, t]);
// ── Keyboard (roving tabindex, #298 pattern) ────────────────────────────
const moveFocus = useCallback((sid, dir) => {
const idx = indexById.get(sid);
if (idx == null) return;
const next = segments[idx + dir];
if (!next) return;
const nid = String(next.id);
selectAndFocus(nid);
ensureVisible(next.start);
// Focus lands via the effect above once the box is mounted; force it if
// the box is already in the window.
const el = boxRefs.current.get(nid);
if (el) el.focus({ preventScroll: true });
}, [indexById, segments, selectAndFocus, ensureVisible]);
const nudge = useCallback((sid, dir, e) => {
if (disabled) return;
const idx = indexById.get(sid);
const s = segments[idx];
if (!s) return;
const step = (e.ctrlKey || e.metaKey) ? KB_STEP_BIG_S : KB_STEP_S;
const delta = dir * step;
let mode;
let proposed;
if (e.altKey) { mode = 'move'; proposed = { start: s.start + delta, end: s.end + delta }; }
else if (e.shiftKey) { mode = 'end'; proposed = { start: s.start, end: s.end + delta }; }
else { mode = 'start'; proposed = { start: s.start + delta, end: s.end }; }
const clamped = clampSegmentEdit(segments, idx, mode, proposed, { duration: duration || Infinity });
if (Math.abs(clamped.start - s.start) < 1e-4 && Math.abs(clamped.end - s.end) < 1e-4) return;
// First nudge of the focus session pushes undo; the rest coalesce.
onCommit?.(sid, clamped, { undo: !kbGestureRef.current });
kbGestureRef.current = true;
announce(t('timeline.resized_announce', { index: idx + 1, start: fmt(clamped.start), end: fmt(clamped.end) }));
}, [disabled, indexById, segments, duration, onCommit, announce, t]);
const snapFocusedEdge = useCallback((sid, useEndEdge) => {
if (disabled || !onsets.length) return;
const idx = indexById.get(sid);
const s = segments[idx];
if (!s) return;
const mode = useEndEdge ? 'end' : 'start';
const target = nearestOnset(useEndEdge ? s.end : s.start, onsets);
if (target == null) return;
const proposed = useEndEdge ? { start: s.start, end: target } : { start: target, end: s.end };
const clamped = clampSegmentEdit(segments, idx, mode, proposed, { duration: duration || Infinity });
if (Math.abs(clamped.start - s.start) < 1e-4 && Math.abs(clamped.end - s.end) < 1e-4) return;
onCommit?.(sid, clamped, { undo: true }); // discrete action = own gesture
announce(t('timeline.snapped_announce', { time: fmt(target) }));
}, [disabled, onsets, indexById, segments, duration, onCommit, announce, t]);
const handleDelete = useCallback((sid) => {
if (disabled) return;
const idx = indexById.get(sid);
if (idx == null) return;
const neighbour = segments[idx + 1] || segments[idx - 1];
onDelete?.(sid);
announce(t('timeline.deleted_announce', { index: idx + 1 }));
if (neighbour) selectAndFocus(String(neighbour.id));
}, [disabled, indexById, segments, onDelete, announce, selectAndFocus, t]);
const onBoxKeyDown = useCallback((e) => {
const sid = e.currentTarget.dataset.segid;
switch (e.key) {
case 'ArrowLeft':
case 'ArrowRight': {
e.preventDefault();
e.stopPropagation();
const dir = e.key === 'ArrowLeft' ? -1 : 1;
if (editMode) nudge(sid, dir, e);
else moveFocus(sid, dir);
break;
}
case 'Enter':
e.preventDefault();
e.stopPropagation();
if (disabled) break;
if (editMode) {
setEditMode(false);
kbGestureRef.current = false;
announce(t('timeline.edit_mode_off'));
} else {
setEditMode(true);
kbGestureRef.current = false;
announce(t('timeline.edit_mode_on'));
}
break;
case 'Escape':
if (editMode) {
e.preventDefault();
e.stopPropagation();
setEditMode(false);
kbGestureRef.current = false;
announce(t('timeline.edit_mode_off'));
}
break;
case 'Delete':
case 'Backspace':
e.preventDefault();
e.stopPropagation();
handleDelete(sid);
break;
case 's':
case 'S':
e.preventDefault();
e.stopPropagation();
snapFocusedEdge(sid, e.shiftKey);
break;
default:
break;
}
}, [editMode, disabled, nudge, moveFocus, handleDelete, snapFocusedEdge, announce, t]);
const onBoxBlur = useCallback((e) => {
// Leaving the track ends the keyboard edit session.
const host = hostRef.current;
if (host && !host.contains(e.relatedTarget)) {
setEditMode(false);
kbGestureRef.current = false;
}
}, []);
if (!segments.length || pxPerSec <= 0) return null;
const innerWidth = Math.max(viewWidth, Math.ceil(duration * pxPerSec));
const playheadX = currentTime * pxPerSec - effScroll;
const windowed = effSegments.slice(lo, hi);
return (
<div className={`seg-track ${disabled ? 'is-disabled' : ''}`} ref={hostRef}>
<canvas
ref={canvasRef}
className="seg-track__onsets"
style={{ height: ONSET_STRIP_H }}
aria-hidden="true"
/>
<div
ref={viewportRef}
className={`seg-track__viewport ${selfScroll ? 'seg-track__viewport--scroll' : ''}`}
onScroll={selfScroll ? (e) => setInnerScroll(e.currentTarget.scrollLeft) : undefined}
>
<div
className="seg-track__lane"
role="listbox"
aria-label={t('timeline.track_label')}
aria-orientation="horizontal"
title={t('timeline.keyboard_hint')}
style={{
width: innerWidth,
transform: selfScroll ? undefined : `translateX(${-effScroll}px)`,
}}
>
{windowed.map((s) => {
const sid = String(s.id);
const idx = indexById.get(sid) ?? 0;
const left = s.start * pxPerSec;
const width = Math.max(2, (s.end - s.start) * pxPerSec);
const isSel = selectedId != null && String(selectedId) === sid;
const isFocus = focusId === sid;
const hasOverlap = overlaps.has(sid);
const stale = staleSet.has(sid);
const fresh = !stale && freshSet.has(sid);
const cls = [
'seg-track__box',
isSel && 'is-selected',
isFocus && editMode && 'is-editing',
hasOverlap && 'is-overlap',
stale && 'is-stale',
fresh && 'is-fresh',
live?.id === sid && 'is-dragging',
].filter(Boolean).join(' ');
return (
<div
key={sid}
ref={(el) => {
if (el) boxRefs.current.set(sid, el);
else boxRefs.current.delete(sid);
}}
role="option"
aria-selected={isSel}
aria-label={t('timeline.segment_aria', {
index: idx + 1, start: fmt(s.start), end: fmt(s.end),
})}
tabIndex={isFocus || (focusId == null && idx === 0) ? 0 : -1}
data-segid={sid}
className={cls}
style={{ left, width, background: speakerColor(s, idx) }}
title={hasOverlap ? t('timeline.overlap_warning') : (s.text || '')}
onPointerDown={onBoxPointerDown}
onPointerMove={onBoxPointerMove}
onPointerUp={onBoxPointerUp}
onPointerCancel={onBoxPointerUp}
onDoubleClick={() => onPlayRange?.(s.start, s.end)}
onKeyDown={onBoxKeyDown}
onFocus={() => setFocusId(sid)}
onBlur={onBoxBlur}
>
{!disabled && <span className="seg-track__handle seg-track__handle--l" data-handle="start" />}
<span className="seg-track__label">
{s.text?.length > 32 ? `${s.text.slice(0, 30)}` : (s.text || '')}
</span>
{isSel && !disabled && width > 64 && (
<span className="seg-track__actions">
<button
type="button"
className="seg-track__action-btn"
aria-label={t('timeline.play_slot')}
title={t('timeline.play_slot')}
onPointerDown={(ev) => ev.stopPropagation()}
onClick={(ev) => { ev.stopPropagation(); onPlayRange?.(s.start, s.end); }}
>
<Play size={9} />
</button>
{onPreviewSegment && (
<button
type="button"
className="seg-track__action-btn"
aria-label={t('timeline.preview_dub')}
title={t('timeline.preview_dub')}
onPointerDown={(ev) => ev.stopPropagation()}
onClick={(ev) => { ev.stopPropagation(); onPreviewSegment(s); }}
>
<Headphones size={9} />
</button>
)}
</span>
)}
{!disabled && <span className="seg-track__handle seg-track__handle--r" data-handle="end" />}
</div>
);
})}
</div>
{playheadX >= 0 && playheadX <= viewWidth && (
<div className="seg-track__playhead" style={{ left: playheadX }} aria-hidden="true" />
)}
</div>
<div className="seg-track__sr-announce" aria-live="polite" role="status">
{announceMsg}
</div>
</div>
);
}
@@ -0,0 +1,232 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import SegmentTrack from './SegmentTrack';
// Mocked transport: fixed pxPerSec/scrollLeft, no WaveSurfer. jsdom has no
// layout, so ResizeObserver reports 0 — stub a viewport width so the
// windowing logic mounts boxes.
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub);
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000);
});
const SEGS = [
{ id: 1, start: 0, end: 2, text: 'first line' },
{ id: '3_a', start: 3, end: 5, text: 'split a' },
{ id: '3_b', start: 5, end: 7, text: 'split b' },
];
function setup(props = {}) {
const onCommit = vi.fn();
const onDelete = vi.fn();
const onSelectSeg = vi.fn();
const onPlayRange = vi.fn();
const utils = render(
<SegmentTrack
segments={SEGS}
pxPerSec={100}
scrollLeft={0}
duration={10}
currentTime={0}
onsets={[0.5, 3.1]}
onCommit={onCommit}
onDelete={onDelete}
onSelectSeg={onSelectSeg}
onPlayRange={onPlayRange}
{...props}
/>,
);
return { onCommit, onDelete, onSelectSeg, onPlayRange, ...utils };
}
const box = (i) => screen.getAllByRole('option')[i];
describe('SegmentTrack — rendering', () => {
it('renders one listbox with a box per visible segment', () => {
setup();
expect(screen.getByRole('listbox')).toBeInTheDocument();
expect(screen.getAllByRole('option')).toHaveLength(3);
});
it('keys boxes by String(id) — split ids render and carry data-segid', () => {
setup();
expect(box(1).dataset.segid).toBe('3_a');
expect(box(2).dataset.segid).toBe('3_b');
});
it('roving tabindex: exactly one tab stop', () => {
setup();
const stops = screen.getAllByRole('option').filter(el => el.tabIndex === 0);
expect(stops).toHaveLength(1);
});
it('marks the selected box', () => {
setup({ selectedId: '3_a' });
expect(box(1)).toHaveAttribute('aria-selected', 'true');
expect(box(0)).toHaveAttribute('aria-selected', 'false');
});
it('renders nothing without pxPerSec', () => {
const { container } = render(
<SegmentTrack segments={SEGS} pxPerSec={0} duration={10} />,
);
expect(container.firstChild).toBeNull();
});
});
describe('SegmentTrack — keyboard', () => {
it('ArrowRight moves focus (no commit outside edit mode)', () => {
const { onCommit, onSelectSeg } = setup();
box(0).focus();
fireEvent.keyDown(box(0), { key: 'ArrowRight' });
expect(onSelectSeg).toHaveBeenCalledWith('3_a');
expect(onCommit).not.toHaveBeenCalled();
});
it('Enter + ArrowRight nudges start by 10ms; Ctrl = 100ms', () => {
const { onCommit } = setup();
box(1).focus();
fireEvent.keyDown(box(1), { key: 'Enter' });
fireEvent.keyDown(box(1), { key: 'ArrowRight' });
expect(onCommit).toHaveBeenCalledWith('3_a', { start: 3.01, end: 5 }, { undo: true });
// Segments prop is static here (mocked transport), so the next nudge
// computes from the unchanged start=3.
fireEvent.keyDown(box(1), { key: 'ArrowLeft', ctrlKey: true });
expect(onCommit).toHaveBeenLastCalledWith('3_a', expect.objectContaining({ start: 2.9 }), { undo: false });
});
it('one undo per focus session — only the FIRST nudge passes undo:true', () => {
const { onCommit } = setup();
box(0).focus();
fireEvent.keyDown(box(0), { key: 'Enter' });
fireEvent.keyDown(box(0), { key: 'ArrowRight' });
fireEvent.keyDown(box(0), { key: 'ArrowRight' });
fireEvent.keyDown(box(0), { key: 'ArrowRight' });
const undoFlags = onCommit.mock.calls.map(c => c[2].undo);
expect(undoFlags).toEqual([true, false, false]);
});
it('Shift nudges the end edge, Alt moves the whole segment', () => {
const { onCommit } = setup();
box(0).focus();
fireEvent.keyDown(box(0), { key: 'Enter' });
fireEvent.keyDown(box(0), { key: 'ArrowLeft', shiftKey: true });
expect(onCommit).toHaveBeenLastCalledWith('1', { start: 0, end: 1.99 }, expect.anything());
fireEvent.keyDown(box(0), { key: 'ArrowRight', altKey: true });
const [, patch] = onCommit.mock.calls.at(-1);
// Alt move preserves the (unchanged prop) segment's duration of 2.0.
expect(patch.end - patch.start).toBeCloseTo(2.0, 5);
});
it('Escape leaves edit mode; arrows go back to roving focus', () => {
const { onCommit, onSelectSeg } = setup();
box(0).focus();
fireEvent.keyDown(box(0), { key: 'Enter' });
fireEvent.keyDown(box(0), { key: 'Escape' });
onSelectSeg.mockClear();
fireEvent.keyDown(box(0), { key: 'ArrowRight' });
expect(onSelectSeg).toHaveBeenCalledWith('3_a');
expect(onCommit).not.toHaveBeenCalled();
});
it('Delete removes the focused segment', () => {
const { onDelete } = setup();
box(1).focus();
fireEvent.keyDown(box(1), { key: 'Delete' });
expect(onDelete).toHaveBeenCalledWith('3_a');
});
it('S snaps the start edge to the nearest onset as its own undo gesture', () => {
const { onCommit } = setup();
box(1).focus(); // start=3, nearest onset 3.1
fireEvent.keyDown(box(1), { key: 's' });
expect(onCommit).toHaveBeenCalledWith('3_a', { start: 3.1, end: 5 }, { undo: true });
});
it('keyboard edits are blocked while disabled', () => {
const { onCommit, onDelete } = setup({ disabled: true });
box(0).focus();
fireEvent.keyDown(box(0), { key: 'Enter' });
fireEvent.keyDown(box(0), { key: 'ArrowRight' });
fireEvent.keyDown(box(0), { key: 'Delete' });
expect(onCommit).not.toHaveBeenCalled();
expect(onDelete).not.toHaveBeenCalled();
});
it('announces commits via the polite live region', () => {
setup();
box(0).focus();
fireEvent.keyDown(box(0), { key: 'Enter' });
fireEvent.keyDown(box(0), { key: 'ArrowRight' });
const live = screen.getByRole('status');
expect(live).toHaveAttribute('aria-live', 'polite');
expect(live.textContent).not.toBe('');
});
});
describe('SegmentTrack — pointer + selection', () => {
it('pointerdown selects the segment (table sync)', () => {
const { onSelectSeg } = setup();
fireEvent.pointerDown(box(2), { button: 0, clientX: 550 });
expect(onSelectSeg).toHaveBeenCalledWith('3_b');
});
it('a drag commits ONCE on pointerup with undo:true', () => {
const { onCommit } = setup();
const el = box(1); // start 3, end 5 @ 100px/s
fireEvent.pointerDown(el, { button: 0, clientX: 400 });
fireEvent.pointerMove(el, { clientX: 405, altKey: true }); // Alt: no snap
fireEvent.pointerMove(el, { clientX: 410, altKey: true });
expect(onCommit).not.toHaveBeenCalled(); // live drag stays local
fireEvent.pointerUp(el);
expect(onCommit).toHaveBeenCalledTimes(1);
const [id, patch, opts] = onCommit.mock.calls[0];
expect(id).toBe('3_a');
expect(patch.start).toBeCloseTo(3.1, 2);
expect(patch.end).toBeCloseTo(5.1, 2);
expect(opts).toEqual({ undo: true });
});
it('Alt-drag past the clamp allows at most 200ms overlap', () => {
const { onCommit } = setup();
const el = box(1); // next segment starts at 5
fireEvent.pointerDown(el, { button: 0, clientX: 400 });
fireEvent.pointerMove(el, { clientX: 700, altKey: true }); // try +3s
fireEvent.pointerUp(el);
const [, patch] = onCommit.mock.calls[0];
expect(patch.end).toBeCloseTo(5.2, 3); // next.start + MAX_OVERLAP
expect(patch.start).toBeCloseTo(3.2, 3);
});
it('drag without snap clamps at the neighbour boundary', () => {
const { onCommit } = setup();
const el = box(1); // prev ends at 2
fireEvent.pointerDown(el, { button: 0, clientX: 400 });
fireEvent.pointerMove(el, { clientX: 100, altKey: false }); // try to move to 0
fireEvent.pointerUp(el);
const [, patch] = onCommit.mock.calls[0];
expect(patch.start).toBeCloseTo(2, 3); // hard non-overlap clamp
});
it('double-click plays the slot on the main player', () => {
const { onPlayRange } = setup();
fireEvent.doubleClick(box(0));
expect(onPlayRange).toHaveBeenCalledWith(0, 2);
});
it('no gestures while disabled', () => {
const { onCommit } = setup({ disabled: true });
const el = box(1);
fireEvent.pointerDown(el, { button: 0, clientX: 400 });
fireEvent.pointerMove(el, { clientX: 450 });
fireEvent.pointerUp(el);
expect(onCommit).not.toHaveBeenCalled();
});
});
+192 -95
View File
@@ -1,50 +1,61 @@
import React, { useEffect, useRef, useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import React, { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react';
import WaveSurfer from 'wavesurfer.js';
import RegionsPlugin from 'wavesurfer.js/dist/plugins/regions.esm.js';
import MinimapPlugin from 'wavesurfer.js/dist/plugins/minimap.esm.js';
import TimelinePlugin from 'wavesurfer.js/dist/plugins/timeline.esm.js';
import { Play, Pause, ZoomIn, ZoomOut, SkipBack, Loader, Keyboard } from 'lucide-react';
import { useAppStore } from '../store';
import SegmentTrack from './SegmentTrack';
import './WaveformErrorBoundary.css';
const REGION_COLORS = [
'rgba(211,134,155,0.3)',
'rgba(131,165,152,0.3)',
'rgba(184,187,38,0.3)',
'rgba(250,189,47,0.3)',
'rgba(142,192,124,0.3)',
'rgba(254,128,25,0.3)',
'rgba(104,157,106,0.3)',
];
/**
* WaveformTimeline
*
* Hosts WaveSurfer (waveform / playhead / zoom / scroll) plus the custom
* SegmentTrack editing lane (#280, item 3 — replaces the Regions plugin).
* All segment-box positions derive from a single {pxPerSec, scrollLeft}
* source read off WaveSurfer's wrapper, so the lane stays pixel-aligned
* with the waveform across zoom/scroll/resize.
*
* Props:
* audioSrc URL / blob URL for audio (used as WaveSurfer media + waveform source)
* videoSrc URL / blob URL for the video preview (optional, shown above waveform)
* segments Array<{ id, start, end, text }>
* onSegmentsChange (fn) => void (receives a setter-style function)
* disabled locks drag/resize of regions
* overlayContent React node rendered as a translucent overlay on the waveform
* audioSrc URL / blob URL for audio (WaveSurfer media + waveform source)
* videoSrc URL / blob URL for the video preview (optional, shown above waveform)
* segments Array<{ id, start, end, text }>
* disabled locks drag/resize of segment boxes
* overlayContent React node rendered as a translucent overlay on the waveform
* onsets speech-onset times (s) for the snap ticks
* selectedSegId selected segment id (timeline ↔ table sync)
* onSelectSeg (id) => void
* incrementalPlan { stale, fresh } cache plan for box tinting
* onSegmentCommit (id, {start,end}, {undo}) => void — one commit per gesture
* onSegmentDelete (id) => void
* onPreviewSegment (seg) => void — synthesize-and-play this segment's dub
*/
function WaveformTimeline({
audioSrc,
videoSrc,
segments = [],
onSegmentsChange,
disabled = false,
overlayContent,
onsets = [],
selectedSegId = null,
onSelectSeg,
incrementalPlan = null,
onSegmentCommit,
onSegmentDelete,
onPreviewSegment,
}, ref) {
const waveContainerRef = useRef(null); // div WaveSurfer draws into
const videoContainerRef = useRef(null); // div we imperatively append the <video> into
const wsRef = useRef(null);
const mediaElRef = useRef(null); // fallback: direct media element if WaveSurfer unavailable
const regionsRef = useRef(null);
const isDraggingRef = useRef(false);
const lastFpRef = useRef(null);
const playRangeEndRef = useRef(null); // playRange() watcher pauses at this time
const [ready, setReady] = useState(false);
// WaveSurfer init threw (WebKit restriction) — media element still works;
// the SegmentTrack then self-scrolls with a locally fixed pxPerSec.
const [fallbackMode, setFallbackMode] = useState(false);
// Single alignment source for the SegmentTrack, read off ws.getWrapper().
const [metrics, setMetrics] = useState({ pxPerSec: 0, scrollLeft: 0 });
const [loadError, setLoadError] = useState(false);
// Specifically: the source returned a non-media response (typically 404
// HTML). Differentiates from a generic decode failure so the error UI
@@ -93,6 +104,8 @@ function WaveformTimeline({
setCurrentTime(0);
setDuration(0);
setIsPlaying(false);
setFallbackMode(false);
playRangeEndRef.current = null;
// ── 1. Create the video element imperatively (stable, no React re-renders) ──
let videoEl = null;
@@ -177,10 +190,6 @@ function WaveformTimeline({
mediaElRef.current = mediaEl; // keep ref for fallback play/pause
// ── 3. Init WaveSurfer with that single media element ────────────────────
const regions = RegionsPlugin.create();
regionsRef.current = regions;
lastFpRef.current = null;
let ws;
try {
// Start at the container's measured height; a ResizeObserver below
@@ -214,7 +223,7 @@ function WaveformTimeline({
barRadius: 2,
normalize: true,
media: mediaEl,
plugins: [regions, minimap, timeline],
plugins: [minimap, timeline],
});
} catch (initErr) {
console.warn('WaveSurfer init failed (WebKit restriction?):', initErr);
@@ -226,18 +235,33 @@ function WaveformTimeline({
};
if (mediaEl.readyState >= 1) waitMeta();
else mediaEl.addEventListener('loadedmetadata', waitMeta, { once: true });
mediaEl.addEventListener('timeupdate', () => setCurrentTime(mediaEl.currentTime));
mediaEl.addEventListener('timeupdate', () => {
setCurrentTime(mediaEl.currentTime);
// playRange watcher — stop at the requested slot end.
if (playRangeEndRef.current != null && mediaEl.currentTime >= playRangeEndRef.current - 0.02) {
playRangeEndRef.current = null;
try { mediaEl.pause(); } catch (_) { /* ignore */ }
}
});
mediaEl.addEventListener('play', () => setIsPlaying(true));
mediaEl.addEventListener('pause', () => setIsPlaying(false));
mediaEl.addEventListener('pause', () => { setIsPlaying(false); playRangeEndRef.current = null; });
mediaEl.addEventListener('ended', () => setIsPlaying(false));
wsRef.current = null;
setFallbackMode(true);
return;
}
ws.on('ready', () => { setDuration(ws.getDuration()); setReady(true); });
ws.on('timeupdate', (t) => setCurrentTime(t));
ws.on('timeupdate', (t) => {
setCurrentTime(t);
// playRange watcher — pause when the requested slot finishes.
if (playRangeEndRef.current != null && t >= playRangeEndRef.current - 0.02) {
playRangeEndRef.current = null;
try { ws.pause(); } catch (_) { /* ignore */ }
}
});
ws.on('play', () => setIsPlaying(true));
ws.on('pause', () => setIsPlaying(false));
ws.on('pause', () => { setIsPlaying(false); playRangeEndRef.current = null; });
ws.on('finish', () => setIsPlaying(false));
// Handle errors (like Safari refusing to decode .mov in WebAudio)
@@ -302,40 +326,6 @@ function WaveformTimeline({
}
});
regions.on('region-updated', (region) => {
const segId = parseInt(region.id.replace('seg-', ''), 10);
if (isNaN(segId) || !onSegmentsChange) return;
isDraggingRef.current = true;
onSegmentsChange(prev =>
(Array.isArray(prev) ? prev : []).map(s => {
if (s.id !== segId) return s;
// Store the very first original duration so successive drags compound correctly
const origDur = s.original_duration || (s.end - s.start);
const newStart = +region.start.toFixed(2);
const newEnd = +region.end.toFixed(2);
const newDuration = newEnd - newStart;
// Speed = (original spoken duration) / (new target duration defined by UI region width)
const newSpeed = newDuration > 0 ? +(origDur / newDuration).toFixed(2) : 1.0;
return {
...s,
start: newStart,
end: newEnd,
speed: newSpeed,
original_duration: origDur
};
})
);
requestAnimationFrame(() => { isDraggingRef.current = false; });
});
regions.on('region-clicked', (region, e) => {
e.stopPropagation();
try { region.play(); } catch (_) { /* WebKit may reject */ }
});
wsRef.current = ws;
return () => {
@@ -352,7 +342,6 @@ function WaveformTimeline({
try { ws.destroy(); } catch (_) {}
wsRef.current = null;
mediaElRef.current = null;
regionsRef.current = null;
// Clear the imperatively-created video element (release src so browser frees decoder)
const c = videoContainerRef.current;
if (c) {
@@ -369,41 +358,77 @@ function WaveformTimeline({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [audioSrc, videoSrc]);
// ── Alignment metrics — the single {pxPerSec, scrollLeft} source ────────────
// Everything the SegmentTrack draws derives from these two numbers, read
// off WaveSurfer's wrapper after every zoom/scroll/redraw/resize. rAF-
// throttled so scroll events can't render-storm.
const metricsRafRef = useRef(0);
const syncMetrics = useCallback(() => {
if (metricsRafRef.current) return;
metricsRafRef.current = requestAnimationFrame(() => {
metricsRafRef.current = 0;
const ws = wsRef.current;
if (!ws) return;
let wrap = null;
try { wrap = ws.getWrapper?.(); } catch (_) { /* destroyed */ }
if (!wrap) return;
const scrollEl = wrap.parentElement || wrap;
const dur = ws.getDuration?.() || 0;
const next = {
pxPerSec: dur > 0 ? wrap.scrollWidth / dur : 0,
scrollLeft: scrollEl.scrollLeft || 0,
};
setMetrics(m => (m.pxPerSec === next.pxPerSec && m.scrollLeft === next.scrollLeft) ? m : next);
});
}, []);
useEffect(() => () => { if (metricsRafRef.current) cancelAnimationFrame(metricsRafRef.current); }, []);
useEffect(() => {
if (!ready || !wsRef.current) return undefined;
const ws = wsRef.current;
ws.on('redraw', syncMetrics);
ws.on('zoom', syncMetrics);
ws.on('scroll', syncMetrics);
let wrap = null;
try { wrap = ws.getWrapper?.(); } catch (_) { /* ignore */ }
const scrollEl = wrap?.parentElement || null;
if (scrollEl) scrollEl.addEventListener('scroll', syncMetrics, { passive: true });
const ro = scrollEl ? new ResizeObserver(syncMetrics) : null;
if (ro && scrollEl) ro.observe(scrollEl);
syncMetrics();
return () => {
try { ws.un('redraw', syncMetrics); ws.un('zoom', syncMetrics); ws.un('scroll', syncMetrics); } catch (_) { /* destroyed */ }
if (scrollEl) scrollEl.removeEventListener('scroll', syncMetrics);
if (ro) ro.disconnect();
};
}, [ready, syncMetrics]);
// ── Zoom ────────────────────────────────────────────────────────────────────
// pendingZoomAnchorRef keeps the time under the cursor fixed across a
// Ctrl/Cmd-wheel zoom: after ws.zoom() we re-read the real pxPerSec and
// restore the anchor's pixel position.
const pendingZoomAnchorRef = useRef(null);
useEffect(() => {
if (wsRef.current && ready) {
try {
wsRef.current.zoom(zoom);
const anchor = pendingZoomAnchorRef.current;
if (anchor) {
pendingZoomAnchorRef.current = null;
const wrap = wsRef.current.getWrapper?.();
const scrollEl = wrap?.parentElement;
const dur = wsRef.current.getDuration?.() || 0;
if (wrap && scrollEl && dur > 0) {
const pps = wrap.scrollWidth / dur;
scrollEl.scrollLeft = Math.max(0, anchor.time * pps - anchor.cursorX);
}
}
} catch (err) {
console.warn('WaveSurfer zoom failed:', err);
}
syncMetrics();
}
}, [zoom, ready]);
// ── Sync regions — skips when dragging or fingerprint unchanged ─────────────
const fingerprint = useMemo(
() => segments.map(s => `${s.id}:${s.start}:${s.end}`).join('|'),
[segments]
);
useEffect(() => {
if (!regionsRef.current || !ready || isDraggingRef.current) return;
if (lastFpRef.current === fingerprint) return;
lastFpRef.current = fingerprint;
regionsRef.current.clearRegions();
segments.forEach((seg, i) => {
regionsRef.current.addRegion({
id: `seg-${seg.id}`,
start: seg.start,
end: seg.end,
color: REGION_COLORS[i % REGION_COLORS.length],
drag: !disabled,
resize: !disabled,
content: seg.text?.length > 32 ? seg.text.slice(0, 30) + '…' : (seg.text || ''),
});
});
}, [fingerprint, ready, disabled, segments]);
}, [zoom, ready, syncMetrics]);
// Imperative seek + scroll hooks — used by the transcript table to jump the
// player to a clicked row, and by the mouse-wheel handler below.
@@ -434,14 +459,62 @@ function WaveformTimeline({
if (!ws || !ready) return;
const wrap = ws.getWrapper?.();
if (!wrap) return;
// Don't fight the page when ctrl/cmd is held — that pinches zoom in browsers.
if (e.ctrlKey || e.metaKey) return;
const scrollEl = wrap.parentElement || wrap;
// Ctrl/Cmd + wheel = zoom centered on the cursor (#280, item 3).
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const dur = ws.getDuration?.() || 0;
if (dur <= 0) return;
const rect = scrollEl.getBoundingClientRect();
const cursorX = e.clientX - rect.left;
const curPps = wrap.scrollWidth / dur;
const timeAt = (scrollEl.scrollLeft + cursorX) / curPps;
const factor = e.deltaY < 0 ? 1.25 : 0.8;
setZoom(z => {
const nz = Math.min(300, Math.max(10, Math.round(z * factor)));
if (nz !== z) pendingZoomAnchorRef.current = { time: timeAt, cursorX };
return nz;
});
return;
}
const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
if (!dx) return;
e.preventDefault();
wrap.scrollLeft += dx;
scrollEl.scrollLeft += dx;
}, [ready]);
// playRange — seek + play, pausing automatically at `end` via the
// timeupdate watcher wired in the init effect. Used by the SegmentTrack
// ("play this slot") on whatever media the player currently holds, so it
// respects the original/dubbed preview toggle for free.
const playRange = useCallback((start, end) => {
playRangeEndRef.current = end;
const ws = wsRef.current;
if (ws) {
try { ws.setTime(start); ws.play(); } catch (_) { playRangeEndRef.current = null; }
return;
}
const el = mediaElRef.current;
if (el) {
try { el.currentTime = start; el.play().catch(() => {}); } catch (_) { playRangeEndRef.current = null; }
}
}, []);
// Scroll a given time into view (keyboard focus moved to an off-screen box).
const ensureTimeVisible = useCallback((timeS) => {
const ws = wsRef.current;
if (!ws) return;
const wrap = ws.getWrapper?.();
const scrollEl = wrap?.parentElement;
const dur = ws.getDuration?.() || 0;
if (!wrap || !scrollEl || dur <= 0) return;
const pps = wrap.scrollWidth / dur;
const x = timeS * pps;
if (x < scrollEl.scrollLeft || x > scrollEl.scrollLeft + scrollEl.clientWidth) {
scrollEl.scrollLeft = Math.max(0, x - scrollEl.clientWidth * 0.3);
}
}, []);
const togglePlay = useCallback(() => {
if (wsRef.current) {
wsRef.current.playPause();
@@ -539,6 +612,30 @@ function WaveformTimeline({
</div>
)}
</div>
{/* Segment editing lane — pixel-aligned with the waveform via the
shared {pxPerSec, scrollLeft} metrics. In the WebKit fallback
(no WaveSurfer) it self-scrolls with a fixed px/sec scale. */}
{ready && segments.length > 0 && (
<SegmentTrack
segments={segments}
pxPerSec={fallbackMode ? zoom : metrics.pxPerSec}
scrollLeft={metrics.scrollLeft}
duration={duration}
currentTime={currentTime}
onsets={onsets}
disabled={disabled}
selectedId={selectedSegId}
onSelectSeg={onSelectSeg}
incrementalPlan={incrementalPlan}
onCommit={onSegmentCommit}
onDelete={onSegmentDelete}
onPlayRange={playRange}
onPreviewSegment={onPreviewSegment}
onEnsureVisible={ensureTimeVisible}
selfScroll={fallbackMode}
/>
)}
</div>
{/* Controls */}
+21 -1
View File
@@ -9,6 +9,7 @@ import { useAppStore } from '../store';
import { askConfirm } from '../utils/dialog';
import { apiPost } from '../api/client';
import { segmentGenInputs } from '../utils/segments';
import { commitMoveResize } from '../utils/timeline';
export default function useSegmentEditing() {
const dubSegments = useAppStore(s => s.dubSegments);
@@ -56,6 +57,23 @@ export default function useSegmentEditing() {
setDubSegments(prev => prev.filter(s => s.id !== id));
}, [dubSegments]);
// Timeline drag/resize commit (#280, item 3). Called ONCE per gesture by
// SegmentTrack (live drag positions stay in component state); keyboard
// nudges coalesce by passing undo:false after the first nudge of a focus
// session. String(id) match fixes the old parseInt('seg-3_a') bug that
// edited the wrong segment after a split. commitMoveResize() preserves
// fingerprint parity (move never touches generation inputs; resize only
// changes `speed`, dropping the key at 1.0).
const segmentMoveResize = useCallback((id, { start, end }, opts = {}) => {
const { undo = true } = opts;
if (undo) pushUndo(dubSegments);
setDubSegments(prev => prev.map(s =>
String(s.id) === String(id) ? commitMoveResize(s, { start, end }) : s));
}, [dubSegments]);
// Timeline selection — syncs the segment table (scroll + highlight).
const [timelineSelSegId, setTimelineSelSegId] = useState(null);
const segmentRestoreOriginal = useCallback((id) => {
pushUndo(dubSegments);
setDubSegments(prev => prev.map(s => s.id === id
@@ -179,7 +197,9 @@ export default function useSegmentEditing() {
undo, redo, pushUndo, editSegments,
// Per-segment operations
segmentEditField, segmentDelete, segmentRestoreOriginal,
segmentSplit, segmentMerge,
segmentSplit, segmentMerge, segmentMoveResize,
// Timeline selection (waveform ↔ table sync)
timelineSelSegId, setTimelineSelSegId,
// Multi-select
selectedSegIds, setSelectedSegIds,
toggleSegSelect, selectAllSegs, clearSegSelection,
+44
View File
@@ -0,0 +1,44 @@
/**
* useTimelineOnsets — lazy fetch of the speech-onset list for the timeline
* editor's snap-to-onset ticks (#280, item 3).
*
* Fetches GET /dub/onsets/{job_id} when the editor becomes active and again
* after a re-transcription (the dub step leaves and re-enters the editing
* state, toggling `active`). The backend caches per job, so refetches are
* cheap. Failures degrade silently — the editor simply has no onset ticks.
*/
import { useEffect, useState } from 'react';
import { apiJson } from '../api/client';
const EMPTY = [];
export default function useTimelineOnsets(jobId, active = true) {
// Keyed by job id so a stale job's onsets are never served for the next
// one — deriving from the key avoids a synchronous reset-setState in the
// effect body.
const [data, setData] = useState({ key: null, onsets: EMPTY, source: null });
useEffect(() => {
if (!jobId || !active) return undefined;
let cancelled = false;
apiJson(`/dub/onsets/${jobId}`)
.then((res) => {
if (cancelled) return;
setData({
key: jobId,
onsets: Array.isArray(res?.onsets) ? res.onsets : EMPTY,
source: res?.source || null,
});
})
.catch(() => {
if (!cancelled) setData({ key: jobId, onsets: EMPTY, source: null });
});
return () => { cancelled = true; };
}, [jobId, active]);
const fresh = active && data.key === jobId;
return {
onsets: fresh ? data.onsets : EMPTY,
source: fresh ? data.source : null,
};
}
+14
View File
@@ -778,6 +778,20 @@
"fit_audio_title": "الصوت مناسب داخل الفتحة.",
"fit_ratio_title": "يمثل صوت تحويل النص إلى كلام (TTS) {{pct}}% من الفتحة."
},
"timeline": {
"track_label": "الخط الزمني للمقاطع",
"keyboard_hint": "مفاتيح الأسهم تحدد مقطعًا · Enter يبدّل وضع التحرير · Delete يحذف · S يحاذي إلى أقرب بداية كلام",
"segment_aria": "المقطع {{index}}: من {{start}} إلى {{end}}",
"overlap_warning": "يتداخل مع مقطع مجاور — سيُشغَّل السطران معًا",
"moved_announce": "نُقل المقطع {{index}} إلى {{start}}",
"resized_announce": "المقطع {{index}}: من {{start}} إلى {{end}}",
"deleted_announce": "حُذف المقطع {{index}}",
"snapped_announce": "حُوذيت الحافة إلى بداية الكلام عند {{time}}",
"edit_mode_on": "وضع التحرير: الأسهم تحرّك البداية، وShift النهاية، وAlt المقطع كاملًا، وEsc للإنهاء",
"edit_mode_off": "وضع التحرير متوقف",
"play_slot": "تشغيل هذا الجزء",
"preview_dub": "معاينة الدبلجة هنا"
},
"voice": {
"personality": "الشخصية",
"pick_personality": "اختر إعدادًا مسبقًا للشخصية...",
+14
View File
@@ -779,6 +779,20 @@
"fit_audio_title": "Audio passt in den Steckplatz.",
"fit_ratio_title": "TTS-Audio macht {{pct}} % des Slots aus."
},
"timeline": {
"track_label": "Segment-Zeitleiste",
"keyboard_hint": "Pfeiltasten wählen ein Segment · Enter schaltet den Bearbeitungsmodus um · Entf löscht · S rastet am nächsten Sprecheinsatz ein",
"segment_aria": "Segment {{index}}: {{start}} bis {{end}}",
"overlap_warning": "Überlappt ein benachbartes Segment — beide Zeilen werden zusammen abgespielt",
"moved_announce": "Segment {{index}} verschoben nach {{start}}",
"resized_announce": "Segment {{index}}: {{start}} bis {{end}}",
"deleted_announce": "Segment {{index}} gelöscht",
"snapped_announce": "Kante am Sprecheinsatz bei {{time}} eingerastet",
"edit_mode_on": "Bearbeitungsmodus: Pfeile verschieben den Anfang, Umschalt das Ende, Alt das ganze Segment, Esc beendet",
"edit_mode_off": "Bearbeitungsmodus aus",
"play_slot": "Diesen Abschnitt abspielen",
"preview_dub": "Synchro hier vorhören"
},
"voice": {
"personality": "Persönlichkeit",
"pick_personality": "Wählen Sie eine Persönlichkeitsvoreinstellung ...",
+14
View File
@@ -659,6 +659,20 @@
"fit_audio_title": "Audio fit inside the slot.",
"fit_ratio_title": "TTS audio is {{pct}}% of the slot."
},
"timeline": {
"track_label": "Segment timeline",
"keyboard_hint": "Arrow keys select a segment · Enter toggles edit mode · Delete removes · S snaps to the nearest speech onset",
"segment_aria": "Segment {{index}}: {{start}} to {{end}}",
"overlap_warning": "Overlaps an adjacent segment — both lines will play together",
"moved_announce": "Segment {{index}} moved to {{start}}",
"resized_announce": "Segment {{index}}: {{start}} to {{end}}",
"deleted_announce": "Segment {{index}} deleted",
"snapped_announce": "Edge snapped to speech onset at {{time}}",
"edit_mode_on": "Edit mode: arrows nudge the start, Shift adjusts the end, Alt moves the segment, Esc finishes",
"edit_mode_off": "Edit mode off",
"play_slot": "Play this slot",
"preview_dub": "Preview dub here"
},
"voice": {
"personality": "Personality",
"pick_personality": "Pick a personality preset…",
+14
View File
@@ -779,6 +779,20 @@
"fit_audio_title": "El audio encaja dentro de la ranura.",
"fit_ratio_title": "El audio TTS es el {{pct}}% de la ranura."
},
"timeline": {
"track_label": "Línea de tiempo de segmentos",
"keyboard_hint": "Las flechas seleccionan un segmento · Enter alterna el modo de edición · Supr elimina · S ajusta al inicio de voz más cercano",
"segment_aria": "Segmento {{index}}: {{start}} a {{end}}",
"overlap_warning": "Se solapa con un segmento adyacente — ambas líneas sonarán a la vez",
"moved_announce": "Segmento {{index}} movido a {{start}}",
"resized_announce": "Segmento {{index}}: {{start}} a {{end}}",
"deleted_announce": "Segmento {{index}} eliminado",
"snapped_announce": "Borde ajustado al inicio de voz en {{time}}",
"edit_mode_on": "Modo edición: las flechas mueven el inicio, Mayús el final, Alt todo el segmento, Esc termina",
"edit_mode_off": "Modo edición desactivado",
"play_slot": "Reproducir este intervalo",
"preview_dub": "Previsualizar el doblaje aquí"
},
"voice": {
"personality": "Personalidad",
"pick_personality": "Elige un ajuste preestablecido de personalidad...",
+14
View File
@@ -779,6 +779,20 @@
"fit_audio_title": "L'audio s'adapte à l'intérieur de la fente.",
"fit_ratio_title": "L'audio TTS représente {{pct}}% de l'emplacement."
},
"timeline": {
"track_label": "Chronologie des segments",
"keyboard_hint": "Les flèches sélectionnent un segment · Entrée bascule le mode édition · Suppr supprime · S cale sur le départ de voix le plus proche",
"segment_aria": "Segment {{index}} : de {{start}} à {{end}}",
"overlap_warning": "Chevauche un segment adjacent — les deux répliques joueront ensemble",
"moved_announce": "Segment {{index}} déplacé à {{start}}",
"resized_announce": "Segment {{index}} : de {{start}} à {{end}}",
"deleted_announce": "Segment {{index}} supprimé",
"snapped_announce": "Bord calé sur le départ de voix à {{time}}",
"edit_mode_on": "Mode édition : les flèches déplacent le début, Maj la fin, Alt tout le segment, Échap termine",
"edit_mode_off": "Mode édition désactivé",
"play_slot": "Lire cet intervalle",
"preview_dub": "Pré-écouter le doublage ici"
},
"voice": {
"personality": "Personnalité",
"pick_personality": "Choisissez un préréglage de personnalité…",
+14
View File
@@ -778,6 +778,20 @@
"fit_audio_title": "ऑडियो स्लॉट के अंदर फ़िट हो जाता है।",
"fit_ratio_title": "टीटीएस ऑडियो स्लॉट का {{pct}}% है।"
},
"timeline": {
"track_label": "सेगमेंट टाइमलाइन",
"keyboard_hint": "तीर कुंजियाँ सेगमेंट चुनती हैं · Enter संपादन मोड बदलता है · Delete हटाता है · S निकटतम वाक्-आरंभ पर स्नैप करता है",
"segment_aria": "सेगमेंट {{index}}: {{start}} से {{end}} तक",
"overlap_warning": "बगल के सेगमेंट से ओवरलैप — दोनों संवाद एक साथ बजेंगे",
"moved_announce": "सेगमेंट {{index}} को {{start}} पर ले जाया गया",
"resized_announce": "सेगमेंट {{index}}: {{start}} से {{end}} तक",
"deleted_announce": "सेगमेंट {{index}} हटाया गया",
"snapped_announce": "किनारा {{time}} पर वाक्-आरंभ से जुड़ गया",
"edit_mode_on": "संपादन मोड: तीर आरंभ खिसकाते हैं, Shift अंत, Alt पूरा सेगमेंट, Esc समाप्त करता है",
"edit_mode_off": "संपादन मोड बंद",
"play_slot": "यह अंश चलाएँ",
"preview_dub": "यहाँ डब का पूर्वावलोकन सुनें"
},
"voice": {
"personality": "व्यक्तित्व",
"pick_personality": "एक व्यक्तित्व पूर्व निर्धारित चुनें...",
+14
View File
@@ -778,6 +778,20 @@
"fit_audio_title": "Audio pas di dalam slot.",
"fit_ratio_title": "Audio TTS adalah {{pct}}% dari slot."
},
"timeline": {
"track_label": "Lini masa segmen",
"keyboard_hint": "Tombol panah memilih segmen · Enter mengalihkan mode edit · Delete menghapus · S menjepret ke awal ucapan terdekat",
"segment_aria": "Segmen {{index}}: {{start}} hingga {{end}}",
"overlap_warning": "Tumpang tindih dengan segmen di sebelahnya — kedua dialog akan berbunyi bersamaan",
"moved_announce": "Segmen {{index}} dipindahkan ke {{start}}",
"resized_announce": "Segmen {{index}}: {{start}} hingga {{end}}",
"deleted_announce": "Segmen {{index}} dihapus",
"snapped_announce": "Tepi dijepretkan ke awal ucapan pada {{time}}",
"edit_mode_on": "Mode edit: panah menggeser awal, Shift akhir, Alt seluruh segmen, Esc selesai",
"edit_mode_off": "Mode edit nonaktif",
"play_slot": "Putar bagian ini",
"preview_dub": "Pratinjau sulih suara di sini"
},
"voice": {
"personality": "Kepribadian",
"pick_personality": "Pilih preset kepribadian…",
+14
View File
@@ -778,6 +778,20 @@
"fit_audio_title": "L'audio si inserisce all'interno dello slot.",
"fit_ratio_title": "L'audio TTS è il {{pct}}% dello slot."
},
"timeline": {
"track_label": "Timeline dei segmenti",
"keyboard_hint": "Le frecce selezionano un segmento · Invio attiva o disattiva la modalità modifica · Canc elimina · S aggancia all'attacco vocale più vicino",
"segment_aria": "Segmento {{index}}: da {{start}} a {{end}}",
"overlap_warning": "Si sovrappone a un segmento adiacente — le due battute suoneranno insieme",
"moved_announce": "Segmento {{index}} spostato a {{start}}",
"resized_announce": "Segmento {{index}}: da {{start}} a {{end}}",
"deleted_announce": "Segmento {{index}} eliminato",
"snapped_announce": "Bordo agganciato all'attacco vocale a {{time}}",
"edit_mode_on": "Modalità modifica: le frecce spostano l'inizio, Maiusc la fine, Alt l'intero segmento, Esc termina",
"edit_mode_off": "Modalità modifica disattivata",
"play_slot": "Riproduci questo intervallo",
"preview_dub": "Anteprima del doppiaggio qui"
},
"voice": {
"personality": "Personalità",
"pick_personality": "Scegli una personalità preimpostata...",
+14
View File
@@ -779,6 +779,20 @@
"fit_audio_title": "オーディオはスロット内に収まります。",
"fit_ratio_title": "TTS オーディオはスロットの {{pct}}% です。"
},
"timeline": {
"track_label": "セグメントタイムライン",
"keyboard_hint": "矢印キーでセグメントを選択 · Enterで編集モード切替 · Deleteで削除 · Sで最寄りの発話開始位置にスナップ",
"segment_aria": "セグメント {{index}}: {{start}} から {{end}}",
"overlap_warning": "隣のセグメントと重なっています — 両方のセリフが同時に再生されます",
"moved_announce": "セグメント {{index}} を {{start}} に移動しました",
"resized_announce": "セグメント {{index}}: {{start}} から {{end}}",
"deleted_announce": "セグメント {{index}} を削除しました",
"snapped_announce": "エッジを {{time}} の発話開始位置にスナップしました",
"edit_mode_on": "編集モード: 矢印で開始位置、Shiftで終了位置、Altでセグメント全体を移動、Escで終了",
"edit_mode_off": "編集モードを終了しました",
"play_slot": "この区間を再生",
"preview_dub": "ここで吹き替えをプレビュー"
},
"voice": {
"personality": "性格",
"pick_personality": "性格のプリセットを選択してください…",
+14
View File
@@ -778,6 +778,20 @@
"fit_audio_title": "오디오는 슬롯 안에 맞습니다.",
"fit_ratio_title": "TTS 오디오는 슬롯의 {{pct}}%입니다."
},
"timeline": {
"track_label": "세그먼트 타임라인",
"keyboard_hint": "화살표 키로 세그먼트 선택 · Enter로 편집 모드 전환 · Delete로 삭제 · S로 가장 가까운 발화 시작점에 스냅",
"segment_aria": "세그먼트 {{index}}: {{start}}부터 {{end}}까지",
"overlap_warning": "인접 세그먼트와 겹칩니다 — 두 대사가 동시에 재생됩니다",
"moved_announce": "세그먼트 {{index}}을(를) {{start}}(으)로 이동했습니다",
"resized_announce": "세그먼트 {{index}}: {{start}}부터 {{end}}까지",
"deleted_announce": "세그먼트 {{index}}이(가) 삭제되었습니다",
"snapped_announce": "가장자리를 {{time}}의 발화 시작점에 스냅했습니다",
"edit_mode_on": "편집 모드: 화살표는 시작, Shift는 끝, Alt는 세그먼트 전체 이동, Esc로 종료",
"edit_mode_off": "편집 모드 꺼짐",
"play_slot": "이 구간 재생",
"preview_dub": "여기서 더빙 미리 듣기"
},
"voice": {
"personality": "성격",
"pick_personality": "성격 사전 설정을 선택하세요…",
+14
View File
@@ -778,6 +778,20 @@
"fit_audio_title": "Audio past in de sleuf.",
"fit_ratio_title": "TTS-audio is {{pct}}% van de sleuf."
},
"timeline": {
"track_label": "Segmenttijdlijn",
"keyboard_hint": "Pijltjestoetsen selecteren een segment · Enter schakelt de bewerkmodus · Delete verwijdert · S klikt vast op de dichtstbijzijnde spraakinzet",
"segment_aria": "Segment {{index}}: {{start}} tot {{end}}",
"overlap_warning": "Overlapt een aangrenzend segment — beide regels klinken tegelijk",
"moved_announce": "Segment {{index}} verplaatst naar {{start}}",
"resized_announce": "Segment {{index}}: {{start}} tot {{end}}",
"deleted_announce": "Segment {{index}} verwijderd",
"snapped_announce": "Rand vastgeklikt op spraakinzet bij {{time}}",
"edit_mode_on": "Bewerkmodus: pijltjes verschuiven het begin, Shift het einde, Alt het hele segment, Esc sluit af",
"edit_mode_off": "Bewerkmodus uit",
"play_slot": "Dit fragment afspelen",
"preview_dub": "Dub hier voorbeluisteren"
},
"voice": {
"personality": "Persoonlijkheid",
"pick_personality": "Kies een persoonlijkheidsvoorinstelling...",
+14
View File
@@ -778,6 +778,20 @@
"fit_audio_title": "Dźwięk mieści się w gnieździe.",
"fit_ratio_title": "Dźwięk TTS zajmuje {{pct}}% szczeliny."
},
"timeline": {
"track_label": "Oś czasu segmentów",
"keyboard_hint": "Strzałki wybierają segment · Enter przełącza tryb edycji · Delete usuwa · S przyciąga do najbliższego początku mowy",
"segment_aria": "Segment {{index}}: od {{start}} do {{end}}",
"overlap_warning": "Nakłada się na sąsiedni segment — obie kwestie zagrają jednocześnie",
"moved_announce": "Segment {{index}} przesunięty do {{start}}",
"resized_announce": "Segment {{index}}: od {{start}} do {{end}}",
"deleted_announce": "Segment {{index}} usunięty",
"snapped_announce": "Krawędź przyciągnięta do początku mowy w {{time}}",
"edit_mode_on": "Tryb edycji: strzałki przesuwają początek, Shift koniec, Alt cały segment, Esc kończy",
"edit_mode_off": "Tryb edycji wyłączony",
"play_slot": "Odtwórz ten fragment",
"preview_dub": "Odsłuchaj dubbing tutaj"
},
"voice": {
"personality": "Osobowość",
"pick_personality": "Wybierz gotowe ustawienie osobowości…",
+14
View File
@@ -778,6 +778,20 @@
"fit_audio_title": "O áudio cabe dentro do slot.",
"fit_ratio_title": "O áudio TTS é {{pct}}% do slot."
},
"timeline": {
"track_label": "Linha do tempo de segmentos",
"keyboard_hint": "As setas selecionam um segmento · Enter alterna o modo de edição · Delete remove · S encaixa no início de fala mais próximo",
"segment_aria": "Segmento {{index}}: {{start}} a {{end}}",
"overlap_warning": "Sobrepõe um segmento adjacente — as duas falas tocarão juntas",
"moved_announce": "Segmento {{index}} movido para {{start}}",
"resized_announce": "Segmento {{index}}: {{start}} a {{end}}",
"deleted_announce": "Segmento {{index}} excluído",
"snapped_announce": "Borda encaixada no início de fala em {{time}}",
"edit_mode_on": "Modo de edição: setas movem o início, Shift o fim, Alt o segmento inteiro, Esc conclui",
"edit_mode_off": "Modo de edição desativado",
"play_slot": "Reproduzir este trecho",
"preview_dub": "Pré-visualizar a dublagem aqui"
},
"voice": {
"personality": "Personalidade",
"pick_personality": "Escolha uma predefinição de personalidade…",
+14
View File
@@ -778,6 +778,20 @@
"fit_audio_title": "Аудио поместилось внутри слота.",
"fit_ratio_title": "Звук TTS занимает {{pct}}% слота."
},
"timeline": {
"track_label": "Шкала сегментов",
"keyboard_hint": "Стрелки выбирают сегмент · Enter переключает режим правки · Delete удаляет · S привязывает к ближайшему началу речи",
"segment_aria": "Сегмент {{index}}: с {{start}} до {{end}}",
"overlap_warning": "Перекрывается с соседним сегментом — обе реплики прозвучат одновременно",
"moved_announce": "Сегмент {{index}} перемещён на {{start}}",
"resized_announce": "Сегмент {{index}}: с {{start}} до {{end}}",
"deleted_announce": "Сегмент {{index}} удалён",
"snapped_announce": "Край привязан к началу речи в {{time}}",
"edit_mode_on": "Режим правки: стрелки сдвигают начало, Shift — конец, Alt — весь сегмент, Esc — завершить",
"edit_mode_off": "Режим правки выключен",
"play_slot": "Воспроизвести этот участок",
"preview_dub": "Прослушать дубляж здесь"
},
"voice": {
"personality": "Личность",
"pick_personality": "Выберите персональную предустановку…",
+14
View File
@@ -778,6 +778,20 @@
"fit_audio_title": "Ljudet passar in i öppningen.",
"fit_ratio_title": "TTS-ljud är {{pct}}% av kortplatsen."
},
"timeline": {
"track_label": "Segmenttidslinje",
"keyboard_hint": "Piltangenterna väljer ett segment · Enter växlar redigeringsläge · Delete tar bort · S fäster vid närmaste talstart",
"segment_aria": "Segment {{index}}: {{start}} till {{end}}",
"overlap_warning": "Överlappar ett intilliggande segment — båda replikerna spelas samtidigt",
"moved_announce": "Segment {{index}} flyttat till {{start}}",
"resized_announce": "Segment {{index}}: {{start}} till {{end}}",
"deleted_announce": "Segment {{index}} borttaget",
"snapped_announce": "Kanten fäst vid talstart vid {{time}}",
"edit_mode_on": "Redigeringsläge: pilarna flyttar början, Skift slutet, Alt hela segmentet, Esc avslutar",
"edit_mode_off": "Redigeringsläge av",
"play_slot": "Spela upp det här avsnittet",
"preview_dub": "Förhandslyssna på dubbningen här"
},
"voice": {
"personality": "Personlighet",
"pick_personality": "Välj en personlighetsförinställning...",
+14
View File
@@ -778,6 +778,20 @@
"fit_audio_title": "เสียงพอดีกับช่อง",
"fit_ratio_title": "เสียง TTS คือ {{pct}}% ของช่อง"
},
"timeline": {
"track_label": "ไทม์ไลน์ของเซกเมนต์",
"keyboard_hint": "ปุ่มลูกศรเลือกเซกเมนต์ · Enter สลับโหมดแก้ไข · Delete ลบ · S สแนปไปยังจุดเริ่มเสียงพูดที่ใกล้ที่สุด",
"segment_aria": "เซกเมนต์ {{index}}: {{start}} ถึง {{end}}",
"overlap_warning": "ทับซ้อนกับเซกเมนต์ข้างเคียง — บทพูดทั้งสองจะเล่นพร้อมกัน",
"moved_announce": "ย้ายเซกเมนต์ {{index}} ไปที่ {{start}} แล้ว",
"resized_announce": "เซกเมนต์ {{index}}: {{start}} ถึง {{end}}",
"deleted_announce": "ลบเซกเมนต์ {{index}} แล้ว",
"snapped_announce": "สแนปขอบไปยังจุดเริ่มเสียงพูดที่ {{time}} แล้ว",
"edit_mode_on": "โหมดแก้ไข: ลูกศรเลื่อนจุดเริ่ม, Shift เลื่อนจุดจบ, Alt เลื่อนทั้งเซกเมนต์, Esc เพื่อจบ",
"edit_mode_off": "ปิดโหมดแก้ไขแล้ว",
"play_slot": "เล่นช่วงนี้",
"preview_dub": "ฟังตัวอย่างพากย์ตรงนี้"
},
"voice": {
"personality": "บุคลิกภาพ",
"pick_personality": "เลือกพรีเซ็ตบุคลิกภาพ...",
+14
View File
@@ -778,6 +778,20 @@
"fit_audio_title": "Ses yuvanın içine sığar.",
"fit_ratio_title": "TTS sesi yuvanın %{{pct}} kadarıdır."
},
"timeline": {
"track_label": "Segment zaman çizelgesi",
"keyboard_hint": "Ok tuşları segment seçer · Enter düzenleme modunu açıp kapatır · Delete siler · S en yakın konuşma başlangıcına hizalar",
"segment_aria": "Segment {{index}}: {{start}} - {{end}}",
"overlap_warning": "Bitişik bir segmentle çakışıyor — iki replik birlikte çalınacak",
"moved_announce": "Segment {{index}}, {{start}} konumuna taşındı",
"resized_announce": "Segment {{index}}: {{start}} - {{end}}",
"deleted_announce": "Segment {{index}} silindi",
"snapped_announce": "Kenar, {{time}} konumundaki konuşma başlangıcına hizalandı",
"edit_mode_on": "Düzenleme modu: oklar başlangıcı, Shift bitişi, Alt tüm segmenti kaydırır, Esc bitirir",
"edit_mode_off": "Düzenleme modu kapalı",
"play_slot": "Bu aralığı oynat",
"preview_dub": "Dublajı burada önizle"
},
"voice": {
"personality": "Kişilik",
"pick_personality": "Bir kişilik ön ayarı seçin…",
+14
View File
@@ -778,6 +778,20 @@
"fit_audio_title": "Аудіо вміщується в слот.",
"fit_ratio_title": "Аудіо TTS займає {{pct}}% слота."
},
"timeline": {
"track_label": "Шкала сегментів",
"keyboard_hint": "Стрілки вибирають сегмент · Enter перемикає режим редагування · Delete видаляє · S прив'язує до найближчого початку мовлення",
"segment_aria": "Сегмент {{index}}: з {{start}} до {{end}}",
"overlap_warning": "Перекривається із сусіднім сегментом — обидві репліки звучатимуть одночасно",
"moved_announce": "Сегмент {{index}} переміщено на {{start}}",
"resized_announce": "Сегмент {{index}}: з {{start}} до {{end}}",
"deleted_announce": "Сегмент {{index}} видалено",
"snapped_announce": "Край прив'язано до початку мовлення о {{time}}",
"edit_mode_on": "Режим редагування: стрілки зсувають початок, Shift — кінець, Alt — весь сегмент, Esc — завершити",
"edit_mode_off": "Режим редагування вимкнено",
"play_slot": "Відтворити цей фрагмент",
"preview_dub": "Прослухати дубляж тут"
},
"voice": {
"personality": "Особистість",
"pick_personality": "Виберіть налаштування особистості…",
+14
View File
@@ -778,6 +778,20 @@
"fit_audio_title": "Âm thanh vừa vặn bên trong khe cắm.",
"fit_ratio_title": "Âm thanh TTS chiếm {{pct}}% dung lượng khe."
},
"timeline": {
"track_label": "Dòng thời gian phân đoạn",
"keyboard_hint": "Phím mũi tên chọn phân đoạn · Enter bật/tắt chế độ chỉnh sửa · Delete xóa · S hít vào điểm bắt đầu giọng nói gần nhất",
"segment_aria": "Phân đoạn {{index}}: {{start}} đến {{end}}",
"overlap_warning": "Chồng lấn phân đoạn liền kề — cả hai lời thoại sẽ phát cùng lúc",
"moved_announce": "Phân đoạn {{index}} đã chuyển đến {{start}}",
"resized_announce": "Phân đoạn {{index}}: {{start}} đến {{end}}",
"deleted_announce": "Đã xóa phân đoạn {{index}}",
"snapped_announce": "Cạnh đã hít vào điểm bắt đầu giọng nói tại {{time}}",
"edit_mode_on": "Chế độ chỉnh sửa: mũi tên dịch điểm đầu, Shift điểm cuối, Alt cả phân đoạn, Esc kết thúc",
"edit_mode_off": "Đã tắt chế độ chỉnh sửa",
"play_slot": "Phát đoạn này",
"preview_dub": "Nghe thử lồng tiếng tại đây"
},
"voice": {
"personality": "Tính cách",
"pick_personality": "Chọn cài đặt trước về tính cách…",
+14
View File
@@ -738,6 +738,20 @@
"fit_audio_title": "音频适合插槽内。",
"fit_ratio_title": "TTS 音频占插槽的 {{pct}}%。"
},
"timeline": {
"track_label": "片段时间轴",
"keyboard_hint": "方向键选择片段 · Enter 切换编辑模式 · Delete 删除 · S 吸附到最近的语音起点",
"segment_aria": "片段 {{index}}{{start}} 至 {{end}}",
"overlap_warning": "与相邻片段重叠 — 两句台词将同时播放",
"moved_announce": "片段 {{index}} 已移动到 {{start}}",
"resized_announce": "片段 {{index}}{{start}} 至 {{end}}",
"deleted_announce": "片段 {{index}} 已删除",
"snapped_announce": "边缘已吸附到 {{time}} 处的语音起点",
"edit_mode_on": "编辑模式:方向键微调起点,Shift 调整终点,Alt 移动整个片段,Esc 结束",
"edit_mode_off": "已退出编辑模式",
"play_slot": "播放此区间",
"preview_dub": "在此预听配音"
},
"voice": {
"personality": "风格",
"pick_personality": "选择一个风格预设…",
+14
View File
@@ -778,6 +778,20 @@
"fit_audio_title": "音訊適合插槽內。",
"fit_ratio_title": "TTS 音訊佔插槽的 {{pct}}%。"
},
"timeline": {
"track_label": "片段時間軸",
"keyboard_hint": "方向鍵選擇片段 · Enter 切換編輯模式 · Delete 刪除 · S 吸附到最近的語音起點",
"segment_aria": "片段 {{index}}{{start}} 至 {{end}}",
"overlap_warning": "與相鄰片段重疊 — 兩句台詞將同時播放",
"moved_announce": "片段 {{index}} 已移動到 {{start}}",
"resized_announce": "片段 {{index}}{{start}} 至 {{end}}",
"deleted_announce": "片段 {{index}} 已刪除",
"snapped_announce": "邊緣已吸附到 {{time}} 處的語音起點",
"edit_mode_on": "編輯模式:方向鍵微調起點,Shift 調整終點,Alt 移動整個片段,Esc 結束",
"edit_mode_off": "已離開編輯模式",
"play_slot": "播放此區間",
"preview_dub": "在此預聽配音"
},
"voice": {
"personality": "個性",
"pick_personality": "選擇個性預設...",
+21 -2
View File
@@ -23,6 +23,7 @@ import { listTranslationEngines, installTranslationEngine } from '../api/engines
import toast from 'react-hot-toast';
import { toastErrorWithReport } from '../utils/errorToast';
import { Button, Segmented, Badge, Progress } from '../ui';
import useTimelineOnsets from '../hooks/useTimelineOnsets';
import { openDocsFor, classifyError } from '../utils/errorDocsMap';
import GlossaryPanel from '../components/GlossaryPanel';
import ExportModal from '../components/ExportModal';
@@ -91,6 +92,7 @@ export default function DubTab(props) {
triggerDownload, fileToMediaUrl,
editSegments, saveProject, resetDub,
segmentEditField, segmentDelete, segmentRestoreOriginal, segmentSplit, segmentMerge,
segmentMoveResize, timelineSelSegId, setTimelineSelSegId,
toggleSegSelect, selectAllSegs, clearSegSelection,
bulkApplyToSelected, bulkDeleteSelected,
} = props;
@@ -147,6 +149,17 @@ export default function DubTab(props) {
const seekWaveform = useCallback((time) => {
waveformRef.current?.seekTo?.(time);
}, []);
// Speech-onset ticks for the timeline editor (#280, item 3). Lazy: only
// fetched while the editor is live; re-fetched after a re-transcription
// because the step leaves and re-enters the editing state.
const editorActive = !!dubJobId && (dubStep === 'editing' || dubStep === 'generating' || dubStep === 'done');
const { onsets: timelineOnsets } = useTimelineOnsets(dubJobId, editorActive);
// "Preview dub here" from a timeline box: park the player at the slot
// start (so the video frame matches), then synthesize + play the line.
const onTimelinePreviewSegment = useCallback((seg) => {
seekWaveform(seg.start);
handleSegmentPreview?.(seg, { preventDefault() {} });
}, [seekWaveform, handleSegmentPreview]);
const [ingestUrl, setIngestUrl] = useState('');
// Dubbing demo: show the side-by-side player above the drop zone on
// first-run / no-project state. localStorage flag persists dismissal
@@ -366,7 +379,6 @@ export default function DubTab(props) {
audioSrc={dubLocalBlobUrl?.audioUrl}
videoSrc={dubLocalBlobUrl?.videoUrl}
segments={[]}
onSegmentsChange={() => { }}
disabled={true}
overlayContent={
dubStep === 'uploading' ? (
@@ -676,7 +688,13 @@ export default function DubTab(props) {
audioSrc={`${API}/dub/audio/${dubJobId}`}
videoSrc={videoSrc}
segments={dubSegments}
onSegmentsChange={setDubSegments}
onsets={timelineOnsets}
selectedSegId={timelineSelSegId}
onSelectSeg={setTimelineSelSegId}
incrementalPlan={incrementalPlan}
onSegmentCommit={segmentMoveResize}
onSegmentDelete={segmentDelete}
onPreviewSegment={onTimelinePreviewSegment}
disabled={dubStep === 'generating' || dubStep === 'stopping'}
overlayContent={(dubStep === 'generating' || dubStep === 'stopping') ? (
<div className="dub-gen-overlay">
@@ -1100,6 +1118,7 @@ export default function DubTab(props) {
onSplit={segmentSplit}
onMerge={segmentMerge}
onSeek={seekWaveform}
timelineSelectedId={timelineSelSegId}
/>
</Suspense>
</div>
+206
View File
@@ -0,0 +1,206 @@
/**
* timeline.js pure math/state helpers for the dub timeline segment editor
* (#280, item 3). Everything here is DOM-free and unit-tested; SegmentTrack
* only does rendering + pointer/keyboard plumbing on top of these.
*
* All times are seconds (float), all pixels are CSS px.
*/
// Minimum slot a segment may be resized down to. Matches the backend's
// MIN_SEG_DUR_S in services/onset_align.py.
export const MIN_SEG_DUR = 0.3;
// Alt-drag may push past the hard non-overlap clamp by at most this much.
// The backend mix loop sums placements, so a small overlap is audibly safe.
export const MAX_OVERLAP = 0.2;
// Snap radius in *pixels* — converted to seconds via pxPerSec at call sites.
export const SNAP_PX = 8;
// Below this zoom the integer-second grid joins the snap candidates.
export const GRID_SNAP_MAX_PX_PER_SEC = 40;
// Segment box palette — was WaveformTimeline's region palette; lives here so
// both the track and any legend can share it without circular imports.
export const REGION_COLORS = [
'rgba(211,134,155,0.3)',
'rgba(131,165,152,0.3)',
'rgba(184,187,38,0.3)',
'rgba(250,189,47,0.3)',
'rgba(142,192,124,0.3)',
'rgba(254,128,25,0.3)',
'rgba(104,157,106,0.3)',
];
export const timeToPx = (t, pxPerSec) => t * pxPerSec;
export const pxToTime = (px, pxPerSec) => (pxPerSec > 0 ? px / pxPerSec : 0);
/**
* visibleSegmentRange windowing for the virtualized track.
*
* Binary search over segments sorted by `start` for the index window
* covering [viewStart, viewEnd] (+ buffer). Returns [lo, hi) render
* segments.slice(lo, hi). Tolerates the MAX_OVERLAP overlaps the editor
* allows by walking `lo` back while the previous segment still reaches
* into view.
*/
export function visibleSegmentRange(segments, viewStart, viewEnd, bufferS = 2) {
const n = segments.length;
if (!n) return [0, 0];
const t0 = viewStart - bufferS;
const t1 = viewEnd + bufferS;
// lo: first segment whose end could reach t0 — lower_bound on start >= t0,
// then step back over any segments that start earlier but end inside view.
let a = 0, b = n;
while (a < b) {
const mid = (a + b) >> 1;
if (segments[mid].start < t0) a = mid + 1; else b = mid;
}
let lo = a;
while (lo > 0 && segments[lo - 1].end > t0) lo -= 1;
// hi: first segment that starts after t1 (upper_bound on start > t1).
a = lo; b = n;
while (a < b) {
const mid = (a + b) >> 1;
if (segments[mid].start <= t1) a = mid + 1; else b = mid;
}
return [lo, a];
}
/**
* snapTime pure snap: nearest candidate within thresholdS wins.
* Ties resolve to the earliest candidate so behaviour is deterministic.
* Returns { time, candidate } candidate === null means "no snap".
*/
export function snapTime(t, candidates, thresholdS) {
let best = null;
let bestDist = Infinity;
for (const c of candidates) {
if (!Number.isFinite(c)) continue;
const d = Math.abs(c - t);
if (d <= thresholdS && (d < bestDist || (d === bestDist && best !== null && c < best))) {
best = c;
bestDist = d;
}
}
return best === null ? { time: t, candidate: null } : { time: best, candidate: best };
}
/**
* snapCandidates the candidate set for a drag, per the editor spec:
* onsets + adjacent segment edges + playhead + (at low zoom) the
* integer-second grid around t.
*/
export function snapCandidates({ onsets = [], prevEnd, nextStart, playhead, pxPerSec, t }) {
const out = [];
for (const o of onsets) out.push(o);
if (Number.isFinite(prevEnd)) out.push(prevEnd);
if (Number.isFinite(nextStart)) out.push(nextStart);
if (Number.isFinite(playhead)) out.push(playhead);
if (pxPerSec > 0 && pxPerSec < GRID_SNAP_MAX_PX_PER_SEC && Number.isFinite(t)) {
out.push(Math.floor(t), Math.ceil(t));
}
return out;
}
/**
* clampSegmentEdit enforce the overlap/ordering rules on a proposed edit.
*
* mode: 'start' | 'end' | 'move'
* Default = hard non-overlap: edges clamp exactly at the neighbour's
* boundary (segments may touch, never cross). With allowOverlap (Alt held)
* the edge may push up to MAX_OVERLAP past the neighbour. Reordering past a
* neighbour is never possible. Resizes preserve MIN_SEG_DUR against the
* opposite edge; moves preserve duration.
*/
export function clampSegmentEdit(segments, index, mode, proposed, opts = {}) {
const { allowOverlap = false, duration = Infinity } = opts;
const seg = segments[index];
const prev = index > 0 ? segments[index - 1] : null;
const next = index < segments.length - 1 ? segments[index + 1] : null;
const give = allowOverlap ? MAX_OVERLAP : 0;
const minStart = Math.max(0, prev ? prev.end - give : 0);
const maxEnd = Math.min(duration, next ? next.start + give : duration);
let start;
let end;
if (mode === 'move') {
const dur = seg.end - seg.start;
start = Math.max(minStart, Math.min(proposed.start, maxEnd - dur));
// Degenerate squeeze (neighbours closer than the segment is long):
// pin to the lower bound rather than producing start > end games.
if (start < minStart) start = minStart;
end = start + dur;
} else if (mode === 'start') {
start = Math.min(Math.max(proposed.start, minStart), seg.end - MIN_SEG_DUR);
end = seg.end;
} else {
end = Math.max(Math.min(proposed.end, maxEnd), seg.start + MIN_SEG_DUR);
start = seg.start;
}
return { start: +start.toFixed(3), end: +end.toFixed(3) };
}
/**
* commitMoveResize produce the updated segment object for a finished
* move/resize gesture, with FINGERPRINT PARITY against utils/segments.js'
* segmentGenInputs():
*
* - A pure MOVE (duration preserved) touches ONLY start/end. Neither field
* is a generation input, so the segment stays cache-fresh.
* - A RESIZE recomputes speed exactly like the old Regions handler:
* speed = +(original_duration / newDuration).toFixed(2), persisting the
* very first original_duration so successive drags compound correctly.
* - If a resize lands back at speed === 1.0 the `speed` key is DELETED
* instead of stored: the backend's _canon_value hashes a missing field
* as "" but 1.0 as 1.0, so storing the literal would wrongly mark a
* never-speed-adjusted segment stale.
*/
export function commitMoveResize(seg, { start, end }) {
const newStart = +start.toFixed(2);
const newEnd = +end.toFixed(2);
const oldDur = seg.end - seg.start;
const newDur = newEnd - newStart;
const isMove = Math.abs(newDur - oldDur) < 0.005;
if (isMove) {
return { ...seg, start: newStart, end: newEnd };
}
const origDur = seg.original_duration || oldDur;
const newSpeed = newDur > 0 ? +(origDur / newDur).toFixed(2) : 1.0;
const next = { ...seg, start: newStart, end: newEnd, original_duration: origDur };
if (newSpeed === 1) {
delete next.speed;
} else {
next.speed = newSpeed;
}
return next;
}
/**
* detectOverlaps Set of String(id) for every segment that overlaps a
* neighbour (start-sorted sweep; tiny epsilon so touching edges don't flag).
*/
export function detectOverlaps(segments, epsilon = 1e-6) {
const flagged = new Set();
if (segments.length < 2) return flagged;
const sorted = [...segments].sort((x, y) => x.start - y.start || x.end - y.end);
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1];
const cur = sorted[i];
if (cur.start < prev.end - epsilon) {
flagged.add(String(prev.id));
flagged.add(String(cur.id));
}
}
return flagged;
}
/** nearestOnset — closest onset to t, or null when none exist. */
export function nearestOnset(t, onsets) {
let best = null;
let bestDist = Infinity;
for (const o of onsets) {
const d = Math.abs(o - t);
if (d < bestDist) { best = o; bestDist = d; }
}
return best;
}
+214
View File
@@ -0,0 +1,214 @@
import { describe, it, expect } from 'vitest';
import {
MIN_SEG_DUR, MAX_OVERLAP,
visibleSegmentRange, snapTime, snapCandidates,
clampSegmentEdit, commitMoveResize, detectOverlaps, nearestOnset,
} from './timeline';
import { segmentGenInputs } from './segments';
const seg = (id, start, end, extra = {}) => ({ id, start, end, text: `t${id}`, ...extra });
describe('visibleSegmentRange', () => {
const segs = [seg(1, 0, 2), seg(2, 2, 4), seg(3, 4, 6), seg(4, 6, 8), seg(5, 8, 10)];
it('returns the window covering the view', () => {
const [lo, hi] = visibleSegmentRange(segs, 4.5, 5.5, 0);
expect(segs.slice(lo, hi).map(s => s.id)).toEqual([3]);
});
it('includes buffer on both sides', () => {
const [lo, hi] = visibleSegmentRange(segs, 4.5, 5.5, 2);
expect(segs.slice(lo, hi).map(s => s.id)).toEqual([2, 3, 4]);
});
it('empty list → [0,0]', () => {
expect(visibleSegmentRange([], 0, 10)).toEqual([0, 0]);
});
it('view before all segments → empty window', () => {
const [lo, hi] = visibleSegmentRange(segs, -100, -50, 0);
expect(hi - lo).toBe(0);
});
it('view after all segments → empty window at end', () => {
const [lo, hi] = visibleSegmentRange(segs, 100, 200, 0);
expect(lo).toBe(segs.length);
expect(hi).toBe(segs.length);
});
it('view spanning everything returns all', () => {
const [lo, hi] = visibleSegmentRange(segs, -1, 11, 0);
expect([lo, hi]).toEqual([0, segs.length]);
});
it('walks lo back over an earlier segment that overlaps into view', () => {
const overlapping = [seg(1, 0, 5.2), seg(2, 5, 7), seg(3, 7, 9)];
const [lo, hi] = visibleSegmentRange(overlapping, 5.05, 6, 0);
expect(overlapping.slice(lo, hi).map(s => s.id)).toEqual([1, 2]);
});
it('boundary exactly on a segment edge', () => {
const [lo, hi] = visibleSegmentRange(segs, 2, 4, 0);
// seg 1 ends exactly at 2 (end > t0 is false), segs 2 and 3 qualify.
expect(segs.slice(lo, hi).map(s => s.id)).toContain(2);
expect(segs.slice(lo, hi).map(s => s.id)).toContain(3);
});
});
describe('snapTime', () => {
it('snaps to the nearest candidate within threshold', () => {
expect(snapTime(5.05, [4.0, 5.0, 6.0], 0.1)).toEqual({ time: 5.0, candidate: 5.0 });
});
it('no snap outside threshold', () => {
expect(snapTime(5.5, [4.0, 6.0], 0.1)).toEqual({ time: 5.5, candidate: null });
});
it('tie resolves to the earliest candidate', () => {
const r = snapTime(5.0, [4.9, 5.1], 0.2);
expect(r.candidate).toBe(4.9);
});
it('ignores non-finite candidates', () => {
expect(snapTime(1.0, [NaN, Infinity, 1.02], 0.05).candidate).toBe(1.02);
});
it('exact hit snaps with zero distance', () => {
expect(snapTime(3.0, [3.0], 0.0).candidate).toBe(3.0);
});
});
describe('snapCandidates', () => {
it('includes onsets, neighbour edges and playhead', () => {
const c = snapCandidates({ onsets: [1.1, 2.2], prevEnd: 0.5, nextStart: 3.3, playhead: 2.0, pxPerSec: 100, t: 1.5 });
expect(c).toEqual(expect.arrayContaining([1.1, 2.2, 0.5, 3.3, 2.0]));
// High zoom → no integer grid.
expect(c).not.toContain(1);
});
it('adds the integer grid only at low zoom', () => {
const c = snapCandidates({ onsets: [], pxPerSec: 20, t: 7.4 });
expect(c).toEqual(expect.arrayContaining([7, 8]));
});
});
describe('clampSegmentEdit', () => {
const segs = [seg(1, 0, 2), seg(2, 3, 5), seg(3, 6, 8)];
it('start edge clamps at previous neighbour boundary', () => {
const r = clampSegmentEdit(segs, 1, 'start', { start: 1.0, end: 5 });
expect(r).toEqual({ start: 2, end: 5 });
});
it('end edge clamps at next neighbour boundary', () => {
const r = clampSegmentEdit(segs, 1, 'end', { start: 3, end: 7.5 });
expect(r).toEqual({ start: 3, end: 6 });
});
it('resize preserves MIN_SEG_DUR against the opposite edge', () => {
const r = clampSegmentEdit(segs, 1, 'start', { start: 4.99, end: 5 });
expect(r.start).toBeCloseTo(5 - MIN_SEG_DUR, 5);
const r2 = clampSegmentEdit(segs, 1, 'end', { start: 3, end: 3.01 });
expect(r2.end).toBeCloseTo(3 + MIN_SEG_DUR, 5);
});
it('Alt allows up to MAX_OVERLAP past the neighbour, never more', () => {
const r = clampSegmentEdit(segs, 1, 'start', { start: 1.0, end: 5 }, { allowOverlap: true });
expect(r.start).toBeCloseTo(2 - MAX_OVERLAP, 5);
const r2 = clampSegmentEdit(segs, 1, 'end', { start: 3, end: 9 }, { allowOverlap: true });
expect(r2.end).toBeCloseTo(6 + MAX_OVERLAP, 5);
});
it('move preserves duration and clamps inside both neighbours', () => {
const r = clampSegmentEdit(segs, 1, 'move', { start: 0.5, end: 2.5 });
expect(r).toEqual({ start: 2, end: 4 });
const r2 = clampSegmentEdit(segs, 1, 'move', { start: 5.5, end: 7.5 });
expect(r2).toEqual({ start: 4, end: 6 });
});
it('move never crosses zero for the first segment', () => {
const r = clampSegmentEdit(segs, 0, 'move', { start: -3, end: -1 });
expect(r).toEqual({ start: 0, end: 2 });
});
it('end of the last segment clamps to the track duration', () => {
const r = clampSegmentEdit(segs, 2, 'end', { start: 6, end: 50 }, { duration: 10 });
expect(r.end).toBe(10);
});
});
describe('commitMoveResize — fingerprint parity (#281 invariants)', () => {
it('a pure move leaves segmentGenInputs untouched', () => {
const before = seg('3_a', 2, 4, { profile_id: 'p1', instruct: 'calm', target_lang: 'de' });
const after = commitMoveResize(before, { start: 3, end: 5 });
expect(after.start).toBe(3);
expect(after.end).toBe(5);
expect(segmentGenInputs(after)).toEqual(segmentGenInputs(before));
// No speed/original_duration introduced by a move.
expect('speed' in after).toBe(false);
expect('original_duration' in after).toBe(false);
});
it('a resize changes ONLY speed among generation inputs', () => {
const before = seg(7, 2, 4, { profile_id: 'p1' });
const after = commitMoveResize(before, { start: 2, end: 3 });
const gi0 = segmentGenInputs(before);
const gi1 = segmentGenInputs(after);
expect(gi1.speed).toBe(2); // 2s original / 1s slot
expect({ ...gi1, speed: undefined }).toEqual({ ...gi0, speed: undefined });
expect(after.original_duration).toBe(2);
});
it('successive resizes compound against the FIRST original_duration', () => {
const s0 = seg(1, 0, 4);
const s1 = commitMoveResize(s0, { start: 0, end: 2 }); // speed 2
expect(s1.speed).toBe(2);
const s2 = commitMoveResize(s1, { start: 0, end: 8 }); // back from 4s original → 0.5
expect(s2.speed).toBe(0.5);
expect(s2.original_duration).toBe(4);
});
it('resize landing at speed 1.0 DELETES the key (missing hashes as "")', () => {
const s0 = seg(1, 0, 4);
const s1 = commitMoveResize(s0, { start: 0, end: 2 });
const s2 = commitMoveResize(s1, { start: 0, end: 4 }); // back to original duration
expect('speed' in s2).toBe(false);
expect(segmentGenInputs(s2).speed).toBeUndefined();
});
it('string split ids ("3_a") survive untouched — no parseInt mangling', () => {
const after = commitMoveResize(seg('3_a', 1, 2), { start: 1.5, end: 2.5 });
expect(after.id).toBe('3_a');
});
it('zero/negative duration guard yields speed key dropped (1.0)', () => {
const after = commitMoveResize(seg(1, 0, 2), { start: 2, end: 2 });
expect('speed' in after).toBe(false);
});
});
describe('detectOverlaps', () => {
it('flags both members of an overlapping pair', () => {
const set = detectOverlaps([seg(1, 0, 2.1), seg(2, 2, 4)]);
expect(set.has('1')).toBe(true);
expect(set.has('2')).toBe(true);
});
it('touching edges do not flag', () => {
expect(detectOverlaps([seg(1, 0, 2), seg(2, 2, 4)]).size).toBe(0);
});
it('unsorted input still detected; ids stringified', () => {
const set = detectOverlaps([seg('3_b', 5, 7), seg('3_a', 4, 5.1)]);
expect(set).toEqual(new Set(['3_a', '3_b']));
});
});
describe('nearestOnset', () => {
it('returns the closest onset', () => {
expect(nearestOnset(2.4, [0, 2.5, 5])).toBe(2.5);
});
it('empty → null', () => {
expect(nearestOnset(1, [])).toBeNull();
});
});
+144
View File
@@ -0,0 +1,144 @@
"""Tests for GET /dub/onsets/{job_id} — timeline snap-to-onset data (#280, item 3).
The route prefers the Demucs vocals track, falls back to the mixed audio,
caches the result as onsets.json in the job dir, and recomputes when the
source audio is newer than the cache.
"""
from __future__ import annotations
import asyncio
import json
import os
import numpy as np
import pytest
os.environ.setdefault("OMNIVOICE_MODEL", "test")
SR = 16000
def _write_wav(path, lead_silence_s=1.0, speech_s=1.0):
import soundfile as sf
t = np.arange(int(speech_s * SR)) / SR
tone = (0.5 * np.sin(2 * np.pi * 220.0 * t)).astype(np.float32)
audio = np.concatenate([np.zeros(int(lead_silence_s * SR), dtype=np.float32), tone])
sf.write(str(path), audio, SR)
return audio
@pytest.fixture
def job_env(tmp_path, monkeypatch):
"""A fake dub job dir + monkeypatched _get_job / DUB_DIR."""
from api.routers import dub_export
job_id = "job-onsets"
job_dir = tmp_path / job_id
job_dir.mkdir()
job = {"id": job_id}
monkeypatch.setattr(dub_export, "DUB_DIR", str(tmp_path))
monkeypatch.setattr(dub_export, "_get_job", lambda jid: job if jid == job_id else None)
return {"job_id": job_id, "job_dir": job_dir, "job": job, "module": dub_export}
def _call(module, job_id):
return asyncio.run(module.dub_get_onsets(job_id))
def test_404_when_job_missing(job_env):
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc:
_call(job_env["module"], "nope")
assert exc.value.status_code == 404
def test_404_when_no_audio_available(job_env):
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc:
_call(job_env["module"], job_env["job_id"])
assert exc.value.status_code == 404
def test_prefers_vocals_over_mix(job_env):
vocals = job_env["job_dir"] / "vocals.wav"
mix = job_env["job_dir"] / "audio.wav"
_write_wav(vocals, lead_silence_s=2.0)
_write_wav(mix, lead_silence_s=0.5)
job_env["job"]["vocals_path"] = str(vocals)
job_env["job"]["audio_path"] = str(mix)
res = _call(job_env["module"], job_env["job_id"])
assert res["source"] == "vocals"
assert len(res["onsets"]) == 1
assert res["onsets"][0] == pytest.approx(2.0, abs=0.06)
def test_falls_back_to_mix_when_vocals_missing(job_env):
mix = job_env["job_dir"] / "audio.wav"
_write_wav(mix, lead_silence_s=0.5)
job_env["job"]["vocals_path"] = str(job_env["job_dir"] / "gone.wav") # doesn't exist
job_env["job"]["audio_path"] = str(mix)
res = _call(job_env["module"], job_env["job_id"])
assert res["source"] == "mix"
assert res["onsets"][0] == pytest.approx(0.5, abs=0.06)
def test_caches_onsets_json_and_reuses_it(job_env):
mix = job_env["job_dir"] / "audio.wav"
_write_wav(mix, lead_silence_s=1.0)
job_env["job"]["audio_path"] = str(mix)
res1 = _call(job_env["module"], job_env["job_id"])
cache = job_env["job_dir"] / "onsets.json"
assert cache.exists()
assert json.loads(cache.read_text()) == res1
# Poison the cache with a sentinel — the route must serve it verbatim
# (i.e. no recompute) while the audio mtime is older than the cache.
sentinel = {"onsets": [99.9], "source": "mix"}
cache.write_text(json.dumps(sentinel))
os.utime(str(mix), (0, 0)) # audio much older than cache
res2 = _call(job_env["module"], job_env["job_id"])
assert res2 == sentinel
def test_recomputes_when_audio_newer_than_cache(job_env):
mix = job_env["job_dir"] / "audio.wav"
_write_wav(mix, lead_silence_s=1.0)
job_env["job"]["audio_path"] = str(mix)
cache = job_env["job_dir"] / "onsets.json"
cache.write_text(json.dumps({"onsets": [99.9], "source": "mix"}))
os.utime(str(cache), (0, 0)) # cache much older than audio
res = _call(job_env["module"], job_env["job_id"])
assert res["onsets"][0] == pytest.approx(1.0, abs=0.06)
# Fresh cache written back.
assert json.loads(cache.read_text()) == res
def test_corrupt_cache_recomputes(job_env):
mix = job_env["job_dir"] / "audio.wav"
_write_wav(mix, lead_silence_s=1.0)
job_env["job"]["audio_path"] = str(mix)
cache = job_env["job_dir"] / "onsets.json"
cache.write_text("{not json")
# Make the corrupt cache look fresh so only the parse guard saves us.
os.utime(str(mix), (0, 0))
res = _call(job_env["module"], job_env["job_id"])
assert res["onsets"][0] == pytest.approx(1.0, abs=0.06)
def test_traversal_job_id_rejected(job_env, monkeypatch):
from fastapi import HTTPException
module = job_env["module"]
# Pretend every job id resolves so the realpath containment guard is the
# only thing standing between a traversal id and the filesystem.
mix = job_env["job_dir"] / "audio.wav"
_write_wav(mix)
monkeypatch.setattr(module, "_get_job", lambda jid: {"id": jid, "audio_path": str(mix)})
with pytest.raises(HTTPException) as exc:
_call(module, "../../etc")
assert exc.value.status_code == 400
+67
View File
@@ -16,6 +16,7 @@ from services.onset_align import (
MIN_SEG_DUR_S,
PRE_ROLL_S,
detect_speech_onset,
detect_speech_onsets,
snap_segment_starts,
)
@@ -156,3 +157,69 @@ def test_snap_tolerates_malformed_segment_entries():
n = snap_segment_starts(segs, audio, SR)
assert n >= 1
assert segs[2]["start"] == pytest.approx(2.0 - PRE_ROLL_S, abs=0.08)
# ── detect_speech_onsets (full-track, #280 item 3) ──────────────────────────
def test_onsets_silence_returns_empty():
assert detect_speech_onsets(_silence(3.0), SR) == []
def test_onsets_empty_or_invalid_audio():
assert detect_speech_onsets(np.zeros(0, dtype=np.float32), SR) == []
assert detect_speech_onsets(_tone(1.0), 0) == []
def test_onsets_two_bursts_yield_two_onsets():
# 1 s silence, 1 s tone, 1 s silence, 1 s tone — exactly two rises.
audio = np.concatenate([_silence(1.0), _tone(1.0), _silence(1.0), _tone(1.0)])
onsets = detect_speech_onsets(audio, SR)
assert len(onsets) == 2
assert onsets[0] == pytest.approx(1.0, abs=0.06)
assert onsets[1] == pytest.approx(3.0, abs=0.06)
def test_onsets_speech_at_t0_registers():
audio = np.concatenate([_tone(1.0), _silence(2.0)])
onsets = detect_speech_onsets(audio, SR)
assert len(onsets) == 1
assert onsets[0] == pytest.approx(0.0, abs=0.06)
def test_onsets_hysteresis_ignores_short_dip():
# A 60 ms dip inside a burst (< MIN_ONSET_GAP_S of 150 ms below the
# threshold) must NOT register a second onset.
audio = np.concatenate([
_silence(1.0), _tone(0.5), _silence(0.06), _tone(0.5),
])
onsets = detect_speech_onsets(audio, SR)
assert len(onsets) == 1
assert onsets[0] == pytest.approx(1.0, abs=0.06)
def test_onsets_hysteresis_long_gap_registers_new_onset():
# A 400 ms gap (> MIN_ONSET_GAP_S) re-arms the detector.
audio = np.concatenate([
_silence(1.0), _tone(0.5), _silence(0.4), _tone(0.5),
])
onsets = detect_speech_onsets(audio, SR)
assert len(onsets) == 2
assert onsets[1] == pytest.approx(1.9, abs=0.06)
def test_onsets_stereo_audio_accepted():
mono = np.concatenate([_silence(1.0), _tone(1.0)])
stereo = np.stack([mono, mono], axis=1)
onsets = detect_speech_onsets(stereo, SR)
assert len(onsets) == 1
assert onsets[0] == pytest.approx(1.0, abs=0.06)
def test_onsets_sorted_ascending():
audio = np.concatenate(
[_silence(0.5), _tone(0.3)] * 4
)
onsets = detect_speech_onsets(audio, SR)
assert onsets == sorted(onsets)
assert len(onsets) == 4