diff --git a/code/Engine/docs/02_MODULES.md b/code/Engine/docs/02_MODULES.md
index a4ec4d2..861686c 100644
--- a/code/Engine/docs/02_MODULES.md
+++ b/code/Engine/docs/02_MODULES.md
@@ -28,6 +28,7 @@ Versão: `0.6.35` · Última varredura: 2026-08-19
| `model_manager.py` | 748 | Modelos Whisper: catálogo, download, config |
| `voice_timeline.py` | 600 | O JSON de voz que a IA lê |
| `phrase_review.py` | 547 | Revisão de frases (etapa 5 do assistente) |
+| `speaker_review.py` | 207 | Revisão de falantes (etapa 3 do assistente) |
| `collision.py` | 472 | Colisão entre títulos na tela |
| `font_metrics.py` | 445 | Largura real de glifos por fonte |
| `templates.py` | 387 | Templates de timeline |
@@ -36,7 +37,7 @@ Versão: `0.6.35` · Última varredura: 2026-08-19
| `forced_align.py` | 181 | Alinhamento forçado opcional (whisperx/wav2vec2) que corrige o viés de ~0,4s no início das palavras |
| `live.py` | 273 | Modo Live (push_to_fcp) |
| `diff.py` | 269 | Comparação de timelines |
-| `voice_actions.py` | 263 | Decisões de edição (cut/zoom/text/marker) |
+| `voice_actions.py` | 319 | Decisões de edição (cut/zoom/text/marker) |
| `export.py` | 226 | Export Resolve v1.9 + FCP7 XMEML v5 |
| `voice_features.py` | 220 | Pitch, energia, ritmo, pausas |
| `diarize.py` | 180 | Quem falou (pyannote) |
@@ -55,9 +56,10 @@ editorial, todos operando sobre o mesmo documento e os mesmos índices.
| Módulo | Linhas | Conteúdo |
|--------|-------:|----------|
| `core.py` | 723 | `ModifierCore`: carga, índices, navegação na spine, `save` |
-| `titles.py` | 600 | Títulos de texto e legendas dinâmicas |
+| `titles.py` | 867 | Títulos de texto e legendas dinâmicas |
| `cut.py` | 333 | Dividir, cortar faixas, apagar |
-| `speed.py` | 297 | Velocidade e zoom (punch-in) |
+| `speed.py` | 94 | Velocidade de reprodução |
+| `zoom.py` | 204 | Zoom (punch-in) via clipe de ajuste conectado |
| `helpers.py` | 279 | Sanitização, escalas, construtores de elemento |
| `rapid.py` | 240 | Flash frames, rapid trim, preencher buracos |
| `validation.py` | 232 | Verificações estruturais antes de salvar |
@@ -66,7 +68,8 @@ editorial, todos operando sobre o mesmo documento e os mesmos índices.
| `document.py` | 170 | Assets de vídeo, timebases, `write_fcpxml` |
| `markers.py` | 165 | Marcadores: um, por timecode, em lote |
| `audio.py` | 162 | Clipes de áudio e cama musical |
-| `generator.py` | 147 | `FCPXMLWriter` — cria documento do zero |
+| `generator.py` | 90 | `FCPXMLWriter` — orquestra a criação do zero (estado + delegação) |
+| `builders.py` | — | Um builder por tipo de elemento: `FormatBuilder`, `AssetBuilder`, `MarkerBuilder`, `KeywordBuilder`, `ClipBuilder`, `SequenceBuilder`, `LibraryBuilder` |
| `reorder.py` | 126 | Reordenar e recalcular offsets |
| `trim.py` | 125 | Aparar e propagar o ripple |
| `transitions.py` | 94 | Transições entre vizinhos |
@@ -121,7 +124,10 @@ voice_features.py pitch, energia, ritmo, pausas
▼
emphasis.py combina tudo num índice 0–1 por palavra
▼
-voice_timeline.py monta o _voice_timeline.json ◄── é isto que a IA lê
+voice_timeline.py monta o _voice_timeline.json ◄── a análise crua
+ ▼
+speaker_review.py (opcional) filtra falante mutado + linha riscada
+ → _voice_timeline_clean.json ◄── é isto que a IA prefere
▼
[decisão: skill "editar-por-voz", ou a mão do usuário]
▼
@@ -155,6 +161,36 @@ Saída em camadas, para um modelo raciocinar do topo e descer só onde importa:
`layers` existe para separar *"a fala é monótona"* de *"a análise acústica nunca
carregou"* — os dois deixam os mesmos zeros nos dados.
+### `speaker_review.py` — a triagem antes da IA
+
+Roda logo após `analyze_voice` (etapa 3 do assistente, tela `SpeakerReviewView`
+no app): lista quem foi detectado (`speaker_profiles`, com % de fala e falas
+de amostra) e a transcrição segmento a segmento, para o usuário nomear cada
+falante, mutar quem não interessa (ex.: o entrevistador) e riscar linhas soltas
+antes de qualquer IA ver o arquivo. `build_speaker_review` nunca toca a
+timeline crua; `apply_speaker_review`/`write_clean_voice_timeline` produzem
+uma cópia separada, `_voice_timeline_clean.json`, reaproveitando
+`enrich_words`/`_segment_rows`/`_summary` de `voice_timeline.py` para
+recalcular a ênfase só sobre quem sobrou — mesma lógica de `restrict_to_kept`,
+por falante/segmento em vez de por intervalo de tempo. O merge de decisões
+salvas segue o padrão de `phrase_review.merge_saved_decisions`: sempre
+reconstrói da análise atual, só as escolhas humanas persistem.
+
+A skill "editar-por-voz", `generate_voice_script` e `cmd_build_phrase_review`
+(etapa 4/5, `admin/api/review.py`) preferem o `_clean` quando ele existe; sem
+revisão salva, seguem lendo o `_voice_timeline.json` normal — a etapa 3 é
+sempre opcional. Os três pontos de leitura precisam concordar nessa
+preferência: se um deles voltar a ler o arquivo cru direto, a revisão de
+falantes vira letra morta sem nenhum erro visível (ver `05_EXPERIENCIAS.md`).
+
+Cada linha em `build_speaker_review` carrega suas `words` originais (ênfase
+por palavra), para a tela desenhar os mesmos chips da etapa 5 sem esperar um
+recálculo. `apply_speaker_review` é reaproveitada por dois caminhos: gravar
+(`write_clean_voice_timeline`, via `save_speaker_review`) e só **prever**
+(`cmd_recalc_speaker_review`, sem tocar disco) — o botão "Recalcular" da tela
+usa o segundo caminho para atualizar a ênfase só sobre quem sobreviveu ao
+corte, sem reprocessar áudio.
+
### `phrase_review.py` — a revisão humana
Junta o timeline de voz com as ações da IA numa lista de frases editáveis, e
@@ -175,6 +211,25 @@ converte de volta. Frase inativa vira `cut`; ênfase ≥ 1 vira `zoom` mais um
Estes três não estão divididos porque **cada um já é um assunto só**. O
`text_layout.py` tem 901 linhas de um problema coeso: diagramação.
+### Separação de role entre legendas dinâmicas e convencionais
+
+As duas categorias são ambas `
` conectados, mas recebem **roles
+diferentes** para ficarem didáticas na timeline do FCP (cada role ganha cor
+própria no índice). O atributo usado em `` é `role` (CDATA) — **nunca**
+`videoRole`, que é DTD-inválido para títulos (ver `05_EXPERIENCIAS.md`,
+entrada 32).
+
+| Categoria | `role` | De onde vem |
+|-----------|--------|-------------|
+| Legendas dinâmicas | `titles.dinamicas` | `DynamicSubtitleConfig.role` / `load_dynamic_subtitle_config()["role"]` |
+| Legendas convencionais | `titles.convencionais` | `load_plain_subtitle_config()["role"]` |
+
+A cor do texto em si continua nos configs de fonte (abas do app), não no
+role. Os geradores `generate_dynamic_subtitles` (writer/titles.py),
+`handle_generate_plain_subtitles` e `handle_generate_subtitles_by_emphasis`
+(server_tools/subtitles.py) aplicam o role em cada `` criado; o
+parâmetro `role` das ferramentas MCP sobrescreve o default.
+
---
## Armadilhas do FCPXML (custaram sessões de depuração)
diff --git a/code/Engine/docs/05_EXPERIENCIAS.md b/code/Engine/docs/05_EXPERIENCIAS.md
index 758eb47..a737092 100644
--- a/code/Engine/docs/05_EXPERIENCIAS.md
+++ b/code/Engine/docs/05_EXPERIENCIAS.md
@@ -45,6 +45,12 @@ que merece entrada.
| 25 | 2026-08-20 | `admin/api/shared.py` apontava para `admin/code` (inexistente) após a divisão — install editável mascarou o bug em toda validação anterior | `resolvido` |
| 26 | 2026-08-21 | `generate_voice_script` (IA local/Ollama) caía com "Falha ao gerar roteiro por IA local" — prompt embutia a timeline inteira (47k tokens) e estourava `num_ctx`; e `response.json()` de conexão caída escapava como `JSONDecodeError` | `resolvido` |
| 27 | 2026-08-21 | Cortes escritos rente ao timestamp da palavra soam secos — critério da skill e prompt do modelo local não instruíam folga na borda | `resolvido` |
+| 28 | 2026-08-21 | Frases desativadas em sequência deixavam fatias de 0,1-0,5s sobrando entre clipes | `resolvido` |
+| 29 | 2026-08-21 | `remove_media_silence` (dB) não corta lacuna sem fala mas com som real — trecho sobrevivia intacto na timeline final | `resolvido` |
+| 30 | 2026-08-24 | `add_zoom` animava `` direto no clipe, diferente de como o FCP realmente exporta zoom (clipe de ajuste conectado) | `resolvido` |
+| 31 | 2026-08-24 | Revisão de falantes ("Quem fica na edição") salvava certo, mas etapa 4 (Revisão de frases) lia a timeline crua, ignorando falantes mutados/linhas riscadas | `resolvido` |
+| 32 | 2026-08-24 | Separar legendas dinâmicas de convencionais por role: `` aceita `role` (CDATA), NÃO `videoRole` — este último é DTD-inválido para títulos e quebra a validação | `resolvido` |
+| 33 | 2026-09-22 | Legenda comum sobreposta à composição dinâmica em `generate_subtitles_by_emphasis`; regenerar acumulava títulos em vez de substituir | `resolvido` |
> Mantenha o índice acima sempre sincronizado com as entradas mais recentes.
@@ -1494,3 +1500,235 @@ o outro; percentil entrega um punhado útil nos dois casos.
> "cortar o trecho desativado" quando frases se sucedem sem conteúdo mantido
> entre elas — a pausa entre duas coisas descartadas também precisa ser
> descartada, e ninguém a cobre por definição se o corte for por frase.
+
+---
+
+### 2026-08-21 — `remove_media_silence` (dB) não pega lacuna sem fala com som real
+
+- **Sintoma:** usuário viu, no projeto Mastopexia real, um trecho de ~1,9s
+ sem fala (imagem parada antes da tomada começar) que sobreviveu intacto
+ na timeline final — depois de `apply_voice_actions`, `remove_media_silence`
+ e `generate_dynamic_subtitles` já terem rodado. Achou que era bug de ordem
+ no encadeamento das etapas ("corta e depois volta").
+- **Investigação:** não era ordem. Extraído o áudio real do trecho
+ (`ffmpeg -af volumedetect`): `mean_volume -21.4dB`, `max_volume 0.0dB` —
+ longe do limiar padrão de silêncio (-30dB). Rodado `detect_silence` nos
+ mesmos limiares do sistema (-30/-25/-20/-16dB): nenhum sinaliza o trecho.
+ O trecho tem som real (roupa, respiração, ambiente) mas nenhuma palavra —
+ exatamente o caso que `06-texto-corte-marcador.md` já descrevia
+ ("ausência de fala não é ausência de som"), só que sem ferramenta para
+ agir sobre ele: `remove_media_silence` só enxerga volume, nunca vai
+ cortar algo que soa alto mas não tem fala.
+- **Correção:** nova função pura `speech_gap_cut_actions()` em
+ `fcpxml/voice_actions.py` — gera `cut`s a partir dos gaps entre
+ `words[].start/end` do `_voice_timeline.json` (tempo de fonte, como todo
+ `VoiceAction`), com a mesma folga por dentro (`padding`) que
+ `speaker_cut_actions()` já usava. Nova tool MCP `remove_speech_gaps`
+ (`server_tools/voice.py`, mesmo molde de `remove_speakers`): resolve o
+ `media_path`, lê a timeline, gera as ações e reaplica via
+ `handle_apply_voice_actions` — não duplica a lógica de corte no FCPXML.
+ Deliberadamente não corta a lacuna antes da primeiríssima palavra (pode
+ ser quase o arquivo inteiro, antes da tomada começar de verdade).
+- **Ordem revista:** `apply_voice_actions → remove_speech_gaps →
+ remove_media_silence → generate_dynamic_subtitles` — a lacuna "sem fala"
+ some primeiro (cobertura ampla, por transcrição), o que sobra de silêncio
+ técnico *dentro* da fala é apertado depois.
+- **Validação:** `tests/test_voice_actions.py::TestSpeechGapCutActions`
+ (gap acima/abaixo do limiar, lacuna antes da 1ª palavra nunca cortada,
+ segmentos com palavras sobrepostas não quebram, timeline vazia). Suíte
+ completa (1508 testes) roda limpa.
+- **Estado:** `resolvido`
+
+> **Aprendizado:** um detector de silêncio por dB nunca vai cobrir "sem fala
+> com som" — são categorias diferentes, não uma questão de calibrar o
+> limiar. Quando já existe transcrição confiável, ela é a fonte melhor para
+> "onde não tem fala": não depende de threshold nenhum, só da própria
+> palavra existir ou não naquele instante.
+
+---
+
+### 2026-08-24 — Zoom era `` no próprio clipe; FCP exporta como clipe de ajuste
+
+- **Sintoma:** usuário pediu para o zoom parar de mexer diretamente no
+ clipe da timeline e passar a usar um "adjustment clip" com crop
+ animado — o jeito como ele já fazia zoom manualmente no FCP.
+- **Investigação:** não havia amostra real no projeto para confirmar a
+ forma exata do XML (`adjust-crop`? um `` com `` como
+ `fcpxml/writer/adjustment.py` já fazia para filtros?). O usuário enviou
+ um `.fcpxmld` exportado pelo próprio FCP com um zoom manual
+ (`exemplo zoom.fcpxmld`), que revelou a forma real: um `