"""Tests for fcpxml/transcribe.py — transcript intelligence (v0.13). Matching/merging/inversion helpers are pure and run everywhere; the transcribe() integration path degrades to None without faster-whisper, which is itself under test (the graceful-degradation contract). """ import json from pathlib import Path import pytest from fcpxml.parser import FCPXMLParser from fcpxml.transcribe import ( DEFAULT_FILLERS, find_filler_spans, find_phrase_spans, format_timestamp, invert_ranges, merge_ranges, normalize_word, segments_to_srt, transcribe, ) def w(word, start, end): return {"word": word, "start": start, "end": end} WORDS = [ w("So,", 0.0, 0.3), w("um,", 0.4, 0.6), w("welcome", 0.7, 1.1), w("to", 1.15, 1.25), w("the", 1.3, 1.4), w("Show!", 1.45, 1.9), w("You", 2.0, 2.2), w("know,", 2.25, 2.4), w("the", 2.5, 2.6), w("show", 2.65, 3.0), w("is", 3.05, 3.15), w("uh", 3.2, 3.5), w("great.", 3.6, 4.0), ] class TestNormalizeWord: def test_lowercase_and_punctuation(self): assert normalize_word("Show!") == "show" assert normalize_word(" um, ") == "um" def test_keeps_apostrophes(self): assert normalize_word("don't") == "don't" def test_pure_punctuation_becomes_empty(self): assert normalize_word("...") == "" class TestFindPhraseSpans: def test_single_word_multiple_hits(self): spans = find_phrase_spans(WORDS, "um") assert spans == [(0.4, 0.6)] def test_multi_word_phrase(self): spans = find_phrase_spans(WORDS, "welcome to the show") assert spans == [(0.7, 1.9)] def test_case_and_punctuation_insensitive(self): assert find_phrase_spans(WORDS, "THE SHOW!") == [(1.3, 1.9), (2.5, 3.0)] def test_no_match(self): assert find_phrase_spans(WORDS, "absent phrase") == [] def test_empty_phrase(self): assert find_phrase_spans(WORDS, "") == [] assert find_phrase_spans(WORDS, "...") == [] def test_no_overlapping_rescan(self): words = [w("go", 0, 1), w("go", 1, 2), w("go", 2, 3)] assert find_phrase_spans(words, "go go") == [(0, 2)] class TestFindFillerSpans: def test_default_fillers(self): spans = find_filler_spans(WORDS) assert (0.4, 0.6) not in spans assert (3.2, 3.5) in spans def test_um_can_still_be_explicit(self): spans = find_filler_spans(WORDS, fillers=("um",)) assert spans == [(0.4, 0.6)] def test_multi_word_filler(self): spans = find_filler_spans(WORDS, fillers=("you know",)) assert spans == [(2.0, 2.4)] def test_sorted_output(self): spans = find_filler_spans(WORDS, fillers=("um", "so")) assert spans == sorted(spans) def test_defaults_are_conservative(self): # "um", "uma", "like" and "so" are speech, not noise — must not be # default-cut. assert "um" not in DEFAULT_FILLERS assert "uma" not in DEFAULT_FILLERS assert "like" not in DEFAULT_FILLERS assert "so" not in DEFAULT_FILLERS class TestMergeRanges: def test_empty(self): assert merge_ranges([]) == [] def test_overlap_merges(self): assert merge_ranges([(0, 2), (1, 3)]) == [(0, 3)] def test_disjoint_stay_split(self): assert merge_ranges([(0, 1), (2, 3)]) == [(0, 1), (2, 3)] def test_min_gap_bridges(self): assert merge_ranges([(0, 1), (1.05, 2)], min_gap=0.1) == [(0, 2)] def test_unsorted_input(self): assert merge_ranges([(2, 3), (0, 1)]) == [(0, 1), (2, 3)] class TestInvertRanges: def test_middle_keep(self): assert invert_ranges([(1, 2)], 0, 4) == [(0, 1), (2, 4)] def test_keep_at_edges(self): assert invert_ranges([(0, 1), (3, 4)], 0, 4) == [(1, 3)] def test_nothing_kept_cuts_everything(self): assert invert_ranges([], 0, 4) == [(0, 4)] def test_everything_kept_cuts_nothing(self): assert invert_ranges([(0, 4)], 0, 4) == [] def test_keep_outside_window_ignored(self): assert invert_ranges([(10, 12)], 0, 4) == [(0, 4)] def test_overlapping_keeps_merged_first(self): assert invert_ranges([(1, 2), (1.5, 3)], 0, 4) == [(0, 1), (3, 4)] def test_empty_window(self): assert invert_ranges([(0, 1)], 5, 5) == [] class TestTranscribeDegradation: def test_missing_file_returns_none(self): assert transcribe("/nonexistent/path/audio.wav") is None def test_invalid_model_raises(self): with pytest.raises(ValueError, match="model_size"): transcribe("whatever.wav", model_size="huge-nonsense") def test_missing_dependency_returns_none(self, tmp_path, monkeypatch): # A real file, but faster_whisper import blocked -> graceful None. f = tmp_path / "a.wav" f.write_bytes(b"RIFF0000WAVE") import builtins real_import = builtins.__import__ def block(name, *args, **kwargs): if name.startswith("faster_whisper"): raise ImportError("blocked for test") return real_import(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", block) assert transcribe(str(f)) is None class TestSegmentsToSrt: def test_format(self): srt = segments_to_srt( [ {"text": "Hello there.", "start": 0.0, "end": 1.5}, {"text": "Second line.", "start": 61.25, "end": 3661.0}, ] ) assert "1\n00:00:00,000 --> 00:00:01,500\nHello there.\n" in srt assert "2\n00:01:01,250 --> 01:01:01,000\nSecond line.\n" in srt def test_empty(self): assert segments_to_srt([]) == "" class TestFormatTimestamp: def test_zero(self): assert format_timestamp(0) == "00:00:00.000" def test_simple(self): assert format_timestamp(1.5) == "00:00:01.500" def test_minutes_and_hours(self): assert format_timestamp(3661.0) == "01:01:01.000" def test_rounds_to_ms(self): assert format_timestamp(1.234567) == "00:00:01.235" # ===== MCP handler integration (no whisper needed — uses cached transcripts) ===== PROJECT_XML = """ """ TRANSCRIPT = { "source": "interview.wav", "language": "en", "duration": 8.0, "text": "Um, welcome to the show. Uh, today we talk about editing.", "segments": [ {"text": "Um, welcome to the show.", "start": 0.0, "end": 2.5}, {"text": "Uh, today we talk about editing.", "start": 3.0, "end": 7.5}, ], "words": [ {"word": "Um,", "start": 0.0, "end": 0.5}, {"word": "welcome", "start": 0.5, "end": 1.0}, {"word": "to", "start": 1.0, "end": 1.25}, {"word": "the", "start": 1.25, "end": 1.5}, {"word": "show.", "start": 1.5, "end": 2.5}, {"word": "Uh,", "start": 3.0, "end": 3.5}, {"word": "today", "start": 3.5, "end": 4.0}, {"word": "we", "start": 4.0, "end": 4.25}, {"word": "talk", "start": 4.25, "end": 4.75}, {"word": "about", "start": 4.75, "end": 5.25}, {"word": "editing.", "start": 5.25, "end": 7.5}, ], } def _write_fixture(tmp_path: Path) -> str: """Dummy media + cached transcript + project referencing them.""" wav = tmp_path / "interview.wav" wav.write_bytes(b"RIFF0000WAVEnot-real-audio") (tmp_path / "interview_transcript.json").write_text(json.dumps(TRANSCRIPT)) fcpxml_path = tmp_path / "project.fcpxml" fcpxml_path.write_text(PROJECT_XML.format(src=f"file://{wav}")) return str(fcpxml_path) def _spine_segments(path: str, name: str = "interview"): project = FCPXMLParser().parse_file(path) return [c for c in project.primary_timeline.clips if c.name == name] class TestRemoveFillerWordsHandler: async def test_cuts_default_fillers(self, tmp_path): from server import handle_remove_filler_words filepath = _write_fixture(tmp_path) result = await handle_remove_filler_words({"filepath": filepath, "padding": 0}) assert "_defillered" in result[0].text segments = _spine_segments(str(tmp_path / "project_defillered.fcpxml")) # Only uh (3-3.5) is removed by default; Portuguese "um" is preserved # because it is often grammatical speech ("de um jeito"). assert len(segments) == 2 total = sum(c.duration.seconds for c in segments) assert total == pytest.approx(7.5, abs=0.1) assert segments[0].source_start.seconds == pytest.approx(0.0, abs=0.05) assert segments[1].source_start.seconds == pytest.approx(3.5, abs=0.05) async def test_no_fillers_found_saves_nothing(self, tmp_path): from server import handle_remove_filler_words filepath = _write_fixture(tmp_path) result = await handle_remove_filler_words( {"filepath": filepath, "fillers": ["nonexistentword"]} ) assert "nothing saved" in result[0].text.lower() assert not (tmp_path / "project_defillered.fcpxml").exists() async def test_rejects_bad_padding(self, tmp_path): from server import handle_remove_filler_words filepath = _write_fixture(tmp_path) with pytest.raises(ValueError): await handle_remove_filler_words({"filepath": filepath, "padding": 99}) class TestEditByTranscriptHandler: async def test_remove_phrase(self, tmp_path): from server import handle_edit_by_transcript filepath = _write_fixture(tmp_path) await handle_edit_by_transcript( {"filepath": filepath, "phrases": ["welcome to the show"], "padding": 0} ) segments = _spine_segments(str(tmp_path / "project_transcript_edit.fcpxml")) # 0.5-2.5 removed -> two segments, ~6s total assert len(segments) == 2 total = sum(c.duration.seconds for c in segments) assert total == pytest.approx(6.0, abs=0.1) async def test_keep_only_phrase(self, tmp_path): from server import handle_edit_by_transcript filepath = _write_fixture(tmp_path) await handle_edit_by_transcript( {"filepath": filepath, "phrases": ["today we talk about editing"], "mode": "keep_only", "padding": 0} ) segments = _spine_segments(str(tmp_path / "project_transcript_edit.fcpxml")) # keep 3.5-7.5 -> one segment ~4s assert len(segments) == 1 assert segments[0].duration.seconds == pytest.approx(4.0, abs=0.1) assert segments[0].source_start.seconds == pytest.approx(3.5, abs=0.1) async def test_keep_only_without_match_leaves_clip(self, tmp_path): from server import handle_edit_by_transcript filepath = _write_fixture(tmp_path) result = await handle_edit_by_transcript( {"filepath": filepath, "phrases": ["absent phrase"], "mode": "keep_only"} ) assert "left untouched" in result[0].text assert not (tmp_path / "project_transcript_edit.fcpxml").exists() async def test_rejects_empty_phrases(self, tmp_path): from server import handle_edit_by_transcript filepath = _write_fixture(tmp_path) with pytest.raises(ValueError): await handle_edit_by_transcript({"filepath": filepath, "phrases": []}) with pytest.raises(ValueError): await handle_edit_by_transcript({"filepath": filepath, "phrases": [" "]}) async def test_rejects_bad_mode(self, tmp_path): from server import handle_edit_by_transcript filepath = _write_fixture(tmp_path) with pytest.raises(ValueError): await handle_edit_by_transcript( {"filepath": filepath, "phrases": ["x"], "mode": "invert"} ) class TestTranscribeMediaHandler: async def test_uses_cached_transcript(self, tmp_path): from server import handle_transcribe_media filepath = _write_fixture(tmp_path) result = await handle_transcribe_media({"filepath": filepath}) text = result[0].text assert "interview.wav" in text assert "11" in text # word count assert "edit_by_transcript" in text async def test_write_srt_from_cache(self, tmp_path): from server import handle_transcribe_media filepath = _write_fixture(tmp_path) await handle_transcribe_media({"filepath": filepath, "write_srt": True}) srt = (tmp_path / "interview_transcript.srt").read_text() assert "welcome to the show" in srt assert "-->" in srt async def test_no_whisper_no_cache_gives_install_hint(self, tmp_path, monkeypatch): import builtins from server import handle_transcribe_media wav = tmp_path / "raw.wav" wav.write_bytes(b"RIFF0000WAVE") fcpxml_path = tmp_path / "project.fcpxml" fcpxml_path.write_text(PROJECT_XML.format(src=f"file://{wav}")) real_import = builtins.__import__ def block(name, *args, **kwargs): if name.startswith("faster_whisper"): raise ImportError("blocked for test") return real_import(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", block) result = await handle_transcribe_media({"filepath": str(fcpxml_path)}) assert "[transcribe]" in result[0].text async def test_missing_media_reported(self, tmp_path): from server import handle_transcribe_media fcpxml_path = tmp_path / "project.fcpxml" fcpxml_path.write_text(PROJECT_XML.format(src="file:///nonexistent/a.wav")) result = await handle_transcribe_media({"filepath": str(fcpxml_path)}) assert "missing" in result[0].text.lower() class TestTranscriptMarkersHandler: async def test_adds_one_marker_per_segment(self, tmp_path): from server import handle_transcript_markers filepath = _write_fixture(tmp_path) result = await handle_transcript_markers({"filepath": filepath}) text = result[0].text assert "Markers Added" in text assert "2" in text # two segments in TRANSCRIPT project = FCPXMLParser().parse_file(str(tmp_path / "project_transcript_markers.fcpxml")) clip = project.primary_timeline.clips[0] assert len(clip.markers) == 2 assert clip.markers[0].name == "Um, welcome to the show." assert clip.markers[1].name == "Uh, today we talk about editing." async def test_truncates_long_labels(self, tmp_path): from server import handle_transcript_markers filepath = _write_fixture(tmp_path) result = await handle_transcript_markers({"filepath": filepath, "max_label_length": 10}) assert "Markers Added" in result[0].text project = FCPXMLParser().parse_file(str(tmp_path / "project_transcript_markers.fcpxml")) clip = project.primary_timeline.clips[0] assert all(len(m.name) <= 10 for m in clip.markers) async def test_missing_media_reported_not_crashed(self, tmp_path): from server import handle_transcript_markers fcpxml_path = tmp_path / "project.fcpxml" fcpxml_path.write_text(PROJECT_XML.format(src="file:///nonexistent/a.wav")) result = await handle_transcript_markers({"filepath": str(fcpxml_path)}) text = result[0].text assert "nothing saved" in text.lower() or "missing" in text.lower() assert not (tmp_path / "project_transcript_markers.fcpxml").exists()