168 lines
6.5 KiB
Python
168 lines
6.5 KiB
Python
"""Tests for the refine_voice_timeline MCP tool.
|
|
|
|
The tool exists because emphasis is *relative*: cut the loudest moment of a
|
|
recording and every surviving score is still measured against something the
|
|
viewer never sees. These tests pin the re-normalization actually happening,
|
|
and the times staying in original source seconds so the result can be fed
|
|
straight back to apply_voice_actions.
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from tests.test_voice_features_tool import _write_silent_wav
|
|
from tests.test_voice_timeline_tool import _TRANSCRIPT, patched, wav # noqa: F401
|
|
|
|
_ = _write_silent_wav, _TRANSCRIPT # re-exported fixtures need the imports
|
|
|
|
|
|
async def _build(wav_path):
|
|
from server import handle_build_voice_timeline
|
|
|
|
await handle_build_voice_timeline({"media_path": str(wav_path)})
|
|
|
|
|
|
@pytest.fixture
|
|
def two_candidates(monkeypatch):
|
|
"""A transcript where BOTH lines carry a content word.
|
|
|
|
The shared fixture's first line is "isso e" — two function words, which
|
|
``suggest_zoom_windows`` skips by design, so it can never produce more
|
|
than one candidate to cap.
|
|
"""
|
|
import fcpxml.voice_timeline as vt
|
|
import server_tools._shared as _shared_mod
|
|
|
|
transcript = {
|
|
"language": "pt",
|
|
"duration": 4.0,
|
|
"text": "cirurgia rapida seguranca total",
|
|
"segments": [
|
|
{"text": "cirurgia rapida", "start": 0.0, "end": 1.0},
|
|
{"text": "seguranca total", "start": 2.0, "end": 4.0},
|
|
],
|
|
"words": [
|
|
{"word": "cirurgia", "start": 0.0, "end": 0.4, "confidence": 0.9},
|
|
{"word": "rapida", "start": 0.5, "end": 0.7, "confidence": 0.9},
|
|
{"word": "seguranca", "start": 2.0, "end": 2.9, "confidence": 0.9},
|
|
{"word": "total", "start": 3.0, "end": 3.5, "confidence": 0.9},
|
|
],
|
|
}
|
|
monkeypatch.setattr(_shared_mod, "transcribe", lambda *a, **k: transcript)
|
|
monkeypatch.setattr(vt, "extract_pitch", lambda *a, **k: [(2.4, 260.0), (0.2, 120.0)])
|
|
monkeypatch.setattr(vt, "extract_energy", lambda *a, **k: [(2.4, 0.95), (0.2, 0.10)])
|
|
|
|
|
|
class TestRefineVoiceTimelineHandler:
|
|
async def test_requires_an_existing_timeline(self, wav): # noqa: F811
|
|
from server import handle_refine_voice_timeline
|
|
|
|
result = await handle_refine_voice_timeline({"media_path": str(wav), "cuts": []})
|
|
assert "build_voice_timeline" in result[0].text
|
|
|
|
async def test_rejects_disallowed_extension(self, tmp_path):
|
|
from server import handle_refine_voice_timeline
|
|
|
|
bad = tmp_path / "clip.txt"
|
|
bad.write_text("not audio")
|
|
with pytest.raises(ValueError):
|
|
await handle_refine_voice_timeline({"media_path": str(bad), "cuts": []})
|
|
|
|
async def test_compares_raw_against_survivors(self, wav, patched): # noqa: F811
|
|
from server import handle_refine_voice_timeline
|
|
|
|
await _build(wav)
|
|
result = await handle_refine_voice_timeline(
|
|
{"media_path": str(wav), "cuts": [{"start": 0.0, "end": 1.0}]}
|
|
)
|
|
text = result[0].text
|
|
assert "Survivors only" in text
|
|
assert "Average emphasis" in text
|
|
|
|
async def test_cut_words_are_excluded(self, wav, patched): # noqa: F811
|
|
from server import handle_refine_voice_timeline
|
|
|
|
await _build(wav)
|
|
result = await handle_refine_voice_timeline(
|
|
{"media_path": str(wav), "cuts": [{"start": 0.0, "end": 1.0}], "save": True}
|
|
)
|
|
assert "_voice_timeline_refined.json" in result[0].text
|
|
data = json.loads(
|
|
(wav.parent / "clip_voice_timeline_refined.json").read_text(encoding="utf-8")
|
|
)
|
|
words = [w["text"] for s in data["segments"] for w in s["words"]]
|
|
assert "isso" not in words
|
|
assert "seguranca" in words
|
|
|
|
async def test_times_stay_in_original_source_seconds(self, wav, patched): # noqa: F811
|
|
"""A cut at the head must NOT slide the survivors back to zero."""
|
|
from server import handle_refine_voice_timeline
|
|
|
|
await _build(wav)
|
|
await handle_refine_voice_timeline(
|
|
{"media_path": str(wav), "cuts": [{"start": 0.0, "end": 1.0}], "save": True}
|
|
)
|
|
data = json.loads(
|
|
(wav.parent / "clip_voice_timeline_refined.json").read_text(encoding="utf-8")
|
|
)
|
|
first = data["segments"][0]["words"][0]
|
|
assert first["start"] == pytest.approx(2.0)
|
|
|
|
async def test_proposes_zoom_candidates(self, wav, patched): # noqa: F811
|
|
from server import handle_refine_voice_timeline
|
|
|
|
await _build(wav)
|
|
result = await handle_refine_voice_timeline({"media_path": str(wav), "cuts": []})
|
|
assert "Zoom Candidates" in result[0].text
|
|
|
|
async def test_max_zooms_caps_the_list(self, wav, two_candidates): # noqa: F811
|
|
from server import handle_refine_voice_timeline
|
|
|
|
await _build(wav)
|
|
|
|
async def zoom_rows(**extra):
|
|
result = await handle_refine_voice_timeline(
|
|
{"media_path": str(wav), "cuts": [], "min_gap": 0.0, **extra}
|
|
)
|
|
section = result[0].text.split("## Zoom Candidates", 1)[1]
|
|
return [
|
|
ln for ln in section.splitlines()
|
|
if ln.startswith("| ") and ln.rstrip().endswith("|") and "Start" not in ln
|
|
]
|
|
|
|
assert len(await zoom_rows()) == 2
|
|
assert len(await zoom_rows(max_zooms=1)) == 1
|
|
|
|
async def test_malformed_cut_is_reported_not_raised(self, wav, patched): # noqa: F811
|
|
from server import handle_refine_voice_timeline
|
|
|
|
await _build(wav)
|
|
result = await handle_refine_voice_timeline(
|
|
{"media_path": str(wav), "cuts": [{"start": 3.0, "end": 1.0}]}
|
|
)
|
|
text = result[0].text
|
|
assert "Rejected cuts" in text
|
|
assert "must be after start" in text
|
|
|
|
async def test_cutting_everything_says_so(self, wav, patched): # noqa: F811
|
|
from server import handle_refine_voice_timeline
|
|
|
|
await _build(wav)
|
|
result = await handle_refine_voice_timeline(
|
|
{"media_path": str(wav), "cuts": [{"start": 0.0, "end": 60.0}]}
|
|
)
|
|
assert "removed every word" in result[0].text
|
|
|
|
|
|
class TestRefineVoiceTimelineRegistration:
|
|
async def test_tool_is_listed(self):
|
|
from server import list_tools
|
|
|
|
assert "refine_voice_timeline" in {t.name for t in await list_tools()}
|
|
|
|
async def test_tool_is_dispatched(self):
|
|
from server import TOOL_HANDLERS, handle_refine_voice_timeline
|
|
|
|
assert TOOL_HANDLERS["refine_voice_timeline"] is handle_refine_voice_timeline
|