primocode 10.16.0 → 10.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSES/edit-ia-pro.txt +32 -0
- package/LICENSES/fontes-OFL.txt +476 -0
- package/LICENSES/hyperframes-Apache-2.0.txt +205 -0
- package/LICENSES/permanent-marker-Apache-2.0.txt +204 -0
- package/LICENSES/selfie-multiclass-Apache-2.0.txt +227 -0
- package/LICENSES/three-MIT.txt +23 -0
- package/LICENSES/video-use-MIT.txt +24 -0
- package/LICENSES/woven-sfx-CC0.txt +16 -0
- package/LICENSES/yunet-MIT.txt +21 -0
- package/README.md +2 -2
- package/bin/primocode.js +28 -8
- package/editor/LICENSE.md +11 -0
- package/editor/README.md +321 -0
- package/editor/SKILL.md +307 -0
- package/editor/THIRD_PARTY_NOTICES.md +228 -0
- package/editor/assets/baixar.json +591 -0
- package/editor/assets/fonts/anton/OFL.txt +93 -0
- package/editor/assets/fonts/caveat/OFL.txt +93 -0
- package/editor/assets/fonts/figtree/OFL.txt +93 -0
- package/editor/assets/fonts/fonts.json +10 -0
- package/editor/assets/fonts/montserrat/OFL.txt +93 -0
- package/editor/assets/fonts/permanentmarker/LICENSE.txt +202 -0
- package/editor/assets/models/SELFIE_MULTICLASS_LICENSE.txt +227 -0
- package/editor/assets/models/YUNET_LICENSE.txt +21 -0
- package/editor/assets/sfx/licencas/bass_drop.txt +32 -0
- package/editor/assets/sfx/licencas/cash.txt +76 -0
- package/editor/assets/sfx/licencas/charge.txt +31 -0
- package/editor/assets/sfx/licencas/click.txt +46 -0
- package/editor/assets/sfx/licencas/coin.txt +50 -0
- package/editor/assets/sfx/licencas/count.txt +47 -0
- package/editor/assets/sfx/licencas/data.txt +31 -0
- package/editor/assets/sfx/licencas/error.txt +32 -0
- package/editor/assets/sfx/licencas/flash.txt +31 -0
- package/editor/assets/sfx/licencas/gears.txt +82 -0
- package/editor/assets/sfx/licencas/keyboard.txt +48 -0
- package/editor/assets/sfx/licencas/metal_hit.txt +62 -0
- package/editor/assets/sfx/licencas/metal_riser.txt +61 -0
- package/editor/assets/sfx/licencas/mouse_click.txt +50 -0
- package/editor/assets/sfx/licencas/notification.txt +54 -0
- package/editor/assets/sfx/licencas/pop.txt +38 -0
- package/editor/assets/sfx/licencas/rewind.txt +32 -0
- package/editor/assets/sfx/licencas/rise.txt +46 -0
- package/editor/assets/sfx/licencas/sinteticos.txt +21 -0
- package/editor/assets/sfx/licencas/snap.txt +50 -0
- package/editor/assets/sfx/licencas/success.txt +40 -0
- package/editor/assets/sfx/licencas/synth_hit.txt +32 -0
- package/editor/assets/sfx/licencas/tick.txt +14 -0
- package/editor/assets/sfx/licencas/whoosh.txt +47 -0
- package/editor/assets/sfx/licencas/wind.txt +31 -0
- package/editor/assets/sfx/sfx.json +832 -0
- package/editor/assets/sfx/sfx_sinteticos.json +317 -0
- package/editor/assets/sfx/sfx_woven.json +387 -0
- package/editor/assets/sfx/woven/LICENCA.md +16 -0
- package/editor/fichas/briefing-do-video.md +45 -0
- package/editor/fichas/conferencia-da-amostra.md +56 -0
- package/editor/fichas/matriz-de-ganchos.csv +3 -0
- package/editor/fichas/padrao.json +16 -0
- package/editor/fichas/planilha-de-cenas.csv +4 -0
- package/editor/fichas/planilha-de-versoes.csv +4 -0
- package/editor/fichas/plano-em-blocos.md +32 -0
- package/editor/fichas/registro-de-materiais.json +27 -0
- package/editor/helpers/assets_ocr/ocr_vision.swift +41 -0
- package/editor/helpers/bordas_precisas.py +272 -0
- package/editor/helpers/camera_viva.py +426 -0
- package/editor/helpers/captions.py +601 -0
- package/editor/helpers/common.py +308 -0
- package/editor/helpers/compose_final.py +3555 -0
- package/editor/helpers/conferir_corte.py +234 -0
- package/editor/helpers/conferir_dados.py +405 -0
- package/editor/helpers/contact_sheet.py +282 -0
- package/editor/helpers/corte_por_onda.py +433 -0
- package/editor/helpers/corte_seco.py +595 -0
- package/editor/helpers/denoise.py +971 -0
- package/editor/helpers/efeitos.py +408 -0
- package/editor/helpers/efeitos_gerar.py +159 -0
- package/editor/helpers/elevenlabs_api.py +442 -0
- package/editor/helpers/face_layout.py +570 -0
- package/editor/helpers/fetch_images.py +423 -0
- package/editor/helpers/ganho_por_trecho.py +214 -0
- package/editor/helpers/gen_image.py +312 -0
- package/editor/helpers/gen_music.py +316 -0
- package/editor/helpers/gen_sfx.py +148 -0
- package/editor/helpers/grade.py +307 -0
- package/editor/helpers/icloud.py +119 -0
- package/editor/helpers/make_sfx.py +940 -0
- package/editor/helpers/matte.py +1342 -0
- package/editor/helpers/medir_referencia.py +2232 -0
- package/editor/helpers/mixar_trilha.py +354 -0
- package/editor/helpers/montar_corte.py +295 -0
- package/editor/helpers/motion/__init__.py +13 -0
- package/editor/helpers/motion/engine.py +1915 -0
- package/editor/helpers/motion/registry.py +510 -0
- package/editor/helpers/motion/render.py +665 -0
- package/editor/helpers/motion/templates/barra_progresso.py +370 -0
- package/editor/helpers/motion/templates/carimbo.py +132 -0
- package/editor/helpers/motion/templates/checklist.py +271 -0
- package/editor/helpers/motion/templates/comentario_cta.py +380 -0
- package/editor/helpers/motion/templates/contador.py +222 -0
- package/editor/helpers/motion/templates/destaque.py +263 -0
- package/editor/helpers/motion/templates/etiqueta_preco.py +461 -0
- package/editor/helpers/motion/templates/grafico_barras.py +510 -0
- package/editor/helpers/motion/templates/grafico_linha.py +462 -0
- package/editor/helpers/motion/templates/notificacao.py +266 -0
- package/editor/helpers/motion/templates/palavra_impacto.py +410 -0
- package/editor/helpers/motion/templates/passos.py +321 -0
- package/editor/helpers/motion/templates/riscado.py +298 -0
- package/editor/helpers/motion/templates/selo_garantia.py +150 -0
- package/editor/helpers/motion_render.py +241 -0
- package/editor/helpers/motion_sugerir.py +961 -0
- package/editor/helpers/noise_check.py +134 -0
- package/editor/helpers/pack_transcripts.py +223 -0
- package/editor/helpers/palavras_na_onda.py +176 -0
- package/editor/helpers/plano_sfx.py +839 -0
- package/editor/helpers/portao.py +427 -0
- package/editor/helpers/preview_server.py +1177 -0
- package/editor/helpers/rastrear_rosto.py +298 -0
- package/editor/helpers/render_cut.py +1256 -0
- package/editor/helpers/respiros.py +89 -0
- package/editor/helpers/sincronizar_cameras.py +571 -0
- package/editor/helpers/speech_regions.py +423 -0
- package/editor/helpers/style_catalog.json +231 -0
- package/editor/helpers/style_samples.py +246 -0
- package/editor/helpers/tela_verde.py +2113 -0
- package/editor/helpers/text_render.py +2059 -0
- package/editor/helpers/texto_na_tela.py +366 -0
- package/editor/helpers/timeline_view.py +292 -0
- package/editor/helpers/tira_de_quadros.py +113 -0
- package/editor/helpers/transcribe.py +734 -0
- package/editor/helpers/transcribe_batch.py +129 -0
- package/editor/helpers/trilhas.py +1146 -0
- package/editor/helpers/trilhas_humores.json +66 -0
- package/editor/helpers/valores.py +187 -0
- package/editor/helpers/verify_cut.py +820 -0
- package/editor/helpers/voice_levels.py +233 -0
- package/editor/helpers/voz_check.py +386 -0
- package/editor/helpers/voz_limpa.py +81 -0
- package/editor/helpers/watch_preview.py +372 -0
- package/editor/modelo-projeto/LEIA.md +402 -0
- package/editor/modelo-projeto/assets/fontes/OFL-Caveat.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/OFL-JetBrainsMono.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/OFL-Montserrat.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/larguras.json +1 -0
- package/editor/modelo-projeto/assets/three/BufferGeometryUtils.js +1438 -0
- package/editor/modelo-projeto/assets/three/GLTFLoader.js +4895 -0
- package/editor/modelo-projeto/assets/three/LICENSE +21 -0
- package/editor/modelo-projeto/assets/three/ORIGEM.txt +17 -0
- package/editor/modelo-projeto/assets/three/RoomEnvironment.js +185 -0
- package/editor/modelo-projeto/assets/three/three.core.min.js +6 -0
- package/editor/modelo-projeto/assets/three/three.module.min.js +6 -0
- package/editor/modelo-projeto/build.py +4828 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_antes_depois.html +199 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_arquivo.html +232 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_busca.html +315 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_comando.html +304 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_conversa.html +217 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_esquema.html +289 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_medidor.html +249 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_notas.html +233 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_caminho.html +197 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_explodida.html +152 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_tela.html +167 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_volume.html +124 -0
- package/editor/modelo-projeto/compositions/cenas/3d/motor-three.js +728 -0
- package/editor/modelo-projeto/compositions/cenas/LEIA.md +177 -0
- package/editor/modelo-projeto/ferramentas/medir_fontes.py +65 -0
- package/editor/modelo-projeto/ferramentas/pessoa_recortada.py +176 -0
- package/editor/modelo-projeto/ferramentas/rastrear_maos.py +189 -0
- package/editor/modelo-projeto/hyperframes.json +12 -0
- package/editor/modelo-projeto/package.json +11 -0
- package/editor/modelo-projeto/projeto.json +16 -0
- package/editor/preview/app.css +1009 -0
- package/editor/preview/app.js +2122 -0
- package/editor/preview/index.html +307 -0
- package/editor/pyproject.toml +23 -0
- package/editor/references/3d.md +236 -0
- package/editor/references/animacao-catalogo.md +672 -0
- package/editor/references/animacao.md +498 -0
- package/editor/references/comandos.md +92 -0
- package/editor/references/corte-seco-como-funciona.md +50 -0
- package/editor/references/corte-seco.md +88 -0
- package/editor/references/corte.md +290 -0
- package/editor/references/direcao-por-blocos.md +218 -0
- package/editor/references/enquadramento.md +654 -0
- package/editor/references/estilos-prontos.md +355 -0
- package/editor/references/estudio.md +100 -0
- package/editor/references/exemplo-eventos.json +67 -0
- package/editor/references/lettering.md +252 -0
- package/editor/references/modelos.md +494 -0
- package/editor/references/phase2.md +1007 -0
- package/editor/references/referencia-aplicar.md +203 -0
- package/editor/references/referencia-medir.md +375 -0
- package/editor/references/revisao.md +335 -0
- package/editor/references/som-e-trilha.md +146 -0
- package/editor/references/tabela-de-sons.md +130 -0
- package/editor/references/variacoes.md +234 -0
- package/editor/uv.lock +1141 -0
- package/lib/act.js +36 -8
- package/lib/catalogo.js +4 -0
- package/lib/chamada-torta.js +81 -0
- package/lib/editor-pro.js +786 -0
- package/lib/entrega.js +58 -0
- package/lib/estudio/conferencia-visual.js +76 -0
- package/lib/estudio/diretor.js +6 -1
- package/lib/estudio/exportar.js +3 -1
- package/lib/estudio/gosto.js +45 -4
- package/lib/estudio/index.js +8 -5
- package/lib/estudio/kit-ae/base.jsx +119 -13
- package/lib/estudio/kit-ae/cenas.jsx +47 -15
- package/lib/estudio/kit-ae/guarda.jsx +274 -0
- package/lib/estudio/kit-ae/primitivas.jsx +180 -24
- package/lib/estudio/maquina-diretor.js +61 -7
- package/lib/estudio/maquina-foto.js +177 -0
- package/lib/estudio/maquina-guarda.js +207 -0
- package/lib/estudio/maquina-layout.js +22 -1
- package/lib/estudio/maquina.js +60 -11
- package/lib/estudio/referencias.js +1 -1
- package/lib/estudio/visao/visao.swift +97 -0
- package/lib/estudio/visao.js +60 -0
- package/lib/local/cerebro.json +192 -6
- package/lib/local/motor.js +5 -2
- package/lib/mcp.js +105 -7
- package/lib/nuvem.js +11 -1
- package/lib/outra-pasta.js +1 -1
- package/lib/passagem.js +50 -0
- package/lib/perigo.js +29 -12
- package/lib/protecao.js +134 -0
- package/lib/sandbox.js +11 -4
- package/lib/titulo-conversa.js +46 -0
- package/lib/tools.js +3 -0
- package/lib/ui.js +1 -1
- package/package.json +3 -2
- package/skills/editar-meu-video/SKILL.md +6 -2
- package/skills/editar-video/SKILL.md +113 -0
- package/studio/pessoa.py +52 -0
- package/voz/primo_voz/app.py +43 -4
- package/voz/primo_voz/chat.html +17 -6
- package/voz/primo_voz/painel_dados.py +134 -0
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
# Adapted from browser-use/video-use (MIT, (c) 2026 Browser Use). See THIRD_PARTY_NOTICES.md.
|
|
2
|
+
"""Word-level local transcription of one video (Contract 1).
|
|
3
|
+
|
|
4
|
+
Pipeline:
|
|
5
|
+
1. Decode the audio to 16 kHz mono (a mute or phase-cancelled channel is left out).
|
|
6
|
+
2. Measure silences with the same auto threshold as speech_regions.py.
|
|
7
|
+
3. Long silences (>= 2 s) are shortened to 1 s before Whisper sees the audio, so it
|
|
8
|
+
never gets a window of pure room noise (that is where it invents text). Times are
|
|
9
|
+
mapped back to the original file.
|
|
10
|
+
4. Whisper in chunks of <= 28 s cut at pauses (spec cap: 300 s), each with the prompt:
|
|
11
|
+
mlx-whisper on Apple Silicon, faster-whisper (int8, CPU) elsewhere,
|
|
12
|
+
condition_on_previous_text=False and a short PT-BR prompt with fillers.
|
|
13
|
+
5. Hallucination guards: drop words that lie entirely inside a measured silence >= 1 s,
|
|
14
|
+
repeated n-gram loops, and known subtitle-credit phrases sitting on silence.
|
|
15
|
+
6. Word edges that fall inside a measured silence are moved to the speech edge
|
|
16
|
+
(Whisper tends to start the first word after a pause too early).
|
|
17
|
+
7. Too few words for the measured voice (under POUCA_FALA_WPS words per second of speech) is
|
|
18
|
+
reported: on a clip copied out of a longer video that starts mid-sentence, Whisper returned the
|
|
19
|
+
fillers of the prompt ("E aí, olha só, sabe?") instead of 15 s of speech (29/09/2026).
|
|
20
|
+
|
|
21
|
+
A file that is only in iCloud (macOS placeholder) is downloaded first or refused with a message:
|
|
22
|
+
ffmpeg hangs on it without any error (helpers/icloud.py).
|
|
23
|
+
|
|
24
|
+
Output: <edit_dir>/transcripts/<stem>.json, cached by source signature. Also writes
|
|
25
|
+
<edit_dir>/regions/<stem>.json (auto threshold, 0.25 s) when it is missing or stale.
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
uv run python helpers/transcribe.py <video> [--edit-dir DIR] [--language pt]
|
|
29
|
+
[--model large-v3-turbo] [--force] [--prompt "Kiwify, Hubla"] [--no-prompt]
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import bisect
|
|
36
|
+
import os
|
|
37
|
+
import platform
|
|
38
|
+
import re
|
|
39
|
+
import sys
|
|
40
|
+
import time
|
|
41
|
+
import unicodedata
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
import numpy as np
|
|
45
|
+
|
|
46
|
+
from common import file_signature, load_json, save_json
|
|
47
|
+
from icloud import SoNaNuvem, garantir_local
|
|
48
|
+
from speech_regions import (
|
|
49
|
+
SAMPLE_RATE,
|
|
50
|
+
AudioError,
|
|
51
|
+
complement,
|
|
52
|
+
detect_silences,
|
|
53
|
+
estimate_noise,
|
|
54
|
+
load_mono,
|
|
55
|
+
probe_media,
|
|
56
|
+
regions_path,
|
|
57
|
+
window_levels,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
DEFAULT_MODEL = "large-v3-turbo"
|
|
61
|
+
# Nudges Whisper toward verbatim PT-BR with punctuation. Tested on the sample clips:
|
|
62
|
+
# identical words with and without it, better punctuation, no leak into silence.
|
|
63
|
+
DEFAULT_PROMPT = "Ééé, hum, tipo, né, então... Aí, olha só, sabe?"
|
|
64
|
+
MIN_SILENCE_S = 0.25 # same default as speech_regions.py
|
|
65
|
+
LONG_GAP_S = 2.0 # silences at least this long get shortened before Whisper
|
|
66
|
+
KEEP_GAP_S = 0.5 # silence kept on each side of a shortened gap
|
|
67
|
+
CHUNK_TARGET_S = 28.0 # one Whisper window per chunk (spec cap is 300 s)
|
|
68
|
+
CHUNK_MIN_S = 12.0
|
|
69
|
+
GUARD_SILENCE_S = 1.0 # words entirely inside a silence this long are dropped
|
|
70
|
+
EDGE_EPS = 0.02
|
|
71
|
+
POUCA_FALA_WPS = 1.0 # fewer words than this per second of measured voice: Whisper likely returned the prompt
|
|
72
|
+
POUCA_FALA_MIN_S = 4.0 # ... only judged with at least this much voice
|
|
73
|
+
|
|
74
|
+
MLX_REPOS = {
|
|
75
|
+
"large-v3-turbo": "mlx-community/whisper-large-v3-turbo",
|
|
76
|
+
"turbo": "mlx-community/whisper-large-v3-turbo",
|
|
77
|
+
"large-v3": "mlx-community/whisper-large-v3-mlx",
|
|
78
|
+
"medium": "mlx-community/whisper-medium-mlx",
|
|
79
|
+
"small": "mlx-community/whisper-small-mlx",
|
|
80
|
+
"base": "mlx-community/whisper-base-mlx",
|
|
81
|
+
"tiny": "mlx-community/whisper-tiny-mlx",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# Whisper's classic inventions on silence (subtitle credits, YouTube outros).
|
|
85
|
+
KNOWN_HALLUCINATIONS = (
|
|
86
|
+
"legendas pela comunidade amara org",
|
|
87
|
+
"legendas pela comunidade amaraorg",
|
|
88
|
+
"legenda adriana zanotto",
|
|
89
|
+
"legendas adriana zanotto",
|
|
90
|
+
"transcricao e legendas",
|
|
91
|
+
"obrigado por assistir",
|
|
92
|
+
"obrigada por assistir",
|
|
93
|
+
"inscreva se no canal",
|
|
94
|
+
"se inscreva no canal",
|
|
95
|
+
"ate o proximo video",
|
|
96
|
+
"ate a proxima",
|
|
97
|
+
"tchau tchau",
|
|
98
|
+
"e ai",
|
|
99
|
+
"subtitles by the amara org community",
|
|
100
|
+
"thanks for watching",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
VIDEO_EXTS = (".mov", ".mp4", ".m4v", ".mkv", ".webm")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class EngineError(RuntimeError):
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class NameClashError(RuntimeError):
|
|
111
|
+
"""Two different videos would share transcripts/<stem>.json (video.mov + video.mp4)."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def same_video(a: Path, b: Path) -> bool:
|
|
115
|
+
"""Same file, or a copy of it (a symlink, or a copied folder: same size)."""
|
|
116
|
+
try:
|
|
117
|
+
return os.path.samefile(a, b) or os.stat(a).st_size == os.stat(b).st_size
|
|
118
|
+
except OSError:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def check_name_clash(out_path: Path, video: Path) -> None:
|
|
123
|
+
"""Refuse to overwrite the transcript of ANOTHER video that has the same name without extension."""
|
|
124
|
+
data = load_json(out_path) if out_path.exists() else None
|
|
125
|
+
if not isinstance(data, dict) or not data.get("source"):
|
|
126
|
+
return
|
|
127
|
+
other = Path(data["source"])
|
|
128
|
+
if other == video or not other.exists() or same_video(other, video):
|
|
129
|
+
return
|
|
130
|
+
raise NameClashError(
|
|
131
|
+
f"{video.name} e {other.name} têm o mesmo nome sem a extensão e a transcrição de um apagaria a do outro "
|
|
132
|
+
f"({out_path.name}). Renomeie um deles (ex.: {video.stem}_2{video.suffix}) e rode de novo.")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------- engines
|
|
136
|
+
|
|
137
|
+
def default_engine() -> str:
|
|
138
|
+
return "mlx" if sys.platform == "darwin" and platform.machine() == "arm64" else "faster-whisper"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def engine_label(engine: str, model: str) -> str:
|
|
142
|
+
name = Path(model).name if ("/" in model or os.sep in model) else model
|
|
143
|
+
return f"{'mlx-whisper' if engine == 'mlx' else 'faster-whisper'}/{name}"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _mlx_model_path(model: str) -> str:
|
|
147
|
+
if Path(model).exists():
|
|
148
|
+
return str(model)
|
|
149
|
+
repo = MLX_REPOS.get(model, model)
|
|
150
|
+
try:
|
|
151
|
+
from huggingface_hub import snapshot_download
|
|
152
|
+
|
|
153
|
+
return snapshot_download(repo, local_files_only=True)
|
|
154
|
+
except Exception:
|
|
155
|
+
print(f"baixando o modelo {repo} (só na primeira vez)...", file=sys.stderr, flush=True)
|
|
156
|
+
return repo
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _run_mlx(audio: np.ndarray, model: str, language: str | None, prompt: str | None) -> tuple[list[dict], str | None]:
|
|
160
|
+
try:
|
|
161
|
+
import mlx_whisper
|
|
162
|
+
except ImportError as exc:
|
|
163
|
+
raise EngineError("mlx-whisper não está instalado. Rode: uv sync") from exc
|
|
164
|
+
result = mlx_whisper.transcribe(
|
|
165
|
+
audio,
|
|
166
|
+
path_or_hf_repo=_mlx_model_path(model),
|
|
167
|
+
language=language,
|
|
168
|
+
word_timestamps=True,
|
|
169
|
+
condition_on_previous_text=False,
|
|
170
|
+
initial_prompt=prompt,
|
|
171
|
+
verbose=None,
|
|
172
|
+
)
|
|
173
|
+
segments = []
|
|
174
|
+
for seg in result.get("segments", []):
|
|
175
|
+
words = [{"word": w.get("word", ""), "start": float(w["start"]), "end": float(w["end"]),
|
|
176
|
+
"probability": float(w.get("probability", 0.0))} for w in seg.get("words", [])]
|
|
177
|
+
segments.append({"start": float(seg["start"]), "end": float(seg["end"]),
|
|
178
|
+
"text": seg.get("text", ""), "words": words})
|
|
179
|
+
return segments, result.get("language")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
_FASTER_MODELS: dict[str, object] = {}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _run_faster(audio: np.ndarray, model: str, language: str | None, prompt: str | None) -> tuple[list[dict], str | None]:
|
|
186
|
+
try:
|
|
187
|
+
from faster_whisper import WhisperModel
|
|
188
|
+
except ImportError as exc:
|
|
189
|
+
raise EngineError("faster-whisper não está instalado. Rode: uv sync") from exc
|
|
190
|
+
if model not in _FASTER_MODELS:
|
|
191
|
+
_FASTER_MODELS[model] = WhisperModel(model, device="cpu", compute_type="int8",
|
|
192
|
+
cpu_threads=os.cpu_count() or 4)
|
|
193
|
+
fw = _FASTER_MODELS[model]
|
|
194
|
+
seg_iter, info = fw.transcribe(
|
|
195
|
+
audio,
|
|
196
|
+
language=language,
|
|
197
|
+
word_timestamps=True,
|
|
198
|
+
condition_on_previous_text=False,
|
|
199
|
+
initial_prompt=prompt,
|
|
200
|
+
beam_size=5,
|
|
201
|
+
vad_filter=False,
|
|
202
|
+
)
|
|
203
|
+
segments = []
|
|
204
|
+
for seg in seg_iter: # lazy generator: the work happens while iterating
|
|
205
|
+
words = [{"word": w.word, "start": float(w.start), "end": float(w.end),
|
|
206
|
+
"probability": float(w.probability)} for w in (seg.words or [])]
|
|
207
|
+
segments.append({"start": float(seg.start), "end": float(seg.end), "text": seg.text, "words": words})
|
|
208
|
+
return segments, getattr(info, "language", None)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def run_whisper(audio: np.ndarray, engine: str, model: str, language: str | None,
|
|
212
|
+
prompt: str | None) -> tuple[list[dict], str | None]:
|
|
213
|
+
if engine == "mlx":
|
|
214
|
+
return _run_mlx(audio, model, language, prompt)
|
|
215
|
+
return _run_faster(audio, model, language, prompt)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------- long-gap compaction
|
|
219
|
+
|
|
220
|
+
class TimeMap:
|
|
221
|
+
"""Kept pieces of the original timeline, laid end to end in the compacted audio."""
|
|
222
|
+
|
|
223
|
+
def __init__(self, pieces: list[tuple[float, float]]):
|
|
224
|
+
self.orig = pieces
|
|
225
|
+
self.comp_starts: list[float] = []
|
|
226
|
+
t = 0.0
|
|
227
|
+
for s, e in pieces:
|
|
228
|
+
self.comp_starts.append(t)
|
|
229
|
+
t += e - s
|
|
230
|
+
self.length = t
|
|
231
|
+
|
|
232
|
+
def to_orig(self, t: float, side: str = "start") -> float:
|
|
233
|
+
if not self.orig:
|
|
234
|
+
return t
|
|
235
|
+
if side == "start":
|
|
236
|
+
i = bisect.bisect_right(self.comp_starts, t) - 1
|
|
237
|
+
else:
|
|
238
|
+
i = bisect.bisect_left(self.comp_starts, t) - 1
|
|
239
|
+
i = min(max(i, 0), len(self.orig) - 1)
|
|
240
|
+
s, e = self.orig[i]
|
|
241
|
+
return min(max(s + (t - self.comp_starts[i]), s), e)
|
|
242
|
+
|
|
243
|
+
def span_to_orig(self, cs: float, ce: float) -> tuple[float, float]:
|
|
244
|
+
"""Map a word. A word that straddles a join (Whisper pulled its start back across a
|
|
245
|
+
removed gap) is kept inside the piece that holds most of it, ties going to the later one."""
|
|
246
|
+
if not self.orig:
|
|
247
|
+
return cs, ce
|
|
248
|
+
i = min(max(bisect.bisect_right(self.comp_starts, cs) - 1, 0), len(self.orig) - 1)
|
|
249
|
+
j = min(max(bisect.bisect_left(self.comp_starts, ce) - 1, 0), len(self.orig) - 1)
|
|
250
|
+
if j <= i:
|
|
251
|
+
return self.to_orig(cs, "start"), self.to_orig(ce, "end")
|
|
252
|
+
best, best_len = i, -1.0
|
|
253
|
+
for k in range(i, j + 1):
|
|
254
|
+
k0 = self.comp_starts[k]
|
|
255
|
+
k1 = k0 + (self.orig[k][1] - self.orig[k][0])
|
|
256
|
+
part = min(ce, k1) - max(cs, k0)
|
|
257
|
+
if part >= best_len:
|
|
258
|
+
best, best_len = k, part
|
|
259
|
+
k0 = self.comp_starts[best]
|
|
260
|
+
s, e = self.orig[best]
|
|
261
|
+
a = s + max(cs - k0, 0.0)
|
|
262
|
+
b = s + min(ce - k0, e - s)
|
|
263
|
+
return min(a, e), max(min(b, e), min(a, e))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def plan_pieces(silences: list[list[float]], duration: float) -> list[tuple[float, float]]:
|
|
267
|
+
removed = []
|
|
268
|
+
for s, e in silences:
|
|
269
|
+
if e - s < LONG_GAP_S:
|
|
270
|
+
continue
|
|
271
|
+
rs = 0.0 if s <= EDGE_EPS else s + KEEP_GAP_S
|
|
272
|
+
re_ = duration if e >= duration - EDGE_EPS else e - KEEP_GAP_S
|
|
273
|
+
if re_ - rs > 0.05:
|
|
274
|
+
removed.append([rs, re_])
|
|
275
|
+
return [(s, e) for s, e in complement(removed, 0.0, duration) if e - s > 0.01]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def plan_chunks(audio: np.ndarray, sr: int = SAMPLE_RATE, max_s: float = CHUNK_TARGET_S,
|
|
279
|
+
min_s: float = CHUNK_MIN_S) -> list[tuple[int, int]]:
|
|
280
|
+
"""Sample ranges of <= max_s, each cut at a quiet point (a pause) after at least min_s.
|
|
281
|
+
|
|
282
|
+
Chunks fit one Whisper window, so the prompt applies to all of them: with
|
|
283
|
+
condition_on_previous_text=False Whisper only uses the prompt on the first window
|
|
284
|
+
of each call, and later windows came out without punctuation.
|
|
285
|
+
"""
|
|
286
|
+
total = len(audio)
|
|
287
|
+
max_n = int(max_s * sr)
|
|
288
|
+
if total <= max_n:
|
|
289
|
+
return [(0, total)]
|
|
290
|
+
win = int(0.05 * sr)
|
|
291
|
+
rms_db, _ = window_levels(audio, sr)
|
|
292
|
+
smooth = np.convolve(rms_db, np.ones(5) / 5, mode="same") if len(rms_db) >= 5 else rms_db
|
|
293
|
+
bounds, start = [], 0
|
|
294
|
+
while total - start > max_n:
|
|
295
|
+
lo_w = (start + int(min_s * sr)) // win
|
|
296
|
+
hi_w = min((start + max_n) // win, len(smooth))
|
|
297
|
+
seg = smooth[lo_w:hi_w]
|
|
298
|
+
if len(seg) == 0:
|
|
299
|
+
cut = start + max_n
|
|
300
|
+
else:
|
|
301
|
+
quiet = np.where(seg <= seg.min() + 1.5)[0]
|
|
302
|
+
cut = (lo_w + int(quiet[-1])) * win + win // 2
|
|
303
|
+
cut = min(max(cut, start + win), total)
|
|
304
|
+
bounds.append((start, cut))
|
|
305
|
+
start = cut
|
|
306
|
+
if total > start:
|
|
307
|
+
if bounds and total - start < 3 * sr and total - bounds[-1][0] <= 30 * sr:
|
|
308
|
+
bounds[-1] = (bounds[-1][0], total)
|
|
309
|
+
else:
|
|
310
|
+
bounds.append((start, total))
|
|
311
|
+
return bounds
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ---------------------------------------------------------------- word cleanup
|
|
315
|
+
|
|
316
|
+
def _norm(text: str) -> str:
|
|
317
|
+
t = unicodedata.normalize("NFKD", text.lower())
|
|
318
|
+
t = "".join(c for c in t if not unicodedata.combining(c))
|
|
319
|
+
return re.sub(r"[^\w]+", "", t)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _has_letters(text: str) -> bool:
|
|
323
|
+
return any(c.isalnum() for c in text)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def collect_words(segments: list[dict], offset: float, tmap: TimeMap, seg_base: int) -> list[dict]:
|
|
327
|
+
words: list[dict] = []
|
|
328
|
+
for si, seg in enumerate(segments):
|
|
329
|
+
first_in_seg = True
|
|
330
|
+
for w in seg["words"]:
|
|
331
|
+
raw = w.get("word") or ""
|
|
332
|
+
text = raw.strip()
|
|
333
|
+
if not text:
|
|
334
|
+
continue
|
|
335
|
+
s, e = tmap.span_to_orig(offset + w["start"], offset + max(w["end"], w["start"]))
|
|
336
|
+
same_seg = bool(words) and words[-1]["seg"] == seg_base + si
|
|
337
|
+
# Punctuation alone, or a piece with no leading space ("20" + ".8."), belongs to the previous word.
|
|
338
|
+
if same_seg and (not _has_letters(text) or (not first_in_seg and not raw[:1].isspace())):
|
|
339
|
+
prev = words[-1]
|
|
340
|
+
prev["text"] += text
|
|
341
|
+
prev["end"] = max(prev["end"], e)
|
|
342
|
+
prev["prob"] = min(prev["prob"], w["probability"])
|
|
343
|
+
continue
|
|
344
|
+
first_in_seg = False
|
|
345
|
+
if not _has_letters(text):
|
|
346
|
+
continue
|
|
347
|
+
words.append({"text": text, "start": s, "end": max(e, s), "prob": w["probability"], "seg": seg_base + si})
|
|
348
|
+
return words
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _inside(w: dict, s: float, e: float) -> bool:
|
|
352
|
+
return w["start"] >= s - EDGE_EPS and w["end"] <= e + EDGE_EPS
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def silence_guard(words: list[dict], silences: list[list[float]]) -> tuple[list[dict], int]:
|
|
356
|
+
long_sil = [x for x in silences if x[1] - x[0] >= GUARD_SILENCE_S]
|
|
357
|
+
if not long_sil:
|
|
358
|
+
return words, 0
|
|
359
|
+
starts = [x[0] for x in long_sil]
|
|
360
|
+
kept = []
|
|
361
|
+
for w in words:
|
|
362
|
+
i = bisect.bisect_right(starts, w["start"] + EDGE_EPS) - 1
|
|
363
|
+
if i >= 0 and _inside(w, *long_sil[i]):
|
|
364
|
+
continue
|
|
365
|
+
kept.append(w)
|
|
366
|
+
return kept, len(words) - len(kept)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _speech_fraction(s: float, e: float, silences: list[list[float]]) -> float:
|
|
370
|
+
if e - s <= 0:
|
|
371
|
+
mid = s
|
|
372
|
+
return 0.0 if any(a <= mid <= b for a, b in silences) else 1.0
|
|
373
|
+
sil = sum(max(0.0, min(e, b) - max(s, a)) for a, b in silences)
|
|
374
|
+
return max(0.0, 1.0 - sil / (e - s))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def loop_guard(words: list[dict], silences: list[list[float]]) -> tuple[list[dict], int]:
|
|
378
|
+
"""Drop repeated n-gram loops (Whisper stuck repeating itself).
|
|
379
|
+
|
|
380
|
+
A run needs 5+ copies of one word, 4+ copies of 2-3 words or 3+ copies of longer
|
|
381
|
+
n-grams. Copies after the first are dropped only when they look invented: low
|
|
382
|
+
confidence, squeezed timing, or sitting mostly on silence. A real retake said three
|
|
383
|
+
times keeps all its copies.
|
|
384
|
+
"""
|
|
385
|
+
toks = [_norm(w["text"]) for w in words]
|
|
386
|
+
drop = [False] * len(words)
|
|
387
|
+
# The same word twice on top of itself in time (a segment emitted twice) is never real speech.
|
|
388
|
+
for i in range(1, len(words)):
|
|
389
|
+
for j in range(max(0, i - 8), i):
|
|
390
|
+
if not drop[j] and toks[i] and toks[i] == toks[j] and words[i]["start"] < words[j]["end"] - 0.05:
|
|
391
|
+
drop[i] = True
|
|
392
|
+
break
|
|
393
|
+
words = [w for w, d in zip(words, drop) if not d]
|
|
394
|
+
n_overlap = sum(drop)
|
|
395
|
+
toks = [_norm(w["text"]) for w in words]
|
|
396
|
+
drop = [False] * len(words)
|
|
397
|
+
i, n_words = 0, len(words)
|
|
398
|
+
while i < n_words:
|
|
399
|
+
found = None
|
|
400
|
+
for n in range(1, 9):
|
|
401
|
+
if i + 2 * n > n_words:
|
|
402
|
+
break
|
|
403
|
+
pat = toks[i:i + n]
|
|
404
|
+
if not all(pat):
|
|
405
|
+
continue
|
|
406
|
+
k = 1
|
|
407
|
+
while toks[i + k * n:i + (k + 1) * n] == pat:
|
|
408
|
+
k += 1
|
|
409
|
+
need = 5 if n == 1 else 4 if n <= 3 else 3
|
|
410
|
+
if k >= need:
|
|
411
|
+
found = (n, k)
|
|
412
|
+
break
|
|
413
|
+
if not found:
|
|
414
|
+
i += 1
|
|
415
|
+
continue
|
|
416
|
+
n, k = found
|
|
417
|
+
for c in range(1, k):
|
|
418
|
+
copy = words[i + c * n:i + (c + 1) * n]
|
|
419
|
+
mean_prob = sum(w["prob"] for w in copy) / n
|
|
420
|
+
mean_dur = sum(w["end"] - w["start"] for w in copy) / n
|
|
421
|
+
speech = _speech_fraction(copy[0]["start"], copy[-1]["end"], silences)
|
|
422
|
+
if mean_prob < 0.6 or mean_dur < 0.08 or speech < 0.5:
|
|
423
|
+
for j in range(i + c * n, i + (c + 1) * n):
|
|
424
|
+
drop[j] = True
|
|
425
|
+
i += k * n
|
|
426
|
+
kept = [w for w, d in zip(words, drop) if not d]
|
|
427
|
+
return kept, len(words) - len(kept) + n_overlap
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def known_phrase_guard(words: list[dict], silences: list[list[float]]) -> tuple[list[dict], int]:
|
|
431
|
+
"""Drop whole segments that are a known subtitle-credit phrase sitting mostly on silence.
|
|
432
|
+
|
|
433
|
+
A creator can really say "obrigado por assistir", so the text alone is never enough.
|
|
434
|
+
"""
|
|
435
|
+
by_seg: dict[int, list[int]] = {}
|
|
436
|
+
for idx, w in enumerate(words):
|
|
437
|
+
by_seg.setdefault(w["seg"], []).append(idx)
|
|
438
|
+
drop: set[int] = set()
|
|
439
|
+
for idxs in by_seg.values():
|
|
440
|
+
seg_words = [words[i] for i in idxs]
|
|
441
|
+
text = " ".join(_norm(w["text"]) for w in seg_words).strip()
|
|
442
|
+
if text not in KNOWN_HALLUCINATIONS:
|
|
443
|
+
continue
|
|
444
|
+
if _speech_fraction(seg_words[0]["start"], seg_words[-1]["end"], silences) < 0.5:
|
|
445
|
+
drop.update(idxs)
|
|
446
|
+
kept = [w for i, w in enumerate(words) if i not in drop]
|
|
447
|
+
return kept, len(words) - len(kept)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
_FINAL_PUNCT = re.compile(r"[.!?…]$") # sentence end or ellipsis (not commas)
|
|
451
|
+
EDGE_TAIL_S = 0.25 # a speech island this short next to a sentence end is a neighbour's tail
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def refine_edges(words: list[dict], silences: list[list[float]]) -> int:
|
|
455
|
+
"""Move word edges that fall inside a measured silence onto speech. In place.
|
|
456
|
+
|
|
457
|
+
The part of the word outside the pauses is split into speech islands:
|
|
458
|
+
- one island (the word starts or ends inside a pause): the word becomes that island;
|
|
459
|
+
- several islands (a pause inside the word): the longest island wins, after dropping a
|
|
460
|
+
short first island when the previous word ends a sentence (it is that word's tail) and
|
|
461
|
+
a short last island when this word ends a sentence or has "..." (the next word's head);
|
|
462
|
+
- no island (entirely inside a short pause): snap after the pause, or before it when the
|
|
463
|
+
word ends a sentence and the previous one does not.
|
|
464
|
+
"""
|
|
465
|
+
if not silences or not words:
|
|
466
|
+
return 0
|
|
467
|
+
starts = [s for s, _ in silences]
|
|
468
|
+
changed = 0
|
|
469
|
+
for idx, w in enumerate(words):
|
|
470
|
+
ws, we = w["start"], w["end"]
|
|
471
|
+
j = bisect.bisect_right(starts, we) - 1
|
|
472
|
+
hits = []
|
|
473
|
+
while j >= 0 and silences[j][1] > ws:
|
|
474
|
+
if silences[j][0] < we:
|
|
475
|
+
hits.append(silences[j])
|
|
476
|
+
j -= 1
|
|
477
|
+
if not hits:
|
|
478
|
+
continue
|
|
479
|
+
hits.sort()
|
|
480
|
+
dur = we - ws
|
|
481
|
+
own_final = bool(_FINAL_PUNCT.search(w["text"]))
|
|
482
|
+
prev_final = idx > 0 and bool(_FINAL_PUNCT.search(words[idx - 1]["text"]))
|
|
483
|
+
islands = [i for i in complement([[max(a, ws), min(b, we)] for a, b in hits], ws, we) if i[1] - i[0] > 1e-3]
|
|
484
|
+
if not islands:
|
|
485
|
+
ss, se = hits[0]
|
|
486
|
+
if own_final and not prev_final:
|
|
487
|
+
ns, ne = max(ss - min(dur, 0.3), 0.0), ss
|
|
488
|
+
else:
|
|
489
|
+
ns, ne = se, se + min(dur, 0.3)
|
|
490
|
+
else:
|
|
491
|
+
cands = islands
|
|
492
|
+
if len(cands) > 1 and prev_final and cands[0][1] - cands[0][0] <= EDGE_TAIL_S:
|
|
493
|
+
cands = cands[1:]
|
|
494
|
+
if len(cands) > 1 and own_final and cands[-1][1] - cands[-1][0] <= EDGE_TAIL_S:
|
|
495
|
+
cands = cands[:-1]
|
|
496
|
+
ns, ne = max(cands, key=lambda i: (round(i[1] - i[0], 3), i[0]))
|
|
497
|
+
if ne - ns < 0.02:
|
|
498
|
+
ne = ns + min(max(dur, 0.02), 0.08)
|
|
499
|
+
if abs(ns - ws) > 1e-6 or abs(ne - we) > 1e-6:
|
|
500
|
+
w["start"], w["end"] = ns, ne
|
|
501
|
+
changed += 1
|
|
502
|
+
words.sort(key=lambda x: x["start"])
|
|
503
|
+
for a, b in zip(words, words[1:]):
|
|
504
|
+
if b["start"] < a["end"]:
|
|
505
|
+
a["end"] = max(a["start"], b["start"])
|
|
506
|
+
return changed
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def build_segments(words: list[dict]) -> list[dict]:
|
|
510
|
+
groups: dict[int, list[dict]] = {}
|
|
511
|
+
for w in words:
|
|
512
|
+
groups.setdefault(w["seg"], []).append(w)
|
|
513
|
+
segs = []
|
|
514
|
+
for ws in groups.values():
|
|
515
|
+
text = " ".join(w["text"] for w in ws)
|
|
516
|
+
segs.append({"start": round(ws[0]["start"], 3), "end": round(max(w["end"] for w in ws), 3), "text": text})
|
|
517
|
+
segs.sort(key=lambda s: s["start"])
|
|
518
|
+
return segs
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def aviso_pouca_fala(words: list[dict], speech_s: float, prompt: str | None) -> str | None:
|
|
522
|
+
"""Warning when the transcript has far fewer words than the measured voice asks for.
|
|
523
|
+
|
|
524
|
+
Normal speech is 2 to 3 words per second. Under POUCA_FALA_WPS, Whisper most likely returned the prompt
|
|
525
|
+
(its fillers) or skipped a stretch: seen on a clip that started in the middle of a sentence.
|
|
526
|
+
"""
|
|
527
|
+
if speech_s < POUCA_FALA_MIN_S:
|
|
528
|
+
return None
|
|
529
|
+
n = len(words)
|
|
530
|
+
wps = n / speech_s
|
|
531
|
+
if wps >= POUCA_FALA_WPS:
|
|
532
|
+
return None
|
|
533
|
+
vocab = {_norm(x) for x in re.findall(r"\w+", f"{DEFAULT_PROMPT} {prompt or ''}")} - {""}
|
|
534
|
+
eco = [w["text"] for w in words if _norm(w["text"]) in vocab]
|
|
535
|
+
msg = (f"só {n} palavra(s) para {speech_s:.0f} s de voz medida ({wps:.1f} por segundo; fala normal dá 2 a 3). ")
|
|
536
|
+
if n and len(eco) * 2 >= n:
|
|
537
|
+
msg += f"Quase tudo é o texto do prompt (\"{' '.join(eco[:8])}\"): o Whisper devolveu o prompt, não a fala. "
|
|
538
|
+
msg += ("Se o arquivo é um trecho copiado de um vídeo maior, copie de novo começando numa pausa (antes da "
|
|
539
|
+
"primeira palavra de uma frase) e transcreva de novo; senão rode com --no-prompt --force. Se o "
|
|
540
|
+
"trecho só tem música ou ruído, pode ignorar.")
|
|
541
|
+
return msg
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# ---------------------------------------------------------------- main entry
|
|
545
|
+
|
|
546
|
+
def transcript_path(edit_dir: Path | str, video: Path | str) -> Path:
|
|
547
|
+
return Path(edit_dir) / "transcripts" / f"{Path(video).stem}.json"
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def build_prompt(extra: str | None, use_default: bool) -> str | None:
|
|
551
|
+
parts = []
|
|
552
|
+
if use_default:
|
|
553
|
+
parts.append(DEFAULT_PROMPT)
|
|
554
|
+
if extra and extra.strip():
|
|
555
|
+
parts.append(extra.strip())
|
|
556
|
+
return " ".join(parts) if parts else None
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def is_cached(out_path: Path, video: Path, engine: str, model: str, language: str | None, prompt: str | None) -> bool:
|
|
560
|
+
data = load_json(out_path)
|
|
561
|
+
if not isinstance(data, dict):
|
|
562
|
+
return False
|
|
563
|
+
if data.get("source_sig") != file_signature(video):
|
|
564
|
+
return False
|
|
565
|
+
if data.get("engine") != engine_label(engine, model):
|
|
566
|
+
return False
|
|
567
|
+
if language and data.get("language") != language:
|
|
568
|
+
return False
|
|
569
|
+
return data.get("prompt", DEFAULT_PROMPT) == prompt
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def transcribe_one(video: Path | str, edit_dir: Path | str | None = None, language: str | None = "pt",
|
|
573
|
+
model: str = DEFAULT_MODEL, force: bool = False, prompt: str | None = DEFAULT_PROMPT,
|
|
574
|
+
engine: str | None = None, verbose: bool = True) -> Path:
|
|
575
|
+
"""Transcribe one video. Returns the transcript path (cached when the source did not change)."""
|
|
576
|
+
video = Path(video).absolute()
|
|
577
|
+
if not video.exists():
|
|
578
|
+
raise FileNotFoundError(f"vídeo não encontrado: {video}")
|
|
579
|
+
edit_dir = Path(edit_dir).absolute() if edit_dir else video.parent / "edit"
|
|
580
|
+
engine = engine or default_engine()
|
|
581
|
+
language = None if (language or "").lower() in ("", "auto") else language
|
|
582
|
+
out_path = transcript_path(edit_dir, video)
|
|
583
|
+
check_name_clash(out_path, video)
|
|
584
|
+
|
|
585
|
+
if not force and is_cached(out_path, video, engine, model, language, prompt):
|
|
586
|
+
if verbose:
|
|
587
|
+
data = load_json(out_path)
|
|
588
|
+
print(f"{video.stem}: em cache ({len(data.get('words', []))} palavras) · {out_path}")
|
|
589
|
+
if data.get("aviso"):
|
|
590
|
+
print(f"{video.stem}: AVISO: {data['aviso']}", flush=True)
|
|
591
|
+
return out_path
|
|
592
|
+
|
|
593
|
+
garantir_local([video], avisar=(print if verbose else (lambda *_: None)))
|
|
594
|
+
t0 = time.time()
|
|
595
|
+
info = probe_media(video)
|
|
596
|
+
duration = float(info.get("duration") or 0.0)
|
|
597
|
+
mono, audio_desc = load_mono(video)
|
|
598
|
+
if duration <= 0:
|
|
599
|
+
duration = len(mono) / SAMPLE_RATE
|
|
600
|
+
if verbose and audio_desc.get("reason"):
|
|
601
|
+
side = {"left": "esquerdo", "right": "direito"}.get(audio_desc["used"], audio_desc["used"])
|
|
602
|
+
print(f"{video.stem}: usando só o canal {side} ({audio_desc['reason']})", flush=True)
|
|
603
|
+
|
|
604
|
+
est = estimate_noise(mono)
|
|
605
|
+
audio_len = len(mono) / SAMPLE_RATE
|
|
606
|
+
silences = [[min(a, duration), min(b, duration)] for a, b in detect_silences(mono, est["threshold_db"], MIN_SILENCE_S)]
|
|
607
|
+
silences = [x for x in silences if x[1] > x[0]]
|
|
608
|
+
if silences and silences[-1][1] >= min(audio_len, duration) - EDGE_EPS:
|
|
609
|
+
silences[-1][1] = duration
|
|
610
|
+
t_audio = time.time() - t0
|
|
611
|
+
|
|
612
|
+
pieces = plan_pieces(silences, audio_len)
|
|
613
|
+
tmap = TimeMap(pieces)
|
|
614
|
+
comp = np.concatenate([mono[int(s * SAMPLE_RATE):int(e * SAMPLE_RATE)] for s, e in pieces]) if pieces else np.zeros(0, np.float32)
|
|
615
|
+
comp = np.ascontiguousarray(comp, dtype=np.float32)
|
|
616
|
+
|
|
617
|
+
t1 = time.time()
|
|
618
|
+
raw_words: list[dict] = []
|
|
619
|
+
detected_lang = language
|
|
620
|
+
seg_base = 0
|
|
621
|
+
speech_total = sum(e - s for s, e in complement(silences, 0.0, audio_len))
|
|
622
|
+
if len(comp) >= int(0.2 * SAMPLE_RATE) and speech_total >= 0.1:
|
|
623
|
+
for a, b in plan_chunks(comp):
|
|
624
|
+
chunk = comp[a:b]
|
|
625
|
+
if len(chunk) < int(0.2 * SAMPLE_RATE):
|
|
626
|
+
continue
|
|
627
|
+
segments, lang = run_whisper(chunk, engine, model, language, prompt)
|
|
628
|
+
detected_lang = detected_lang or lang
|
|
629
|
+
raw_words.extend(collect_words(segments, a / SAMPLE_RATE, tmap, seg_base))
|
|
630
|
+
seg_base += len(segments)
|
|
631
|
+
t_whisper = time.time() - t1
|
|
632
|
+
|
|
633
|
+
words = sorted(raw_words, key=lambda w: w["start"])
|
|
634
|
+
words, n_sil = silence_guard(words, silences)
|
|
635
|
+
words, n_loop = loop_guard(words, silences)
|
|
636
|
+
words, n_known = known_phrase_guard(words, silences)
|
|
637
|
+
n_refined = refine_edges(words, silences)
|
|
638
|
+
|
|
639
|
+
for w in words:
|
|
640
|
+
w["start"] = round(min(max(w["start"], 0.0), duration), 3)
|
|
641
|
+
w["end"] = round(min(max(w["end"], w["start"]), duration), 3)
|
|
642
|
+
segments = build_segments(words)
|
|
643
|
+
out_words = [{"text": w["text"], "start": w["start"], "end": w["end"], "prob": round(w["prob"], 3)} for w in words]
|
|
644
|
+
aviso = aviso_pouca_fala(out_words, speech_total, prompt)
|
|
645
|
+
|
|
646
|
+
elapsed = time.time() - t0
|
|
647
|
+
doc = {
|
|
648
|
+
"source": str(video),
|
|
649
|
+
"source_sig": file_signature(video),
|
|
650
|
+
"stem": video.stem,
|
|
651
|
+
"duration": round(duration, 3),
|
|
652
|
+
"language": detected_lang or language or "",
|
|
653
|
+
"engine": engine_label(engine, model),
|
|
654
|
+
"words": out_words,
|
|
655
|
+
"segments": segments,
|
|
656
|
+
"prompt": prompt,
|
|
657
|
+
"audio": {**audio_desc, "noise_db": est["threshold_db"], "shortened_gaps": len(pieces) - 1 if pieces else 0},
|
|
658
|
+
"guard": {"silence": n_sil, "loop": n_loop, "known_phrase": n_known, "refined_edges": n_refined},
|
|
659
|
+
"timing": {"audio_s": round(t_audio, 2), "whisper_s": round(t_whisper, 2), "total_s": round(elapsed, 2)},
|
|
660
|
+
"voz_s": round(speech_total, 2),
|
|
661
|
+
}
|
|
662
|
+
if aviso:
|
|
663
|
+
doc["aviso"] = aviso
|
|
664
|
+
save_json(out_path, doc)
|
|
665
|
+
_write_regions_if_stale(edit_dir, video, duration, est, silences, audio_desc)
|
|
666
|
+
|
|
667
|
+
if verbose:
|
|
668
|
+
per_min = elapsed / (duration / 60.0) if duration > 0 else 0.0
|
|
669
|
+
removed = n_sil + n_loop + n_known
|
|
670
|
+
extra = f" · {removed} palavras inventadas removidas" if removed else ""
|
|
671
|
+
print(f"{video.stem}: {len(out_words)} palavras, {duration:.1f}s de áudio, {elapsed:.1f}s "
|
|
672
|
+
f"({per_min:.1f}s por minuto){extra} · {out_path}", flush=True)
|
|
673
|
+
if aviso:
|
|
674
|
+
print(f"{video.stem}: AVISO: {aviso}", flush=True)
|
|
675
|
+
return out_path
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _write_regions_if_stale(edit_dir: Path, video: Path, duration: float, est: dict,
|
|
679
|
+
silences: list[list[float]], audio_desc: dict) -> None:
|
|
680
|
+
path = regions_path(edit_dir, video.stem)
|
|
681
|
+
cached = load_json(path)
|
|
682
|
+
if cached and cached.get("source_sig") == file_signature(video):
|
|
683
|
+
return
|
|
684
|
+
r = lambda x: round(float(x), 3) # noqa: E731
|
|
685
|
+
save_json(path, {
|
|
686
|
+
"source": str(video),
|
|
687
|
+
"source_sig": file_signature(video),
|
|
688
|
+
"stem": video.stem,
|
|
689
|
+
"duration": r(duration),
|
|
690
|
+
"noise_db": est["threshold_db"],
|
|
691
|
+
"noise_mode": "auto",
|
|
692
|
+
"min_silence": MIN_SILENCE_S,
|
|
693
|
+
"speech": [[r(s), r(e)] for s, e in complement(silences, 0.0, duration)],
|
|
694
|
+
"silences": [[r(s), r(e)] for s, e in silences],
|
|
695
|
+
"audio": audio_desc,
|
|
696
|
+
"noise_estimate": est,
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def main() -> None:
|
|
701
|
+
ap = argparse.ArgumentParser(description="Transcreve um vídeo palavra por palavra, localmente (Whisper).")
|
|
702
|
+
ap.add_argument("video", type=Path, help="arquivo de vídeo")
|
|
703
|
+
ap.add_argument("--edit-dir", type=Path, default=None, help="pasta edit (padrão: <pasta do vídeo>/edit)")
|
|
704
|
+
ap.add_argument("--language", default="pt", help="idioma falado (padrão pt; 'auto' detecta)")
|
|
705
|
+
ap.add_argument("--model", default=DEFAULT_MODEL, help="modelo Whisper (padrão large-v3-turbo)")
|
|
706
|
+
ap.add_argument("--force", action="store_true", help="transcreve de novo mesmo com cache")
|
|
707
|
+
ap.add_argument("--prompt", default=None,
|
|
708
|
+
help="palavras que aparecem no vídeo, para o Whisper acertar a grafia (ex.: \"Kiwify, Hubla\")")
|
|
709
|
+
ap.add_argument("--no-prompt", action="store_true", help="não usa o prompt padrão com vícios de fala")
|
|
710
|
+
ap.add_argument("--engine", choices=("auto", "mlx", "faster-whisper"), default="auto",
|
|
711
|
+
help="motor (padrão: mlx no Mac Apple Silicon, faster-whisper nos outros)")
|
|
712
|
+
args = ap.parse_args()
|
|
713
|
+
|
|
714
|
+
video = args.video.absolute()
|
|
715
|
+
if not video.exists():
|
|
716
|
+
sys.exit(f"vídeo não encontrado: {video}")
|
|
717
|
+
if video.is_dir():
|
|
718
|
+
sys.exit(f"isso é uma pasta, não um vídeo: {video}\nPara a pasta inteira use transcribe_batch.py")
|
|
719
|
+
try:
|
|
720
|
+
transcribe_one(
|
|
721
|
+
video,
|
|
722
|
+
edit_dir=args.edit_dir,
|
|
723
|
+
language=args.language,
|
|
724
|
+
model=args.model,
|
|
725
|
+
force=args.force,
|
|
726
|
+
prompt=build_prompt(args.prompt, not args.no_prompt),
|
|
727
|
+
engine=None if args.engine == "auto" else args.engine,
|
|
728
|
+
)
|
|
729
|
+
except (AudioError, EngineError, FileNotFoundError, NameClashError, SoNaNuvem) as exc:
|
|
730
|
+
sys.exit(str(exc))
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
if __name__ == "__main__":
|
|
734
|
+
main()
|