* 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:
co-authored by
Claude Fable 5
mergetest
parent
4b21f82619
commit
65fc5245dc
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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": "اختر إعدادًا مسبقًا للشخصية...",
|
||||
|
||||
@@ -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 ...",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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é…",
|
||||
|
||||
@@ -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": "एक व्यक्तित्व पूर्व निर्धारित चुनें...",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "性格のプリセットを選択してください…",
|
||||
|
||||
@@ -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": "성격 사전 설정을 선택하세요…",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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": "Выберите персональную предустановку…",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "เลือกพรีเซ็ตบุคลิกภาพ...",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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": "Виберіть налаштування особистості…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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": "选择一个风格预设…",
|
||||
|
||||
@@ -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": "選擇個性預設...",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user