Files
VoiceStudio/tests/test_onset_align.py
T
65fc5245dc feat(dub): timeline segment editor — drag, snap-to-onset, keyboard a11y (#280) (#348)
* feat(dub): full-track speech-onset detection + GET /dub/onsets/{job_id} (#280)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:21:07 +05:30

226 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for speech-onset alignment (#280, item 1).
Whisper-family ASR often stretches the first segment's start back over
leading music/silence (speech at 0:03, transcript says 0.0 → the dub
plays 3 s early). `services.onset_align.snap_segment_starts` post-snaps
segment starts forward to the first speech-like frame in the audio.
All tests use synthetic audio — pure NumPy, no models, no platform code.
"""
from __future__ import annotations
import numpy as np
import pytest
from services.onset_align import (
MIN_SEG_DUR_S,
PRE_ROLL_S,
detect_speech_onset,
detect_speech_onsets,
snap_segment_starts,
)
SR = 16000
def _tone(duration_s: float, sr: int = SR, freq: float = 220.0, amp: float = 0.5) -> np.ndarray:
t = np.arange(int(duration_s * sr)) / sr
return (amp * np.sin(2 * np.pi * freq * t)).astype(np.float32)
def _silence(duration_s: float, sr: int = SR) -> np.ndarray:
return np.zeros(int(duration_s * sr), dtype=np.float32)
def _speech_after_silence(lead_s: float, speech_s: float = 2.0) -> np.ndarray:
"""`lead_s` of silence, then `speech_s` of loud tone."""
return np.concatenate([_silence(lead_s), _tone(speech_s)])
# ── detect_speech_onset ─────────────────────────────────────────────────────
def test_detect_onset_finds_speech_after_leading_silence():
audio = _speech_after_silence(2.5)
onset = detect_speech_onset(audio, SR, 0.0, 4.5)
assert onset == pytest.approx(2.5, abs=0.06)
def test_detect_onset_silent_window_returns_none():
audio = _silence(3.0)
assert detect_speech_onset(audio, SR, 0.0, 3.0) is None
def test_detect_onset_empty_or_invalid_window():
audio = _tone(1.0)
assert detect_speech_onset(audio, SR, 2.0, 1.0) is None # end < start
assert detect_speech_onset(audio, SR, 5.0, 6.0) is None # past audio end
assert detect_speech_onset(audio, 0, 0.0, 1.0) is None # bad sr
# ── snap_segment_starts ─────────────────────────────────────────────────────
def test_snap_shifts_first_segment_to_speech_onset():
"""The issue-280 case: speech starts at 3.0 s but the transcript's first
segment claims start=0.0 — the dub then plays 3 s early."""
audio = _speech_after_silence(3.0, speech_s=3.0)
segs = [{"start": 0.0, "end": 6.0, "text": "It's a nice day today"}]
n = snap_segment_starts(segs, audio, SR)
assert n == 1
# Snapped to just before the onset (pre-roll keeps breaths/plosives).
assert segs[0]["start"] == pytest.approx(3.0 - PRE_ROLL_S, abs=0.08)
assert segs[0]["end"] == 6.0 # ends are untouched
def test_snap_never_moves_start_backward():
# Segment starts mid-speech: onset is at/before seg start → no change.
audio = _speech_after_silence(1.0, speech_s=5.0)
segs = [{"start": 2.0, "end": 5.5, "text": "x"}]
n = snap_segment_starts(segs, audio, SR)
assert n == 0
assert segs[0]["start"] == 2.0
def test_snap_ignores_sub_threshold_shift():
# Speech begins 0.1 s into the segment — below MIN_SHIFT_S, leave alone.
audio = _speech_after_silence(1.1, speech_s=3.0)
segs = [{"start": 1.0, "end": 4.0, "text": "x"}]
n = snap_segment_starts(segs, audio, SR)
assert n == 0
assert segs[0]["start"] == 1.0
def test_snap_skips_silent_segment():
audio = _silence(5.0)
segs = [{"start": 0.5, "end": 4.0, "text": "x"}]
assert snap_segment_starts(segs, audio, SR) == 0
assert segs[0]["start"] == 0.5
def test_snap_preserves_minimum_duration():
# Onset is very late in the slot: shift is capped so the segment keeps
# at least MIN_SEG_DUR_S of audio.
audio = np.concatenate([_silence(3.8), _tone(0.4)])
segs = [{"start": 0.0, "end": 4.0, "text": "x"}]
n = snap_segment_starts(segs, audio, SR)
assert n == 1
assert segs[0]["start"] <= 4.0 - MIN_SEG_DUR_S + 1e-6
assert segs[0]["end"] - segs[0]["start"] >= MIN_SEG_DUR_S - 1e-6
def test_snap_skips_too_short_segments():
audio = _speech_after_silence(0.2, speech_s=0.4)
segs = [{"start": 0.0, "end": 0.3, "text": "x"}]
assert snap_segment_starts(segs, audio, SR) == 0
def test_snap_handles_multiple_segments_independently():
# seg A: 2 s silence then speech; seg B: speech immediately.
audio = np.concatenate([
_silence(2.0), _tone(2.0), # 04 s (speech at 2.0)
_tone(3.0), # 47 s (speech immediately)
])
segs = [
{"start": 0.0, "end": 4.0, "text": "a"},
{"start": 4.0, "end": 7.0, "text": "b"},
]
n = snap_segment_starts(segs, audio, SR)
assert n == 1
assert segs[0]["start"] == pytest.approx(2.0 - PRE_ROLL_S, abs=0.08)
assert segs[1]["start"] == 4.0
def test_snap_accepts_stereo_audio():
mono = _speech_after_silence(2.0, speech_s=2.0)
stereo = np.stack([mono, mono], axis=1)
segs = [{"start": 0.0, "end": 4.0, "text": "x"}]
assert snap_segment_starts(segs, stereo, SR) == 1
assert segs[0]["start"] == pytest.approx(2.0 - PRE_ROLL_S, abs=0.08)
def test_snap_no_audio_is_noop():
segs = [{"start": 0.0, "end": 4.0, "text": "x"}]
assert snap_segment_starts(segs, np.zeros(0, dtype=np.float32), SR) == 0
assert snap_segment_starts(segs, None, SR) == 0
assert segs[0]["start"] == 0.0
def test_snap_tolerates_malformed_segment_entries():
audio = _speech_after_silence(2.0)
segs = [
{"start": "bogus", "end": 4.0},
{"end": 4.0}, # missing start → defaults to 0.0, still valid
{"start": 0.0, "end": 4.0, "text": "ok"},
]
# Must not raise; the well-formed entries still get processed.
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