Backend:
- Split monolithic main.py into backend/{api/routers,core,schemas,services}
- core/db.py: allowlist-gated migrations, db_conn context manager (kills SQL injection on ALTER)
- core/tasks.py: lock-guarded listener add/remove/push, snapshot-before-iterate
- services/ffmpeg_utils.py: run_ffmpeg helper with concurrency semaphore, EAGAIN retry, guaranteed reap
- services/segmentation.py: Bengali/CJK/Arabic punctuation, ultra-short tier, stitch_adjacent_shorts,
bounded-loop merge; public clean_up_segments API
- services/model_manager.py: robust lock.locked() handling
- api/routers/dub_core.py: job_id traversal guard, thread-safe _active_procs, timeouts on ffmpeg/demucs,
POST /dub/cleanup-segments endpoint
- api/routers/dub_export.py: guarded SSE listener remove, ffmpeg timeouts via run_ffmpeg
- api/routers/exports.py: destination_path validation, safe source resolver, subprocess list-form
- api/routers/generation.py: contextlib.suppress on tempfile cleanup, db_conn usage, safe output-path helper
- api/routers/system.py: try/finally tmp cleanup, subprocess timeouts
- schemas/requests.py: TranslateSegment.id int->str to match hex segment IDs
- main.py: threading.Lock around crash log writes
Frontend:
- components/SearchableSelect.jsx: popover combobox with search, keyboard nav, popular+recent pins, 200-item cap
- App.jsx: wire SearchableSelect for dub language / ISO code / voice-gen language; Clean Up segments button;
fix blob URL leak (object-shaped prev in setter, unmount cleanup via ref)
- components/WaveformTimeline.jsx: explicit <video> detach instead of innerHTML='' to release decoder
- index.css: ss-* combobox styles matching Gruvbox theme
Tests:
- tests/test_segmentation.py (26 cases), test_dub_transcribe.py, test_dub_export_unique.py, conftest.py
Chore:
- .gitignore: exclude omnivoice.zip, /research/ reference clones
- Remove tracked stray root test scripts + crash_log.txt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
348 lines
14 KiB
Python
348 lines
14 KiB
Python
"""Unit tests for services.segmentation — the dub-grade segmentation pipeline."""
|
||
|
||
import pytest
|
||
|
||
from services.segmentation import (
|
||
MIN_DUR,
|
||
MIN_CHARS,
|
||
MAX_DUR,
|
||
MAX_CHARS,
|
||
MERGE_GAP,
|
||
IDEAL_DUR,
|
||
Segment,
|
||
Word,
|
||
_best_boundary,
|
||
_words_from_whisper,
|
||
_build_segments_from_words,
|
||
_merge_short,
|
||
_apply_scene_cuts,
|
||
segment_transcript,
|
||
assign_speakers_heuristic,
|
||
assign_speakers_from_diarization,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _chunks(*pairs):
|
||
"""Build a whisper-style result from (text, start, end) tuples."""
|
||
return {"chunks": [{"text": t, "timestamp": (s, e)} for t, s, e in pairs]}
|
||
|
||
|
||
def _words(result):
|
||
"""Run _words_from_whisper convenience wrapper."""
|
||
return _words_from_whisper(result)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _best_boundary
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestBestBoundary:
|
||
def test_prefers_sentence_over_clause(self):
|
||
text = "First thought. Second, clause here finishes the line."
|
||
pos = _best_boundary(text, ideal_pos=len(text) // 2)
|
||
# Expect cut right after the period + space
|
||
assert text[pos - 1] == "." or text[pos - 1] == " "
|
||
assert "First thought" in text[:pos]
|
||
|
||
def test_prefers_clause_when_no_sentence(self):
|
||
text = "one two three, four five six seven eight nine"
|
||
pos = _best_boundary(text, ideal_pos=len(text) // 2)
|
||
left = text[:pos].rstrip()
|
||
assert left.endswith(",")
|
||
|
||
def test_falls_back_to_word_boundary(self):
|
||
text = "alpha beta gamma delta epsilon zeta eta theta"
|
||
pos = _best_boundary(text, ideal_pos=len(text) // 2)
|
||
# Split must land ON or AFTER a space; neither side may be mid-word.
|
||
assert pos == len(text) or text[pos] == " " or text[pos - 1] == " "
|
||
left = text[:pos].rstrip()
|
||
right = text[pos:].lstrip()
|
||
# Both sides begin/end on complete words.
|
||
assert not left or left[-1].isalnum() or left[-1] in ".,!?;:"
|
||
assert right[:1].isalpha() or right == ""
|
||
|
||
def test_no_whitespace_returns_full_length(self):
|
||
text = "unsplittableword"
|
||
pos = _best_boundary(text, ideal_pos=len(text) // 2)
|
||
assert pos == len(text)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _words_from_whisper
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestWordsFromWhisper:
|
||
def test_prefers_word_level_when_available(self):
|
||
result = {
|
||
"segments": [
|
||
{
|
||
"start": 0.0, "end": 2.0,
|
||
"words": [
|
||
{"word": "hello", "start": 0.0, "end": 0.5},
|
||
{"word": "world", "start": 0.5, "end": 1.1},
|
||
{"word": "foo", "start": 1.1, "end": 1.8},
|
||
],
|
||
},
|
||
],
|
||
"chunks": [{"text": "hello world foo", "timestamp": (0.0, 2.0)}],
|
||
}
|
||
words = _words(result)
|
||
assert [w.text for w in words] == ["hello", "world", "foo"]
|
||
assert words[0].start == 0.0
|
||
|
||
def test_falls_back_to_chunks(self):
|
||
result = _chunks(("one two three", 0.0, 3.0))
|
||
words = _words(result)
|
||
assert len(words) == 3
|
||
# Time evenly distributed
|
||
assert words[0].start == 0.0
|
||
assert pytest.approx(words[1].start, abs=0.01) == 1.0
|
||
assert pytest.approx(words[2].end, abs=0.01) == 3.0
|
||
|
||
def test_empty_result_returns_empty(self):
|
||
assert _words({}) == []
|
||
assert _words({"chunks": []}) == []
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Core pipeline (segment_transcript)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestSegmentTranscript:
|
||
def test_no_mid_word_splits_on_fragmented_whisper(self):
|
||
"""The screenshot bug — 18 mid-word fragments should collapse to clean segs."""
|
||
result = _chunks(
|
||
("Most hiring team", 0.0, 1.2),
|
||
("much time on the", 1.2, 2.1),
|
||
("Same screening", 2.1, 3.0),
|
||
("same shortlisting", 3.0, 4.4),
|
||
("and again.", 4.4, 5.2),
|
||
("So we built Yupc", 5.2, 6.6),
|
||
("You can create in", 6.6, 7.9),
|
||
("templates yourse", 7.9, 9.0),
|
||
("Then you", 9.0, 9.7),
|
||
("schedule intervie", 9.7, 11.0),
|
||
("The AI", 11.0, 11.7),
|
||
("then runs the", 11.7, 12.6),
|
||
("interview while it", 12.6, 13.9),
|
||
("After that, you ge", 13.9, 15.1),
|
||
("stru", 15.1, 15.3),
|
||
("c", 15.3, 15.4),
|
||
("tured report with", 15.4, 16.7),
|
||
("Try Yupcha if", 16.7, 17.9),
|
||
)
|
||
segs = segment_transcript(result, duration=18.0)
|
||
|
||
assert 1 < len(segs) < 8, f"expected consolidation, got {len(segs)}"
|
||
for s in segs:
|
||
dur = s["end"] - s["start"]
|
||
# No fragment should slip past the floor.
|
||
assert dur >= MIN_DUR or s["end"] == segs[-1]["end"], (
|
||
f"fragment {s!r} below MIN_DUR={MIN_DUR}"
|
||
)
|
||
assert len(s["text"]) >= MIN_CHARS or s["end"] == segs[-1]["end"], (
|
||
f"fragment {s!r} below MIN_CHARS={MIN_CHARS}"
|
||
)
|
||
|
||
def test_no_word_duplicated_across_boundary(self):
|
||
"""The mid-buffer split must use word boundaries, not char ratios."""
|
||
result = _chunks(
|
||
("The cat sat on the mat and slept quietly for hours", 0.0, 15.0),
|
||
)
|
||
segs = segment_transcript(result, duration=15.0)
|
||
if len(segs) < 2:
|
||
pytest.skip("single segment — split path not exercised")
|
||
joined = " ".join(s["text"] for s in segs)
|
||
# No word should appear twice unless present twice in input
|
||
for tok in ("cat", "mat", "quietly", "slept"):
|
||
assert joined.count(tok) <= 1, f"word duplicated across boundary: {tok}"
|
||
|
||
def test_respects_sentence_boundaries(self):
|
||
result = _chunks(
|
||
("Hello, my name is Alice.", 0.0, 2.5),
|
||
("I work at a company in Boston.", 2.5, 5.5),
|
||
("We build software for hospitals.", 5.5, 8.5),
|
||
("It is complex but rewarding work.", 8.5, 11.5),
|
||
("Thanks for listening today.", 11.5, 14.0),
|
||
)
|
||
segs = segment_transcript(result, duration=14.0)
|
||
# Every segment should end on a sentence terminator.
|
||
for s in segs:
|
||
assert s["text"].rstrip().endswith((".", "!", "?"))
|
||
|
||
def test_enforces_max_dur(self):
|
||
# Synthesize a single long chunk; should get split.
|
||
long_text = " ".join(f"word{i}" for i in range(60))
|
||
result = _chunks((long_text, 0.0, 20.0))
|
||
segs = segment_transcript(result, duration=20.0)
|
||
assert len(segs) >= 2
|
||
for s in segs:
|
||
dur = s["end"] - s["start"]
|
||
# Allow small margin — best_boundary may land slightly past IDEAL.
|
||
assert dur <= MAX_DUR + 1.0, f"segment {s!r} exceeds MAX_DUR"
|
||
|
||
def test_single_short_input_returns_single_segment(self):
|
||
result = _chunks(("Hello there.", 0.0, 1.5))
|
||
segs = segment_transcript(result, duration=1.5)
|
||
assert len(segs) == 1
|
||
assert segs[0]["text"] == "Hello there."
|
||
|
||
def test_empty_result_returns_empty(self):
|
||
assert segment_transcript({}, duration=0.0) == []
|
||
assert segment_transcript({"chunks": []}, duration=5.0) == []
|
||
|
||
def test_missing_chunks_uses_flat_text(self):
|
||
segs = segment_transcript({"text": "Short fallback."}, duration=2.0)
|
||
assert len(segs) == 1
|
||
assert segs[0]["text"] == "Short fallback."
|
||
|
||
def test_ids_are_unique(self):
|
||
result = _chunks(
|
||
("One sentence ends here.", 0.0, 3.0),
|
||
("Second one follows now.", 3.0, 6.0),
|
||
("A third rounds it out.", 6.0, 9.0),
|
||
)
|
||
segs = segment_transcript(result, duration=9.0)
|
||
ids = [s["id"] for s in segs]
|
||
assert len(set(ids)) == len(ids)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _merge_short
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestMergeShort:
|
||
def test_folds_fragment_into_previous(self):
|
||
segs = [
|
||
Segment(0.0, 3.0, "First segment is long enough.", "S1"),
|
||
Segment(3.0, 3.4, "ok", "S1"), # fragment
|
||
]
|
||
merged = _merge_short(list(segs))
|
||
assert len(merged) == 1
|
||
assert merged[0].end == 3.4
|
||
assert "ok" in merged[0].text
|
||
|
||
def test_does_not_cross_speaker_boundary_when_gap_too_large(self):
|
||
segs = [
|
||
Segment(0.0, 3.0, "First segment is long enough.", "S1"),
|
||
Segment(9.0, 9.4, "ok", "S2"), # different speaker, far away
|
||
Segment(10.0, 13.0, "Another good length segment ok.", "S2"),
|
||
]
|
||
merged = _merge_short(list(segs))
|
||
speakers = [m.speaker_id for m in merged]
|
||
# The fragment should fold into S2 (same speaker) not S1.
|
||
assert "S1" in speakers and "S2" in speakers
|
||
# S1 segment text unchanged
|
||
s1 = next(m for m in merged if m.speaker_id == "S1")
|
||
assert s1.text == "First segment is long enough."
|
||
|
||
def test_stranded_fragment_survives_when_no_neighbor_matches(self):
|
||
segs = [Segment(0.0, 0.3, "hi", "S1")]
|
||
merged = _merge_short(list(segs))
|
||
# Orphan — nothing to merge with.
|
||
assert len(merged) == 1
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _apply_scene_cuts
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestApplySceneCuts:
|
||
def test_splits_at_safe_cut(self):
|
||
segs = [
|
||
Segment(0.0, 6.0, "We are going to the store. The store sells groceries.", "S1"),
|
||
]
|
||
out = _apply_scene_cuts(list(segs), [3.0])
|
||
assert len(out) == 2
|
||
assert out[0].end == 3.0
|
||
assert out[1].start == 3.0
|
||
|
||
def test_rejects_cut_producing_tiny_left(self):
|
||
segs = [Segment(0.0, 6.0, "Hello world this sentence runs long enough for a cut.", "S1")]
|
||
# Cut at 0.3 would leave < MIN_DUR on the left
|
||
out = _apply_scene_cuts(list(segs), [0.3])
|
||
assert len(out) == 1
|
||
assert out[0].start == 0.0
|
||
|
||
def test_rejects_cut_producing_tiny_right(self):
|
||
segs = [Segment(0.0, 6.0, "Hello world this sentence runs long enough for a cut.", "S1")]
|
||
# Cut at 5.9 would leave < MIN_DUR on the right
|
||
out = _apply_scene_cuts(list(segs), [5.9])
|
||
assert len(out) == 1
|
||
|
||
def test_no_cuts_returns_input_untouched(self):
|
||
segs = [Segment(0.0, 3.0, "Hello world test.", "S1")]
|
||
out = _apply_scene_cuts(list(segs), [])
|
||
assert out == segs
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Speaker assignment
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestSpeakerAssignment:
|
||
def test_heuristic_alternates_on_gap(self):
|
||
segs = [
|
||
{"start": 0.0, "end": 2.0, "text": "a", "id": "1", "speaker_id": "?"},
|
||
{"start": 2.1, "end": 4.0, "text": "b", "id": "2", "speaker_id": "?"}, # small gap
|
||
{"start": 6.0, "end": 8.0, "text": "c", "id": "3", "speaker_id": "?"}, # big gap → switch
|
||
]
|
||
out = assign_speakers_heuristic(segs)
|
||
assert out[0]["speaker_id"] == out[1]["speaker_id"]
|
||
assert out[2]["speaker_id"] != out[1]["speaker_id"]
|
||
|
||
def test_diarization_uses_overlap_weighted_assignment(self):
|
||
# Build a fake diarization with two overlapping turns for the same seg;
|
||
# the one with more overlap should win, not the one at midpoint.
|
||
class FakeTurn:
|
||
def __init__(self, start, end):
|
||
self.start = start
|
||
self.end = end
|
||
|
||
class FakeDiar:
|
||
def itertracks(self, yield_label=True):
|
||
# SPEAKER_00 covers 0.0–1.0 (1.0s overlap with seg 0–2)
|
||
# SPEAKER_01 covers 1.0–1.3 (0.3s overlap) — midpoint 1.0 → SPEAKER_01
|
||
yield FakeTurn(0.0, 1.0), None, "SPEAKER_00"
|
||
yield FakeTurn(1.0, 1.3), None, "SPEAKER_01"
|
||
yield FakeTurn(1.3, 2.0), None, "SPEAKER_00"
|
||
|
||
segs = [{"start": 0.0, "end": 2.0, "text": "x", "id": "1", "speaker_id": "?"}]
|
||
out = assign_speakers_from_diarization(segs, FakeDiar())
|
||
assert out[0]["speaker_id"] == "Speaker 1" # SPEAKER_00 + 1
|
||
|
||
def test_diarization_falls_back_to_midpoint_when_no_overlap(self):
|
||
class FakeTurn:
|
||
def __init__(self, start, end):
|
||
self.start = start
|
||
self.end = end
|
||
|
||
class FakeDiar:
|
||
def itertracks(self, yield_label=True):
|
||
yield FakeTurn(10.0, 20.0), None, "SPEAKER_03" # no overlap with 0–2
|
||
|
||
segs = [{"start": 0.0, "end": 2.0, "text": "x", "id": "1", "speaker_id": "?"}]
|
||
out = assign_speakers_from_diarization(segs, FakeDiar())
|
||
# No overlap, no midpoint match — speaker_id stays "?"
|
||
assert out[0]["speaker_id"] == "?"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# End-to-end contract
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestSegmentDictContract:
|
||
def test_returned_dicts_have_required_keys(self):
|
||
result = _chunks(("Hello there friends, this is enough text.", 0.0, 3.0))
|
||
segs = segment_transcript(result, duration=3.0)
|
||
for s in segs:
|
||
assert {"id", "start", "end", "text", "speaker_id"} <= set(s.keys())
|
||
assert isinstance(s["start"], float)
|
||
assert isinstance(s["end"], float)
|
||
assert isinstance(s["text"], str)
|
||
assert s["end"] > s["start"]
|