Files
gart/code/tests/test_voice_timeline_tool.py
T
João HenriqueandClaude Opus 5 1bebee4359 feat: etapa 5 do assistente — revisão de ênfases com timeline
Transforma a etapa "colar decisões" numa tela de lapidação: a sugestão da
IA chega carregada e o editor afina frase a frase o que é ênfase e o que
fica fora. Essa marcação é o norte da etapa 6 — só as frases com ênfase
recebem zoom e legenda dinâmica; as demais ficam com legenda comum.

O campo de colar o JSON sobe para a etapa 4, então a numeração das etapas
não muda e a etapa 6 segue intacta.

Backend (fcpxml/phrase_review.py):
- build_phrase_review funde o _voice_timeline.json com as actions da IA
- trim por frase que anda em fronteira de palavra; corte parcial da IA
  chega como trim em vez de ser arredondado fora
- phrase_review_to_actions volta a cuts/zooms + emphasis_spans
- merge_saved_decisions reaplica só as decisões salvas sobre uma revisão
  remontada da análise atual, para reprocessar a voz não ficar mascarado
- resolve_source acha a mídia: o voice timeline guarda só o nome do arquivo

App (SwiftUI):
- layout de sala de edição: preview em cima, inspector à direita, timeline
  atravessando embaixo com seis trilhas rotuladas
- preview enquadra no formato de entrega lido do .fcpxml (fonte horizontal,
  projeto vertical), com alternância para a mídia original
- reprodução pula os trechos removidos e para no fim do trecho
- zoom manual por trecho marcado, sem guardar escala: a forma vem das
  configurações de Análise de Voz no render
- emoção da fala exposta por frase

Correções encontradas no caminho:
- VideoPlayer (AVKit) aborta em runtime no app compilado por swiftc;
  trocado por AVPlayerLayer (ver Engine/docs/05_EXPERIENCIAS.md #22)
- teste que ainda afirmava o default zoom scale=1.3 removido do parser (#21)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:29:27 -04:00

112 lines
4.2 KiB
Python

"""Tests for the build_voice_timeline MCP tool."""
import json
import pytest
from tests.test_voice_features_tool import _write_silent_wav
_TRANSCRIPT = {
"language": "pt",
"duration": 4.0,
"text": "isso e seguranca total",
"segments": [
{"text": "isso e", "start": 0.0, "end": 1.0},
{"text": "seguranca total", "start": 2.0, "end": 4.0},
],
"words": [
{"word": "isso", "start": 0.0, "end": 0.4, "confidence": 0.9},
{"word": "e", "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},
],
}
@pytest.fixture
def wav(tmp_path):
path = tmp_path / "clip.wav"
_write_silent_wav(str(path))
return path
@pytest.fixture
def patched(monkeypatch):
"""Deterministic transcription + acoustics, no optional extras needed."""
import fcpxml.voice_timeline as vt
import server_tools._shared as _shared_mod
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 TestBuildVoiceTimelineHandler:
async def test_writes_timeline_json(self, wav, patched):
from server import handle_build_voice_timeline
result = await handle_build_voice_timeline({"media_path": str(wav)})
text = result[0].text
json_path = wav.parent / "clip_voice_timeline.json"
assert str(json_path) in text
data = json.loads(json_path.read_text(encoding="utf-8"))
assert data["summary"]["word_count"] == 4
assert len(data["segments"]) == 2
async def test_reports_which_layers_ran(self, wav, patched):
from server import handle_build_voice_timeline
result = await handle_build_voice_timeline({"media_path": str(wav)})
text = result[0].text
assert "Analysis Layers" in text
assert "Transcript" in text
async def test_rejects_disallowed_extension(self, tmp_path):
from server import handle_build_voice_timeline
bad = tmp_path / "clip.txt"
bad.write_text("not audio")
with pytest.raises(ValueError):
await handle_build_voice_timeline({"media_path": str(bad)})
async def test_untranscribable_media_reports_hint(self, wav, monkeypatch):
import server_tools._shared as _shared_mod
from server import handle_build_voice_timeline
monkeypatch.setattr(_shared_mod, "transcribe", lambda *a, **k: None)
result = await handle_build_voice_timeline({"media_path": str(wav)})
assert "faster-whisper" in result[0].text
async def test_uses_persisted_peak_settings(self, wav, patched, monkeypatch):
"""A wider percentile must surface more peak moments."""
import server_tools.voice as server_mod
from server import handle_build_voice_timeline
def config(percentile):
return {
"energy_threshold": 0.5,
"peak_percentile": percentile,
"emphasis_floor": 0.0,
"emphasis_weights": {
"energy": 0.30, "pitch_variation": 0.25, "rate_variation": 0.20,
"pause_before": 0.15, "duration": 0.10,
},
"emotion_enabled": False,
"emotion_sensitivity": 0.5,
"zoom_scale": 1.3,
"zoom_mode": "in_out",
"zoom_ease_in": 0.25,
"zoom_ease_out": 0.04,
}
monkeypatch.setattr(server_mod, "load_voice_analysis_config", lambda: config(0.01))
await handle_build_voice_timeline({"media_path": str(wav)})
strict = json.loads((wav.parent / "clip_voice_timeline.json").read_text())
monkeypatch.setattr(server_mod, "load_voice_analysis_config", lambda: config(1.0))
await handle_build_voice_timeline({"media_path": str(wav)})
loose = json.loads((wav.parent / "clip_voice_timeline.json").read_text())
assert loose["summary"]["peak_count"] > strict["summary"]["peak_count"]