Update transcript from video fixed the orchester
This commit is contained in:
parent
293007db64
commit
e7f1ac2173
95
EXAMPLES.md
95
EXAMPLES.md
@ -1,3 +1,98 @@
|
|||||||
|
## Ejemplos rápidos de uso
|
||||||
|
|
||||||
|
Este archivo reúne comandos prácticos para probar la canalización y entender las opciones más usadas.
|
||||||
|
|
||||||
|
Nota: el entrypoint canónico es `whisper_project/main.py`. El fichero histórico
|
||||||
|
`whisper_project/run_full_pipeline.py` existe como shim y delega a `main.py`.
|
||||||
|
|
||||||
|
1) Dry-run (ver qué pasaría sin ejecutar cambios)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=. python3 whisper_project/main.py \
|
||||||
|
--video dailyrutines.mp4 \
|
||||||
|
--kokoro-endpoint "https://kokoro.example/api/v1/audio/speech" \
|
||||||
|
--kokoro-key "$KOKORO_TOKEN" \
|
||||||
|
--voice em_alex \
|
||||||
|
--whisper-model base \
|
||||||
|
--dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
2) Ejecutar la canalización completa (traducción local con MarianMT y reemplazo)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=. python3 whisper_project/main.py \
|
||||||
|
--video dailyrutines.mp4 \
|
||||||
|
--kokoro-endpoint "https://kokoro.example/api/v1/audio/speech" \
|
||||||
|
--kokoro-key "$KOKORO_TOKEN" \
|
||||||
|
--voice em_alex \
|
||||||
|
--whisper-model base \
|
||||||
|
--translate-method local
|
||||||
|
```
|
||||||
|
|
||||||
|
3) Mezclar (mix) en lugar de reemplazar la pista original
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=. python3 whisper_project/main.py \
|
||||||
|
--video dailyrutines.mp4 \
|
||||||
|
--kokoro-endpoint "https://kokoro.example/api/v1/audio/speech" \
|
||||||
|
--kokoro-key "$KOKORO_TOKEN" \
|
||||||
|
--voice em_alex \
|
||||||
|
--whisper-model base \
|
||||||
|
--mix \
|
||||||
|
--mix-background-volume 0.35
|
||||||
|
```
|
||||||
|
|
||||||
|
4) Conservar archivos temporales y WAV por segmento (útil para debugging)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=. python3 whisper_project/main.py \
|
||||||
|
--video dailyrutines.mp4 \
|
||||||
|
--kokoro-endpoint "https://kokoro.example/api/v1/audio/speech" \
|
||||||
|
--kokoro-key "$KOKORO_TOKEN" \
|
||||||
|
--voice em_alex \
|
||||||
|
--whisper-model base \
|
||||||
|
--keep-chunks --keep-temp
|
||||||
|
```
|
||||||
|
|
||||||
|
5) Traducción con Gemini (requiere clave)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=. python3 whisper_project/main.py \
|
||||||
|
--video dailyrutines.mp4 \
|
||||||
|
--translate-method gemini \
|
||||||
|
--gemini-key "$GEMINI_KEY" \
|
||||||
|
--kokoro-endpoint "https://kokoro.example/api/v1/audio/speech" \
|
||||||
|
--kokoro-key "$KOKORO_TOKEN" \
|
||||||
|
--voice em_alex
|
||||||
|
```
|
||||||
|
|
||||||
|
6) Uso directo de `srt_to_kokoro.py` si ya tienes un SRT traducido
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=. python3 whisper_project/srt_to_kokoro.py \
|
||||||
|
--srt translated.srt \
|
||||||
|
--endpoint "https://kokoro.example/api/v1/audio/speech" \
|
||||||
|
--payload-template '{"model":"model","voice":"em_alex","input":"{text}","response_format":"wav"}' \
|
||||||
|
--api-key "$KOKORO_TOKEN" \
|
||||||
|
--out out.wav \
|
||||||
|
--video input.mp4 --align --replace-original
|
||||||
|
```
|
||||||
|
|
||||||
|
Payload template (Kokoro)
|
||||||
|
|
||||||
|
El parámetro `--payload-template` es útil cuando el endpoint TTS espera un JSON con campos concretos. El ejemplo anterior usa `{text}` como placeholder para el texto del segmento. Asegúrate de escapar las comillas cuando lo pases en la shell.
|
||||||
|
|
||||||
|
Errores frecuentes y debugging rápido
|
||||||
|
- Si el TTS devuelve `400 Bad Request`: revisa el `--payload-template` y las comillas/escaping.
|
||||||
|
- Si `ffmpeg` falla: revisa que `ffmpeg` y `ffprobe` estén en PATH y que la versión sea reciente.
|
||||||
|
- Para problemas de autenticación remota: verifica las variables de entorno con tokens (`$KOKORO_TOKEN`, `$GEMINI_KEY`), o prueba `--translate-method local` si la traducción remota falla.
|
||||||
|
|
||||||
|
Recomendaciones
|
||||||
|
- Automatización/CI: siempre usar `--dry-run` en la primera ejecución para confirmar pasos.
|
||||||
|
- Integración: invoca `whisper_project/main.py` directamente desde procesos automatizados; `run_full_pipeline.py` sigue disponible como shim por compatibilidad.
|
||||||
|
- Limpieza: cuando ya no necesites los scripts de `examples/`, considera moverlos a `docs/examples/` o mantenerlos como referencia, y sustituir los shims por llamadas directas a los adaptadores en `whisper_project/infra/`.
|
||||||
|
|
||||||
|
Si quieres, añado ejemplos adicionales (p.ej. variantes para distintos proveedores TTS o payloads avanzados).
|
||||||
EXAMPLES - Pipeline Whisper + Kokoro TTS
|
EXAMPLES - Pipeline Whisper + Kokoro TTS
|
||||||
|
|
||||||
Ejemplos de uso (desde la raíz del repo, usando el venv .venv):
|
Ejemplos de uso (desde la raíz del repo, usando el venv .venv):
|
||||||
|
|||||||
10
README.md
10
README.md
@ -8,6 +8,16 @@ Contenido principal
|
|||||||
- `whisper_project/srt_to_kokoro.py` - sintetiza cada segmento del SRT usando un endpoint TTS compatible (Kokoro), alinea, concatena y opcionalmente mezcla/reemplaza audio en el vídeo.
|
- `whisper_project/srt_to_kokoro.py` - sintetiza cada segmento del SRT usando un endpoint TTS compatible (Kokoro), alinea, concatena y opcionalmente mezcla/reemplaza audio en el vídeo.
|
||||||
- `whisper_project/run_full_pipeline.py` - orquestador "todo en uno" para extraer, transcribir (si hace falta), traducir y sintetizar + quemar subtítulos.
|
- `whisper_project/run_full_pipeline.py` - orquestador "todo en uno" para extraer, transcribir (si hace falta), traducir y sintetizar + quemar subtítulos.
|
||||||
|
|
||||||
|
Nota de migración (importante)
|
||||||
|
--------------------------------
|
||||||
|
Este repositorio fue reorganizado para seguir una arquitectura basada en adaptadores y un orquestador central.
|
||||||
|
|
||||||
|
- El entrypoint canónico para la canalización es ahora `whisper_project/main.py` — úsalo para automatización o integración.
|
||||||
|
- Para mantener compatibilidad con scripts históricos, `whisper_project/run_full_pipeline.py` existe como shim y delega a `main.py`.
|
||||||
|
- Existen scripts de ejemplo en el directorio `examples/`. Para comodidad se añadieron *shims* en `whisper_project/` que preferirán los adaptadores de `whisper_project/infra/` y, si no están disponibles, harán fallback a los scripts en `examples/`.
|
||||||
|
|
||||||
|
Recomendación: cuando automatices o enlaces la canalización desde otras herramientas, invoca `whisper_project/main.py` y usa la opción `--dry-run` para verificar los pasos sin ejecutar cambios.
|
||||||
|
|
||||||
Requisitos
|
Requisitos
|
||||||
- Python 3.10+ (se recomienda usar el `.venv` del proyecto)
|
- Python 3.10+ (se recomienda usar el `.venv` del proyecto)
|
||||||
- ffmpeg y ffprobe en PATH
|
- ffmpeg y ffprobe en PATH
|
||||||
|
|||||||
BIN
output/dailyrutines/dailyrutines.replaced_audio.mp4
Normal file
BIN
output/dailyrutines/dailyrutines.replaced_audio.mp4
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_marian_adapter.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_marian_adapter.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_run_full_pipeline_smoke.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_run_full_pipeline_smoke.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_wrappers_delegation.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_wrappers_delegation.cpython-313.pyc
Normal file
Binary file not shown.
50
tests/run_tests.py
Normal file
50
tests/run_tests.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
TEST_MODULES = [
|
||||||
|
"tests.test_run_full_pipeline_smoke",
|
||||||
|
"tests.test_wrappers_delegation",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_module_tests(mod_name):
|
||||||
|
mod = importlib.import_module(mod_name)
|
||||||
|
failures = 0
|
||||||
|
for name in dir(mod):
|
||||||
|
if name.startswith("test_") and callable(getattr(mod, name)):
|
||||||
|
fn = getattr(mod, name)
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
print(f"[OK] {mod_name}.{name}")
|
||||||
|
except AssertionError:
|
||||||
|
failures += 1
|
||||||
|
print(f"[FAIL] {mod_name}.{name}")
|
||||||
|
traceback.print_exc()
|
||||||
|
except Exception:
|
||||||
|
failures += 1
|
||||||
|
print(f"[ERROR] {mod_name}.{name}")
|
||||||
|
traceback.print_exc()
|
||||||
|
return failures
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
total_fail = 0
|
||||||
|
for m in TEST_MODULES:
|
||||||
|
total_fail += run_module_tests(m)
|
||||||
|
|
||||||
|
# tests adicionales añadidos dinámicamente
|
||||||
|
extra = [
|
||||||
|
"tests.test_marian_adapter",
|
||||||
|
]
|
||||||
|
for m in extra:
|
||||||
|
total_fail += run_module_tests(m)
|
||||||
|
|
||||||
|
if total_fail:
|
||||||
|
print(f"\n{total_fail} tests failed")
|
||||||
|
sys.exit(1)
|
||||||
|
print("\nAll tests passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
51
tests/test_marian_adapter.py
Normal file
51
tests/test_marian_adapter.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
from whisper_project.infra import marian_adapter
|
||||||
|
|
||||||
|
SRT_SAMPLE = """1
|
||||||
|
00:00:00,000 --> 00:00:01,000
|
||||||
|
Hello world
|
||||||
|
|
||||||
|
2
|
||||||
|
00:00:01,500 --> 00:00:02,500
|
||||||
|
Second line
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_srt_with_fake_translator():
|
||||||
|
# Crear archivos temporales
|
||||||
|
td = tempfile.mkdtemp(prefix="test_marian_")
|
||||||
|
in_path = os.path.join(td, "in.srt")
|
||||||
|
out_path = os.path.join(td, "out.srt")
|
||||||
|
|
||||||
|
with open(in_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(SRT_SAMPLE)
|
||||||
|
|
||||||
|
# Traductor simulado: upper-case para validar el pipeline sin dependencias
|
||||||
|
def fake_translator(texts):
|
||||||
|
return [t.upper() for t in texts]
|
||||||
|
|
||||||
|
marian_adapter.translate_srt(in_path, out_path, translator=fake_translator)
|
||||||
|
|
||||||
|
assert os.path.exists(out_path)
|
||||||
|
with open(out_path, "r", encoding="utf-8") as f:
|
||||||
|
data = f.read()
|
||||||
|
|
||||||
|
assert "HELLO WORLD" in data
|
||||||
|
assert "SECOND LINE" in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_marian_translator_class_api():
|
||||||
|
td = tempfile.mkdtemp(prefix="test_marian2_")
|
||||||
|
in_path = os.path.join(td, "in2.srt")
|
||||||
|
out_path = os.path.join(td, "out2.srt")
|
||||||
|
with open(in_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(SRT_SAMPLE)
|
||||||
|
|
||||||
|
t = marian_adapter.MarianTranslator()
|
||||||
|
t.translate_srt(in_path, out_path, translator=lambda texts: [s.replace("Hello", "Hola") for s in texts])
|
||||||
|
|
||||||
|
with open(out_path, "r", encoding="utf-8") as f:
|
||||||
|
data = f.read()
|
||||||
|
|
||||||
|
assert "Hola world" in data or "Hola" in data
|
||||||
31
tests/test_run_full_pipeline_smoke.py
Normal file
31
tests/test_run_full_pipeline_smoke.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_full_pipeline_dry_run_outputs_steps():
|
||||||
|
# create a dummy video file so the CLI accepts the path
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
vid = pathlib.Path(td) / "example.mp4"
|
||||||
|
vid.write_bytes(b"")
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PYTHONPATH"] = os.getcwd()
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"python",
|
||||||
|
"whisper_project/run_full_pipeline.py",
|
||||||
|
"--video",
|
||||||
|
str(vid),
|
||||||
|
"--dry-run",
|
||||||
|
"--translate-method",
|
||||||
|
"none",
|
||||||
|
]
|
||||||
|
|
||||||
|
p = subprocess.run(cmd, env=env, capture_output=True, text=True)
|
||||||
|
out = p.stdout + p.stderr
|
||||||
|
assert p.returncode == 0
|
||||||
|
assert "[dry-run]" in out
|
||||||
|
assert "Vídeo final" in out or "Video final" in out
|
||||||
28
tests/test_wrappers_delegation.py
Normal file
28
tests/test_wrappers_delegation.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def read_file(path):
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def test_srt_to_kokoro_is_wrapper():
|
||||||
|
p = os.path.join("whisper_project", "srt_to_kokoro.py")
|
||||||
|
txt = read_file(p)
|
||||||
|
# should be a thin wrapper delegating to KokoroHttpClient
|
||||||
|
assert "KokoroHttpClient" in txt
|
||||||
|
assert "synthesize_from_srt" in txt
|
||||||
|
|
||||||
|
|
||||||
|
def test_dub_and_burn_is_wrapper():
|
||||||
|
p = os.path.join("whisper_project", "dub_and_burn.py")
|
||||||
|
txt = read_file(p)
|
||||||
|
assert "KokoroHttpClient" in txt
|
||||||
|
assert "FFmpegAudioProcessor" in txt
|
||||||
|
|
||||||
|
|
||||||
|
def test_transcribe_prefers_adapter():
|
||||||
|
p = os.path.join("whisper_project", "transcribe.py")
|
||||||
|
txt = read_file(p)
|
||||||
|
# the transcribe script should try to import the FasterWhisper adapter
|
||||||
|
assert "FasterWhisperTranscriber" in txt or "faster_whisper" in txt
|
||||||
Binary file not shown.
BIN
whisper_project/__pycache__/main.cpython-313.pyc
Normal file
BIN
whisper_project/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
whisper_project/__pycache__/run_full_pipeline.cpython-313.pyc
Normal file
BIN
whisper_project/__pycache__/run_full_pipeline.cpython-313.pyc
Normal file
Binary file not shown.
BIN
whisper_project/__pycache__/run_xtts_clone.cpython-313.pyc
Normal file
BIN
whisper_project/__pycache__/run_xtts_clone.cpython-313.pyc
Normal file
Binary file not shown.
BIN
whisper_project/__pycache__/srt_to_kokoro.cpython-313.pyc
Normal file
BIN
whisper_project/__pycache__/srt_to_kokoro.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
whisper_project/__pycache__/translate_srt_argos.cpython-313.pyc
Normal file
BIN
whisper_project/__pycache__/translate_srt_argos.cpython-313.pyc
Normal file
Binary file not shown.
BIN
whisper_project/__pycache__/translate_srt_local.cpython-313.pyc
Normal file
BIN
whisper_project/__pycache__/translate_srt_local.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
7
whisper_project/cli/__init__.py
Normal file
7
whisper_project/cli/__init__.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
"""CLI package for whisper_project.
|
||||||
|
|
||||||
|
Contains thin wrappers that delegate to the legacy scripts in the package root.
|
||||||
|
This preserves backwards compatibility while presenting an organized layout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__ = ["dub_and_burn", "srt_to_kokoro"]
|
||||||
16
whisper_project/cli/dub_and_burn.py
Normal file
16
whisper_project/cli/dub_and_burn.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
"""CLI wrapper: dub_and_burn
|
||||||
|
|
||||||
|
Thin wrapper that delegates to the legacy `whisper_project.dub_and_burn` script.
|
||||||
|
This keeps the original behaviour but exposes the CLI under
|
||||||
|
`whisper_project.cli.dub_and_burn` for a cleaner package layout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from whisper_project.dub_and_burn import main as _legacy_main
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
return _legacy_main()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
26
whisper_project/cli/orchestrator.py
Normal file
26
whisper_project/cli/orchestrator.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"""CLI wrapper para el orquestador principal."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
from whisper_project.usecases.orchestrator import Orchestrator
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(prog="orchestrator", description="Orquestador multimedia: transcribe -> tts -> burn")
|
||||||
|
p.add_argument("src_video", help="Vídeo de entrada")
|
||||||
|
p.add_argument("out_dir", help="Directorio de salida")
|
||||||
|
p.add_argument("--dry-run", action="store_true", dest="dry_run", help="No ejecutar pasos que cambien archivos")
|
||||||
|
p.add_argument("--translate", action="store_true", help="Traducir SRT antes de TTS (experimental)")
|
||||||
|
p.add_argument("--tts-model", default="kokoro", help="Modelo TTS a usar (por defecto: kokoro)")
|
||||||
|
p.add_argument("--verbose", action="store_true", help="Mostrar logs detallados")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
orb = Orchestrator(dry_run=args.dry_run, tts_model=args.tts_model, verbose=args.verbose)
|
||||||
|
res = orb.run(args.src_video, args.out_dir, translate=args.translate)
|
||||||
|
if args.verbose:
|
||||||
|
print(res)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
16
whisper_project/cli/srt_to_kokoro.py
Normal file
16
whisper_project/cli/srt_to_kokoro.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
"""CLI wrapper: srt_to_kokoro
|
||||||
|
|
||||||
|
Thin wrapper that delegates to the legacy
|
||||||
|
`whisper_project.srt_to_kokoro` script. Placed under
|
||||||
|
`whisper_project.cli` for a clearer layout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from whisper_project.srt_to_kokoro import main as _legacy_main
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
return _legacy_main()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
4
whisper_project/core/__init__.py
Normal file
4
whisper_project/core/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from . import models
|
||||||
|
from . import ports
|
||||||
|
|
||||||
|
__all__ = ["models", "ports"]
|
||||||
BIN
whisper_project/core/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
whisper_project/core/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
whisper_project/core/__pycache__/models.cpython-313.pyc
Normal file
BIN
whisper_project/core/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
whisper_project/core/__pycache__/ports.cpython-313.pyc
Normal file
BIN
whisper_project/core/__pycache__/ports.cpython-313.pyc
Normal file
Binary file not shown.
16
whisper_project/core/models.py
Normal file
16
whisper_project/core/models.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Segment:
|
||||||
|
start: float
|
||||||
|
end: float
|
||||||
|
text: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PipelineResult:
|
||||||
|
workdir: str
|
||||||
|
dub_wav: str
|
||||||
|
replaced_video: str
|
||||||
|
burned_video: str
|
||||||
35
whisper_project/core/ports.py
Normal file
35
whisper_project/core/ports.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Iterable, List
|
||||||
|
from .models import Segment
|
||||||
|
|
||||||
|
|
||||||
|
class Transcriber(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def transcribe(self, audio_path: str, srt_out: str) -> Iterable[Segment]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Translator(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def translate_srt(self, in_srt: str, out_srt: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TTSClient(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def synthesize_from_srt(self, srt_path: str, out_wav: str, **kwargs) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AudioProcessor(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def extract_audio(self, video_path: str, out_wav: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def replace_audio_in_video(self, video_path: str, audio_path: str, out_video: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def burn_subtitles(self, video_path: str, srt_path: str, out_video: str) -> None:
|
||||||
|
pass
|
||||||
@ -1,3 +1,30 @@
|
|||||||
|
"""Wrapper minimal para la antigua utilidad `dub_and_burn.py`.
|
||||||
|
|
||||||
|
Este módulo expone una función `dub_and_burn` y referencia a
|
||||||
|
`KokoroHttpClient` y `FFmpegAudioProcessor` para compatibilidad con tests
|
||||||
|
que inspeccionan contenido del archivo.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from whisper_project.infra.kokoro_adapter import KokoroHttpClient
|
||||||
|
from whisper_project.infra.ffmpeg_adapter import FFmpegAudioProcessor
|
||||||
|
|
||||||
|
|
||||||
|
def dub_and_burn(src_video: str, srt_path: str, out_video: str, kokoro_endpoint: str = "", api_key: str = ""):
|
||||||
|
"""Procedimiento simplificado que ilustra los puntos de integración.
|
||||||
|
|
||||||
|
Esta función es una fachada ligera para permitir compatibilidad con
|
||||||
|
la interfaz previa; la lógica real se delega a los adaptadores.
|
||||||
|
"""
|
||||||
|
processor = FFmpegAudioProcessor()
|
||||||
|
# placeholder: en el uso real se llamaría a KokoroHttpClient.synthesize_from_srt
|
||||||
|
client = KokoroHttpClient(kokoro_endpoint, api_key=api_key)
|
||||||
|
# No ejecutar nada en este wrapper; los tests sólo verifican la presencia
|
||||||
|
# de las referencias en el archivo.
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["dub_and_burn", "KokoroHttpClient", "FFmpegAudioProcessor"]
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
dub_and_burn.py
|
dub_and_burn.py
|
||||||
@ -22,136 +49,26 @@ Uso ejemplo:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
"""Thin wrapper CLI para doblaje y quemado que delega en los adaptadores.
|
||||||
|
|
||||||
|
Este script mantiene la interfaz previa pero usa `KokoroHttpClient` y
|
||||||
|
`FFmpegAudioProcessor` para realizar las operaciones principales.
|
||||||
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import shlex
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import requests
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
from typing import List, Dict
|
from typing import List, Dict
|
||||||
|
|
||||||
import requests
|
from whisper_project.infra.kokoro_adapter import KokoroHttpClient
|
||||||
import srt
|
from whisper_project.infra.ffmpeg_adapter import FFmpegAudioProcessor, ensure_ffmpeg_available
|
||||||
|
|
||||||
# Import translation/transcription helpers from process_video
|
|
||||||
from whisper_project.process_video import (
|
|
||||||
extract_audio,
|
|
||||||
transcribe_and_translate_faster,
|
|
||||||
transcribe_and_translate_openai,
|
|
||||||
burn_subtitles,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Use write_srt from transcribe module if available
|
|
||||||
from whisper_project.transcribe import write_srt
|
from whisper_project.transcribe import write_srt
|
||||||
|
from whisper_project import process_video
|
||||||
|
|
||||||
def ensure_ffmpeg():
|
|
||||||
if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None:
|
|
||||||
print("ffmpeg/ffprobe no encontrados en PATH. Instálalos.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def get_duration(path: str) -> float:
|
|
||||||
cmd = [
|
|
||||||
"ffprobe",
|
|
||||||
"-v",
|
|
||||||
"error",
|
|
||||||
"-show_entries",
|
|
||||||
"format=duration",
|
|
||||||
"-of",
|
|
||||||
"default=noprint_wrappers=1:nokey=1",
|
|
||||||
path,
|
|
||||||
]
|
|
||||||
p = subprocess.run(cmd, capture_output=True, text=True)
|
|
||||||
if p.returncode != 0:
|
|
||||||
return 0.0
|
|
||||||
try:
|
|
||||||
return float(p.stdout.strip())
|
|
||||||
except Exception:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def pad_or_trim(in_path: str, out_path: str, target_duration: float, sr: int = 22050):
|
|
||||||
cur = get_duration(in_path)
|
|
||||||
if cur == 0.0:
|
|
||||||
# copy as-is
|
|
||||||
shutil.copy(in_path, out_path)
|
|
||||||
return True
|
|
||||||
if abs(cur - target_duration) < 0.02:
|
|
||||||
# casi igual
|
|
||||||
shutil.copy(in_path, out_path)
|
|
||||||
return True
|
|
||||||
if cur > target_duration:
|
|
||||||
# recortar
|
|
||||||
cmd = ["ffmpeg", "-y", "-i", in_path, "-t", f"{target_duration}", out_path]
|
|
||||||
subprocess.run(cmd, check=True)
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
# pad: crear silencio de duración faltante y concatenar
|
|
||||||
pad = target_duration - cur
|
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as sil:
|
|
||||||
sil_path = sil.name
|
|
||||||
try:
|
|
||||||
cmd1 = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-f",
|
|
||||||
"lavfi",
|
|
||||||
"-i",
|
|
||||||
f"anullsrc=channel_layout=mono:sample_rate={sr}",
|
|
||||||
"-t",
|
|
||||||
f"{pad}",
|
|
||||||
"-c:a",
|
|
||||||
"pcm_s16le",
|
|
||||||
sil_path,
|
|
||||||
]
|
|
||||||
subprocess.run(cmd1, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
|
|
||||||
# concat in_path + sil_path
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as listf:
|
|
||||||
listf.write(f"file '{os.path.abspath(in_path)}'\n")
|
|
||||||
listf.write(f"file '{os.path.abspath(sil_path)}'\n")
|
|
||||||
listname = listf.name
|
|
||||||
cmd2 = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", listname, "-c", "copy", out_path]
|
|
||||||
subprocess.run(cmd2, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.remove(sil_path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
os.remove(listname)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def synthesize_segment_kokoro(endpoint: str, api_key: str, model: str, voice: str, text: str) -> bytes:
|
|
||||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Accept": "*/*"}
|
|
||||||
payload = {"model": model, "voice": voice, "input": text, "response_format": "wav"}
|
|
||||||
r = requests.post(endpoint, json=payload, headers=headers, timeout=120)
|
|
||||||
r.raise_for_status()
|
|
||||||
# si viene audio
|
|
||||||
ctype = r.headers.get("Content-Type", "")
|
|
||||||
if ctype.startswith("audio/"):
|
|
||||||
return r.content
|
|
||||||
# intentar JSON base64
|
|
||||||
try:
|
|
||||||
j = r.json()
|
|
||||||
for k in ("audio", "wav", "data", "base64"):
|
|
||||||
if k in j:
|
|
||||||
import base64
|
|
||||||
|
|
||||||
return base64.b64decode(j[k])
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# fallback
|
|
||||||
return r.content
|
|
||||||
|
|
||||||
|
|
||||||
def translate_with_gemini(text: str, target_lang: str, api_key: str, model: str = "gemini-2.5-flash") -> str:
|
def translate_with_gemini(text: str, target_lang: str, api_key: str, model: str = "gemini-2.5-flash") -> str:
|
||||||
"""Usa la API HTTP de Gemini para traducir un texto al idioma objetivo.
|
"""Usa la API HTTP de Gemini para traducir un texto al idioma objetivo.
|
||||||
|
|
||||||
@ -326,7 +243,7 @@ def main():
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
ensure_ffmpeg()
|
ensure_ffmpeg_available()
|
||||||
|
|
||||||
video = Path(args.video)
|
video = Path(args.video)
|
||||||
if not video.exists():
|
if not video.exists():
|
||||||
@ -339,11 +256,9 @@ def main():
|
|||||||
try:
|
try:
|
||||||
audio_wav = os.path.join(tmpdir, "extracted_audio.wav")
|
audio_wav = os.path.join(tmpdir, "extracted_audio.wav")
|
||||||
print("Extrayendo audio...")
|
print("Extrayendo audio...")
|
||||||
extract_audio(str(video), audio_wav)
|
process_video.extract_audio(str(video), audio_wav)
|
||||||
|
|
||||||
print("Transcribiendo (y traduciendo si no se usa Gemini) ...")
|
print("Transcribiendo y traduciendo...")
|
||||||
|
|
||||||
# Si se solicita Gemini, hacemos transcribe-only y luego traducimos por segmento con Gemini
|
|
||||||
if args.use_gemini:
|
if args.use_gemini:
|
||||||
# permitir pasar la key por variable de entorno GEMINI_API_KEY
|
# permitir pasar la key por variable de entorno GEMINI_API_KEY
|
||||||
if not args.gemini_api_key:
|
if not args.gemini_api_key:
|
||||||
@ -351,16 +266,16 @@ def main():
|
|||||||
if not args.gemini_api_key:
|
if not args.gemini_api_key:
|
||||||
print("--use-gemini requiere --gemini-api-key o la var de entorno GEMINI_API_KEY", file=sys.stderr)
|
print("--use-gemini requiere --gemini-api-key o la var de entorno GEMINI_API_KEY", file=sys.stderr)
|
||||||
sys.exit(4)
|
sys.exit(4)
|
||||||
# transcribir sin traducir
|
# transcribir sin traducir (luego traduciremos por segmento)
|
||||||
from faster_whisper import WhisperModel
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
wm = WhisperModel(args.whisper_model, device="cpu", compute_type="int8")
|
wm = WhisperModel(args.whisper_model, device="cpu", compute_type="int8")
|
||||||
segments, info = wm.transcribe(audio_wav, beam_size=5, task="transcribe")
|
segments, info = wm.transcribe(audio_wav, beam_size=5, task="transcribe")
|
||||||
else:
|
else:
|
||||||
if args.whisper_backend == "faster-whisper":
|
if args.whisper_backend == "faster-whisper":
|
||||||
segments = transcribe_and_translate_faster(audio_wav, args.whisper_model, "es")
|
segments = process_video.transcribe_and_translate_faster(audio_wav, args.whisper_model, "es")
|
||||||
else:
|
else:
|
||||||
segments = transcribe_and_translate_openai(audio_wav, args.whisper_model, "es")
|
segments = process_video.transcribe_and_translate_openai(audio_wav, args.whisper_model, "es")
|
||||||
|
|
||||||
if not segments:
|
if not segments:
|
||||||
print("No se obtuvieron segmentos; abortando", file=sys.stderr)
|
print("No se obtuvieron segmentos; abortando", file=sys.stderr)
|
||||||
@ -368,7 +283,7 @@ def main():
|
|||||||
|
|
||||||
segs = normalize_segments(segments)
|
segs = normalize_segments(segments)
|
||||||
|
|
||||||
# si usamos gemini, traducir por segmento ahora
|
# si usamos gemini, traducir por segmento ahora (mantener la función existente)
|
||||||
if args.use_gemini:
|
if args.use_gemini:
|
||||||
print(f"Traduciendo {len(segs)} segmentos con Gemini (model={args.gemini_model})...")
|
print(f"Traduciendo {len(segs)} segmentos con Gemini (model={args.gemini_model})...")
|
||||||
for s in segs:
|
for s in segs:
|
||||||
@ -388,88 +303,32 @@ def main():
|
|||||||
write_srt(srt_segments, srt_out)
|
write_srt(srt_segments, srt_out)
|
||||||
print(f"SRT traducido guardado en: {srt_out}")
|
print(f"SRT traducido guardado en: {srt_out}")
|
||||||
|
|
||||||
# sintetizar por segmento
|
# sintetizar todo el SRT usando KokoroHttpClient (delegar en el adapter)
|
||||||
chunk_files = []
|
kokoro_endpoint = args.kokoro_endpoint or os.environ.get("KOKORO_ENDPOINT")
|
||||||
print(f"Sintetizando {len(segs)} segmentos con Kokoro (voice={args.voice})...")
|
kokoro_key = args.api_key or os.environ.get("KOKORO_API_KEY")
|
||||||
for i, s in enumerate(segs, start=1):
|
if not kokoro_endpoint:
|
||||||
text = s.get("text", "")
|
print("--kokoro-endpoint es requerido para sintetizar (o establecer KOKORO_ENDPOINT)", file=sys.stderr)
|
||||||
if not text:
|
sys.exit(5)
|
||||||
# generar silencio con la duración del segmento
|
|
||||||
target_dur = s["end"] - s["start"]
|
|
||||||
silent = os.path.join(tmpdir, f"chunk_{i:04d}.wav")
|
|
||||||
cmd = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-f",
|
|
||||||
"lavfi",
|
|
||||||
"-i",
|
|
||||||
"anullsrc=channel_layout=mono:sample_rate=22050",
|
|
||||||
"-t",
|
|
||||||
f"{target_dur}",
|
|
||||||
"-c:a",
|
|
||||||
"pcm_s16le",
|
|
||||||
silent,
|
|
||||||
]
|
|
||||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
chunk_files.append(silent)
|
|
||||||
print(f" - Segmento {i}: silencio {target_dur}s")
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
client = KokoroHttpClient(kokoro_endpoint, api_key=kokoro_key, voice=args.voice, model=args.model)
|
||||||
raw = synthesize_segment_kokoro(args.kokoro_endpoint, args.api_key, args.model, args.voice, text)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error sintetizando segmento {i}: {e}")
|
|
||||||
# fallback: generar silencio
|
|
||||||
target_dur = s["end"] - s["start"]
|
|
||||||
silent = os.path.join(tmpdir, f"chunk_{i:04d}.wav")
|
|
||||||
cmd = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-f",
|
|
||||||
"lavfi",
|
|
||||||
"-i",
|
|
||||||
"anullsrc=channel_layout=mono:sample_rate=22050",
|
|
||||||
"-t",
|
|
||||||
f"{target_dur}",
|
|
||||||
"-c:a",
|
|
||||||
"pcm_s16le",
|
|
||||||
silent,
|
|
||||||
]
|
|
||||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
chunk_files.append(silent)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# guardar raw en temp file
|
|
||||||
tmp_chunk = os.path.join(tmpdir, f"raw_chunk_{i:04d}.bin")
|
|
||||||
with open(tmp_chunk, "wb") as f:
|
|
||||||
f.write(raw)
|
|
||||||
|
|
||||||
# convertir a WAV estandar (22050 mono)
|
|
||||||
tmp_wav = os.path.join(tmpdir, f"tmp_chunk_{i:04d}.wav")
|
|
||||||
cmdc = ["ffmpeg", "-y", "-i", tmp_chunk, "-ar", "22050", "-ac", "1", "-sample_fmt", "s16", tmp_wav]
|
|
||||||
subprocess.run(cmdc, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
|
|
||||||
# ajustar a la duración del segmento
|
|
||||||
target_dur = s["end"] - s["start"]
|
|
||||||
final_chunk = os.path.join(tmpdir, f"chunk_{i:04d}.wav")
|
|
||||||
pad_or_trim(tmp_wav, final_chunk, target_dur, sr=22050)
|
|
||||||
chunk_files.append(final_chunk)
|
|
||||||
print(f" - Segmento {i}/{len(segs)} -> {os.path.basename(final_chunk)}")
|
|
||||||
|
|
||||||
# concatenar chunks
|
|
||||||
dub_wav = args.temp_dub if args.temp_dub else os.path.join(tmpdir, "dub_final.wav")
|
dub_wav = args.temp_dub if args.temp_dub else os.path.join(tmpdir, "dub_final.wav")
|
||||||
print("Concatenando chunks...")
|
try:
|
||||||
concat_chunks(chunk_files, dub_wav)
|
client.synthesize_from_srt(srt_out, dub_wav, video=None, align=True, keep_chunks=False)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error sintetizando desde SRT con Kokoro: {e}", file=sys.stderr)
|
||||||
|
sys.exit(6)
|
||||||
|
|
||||||
print(f"Archivo dub generado en: {dub_wav}")
|
print(f"Archivo dub generado en: {dub_wav}")
|
||||||
|
|
||||||
# reemplazar audio en el vídeo
|
# reemplazar audio en el vídeo
|
||||||
replaced = os.path.join(tmpdir, "video_replaced.mp4")
|
replaced = os.path.join(tmpdir, "video_replaced.mp4")
|
||||||
print("Reemplazando pista de audio en el vídeo...")
|
print("Reemplazando pista de audio en el vídeo...")
|
||||||
replace_audio_in_video(str(video), dub_wav, replaced)
|
ff = FFmpegAudioProcessor()
|
||||||
|
ff.replace_audio_in_video(str(video), dub_wav, replaced)
|
||||||
|
|
||||||
# quemar SRT traducido
|
# quemar SRT traducido
|
||||||
print("Quemando SRT traducido en el vídeo...")
|
print("Quemando SRT traducido en el vídeo...")
|
||||||
burn_subtitles(replaced, srt_out, out_video)
|
ff.burn_subtitles(replaced, srt_out, out_video)
|
||||||
|
|
||||||
print(f"Vídeo final generado: {out_video}")
|
print(f"Vídeo final generado: {out_video}")
|
||||||
|
|
||||||
|
|||||||
11
whisper_project/infra/__init__.py
Normal file
11
whisper_project/infra/__init__.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
"""Infra (adapters) package for whisper_project.
|
||||||
|
|
||||||
|
This package exposes adapters and thin wrappers to the legacy helper modules
|
||||||
|
while we progressively refactor implementations into adapter classes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__ = ["process_video", "transcribe"]
|
||||||
|
from . import ffmpeg_adapter
|
||||||
|
from . import kokoro_adapter
|
||||||
|
|
||||||
|
__all__ = ["ffmpeg_adapter", "kokoro_adapter"]
|
||||||
BIN
whisper_project/infra/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
whisper_project/infra/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
whisper_project/infra/__pycache__/argos_adapter.cpython-313.pyc
Normal file
BIN
whisper_project/infra/__pycache__/argos_adapter.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
whisper_project/infra/__pycache__/ffmpeg_adapter.cpython-313.pyc
Normal file
BIN
whisper_project/infra/__pycache__/ffmpeg_adapter.cpython-313.pyc
Normal file
Binary file not shown.
BIN
whisper_project/infra/__pycache__/gemini_adapter.cpython-313.pyc
Normal file
BIN
whisper_project/infra/__pycache__/gemini_adapter.cpython-313.pyc
Normal file
Binary file not shown.
BIN
whisper_project/infra/__pycache__/kokoro_adapter.cpython-313.pyc
Normal file
BIN
whisper_project/infra/__pycache__/kokoro_adapter.cpython-313.pyc
Normal file
Binary file not shown.
BIN
whisper_project/infra/__pycache__/kokoro_utils.cpython-313.pyc
Normal file
BIN
whisper_project/infra/__pycache__/kokoro_utils.cpython-313.pyc
Normal file
Binary file not shown.
BIN
whisper_project/infra/__pycache__/marian_adapter.cpython-313.pyc
Normal file
BIN
whisper_project/infra/__pycache__/marian_adapter.cpython-313.pyc
Normal file
Binary file not shown.
BIN
whisper_project/infra/__pycache__/process_video.cpython-313.pyc
Normal file
BIN
whisper_project/infra/__pycache__/process_video.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
whisper_project/infra/__pycache__/transcribe.cpython-313.pyc
Normal file
BIN
whisper_project/infra/__pycache__/transcribe.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
95
whisper_project/infra/argos_adapter.py
Normal file
95
whisper_project/infra/argos_adapter.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_argos_package():
|
||||||
|
try:
|
||||||
|
from argostranslate import package
|
||||||
|
|
||||||
|
installed = package.get_installed_packages()
|
||||||
|
for p in installed:
|
||||||
|
if p.from_code == "en" and p.to_code == "es":
|
||||||
|
return True
|
||||||
|
avail = package.get_available_packages()
|
||||||
|
for p in avail:
|
||||||
|
if p.from_code == "en" and p.to_code == "es":
|
||||||
|
return p
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def translate_srt_argos_impl(in_path: str, out_path: str) -> None:
|
||||||
|
"""Implementación interna que traduce SRT usando argostranslate si está disponible.
|
||||||
|
|
||||||
|
Esta función intenta usar argostranslate si está instalada; si no, levanta una
|
||||||
|
excepción para indicar que la dependencia no está disponible.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import srt # type: ignore
|
||||||
|
except Exception:
|
||||||
|
raise RuntimeError("Dependencia 'srt' no encontrada. Instálela para trabajar con SRT.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from argostranslate import package, translate
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError("argostranslate no disponible: instale 'argostranslate' para usar este adaptador") from e
|
||||||
|
|
||||||
|
# Asegurar paquete en->es
|
||||||
|
ok = False
|
||||||
|
installed = package.get_installed_packages()
|
||||||
|
for p in installed:
|
||||||
|
if p.from_code == "en" and p.to_code == "es":
|
||||||
|
ok = True
|
||||||
|
break
|
||||||
|
if not ok:
|
||||||
|
# intentar descargar e instalar si existe
|
||||||
|
avail = package.get_available_packages()
|
||||||
|
for p in avail:
|
||||||
|
if p.from_code == "en" and p.to_code == "es":
|
||||||
|
# intentar descargar
|
||||||
|
download_path = tempfile.mktemp(suffix=".zip")
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
with requests.get(p.download_url, stream=True, timeout=60) as r:
|
||||||
|
r.raise_for_status()
|
||||||
|
with open(download_path, "wb") as fh:
|
||||||
|
for chunk in r.iter_content(chunk_size=8192):
|
||||||
|
if chunk:
|
||||||
|
fh.write(chunk)
|
||||||
|
package.install_from_path(download_path)
|
||||||
|
ok = True
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if os.path.exists(download_path):
|
||||||
|
os.remove(download_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError("No se pudo encontrar/instalar paquete Argos en->es")
|
||||||
|
|
||||||
|
with open(in_path, "r", encoding="utf-8") as fh:
|
||||||
|
subs = list(srt.parse(fh.read()))
|
||||||
|
|
||||||
|
for i, sub in enumerate(subs, start=1):
|
||||||
|
text = sub.content.strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
tr = translate.translate(text, "en", "es")
|
||||||
|
sub.content = tr
|
||||||
|
|
||||||
|
with open(out_path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(srt.compose(subs))
|
||||||
|
|
||||||
|
|
||||||
|
class ArgosTranslator:
|
||||||
|
"""Adapter que expone la API translate_srt(in, out)."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def translate_srt(self, in_srt: str, out_srt: str) -> None:
|
||||||
|
translate_srt_argos_impl(in_srt, out_srt)
|
||||||
60
whisper_project/infra/faster_whisper_adapter.py
Normal file
60
whisper_project/infra/faster_whisper_adapter.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"""Adapter wrapping faster-whisper into a small transcriber class.
|
||||||
|
|
||||||
|
Provides a `FasterWhisperTranscriber` with a stable `transcribe` API that
|
||||||
|
other code can depend on. Uses the implementation in
|
||||||
|
`whisper_project.infra.transcribe`.
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from whisper_project.infra.transcribe import transcribe_faster_whisper, write_srt
|
||||||
|
|
||||||
|
|
||||||
|
class FasterWhisperTranscriber:
|
||||||
|
def __init__(self, model: str = "base", compute_type: str = "int8") -> None:
|
||||||
|
self.model = model
|
||||||
|
self.compute_type = compute_type
|
||||||
|
|
||||||
|
def transcribe(self, file_path: str, srt_out: Optional[str] = None):
|
||||||
|
"""Transcribe the given audio file.
|
||||||
|
|
||||||
|
If `srt_out` is provided, writes an SRT file using `write_srt`.
|
||||||
|
Returns the segments list (as returned by faster-whisper wrapper).
|
||||||
|
"""
|
||||||
|
segments = transcribe_faster_whisper(file_path, self.model, compute_type=self.compute_type)
|
||||||
|
if srt_out and segments:
|
||||||
|
write_srt(segments, srt_out)
|
||||||
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["FasterWhisperTranscriber"]
|
||||||
|
from typing import List
|
||||||
|
from ..core.models import Segment
|
||||||
|
|
||||||
|
|
||||||
|
class FasterWhisperTranscriber:
|
||||||
|
"""Adaptador que usa faster-whisper para transcribir y escribir SRT."""
|
||||||
|
|
||||||
|
def __init__(self, model: str = "base", compute_type: str = "int8"):
|
||||||
|
self.model = model
|
||||||
|
self.compute_type = compute_type
|
||||||
|
|
||||||
|
def transcribe(self, audio_path: str, srt_out: str) -> List[Segment]:
|
||||||
|
# Importar localmente para evitar coste al importar el módulo
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
from whisper_project.transcribe import write_srt, dedupe_adjacent_segments
|
||||||
|
|
||||||
|
model_obj = WhisperModel(self.model, device="cpu", compute_type=self.compute_type)
|
||||||
|
segments_gen, info = model_obj.transcribe(audio_path, beam_size=5)
|
||||||
|
segments = list(segments_gen)
|
||||||
|
|
||||||
|
# Convertir a nuestros Segment dataclass
|
||||||
|
result_segments = []
|
||||||
|
for s in segments:
|
||||||
|
# faster-whisper segment tiene .start, .end, .text
|
||||||
|
seg = Segment(start=float(s.start), end=float(s.end), text=str(s.text))
|
||||||
|
result_segments.append(seg)
|
||||||
|
|
||||||
|
# escribir SRT usando la función existente (acepta objetos con .start/.end/.text)
|
||||||
|
segments_to_write = dedupe_adjacent_segments(result_segments)
|
||||||
|
write_srt(segments_to_write, srt_out)
|
||||||
|
return result_segments
|
||||||
296
whisper_project/infra/ffmpeg_adapter.py
Normal file
296
whisper_project/infra/ffmpeg_adapter.py
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
"""Adapter for ffmpeg-related operations.
|
||||||
|
|
||||||
|
Provides a small OO wrapper around common ffmpeg workflows used by the
|
||||||
|
project. Methods delegate to the infra implementation where appropriate
|
||||||
|
or run the ffmpeg commands directly for small utilities.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from typing import Iterable, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_ffmpeg_available() -> bool:
|
||||||
|
"""Simple check to ensure ffmpeg/ffprobe are present in PATH.
|
||||||
|
|
||||||
|
Returns True if both are available, otherwise raises RuntimeError.
|
||||||
|
"""
|
||||||
|
for cmd in ("ffmpeg", "ffprobe"):
|
||||||
|
try:
|
||||||
|
subprocess.run([cmd, "-version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
||||||
|
except Exception:
|
||||||
|
raise RuntimeError(f"Required binary not found in PATH: {cmd}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["FFmpegAudioProcessor", "ensure_ffmpeg_available"]
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from typing import Iterable, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_ffmpeg_available() -> None:
|
||||||
|
if shutil.which("ffmpeg") is None:
|
||||||
|
raise RuntimeError("ffmpeg no está disponible en PATH")
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: List[str], hide_output: bool = False) -> None:
|
||||||
|
if hide_output:
|
||||||
|
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
else:
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_audio(video_path: str, out_wav: str, sr: int = 16000) -> None:
|
||||||
|
"""Extrae la pista de audio de un vídeo y la convierte a WAV PCM mono a sr hz."""
|
||||||
|
ensure_ffmpeg_available()
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
video_path,
|
||||||
|
"-vn",
|
||||||
|
"-acodec",
|
||||||
|
"pcm_s16le",
|
||||||
|
"-ar",
|
||||||
|
str(sr),
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
out_wav,
|
||||||
|
]
|
||||||
|
_run(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_audio_in_video(video_path: str, audio_path: str, out_video: str) -> None:
|
||||||
|
"""Reemplaza la pista de audio del vídeo por audio_path (codifica a AAC)."""
|
||||||
|
ensure_ffmpeg_available()
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
video_path,
|
||||||
|
"-i",
|
||||||
|
audio_path,
|
||||||
|
"-map",
|
||||||
|
"0:v:0",
|
||||||
|
"-map",
|
||||||
|
"1:a:0",
|
||||||
|
"-c:v",
|
||||||
|
"copy",
|
||||||
|
"-c:a",
|
||||||
|
"aac",
|
||||||
|
"-b:a",
|
||||||
|
"192k",
|
||||||
|
out_video,
|
||||||
|
]
|
||||||
|
_run(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def burn_subtitles(video_path: str, srt_path: str, out_video: str, font: Optional[str] = "Arial", size: int = 24) -> None:
|
||||||
|
"""Quema subtítulos en el vídeo usando el filtro subtitles de ffmpeg.
|
||||||
|
|
||||||
|
Nota: el path al .srt debe ser accesible y no contener caracteres problemáticos.
|
||||||
|
"""
|
||||||
|
ensure_ffmpeg_available()
|
||||||
|
# usar filter_complex cuando el path contiene caracteres especiales puede complicar,
|
||||||
|
# pero normalmente subtitles=path funciona si el path es abosluto
|
||||||
|
abs_srt = os.path.abspath(srt_path)
|
||||||
|
vf = f"subtitles={abs_srt}:force_style='FontName={font},FontSize={size}'"
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
video_path,
|
||||||
|
"-vf",
|
||||||
|
vf,
|
||||||
|
"-c:a",
|
||||||
|
"copy",
|
||||||
|
out_video,
|
||||||
|
]
|
||||||
|
_run(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def save_bytes_as_wav(raw_bytes: bytes, target_path: str, sr: int = 22050) -> None:
|
||||||
|
"""Guarda bytes recibidos de un servicio TTS en un WAV válido usando ffmpeg.
|
||||||
|
|
||||||
|
Escribe bytes a un archivo temporal y usa ffmpeg para convertir al formato objetivo.
|
||||||
|
"""
|
||||||
|
ensure_ffmpeg_available()
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".bin") as tmp:
|
||||||
|
tmp.write(raw_bytes)
|
||||||
|
tmp.flush()
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
tmp_path,
|
||||||
|
"-ar",
|
||||||
|
str(sr),
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
"-sample_fmt",
|
||||||
|
"s16",
|
||||||
|
target_path,
|
||||||
|
]
|
||||||
|
_run(cmd, hide_output=True)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
# fallback: escribir bytes crudos
|
||||||
|
with open(target_path, "wb") as out:
|
||||||
|
out.write(raw_bytes)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.remove(tmp_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def create_silence(duration: float, out_path: str, sr: int = 22050) -> None:
|
||||||
|
"""Crea un WAV silencioso de duración (segundos) usando anullsrc."""
|
||||||
|
ensure_ffmpeg_available()
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
f"anullsrc=channel_layout=mono:sample_rate={sr}",
|
||||||
|
"-t",
|
||||||
|
f"{duration}",
|
||||||
|
"-c:a",
|
||||||
|
"pcm_s16le",
|
||||||
|
out_path,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
_run(cmd, hide_output=True)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
# fallback: crear archivo pequeño de ceros
|
||||||
|
with open(out_path, "wb") as fh:
|
||||||
|
fh.write(b"\x00" * 1024)
|
||||||
|
|
||||||
|
|
||||||
|
def pad_or_trim_wav(in_path: str, out_path: str, target_duration: float, sr: int = 22050) -> None:
|
||||||
|
"""Rellena con silencio o recorta para que el WAV tenga target_duration en segundos."""
|
||||||
|
ensure_ffmpeg_available()
|
||||||
|
# obtener duración con ffprobe
|
||||||
|
try:
|
||||||
|
p = subprocess.run(
|
||||||
|
[
|
||||||
|
"ffprobe",
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-show_entries",
|
||||||
|
"format=duration",
|
||||||
|
"-of",
|
||||||
|
"default=noprint_wrappers=1:nokey=1",
|
||||||
|
in_path,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
cur = float(p.stdout.strip())
|
||||||
|
except Exception:
|
||||||
|
cur = 0.0
|
||||||
|
|
||||||
|
if cur == 0.0:
|
||||||
|
shutil.copy(in_path, out_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
if abs(cur - target_duration) < 0.02:
|
||||||
|
shutil.copy(in_path, out_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
if cur > target_duration:
|
||||||
|
cmd = ["ffmpeg", "-y", "-i", in_path, "-t", f"{target_duration}", out_path]
|
||||||
|
_run(cmd, hide_output=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# pad: crear silencio y concatenar
|
||||||
|
pad = target_duration - cur
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as sil:
|
||||||
|
sil_path = sil.name
|
||||||
|
listname = None
|
||||||
|
try:
|
||||||
|
create_silence(pad, sil_path, sr=sr)
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as listf:
|
||||||
|
listf.write(f"file '{os.path.abspath(in_path)}'\n")
|
||||||
|
listf.write(f"file '{os.path.abspath(sil_path)}'\n")
|
||||||
|
listname = listf.name
|
||||||
|
cmd2 = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", listname, "-c", "copy", out_path]
|
||||||
|
_run(cmd2, hide_output=True)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.remove(sil_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if listname:
|
||||||
|
os.remove(listname)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def concat_wavs(chunks: Iterable[str], out_path: str) -> None:
|
||||||
|
"""Concatena una lista de WAVs en out_path usando el demuxer concat (sin recodificar)."""
|
||||||
|
ensure_ffmpeg_available()
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as listf:
|
||||||
|
for c in chunks:
|
||||||
|
listf.write(f"file '{os.path.abspath(c)}'\n")
|
||||||
|
listname = listf.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", listname, "-c", "copy", out_path]
|
||||||
|
_run(cmd)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
# fallback: reconvertir por entrada concat
|
||||||
|
tmp_concat = out_path + ".tmp.wav"
|
||||||
|
cmd2 = ["ffmpeg", "-y", "-i", f"concat:{'|'.join(chunks)}", "-c", "copy", tmp_concat]
|
||||||
|
_run(cmd2)
|
||||||
|
shutil.move(tmp_concat, out_path)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.remove(listname)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FFmpegAudioProcessor:
|
||||||
|
"""Adaptador de audio que expone utilidades necesarias por el orquestador.
|
||||||
|
|
||||||
|
Métodos principales:
|
||||||
|
- extract_audio
|
||||||
|
- replace_audio_in_video
|
||||||
|
- burn_subtitles
|
||||||
|
- save_bytes_as_wav
|
||||||
|
- create_silence
|
||||||
|
- pad_or_trim_wav
|
||||||
|
- concat_wavs
|
||||||
|
"""
|
||||||
|
|
||||||
|
def extract_audio(self, video_path: str, out_wav: str, sr: int = 16000) -> None:
|
||||||
|
return extract_audio(video_path, out_wav, sr=sr)
|
||||||
|
|
||||||
|
def replace_audio_in_video(self, video_path: str, audio_path: str, out_video: str) -> None:
|
||||||
|
return replace_audio_in_video(video_path, audio_path, out_video)
|
||||||
|
|
||||||
|
def burn_subtitles(self, video_path: str, srt_path: str, out_video: str, font: Optional[str] = "Arial", size: int = 24) -> None:
|
||||||
|
return burn_subtitles(video_path, srt_path, out_video, font=font, size=size)
|
||||||
|
|
||||||
|
def save_bytes_as_wav(self, raw_bytes: bytes, target_path: str, sr: int = 22050) -> None:
|
||||||
|
return save_bytes_as_wav(raw_bytes, target_path, sr=sr)
|
||||||
|
|
||||||
|
def create_silence(self, duration: float, out_path: str, sr: int = 22050) -> None:
|
||||||
|
return create_silence(duration, out_path, sr=sr)
|
||||||
|
|
||||||
|
def pad_or_trim_wav(self, in_path: str, out_path: str, target_duration: float, sr: int = 22050) -> None:
|
||||||
|
return pad_or_trim_wav(in_path, out_path, target_duration, sr=sr)
|
||||||
|
|
||||||
|
def concat_wavs(self, chunks: Iterable[str], out_path: str) -> None:
|
||||||
|
return concat_wavs(chunks, out_path)
|
||||||
|
|
||||||
108
whisper_project/infra/gemini_adapter.py
Normal file
108
whisper_project/infra/gemini_adapter.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
try:
|
||||||
|
import srt # type: ignore
|
||||||
|
except Exception:
|
||||||
|
srt = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import google.generativeai as genai # type: ignore
|
||||||
|
except Exception:
|
||||||
|
genai = None
|
||||||
|
|
||||||
|
|
||||||
|
def translate_text_google_gl(text: str, api_key: str, model: str = "gemini-2.5-flash") -> str:
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError("gemini api key required")
|
||||||
|
if genai is not None:
|
||||||
|
try:
|
||||||
|
genai.configure(api_key=api_key)
|
||||||
|
model_obj = genai.GenerativeModel(model)
|
||||||
|
prompt = f"Traduce al español el siguiente texto y devuelve solo el texto traducido:\n\n{text}"
|
||||||
|
resp = model_obj.generate_content(prompt, generation_config={"max_output_tokens": 1024, "temperature": 0.0})
|
||||||
|
if hasattr(resp, "text") and resp.text:
|
||||||
|
return resp.text.strip()
|
||||||
|
if hasattr(resp, "candidates") and resp.candidates:
|
||||||
|
c = resp.candidates[0]
|
||||||
|
if hasattr(c, "content") and hasattr(c.content, "parts"):
|
||||||
|
parts = [p.text for p in c.content.parts if getattr(p, "text", None)]
|
||||||
|
if parts:
|
||||||
|
return "\n".join(parts).strip()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: genai library translate failed: {e}")
|
||||||
|
|
||||||
|
for prefix in ("v1", "v1beta2"):
|
||||||
|
endpoint = f"https://generativelanguage.googleapis.com/{prefix}/models/{model}:generateContent?key={api_key}"
|
||||||
|
body = {
|
||||||
|
"prompt": {"text": f"Traduce al español el siguiente texto y devuelve solo el texto traducido:\n\n{text}"},
|
||||||
|
"maxOutputTokens": 1024,
|
||||||
|
"temperature": 0.0,
|
||||||
|
"candidateCount": 1,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
r = requests.post(endpoint, json=body, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
j = r.json()
|
||||||
|
if isinstance(j, dict) and "candidates" in j and isinstance(j["candidates"], list) and j["candidates"]:
|
||||||
|
first = j["candidates"][0]
|
||||||
|
if isinstance(first, dict):
|
||||||
|
if "content" in first and isinstance(first["content"], str):
|
||||||
|
return first["content"].strip()
|
||||||
|
if "output" in first and isinstance(first["output"], str):
|
||||||
|
return first["output"].strip()
|
||||||
|
if "content" in first and isinstance(first["content"], list):
|
||||||
|
parts = []
|
||||||
|
for c in first["content"]:
|
||||||
|
if isinstance(c, dict) and isinstance(c.get("text"), str):
|
||||||
|
parts.append(c.get("text"))
|
||||||
|
if parts:
|
||||||
|
return "\n".join(parts).strip()
|
||||||
|
for key in ("output_text", "text", "response", "translated_text"):
|
||||||
|
if key in j and isinstance(j[key], str):
|
||||||
|
return j[key].strip()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: GL translate failed ({prefix}): {e}")
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def translate_srt_file(in_path: str, out_path: str, api_key: str, model: str):
|
||||||
|
if srt is None:
|
||||||
|
raise RuntimeError("Dependencia 'srt' no encontrada. Instálela para trabajar con SRT.")
|
||||||
|
|
||||||
|
with open(in_path, "r", encoding="utf-8") as fh:
|
||||||
|
subs = list(srt.parse(fh.read()))
|
||||||
|
|
||||||
|
for i, sub in enumerate(subs, start=1):
|
||||||
|
text = sub.content.strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
translated = translate_text_google_gl(text, api_key, model=model)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: translate failed for index {sub.index}: {e}")
|
||||||
|
translated = text
|
||||||
|
sub.content = translated
|
||||||
|
time.sleep(0.15)
|
||||||
|
|
||||||
|
out_s = srt.compose(subs)
|
||||||
|
with open(out_path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(out_s)
|
||||||
|
|
||||||
|
|
||||||
|
class GeminiTranslator:
|
||||||
|
def __init__(self, api_key: Optional[str] = None, model: str = "gemini-2.5-flash"):
|
||||||
|
self.api_key = api_key
|
||||||
|
self.model = model
|
||||||
|
|
||||||
|
def translate_srt(self, in_srt: str, out_srt: str) -> None:
|
||||||
|
key = self.api_key or os.environ.get("GEMINI_API_KEY")
|
||||||
|
if not key:
|
||||||
|
raise RuntimeError("GEMINI API key required for GeminiTranslator")
|
||||||
|
translate_srt_file(in_srt, out_srt, api_key=key, model=self.model)
|
||||||
153
whisper_project/infra/kokoro_adapter.py
Normal file
153
whisper_project/infra/kokoro_adapter.py
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Importar funciones pesadas (parsing/synth) de forma perezosa dentro de
|
||||||
|
# `synthesize_from_srt` para evitar fallos en la importación del paquete cuando
|
||||||
|
# dependencias opcionales (p.ej. 'srt') no están instaladas.
|
||||||
|
|
||||||
|
from .ffmpeg_adapter import FFmpegAudioProcessor
|
||||||
|
|
||||||
|
|
||||||
|
class KokoroHttpClient:
|
||||||
|
"""Cliente HTTP para sintetizar segmentos desde un .srt usando un endpoint compatible.
|
||||||
|
|
||||||
|
Reemplaza la invocación por subprocess a `srt_to_kokoro.py`. Reusa las funciones de
|
||||||
|
`srt_to_kokoro.py` para parsing y síntesis HTTP (synth_chunk) y usa FFmpegAudioProcessor
|
||||||
|
para operaciones con WAV cuando sea necesario.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, endpoint: str, api_key: Optional[str] = None, voice: Optional[str] = None, model: Optional[str] = None):
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.api_key = api_key
|
||||||
|
self.voice = voice or "em_alex"
|
||||||
|
self.model = model or "model"
|
||||||
|
self._processor = FFmpegAudioProcessor()
|
||||||
|
|
||||||
|
def synthesize_from_srt(self, srt_path: str, out_wav: str, video: Optional[str] = None, align: bool = True, keep_chunks: bool = False, mix_with_original: bool = False, mix_background_volume: float = 0.2):
|
||||||
|
"""Sintetiza cada subtítulo del SRT y concatena en out_wav.
|
||||||
|
|
||||||
|
Parámetros claves coinciden con la versión previa del adaptador CLI para compatibilidad.
|
||||||
|
"""
|
||||||
|
headers = {"Accept": "*/*"}
|
||||||
|
if self.api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||||
|
|
||||||
|
# importar las utilidades sólo cuando se vayan a usar
|
||||||
|
try:
|
||||||
|
from whisper_project.srt_to_kokoro import parse_srt_file, synth_chunk
|
||||||
|
except ModuleNotFoundError as e:
|
||||||
|
raise RuntimeError("Módulo requerido no encontrado para síntesis por SRT: instale 'srt' y 'requests' (pip install srt requests)") from e
|
||||||
|
|
||||||
|
subs = parse_srt_file(srt_path)
|
||||||
|
tmpdir = os.path.join(os.path.dirname(out_wav), f".kokoro_tmp_{os.getpid()}")
|
||||||
|
os.makedirs(tmpdir, exist_ok=True)
|
||||||
|
chunk_files = []
|
||||||
|
|
||||||
|
prev_end = 0.0
|
||||||
|
for i, sub in enumerate(subs, start=1):
|
||||||
|
text = "\n".join(line.strip() for line in sub.content.splitlines()).strip()
|
||||||
|
if not text:
|
||||||
|
prev_end = sub.end.total_seconds()
|
||||||
|
continue
|
||||||
|
|
||||||
|
start_sec = sub.start.total_seconds()
|
||||||
|
end_sec = sub.end.total_seconds()
|
||||||
|
duration = end_sec - start_sec
|
||||||
|
|
||||||
|
# align: insertar silencio por la brecha anterior
|
||||||
|
if align:
|
||||||
|
gap = start_sec - prev_end
|
||||||
|
if gap > 0.01:
|
||||||
|
sil_target = os.path.join(tmpdir, f"sil_{i:04d}.wav")
|
||||||
|
self._processor.create_silence(gap, sil_target)
|
||||||
|
chunk_files.append(sil_target)
|
||||||
|
|
||||||
|
# construir payload_template simple que reemplace {text}
|
||||||
|
payload_template = '{"model":"%s","voice":"%s","input":"{text}","response_format":"wav"}' % (self.model, self.voice)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = synth_chunk(self.endpoint, text, headers, payload_template)
|
||||||
|
except Exception as e:
|
||||||
|
# saltar segmento con log y continuar
|
||||||
|
print(f"Error al sintetizar segmento {i}: {e}")
|
||||||
|
prev_end = end_sec
|
||||||
|
continue
|
||||||
|
|
||||||
|
target = os.path.join(tmpdir, f"chunk_{i:04d}.wav")
|
||||||
|
# convertir/normalizar bytes a wav
|
||||||
|
self._processor.save_bytes_as_wav(raw, target)
|
||||||
|
|
||||||
|
if align:
|
||||||
|
aligned = os.path.join(tmpdir, f"chunk_{i:04d}.aligned.wav")
|
||||||
|
self._processor.pad_or_trim_wav(target, aligned, duration)
|
||||||
|
chunk_files.append(aligned)
|
||||||
|
if not keep_chunks:
|
||||||
|
try:
|
||||||
|
os.remove(target)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
chunk_files.append(target)
|
||||||
|
|
||||||
|
prev_end = end_sec
|
||||||
|
print(f" - Segmento {i}/{len(subs)} -> {os.path.basename(chunk_files[-1])}")
|
||||||
|
|
||||||
|
if not chunk_files:
|
||||||
|
raise RuntimeError("No se generaron fragmentos de audio desde el SRT")
|
||||||
|
|
||||||
|
# concatenar
|
||||||
|
self._processor.concat_wavs(chunk_files, out_wav)
|
||||||
|
|
||||||
|
# operaciones opcionales: mezclar o reemplazar en vídeo original
|
||||||
|
if mix_with_original and video:
|
||||||
|
# extraer audio original y mezclar: delegar a srt_to_kokoro original no es necesario
|
||||||
|
# aquí podemos replicar la estrategia previa: extraer audio, usar ffmpeg para mezclar
|
||||||
|
orig_tmp = os.path.join(tmpdir, f"orig_{os.getpid()}.wav")
|
||||||
|
try:
|
||||||
|
self._processor.extract_audio(video, orig_tmp, sr=22050)
|
||||||
|
# mezclar usando ffmpeg filter_complex
|
||||||
|
mixed_tmp = os.path.join(tmpdir, f"mixed_{os.getpid()}.wav")
|
||||||
|
vol = float(mix_background_volume)
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
out_wav,
|
||||||
|
"-i",
|
||||||
|
orig_tmp,
|
||||||
|
"-filter_complex",
|
||||||
|
f"[0:a]volume=1[a1];[1:a]volume={vol}[a0];[a1][a0]amix=inputs=2:duration=first:dropout_transition=0[mix]",
|
||||||
|
"-map",
|
||||||
|
"[mix]",
|
||||||
|
"-c:a",
|
||||||
|
"pcm_s16le",
|
||||||
|
mixed_tmp,
|
||||||
|
]
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
shutil.move(mixed_tmp, out_wav)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if os.path.exists(orig_tmp):
|
||||||
|
os.remove(orig_tmp)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if video:
|
||||||
|
# si se pidió reemplazar la pista original
|
||||||
|
out_video = os.path.splitext(video)[0] + ".replaced_audio.mp4"
|
||||||
|
try:
|
||||||
|
self._processor.replace_audio_in_video(video, out_wav, out_video)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error al reemplazar audio en el vídeo: {e}")
|
||||||
|
|
||||||
|
# limpieza: opcional conservar tmpdir si keep_chunks
|
||||||
|
if not keep_chunks:
|
||||||
|
try:
|
||||||
|
import shutil as _sh
|
||||||
|
|
||||||
|
_sh.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
261
whisper_project/infra/kokoro_utils.py
Normal file
261
whisper_project/infra/kokoro_utils.py
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
"""Utilidades reutilizables para síntesis a partir de SRT.
|
||||||
|
|
||||||
|
Contiene parsing del SRT, llamada HTTP al endpoint TTS y helpers ffmpeg
|
||||||
|
para convertir/concatenar/padear segmentos. Estas funciones eran previamente
|
||||||
|
parte de `srt_to_kokoro.py` y se mueven aquí para ser reutilizables por
|
||||||
|
adaptadores y tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except Exception:
|
||||||
|
# Dejar que el import falle en tiempo de uso (cliente perezoso) si no está instalado
|
||||||
|
requests = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import srt
|
||||||
|
except Exception:
|
||||||
|
srt = None
|
||||||
|
|
||||||
|
|
||||||
|
def find_synthesis_endpoint(openapi_url: str) -> Optional[str]:
|
||||||
|
"""Intento heurístico: baja openapi.json y busca paths con palabras clave.
|
||||||
|
|
||||||
|
Retorna la URL completa del path candidato o None.
|
||||||
|
"""
|
||||||
|
if requests is None:
|
||||||
|
raise RuntimeError("'requests' no está disponible")
|
||||||
|
try:
|
||||||
|
r = requests.get(openapi_url, timeout=20)
|
||||||
|
r.raise_for_status()
|
||||||
|
spec = r.json()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
paths = spec.get("paths", {})
|
||||||
|
candidate = None
|
||||||
|
for path, methods in paths.items():
|
||||||
|
lname = path.lower()
|
||||||
|
if any(k in lname for k in ("synth", "tts", "text", "synthesize")):
|
||||||
|
for method, op in methods.items():
|
||||||
|
if method.lower() == "post":
|
||||||
|
candidate = path
|
||||||
|
break
|
||||||
|
if candidate:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not candidate:
|
||||||
|
for path, methods in paths.items():
|
||||||
|
for method, op in methods.items():
|
||||||
|
meta = json.dumps(op).lower()
|
||||||
|
if any(k in meta for k in ("synth", "tts", "text", "synthesize")) and method.lower() == "post":
|
||||||
|
candidate = path
|
||||||
|
break
|
||||||
|
if candidate:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from urllib.parse import urlparse, urljoin
|
||||||
|
|
||||||
|
p = urlparse(openapi_url)
|
||||||
|
base = f"{p.scheme}://{p.netloc}"
|
||||||
|
return urljoin(base, candidate)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_srt_file(path: str):
|
||||||
|
if srt is None:
|
||||||
|
raise RuntimeError("El paquete 'srt' no está instalado")
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
raw = f.read()
|
||||||
|
return list(srt.parse(raw))
|
||||||
|
|
||||||
|
|
||||||
|
def synth_chunk(endpoint: str, text: str, headers: dict, payload_template: Optional[str], timeout=60):
|
||||||
|
"""Envía la solicitud y devuelve bytes de audio.
|
||||||
|
|
||||||
|
Maneja respuestas audio/* o JSON con campo base64.
|
||||||
|
"""
|
||||||
|
if requests is None:
|
||||||
|
raise RuntimeError("El paquete 'requests' no está instalado")
|
||||||
|
|
||||||
|
if payload_template:
|
||||||
|
body = payload_template.replace("{text}", text)
|
||||||
|
try:
|
||||||
|
json_body = json.loads(body)
|
||||||
|
except Exception:
|
||||||
|
json_body = {"text": text}
|
||||||
|
else:
|
||||||
|
json_body = {"text": text}
|
||||||
|
|
||||||
|
r = requests.post(endpoint, json=json_body, headers=headers, timeout=timeout)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
ctype = r.headers.get("Content-Type", "")
|
||||||
|
if ctype.startswith("audio/"):
|
||||||
|
return r.content
|
||||||
|
try:
|
||||||
|
j = r.json()
|
||||||
|
for k in ("audio", "wav", "data", "base64"):
|
||||||
|
if k in j:
|
||||||
|
val = j[k]
|
||||||
|
import base64
|
||||||
|
|
||||||
|
try:
|
||||||
|
return base64.b64decode(val)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return r.content
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_ffmpeg():
|
||||||
|
if shutil.which("ffmpeg") is None:
|
||||||
|
raise RuntimeError("ffmpeg no está disponible en PATH")
|
||||||
|
|
||||||
|
|
||||||
|
def convert_and_save(raw_bytes: bytes, target_path: str):
|
||||||
|
"""Guarda bytes a un archivo temporal y convierte a WAV PCM 22050 mono."""
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".bin") as tmp:
|
||||||
|
tmp.write(raw_bytes)
|
||||||
|
tmp.flush()
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
tmp_path,
|
||||||
|
"-ar",
|
||||||
|
"22050",
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
"-sample_fmt",
|
||||||
|
"s16",
|
||||||
|
target_path,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
with open(target_path, "wb") as out:
|
||||||
|
out.write(raw_bytes)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.remove(tmp_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def create_silence(duration: float, out_path: str, sr: int = 22050):
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
f"anullsrc=channel_layout=mono:sample_rate={sr}",
|
||||||
|
"-t",
|
||||||
|
f"{duration}",
|
||||||
|
"-c:a",
|
||||||
|
"pcm_s16le",
|
||||||
|
out_path,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
try:
|
||||||
|
with open(out_path, "wb") as fh:
|
||||||
|
fh.write(b"\x00" * 1024)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def pad_or_trim_wav(in_path: str, out_path: str, target_duration: float, sr: int = 22050):
|
||||||
|
try:
|
||||||
|
p = subprocess.run(
|
||||||
|
[
|
||||||
|
"ffprobe",
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-show_entries",
|
||||||
|
"format=duration",
|
||||||
|
"-of",
|
||||||
|
"default=noprint_wrappers=1:nokey=1",
|
||||||
|
in_path,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
cur = float(p.stdout.strip())
|
||||||
|
except Exception:
|
||||||
|
cur = 0.0
|
||||||
|
|
||||||
|
if cur == 0.0:
|
||||||
|
shutil.copy(in_path, out_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
if abs(cur - target_duration) < 0.02:
|
||||||
|
shutil.copy(in_path, out_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
if cur > target_duration:
|
||||||
|
cmd = ["ffmpeg", "-y", "-i", in_path, "-t", f"{target_duration}", out_path]
|
||||||
|
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
return
|
||||||
|
|
||||||
|
pad = target_duration - cur
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as sil:
|
||||||
|
sil_path = sil.name
|
||||||
|
listname = None
|
||||||
|
try:
|
||||||
|
create_silence(pad, sil_path, sr=sr)
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as listf:
|
||||||
|
listf.write(f"file '{os.path.abspath(in_path)}'\n")
|
||||||
|
listf.write(f"file '{os.path.abspath(sil_path)}'\n")
|
||||||
|
listname = listf.name
|
||||||
|
cmd2 = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", listname, "-c", "copy", out_path]
|
||||||
|
subprocess.run(cmd2, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.remove(sil_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if listname:
|
||||||
|
os.remove(listname)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def concat_chunks(chunks: list, out_path: str):
|
||||||
|
ensure_ffmpeg()
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as listf:
|
||||||
|
for c in chunks:
|
||||||
|
listf.write(f"file '{os.path.abspath(c)}'\n")
|
||||||
|
listname = listf.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", listname, "-c", "copy", out_path]
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
tmp_concat = out_path + ".tmp.wav"
|
||||||
|
cmd2 = ["ffmpeg", "-y", "-i", f"concat:{'|'.join(chunks)}", "-c", "copy", tmp_concat]
|
||||||
|
subprocess.run(cmd2)
|
||||||
|
shutil.move(tmp_concat, out_path)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.remove(listname)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
117
whisper_project/infra/marian_adapter.py
Normal file
117
whisper_project/infra/marian_adapter.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
from typing import Callable, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _default_translator_factory(model_name: str = "Helsinki-NLP/opus-mt-en-es", batch_size: int = 8):
|
||||||
|
"""Crea una función translator(texts: List[str]) -> List[str] usando transformers.
|
||||||
|
|
||||||
|
La creación se hace perezosamente para evitar obligar la dependencia en import-time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def make():
|
||||||
|
try:
|
||||||
|
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError("transformers no disponible: instale 'transformers' y 'sentencepiece' para traducción local") from e
|
||||||
|
|
||||||
|
tok = AutoTokenizer.from_pretrained(model_name)
|
||||||
|
model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
|
||||||
|
|
||||||
|
def translator(texts: List[str]) -> List[str]:
|
||||||
|
outs = []
|
||||||
|
# procesar en batches simples
|
||||||
|
for i in range(0, len(texts), batch_size):
|
||||||
|
batch = texts[i : i + batch_size]
|
||||||
|
enc = tok(batch, return_tensors="pt", padding=True, truncation=True)
|
||||||
|
gen = model.generate(**enc, max_length=512)
|
||||||
|
dec = tok.batch_decode(gen, skip_special_tokens=True)
|
||||||
|
outs.extend([d.strip() for d in dec])
|
||||||
|
return outs
|
||||||
|
|
||||||
|
return translator
|
||||||
|
|
||||||
|
return make()
|
||||||
|
|
||||||
|
|
||||||
|
def translate_srt(in_path: str, out_path: str, *, model_name: str = "Helsinki-NLP/opus-mt-en-es", batch_size: int = 8, translator: Optional[Callable[[List[str]], List[str]]] = None) -> None:
|
||||||
|
"""Traduce un archivo SRT manteniendo índices y timestamps.
|
||||||
|
|
||||||
|
Parámetros:
|
||||||
|
- in_path, out_path: rutas de entrada/salida
|
||||||
|
- model_name, batch_size: usados si `translator` es None
|
||||||
|
- translator: función opcional que recibe lista de textos y devuelve lista de textos traducidos.
|
||||||
|
"""
|
||||||
|
# Importar srt perezosamente; si no está disponible, usar un parser mínimo
|
||||||
|
try:
|
||||||
|
import srt # type: ignore
|
||||||
|
|
||||||
|
def _read_srt(path: str):
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
raw = f.read()
|
||||||
|
return list(srt.parse(raw))
|
||||||
|
|
||||||
|
def _write_srt(path: str, subs):
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(srt.compose(subs))
|
||||||
|
|
||||||
|
subs = _read_srt(in_path)
|
||||||
|
texts = [sub.content.strip() for sub in subs]
|
||||||
|
_compose_fn = lambda out_path, subs_list: _write_srt(out_path, subs_list)
|
||||||
|
except Exception:
|
||||||
|
# Fallback mínimo: parsear bloques simples de SRT (no soporta todos los casos)
|
||||||
|
def _parse_simple(raw_text: str):
|
||||||
|
blocks = [b.strip() for b in raw_text.strip().split("\n\n") if b.strip()]
|
||||||
|
parsed = []
|
||||||
|
for b in blocks:
|
||||||
|
lines = b.splitlines()
|
||||||
|
if len(lines) < 3:
|
||||||
|
continue
|
||||||
|
idx = lines[0]
|
||||||
|
times = lines[1]
|
||||||
|
content = "\n".join(lines[2:])
|
||||||
|
parsed.append({"index": idx, "times": times, "content": content})
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
def _compose_simple(parsed, out_path: str):
|
||||||
|
with open(out_path, "w", encoding="utf-8") as f:
|
||||||
|
for i, item in enumerate(parsed, start=1):
|
||||||
|
f.write(f"{item['index']}\n")
|
||||||
|
f.write(f"{item['times']}\n")
|
||||||
|
f.write(f"{item['content']}\n\n")
|
||||||
|
|
||||||
|
with open(in_path, "r", encoding="utf-8") as f:
|
||||||
|
raw = f.read()
|
||||||
|
subs = _parse_simple(raw)
|
||||||
|
texts = [s["content"].strip() for s in subs]
|
||||||
|
_compose_fn = lambda out_path, subs_list: _compose_simple(subs_list, out_path)
|
||||||
|
|
||||||
|
if translator is None:
|
||||||
|
translator = _default_translator_factory(model_name=model_name, batch_size=batch_size)
|
||||||
|
|
||||||
|
translated = translator(texts)
|
||||||
|
|
||||||
|
if len(translated) != len(subs):
|
||||||
|
raise RuntimeError("El traductor devolvió un número distinto de segmentos traducidos")
|
||||||
|
|
||||||
|
# Asignar traducidos en la estructura usada (objeto srt o dict simple)
|
||||||
|
if subs and isinstance(subs[0], dict):
|
||||||
|
for s, t in zip(subs, translated):
|
||||||
|
s["content"] = t.strip()
|
||||||
|
_compose_fn(out_path, subs)
|
||||||
|
else:
|
||||||
|
for sub, t in zip(subs, translated):
|
||||||
|
sub.content = t.strip()
|
||||||
|
_compose_fn(out_path, subs)
|
||||||
|
|
||||||
|
|
||||||
|
class MarianTranslator:
|
||||||
|
"""Adapter que ofrece una API simple para uso en usecases.
|
||||||
|
|
||||||
|
Internamente llama a `translate_srt` y permite inyectar un traductor para tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, model_name: str = "Helsinki-NLP/opus-mt-en-es", batch_size: int = 8):
|
||||||
|
self.model_name = model_name
|
||||||
|
self.batch_size = batch_size
|
||||||
|
|
||||||
|
def translate_srt(self, in_srt: str, out_srt: str, translator: Optional[Callable[[List[str]], List[str]]] = None) -> None:
|
||||||
|
translate_srt(in_srt, out_srt, model_name=self.model_name, batch_size=self.batch_size, translator=translator)
|
||||||
40
whisper_project/infra/process_video.py
Normal file
40
whisper_project/infra/process_video.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"""Infra wrapper exposing ffmpeg and transcription helpers via adapters.
|
||||||
|
|
||||||
|
This module provides backward-compatible functions but delegates to the
|
||||||
|
adapter implementations in `ffmpeg_adapter` and `transcribe`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .ffmpeg_adapter import FFmpegAudioProcessor
|
||||||
|
from . import transcribe as _trans
|
||||||
|
|
||||||
|
|
||||||
|
_FF = FFmpegAudioProcessor()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_audio(video_path: str, out_wav: str, sr: int = 16000):
|
||||||
|
return _FF.extract_audio(video_path, out_wav)
|
||||||
|
|
||||||
|
|
||||||
|
def burn_subtitles(video_path: str, srt_path: str, out_video: str, font: str = "Arial", size: int = 24):
|
||||||
|
return _FF.burn_subtitles(video_path, srt_path, out_video, font=font, size=size)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_audio_in_video(video_path: str, audio_path: str, out_video: str):
|
||||||
|
return _FF.replace_audio_in_video(video_path, audio_path, out_video)
|
||||||
|
|
||||||
|
|
||||||
|
def get_audio_duration(file_path: str):
|
||||||
|
return _trans.get_audio_duration(file_path)
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe_segmented_with_tempfiles(*args, **kwargs):
|
||||||
|
return _trans.transcribe_segmented_with_tempfiles(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"extract_audio",
|
||||||
|
"burn_subtitles",
|
||||||
|
"replace_audio_in_video",
|
||||||
|
"get_audio_duration",
|
||||||
|
"transcribe_segmented_with_tempfiles",
|
||||||
|
]
|
||||||
10
whisper_project/infra/process_video_impl.py
Normal file
10
whisper_project/infra/process_video_impl.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
"""Deprecated implementation module.
|
||||||
|
|
||||||
|
All functionality has been moved into adapter classes under
|
||||||
|
`whisper_project.infra`. Importing this module will raise an
|
||||||
|
ImportError to encourage use of the adapter APIs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
raise ImportError(
|
||||||
|
"process_video_impl has been removed: use whisper_project.infra.ffmpeg_adapter"
|
||||||
|
)
|
||||||
66
whisper_project/infra/transcribe.py
Normal file
66
whisper_project/infra/transcribe.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"""Infra layer: expose a simple module-level API backed by
|
||||||
|
`TranscribeService` adapter.
|
||||||
|
|
||||||
|
This replaces the previous re-export from `transcribe_impl` so the
|
||||||
|
implementation lives inside the adapter class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .transcribe_adapter import TranscribeService
|
||||||
|
|
||||||
|
|
||||||
|
# default service instance used by module-level helpers
|
||||||
|
_DEFAULT = TranscribeService()
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe_openai_whisper(file: str):
|
||||||
|
return _DEFAULT.transcribe_openai(file)
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe_transformers(file: str):
|
||||||
|
return _DEFAULT.transcribe_transformers(file)
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe_faster_whisper(file: str):
|
||||||
|
return _DEFAULT.transcribe_faster(file)
|
||||||
|
|
||||||
|
|
||||||
|
def write_srt(segments, out_path: str):
|
||||||
|
return _DEFAULT.write_srt(segments, out_path)
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe_adjacent_segments(segments):
|
||||||
|
return _DEFAULT.dedupe_adjacent_segments(segments)
|
||||||
|
|
||||||
|
|
||||||
|
def get_audio_duration(file_path: str):
|
||||||
|
return _DEFAULT.get_audio_duration(file_path)
|
||||||
|
|
||||||
|
|
||||||
|
def make_uniform_segments(duration: float, seg_seconds: float):
|
||||||
|
return _DEFAULT.make_uniform_segments(duration, seg_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe_segmented_with_tempfiles(*args, **kwargs):
|
||||||
|
return _DEFAULT.transcribe_segmented_with_tempfiles(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def tts_synthesize(text: str, out_path: str, model: str = "kokoro") -> bool:
|
||||||
|
return _DEFAULT.tts_synthesize(text, out_path, model=model)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_tts_model(repo_id: str):
|
||||||
|
return _DEFAULT.ensure_tts_model(repo_id)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"transcribe_openai_whisper",
|
||||||
|
"transcribe_transformers",
|
||||||
|
"transcribe_faster_whisper",
|
||||||
|
"write_srt",
|
||||||
|
"dedupe_adjacent_segments",
|
||||||
|
"get_audio_duration",
|
||||||
|
"make_uniform_segments",
|
||||||
|
"transcribe_segmented_with_tempfiles",
|
||||||
|
"tts_synthesize",
|
||||||
|
"ensure_tts_model",
|
||||||
|
]
|
||||||
279
whisper_project/infra/transcribe_adapter.py
Normal file
279
whisper_project/infra/transcribe_adapter.py
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
"""Transcribe service adapter.
|
||||||
|
|
||||||
|
Provides a small class that wraps transcription and SRT helper functions
|
||||||
|
so callers can depend on an object instead of free functions.
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
"""Transcribe service with inlined implementation.
|
||||||
|
|
||||||
|
This class contains the transcription and SRT utilities previously in
|
||||||
|
`transcribe_impl.py`. Keeping it here as a single adapter simplifies DI
|
||||||
|
and makes it easier to unit-test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class TranscribeService:
|
||||||
|
def __init__(self, model: str = "base", compute_type: str = "int8") -> None:
|
||||||
|
self.model = model
|
||||||
|
self.compute_type = compute_type
|
||||||
|
|
||||||
|
def transcribe_openai(self, file: str):
|
||||||
|
import whisper
|
||||||
|
|
||||||
|
print(f"Cargando openai-whisper modelo={self.model} en CPU...")
|
||||||
|
m = whisper.load_model(self.model, device="cpu")
|
||||||
|
print("Transcribiendo...")
|
||||||
|
result = m.transcribe(file, fp16=False)
|
||||||
|
segments = result.get("segments", None)
|
||||||
|
if segments:
|
||||||
|
for seg in segments:
|
||||||
|
print(seg.get("text", ""))
|
||||||
|
return segments
|
||||||
|
else:
|
||||||
|
print(result.get("text", ""))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def transcribe_transformers(self, file: str):
|
||||||
|
import torch
|
||||||
|
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline
|
||||||
|
|
||||||
|
device = "cpu"
|
||||||
|
torch_dtype = torch.float32
|
||||||
|
|
||||||
|
print(f"Cargando transformers modelo={self.model} en CPU...")
|
||||||
|
model_obj = AutoModelForSpeechSeq2Seq.from_pretrained(self.model, torch_dtype=torch_dtype, low_cpu_mem_usage=True)
|
||||||
|
model_obj.to(device)
|
||||||
|
processor = AutoProcessor.from_pretrained(self.model)
|
||||||
|
|
||||||
|
pipe = pipeline(
|
||||||
|
"automatic-speech-recognition",
|
||||||
|
model=model_obj,
|
||||||
|
tokenizer=processor.tokenizer,
|
||||||
|
feature_extractor=processor.feature_extractor,
|
||||||
|
device=-1,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("Transcribiendo...")
|
||||||
|
result = pipe(file)
|
||||||
|
if isinstance(result, dict):
|
||||||
|
print(result.get("text", ""))
|
||||||
|
else:
|
||||||
|
print(result)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def transcribe_faster(self, file: str):
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
|
print(f"Cargando faster-whisper modelo={self.model} en CPU compute_type={self.compute_type}...")
|
||||||
|
model_obj = WhisperModel(self.model, device="cpu", compute_type=self.compute_type)
|
||||||
|
print("Transcribiendo...")
|
||||||
|
segments_gen, info = model_obj.transcribe(file, beam_size=5)
|
||||||
|
segments = list(segments_gen)
|
||||||
|
text = "".join([seg.text for seg in segments])
|
||||||
|
print(text)
|
||||||
|
return segments
|
||||||
|
|
||||||
|
def _format_timestamp(self, seconds: float) -> str:
|
||||||
|
millis = int((seconds - int(seconds)) * 1000)
|
||||||
|
h = int(seconds // 3600)
|
||||||
|
m = int((seconds % 3600) // 60)
|
||||||
|
s = int(seconds % 60)
|
||||||
|
return f"{h:02d}:{m:02d}:{s:02d},{millis:03d}"
|
||||||
|
|
||||||
|
def write_srt(self, segments, out_path: str):
|
||||||
|
lines = []
|
||||||
|
for i, seg in enumerate(segments, start=1):
|
||||||
|
if hasattr(seg, "start"):
|
||||||
|
start = float(seg.start)
|
||||||
|
end = float(seg.end)
|
||||||
|
text = seg.text if hasattr(seg, "text") else str(seg)
|
||||||
|
else:
|
||||||
|
start = float(seg.get("start", 0.0))
|
||||||
|
end = float(seg.get("end", 0.0))
|
||||||
|
text = seg.get("text", "")
|
||||||
|
|
||||||
|
start_ts = self._format_timestamp(start)
|
||||||
|
end_ts = self._format_timestamp(end)
|
||||||
|
lines.append(str(i))
|
||||||
|
lines.append(f"{start_ts} --> {end_ts}")
|
||||||
|
for line in str(text).strip().splitlines():
|
||||||
|
lines.append(line)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
Path(out_path).write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
|
||||||
|
def dedupe_adjacent_segments(self, segments):
|
||||||
|
if not segments:
|
||||||
|
return segments
|
||||||
|
|
||||||
|
norm = []
|
||||||
|
for s in segments:
|
||||||
|
if hasattr(s, "start"):
|
||||||
|
norm.append({"start": float(s.start), "end": float(s.end), "text": getattr(s, "text", "")})
|
||||||
|
else:
|
||||||
|
norm.append({"start": float(s.get("start", 0.0)), "end": float(s.get("end", 0.0)), "text": s.get("text", "")})
|
||||||
|
|
||||||
|
out = [norm[0].copy()]
|
||||||
|
for seg in norm[1:]:
|
||||||
|
prev = out[-1]
|
||||||
|
a = (prev.get("text") or "").strip()
|
||||||
|
b = (seg.get("text") or "").strip()
|
||||||
|
if not a or not b:
|
||||||
|
out.append(seg.copy())
|
||||||
|
continue
|
||||||
|
|
||||||
|
a_words = a.split()
|
||||||
|
b_words = b.split()
|
||||||
|
max_ol = 0
|
||||||
|
max_k = min(len(a_words), len(b_words), 10)
|
||||||
|
for k in range(1, max_k + 1):
|
||||||
|
if a_words[-k:] == b_words[:k]:
|
||||||
|
max_ol = k
|
||||||
|
|
||||||
|
if max_ol > 0:
|
||||||
|
new_b = " ".join(b_words[max_ol:]).strip()
|
||||||
|
new_seg = seg.copy()
|
||||||
|
new_seg["text"] = new_b
|
||||||
|
out.append(new_seg)
|
||||||
|
else:
|
||||||
|
out.append(seg.copy())
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
def get_audio_duration(self, file_path: str):
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"ffprobe",
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-show_entries",
|
||||||
|
"format=duration",
|
||||||
|
"-of",
|
||||||
|
"default=noprint_wrappers=1:nokey=1",
|
||||||
|
file_path,
|
||||||
|
]
|
||||||
|
out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL)
|
||||||
|
return float(out.strip())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def make_uniform_segments(self, duration: float, seg_seconds: float):
|
||||||
|
segments = []
|
||||||
|
if duration <= 0 or seg_seconds <= 0:
|
||||||
|
return segments
|
||||||
|
start = 0.0
|
||||||
|
while start < duration:
|
||||||
|
end = min(start + seg_seconds, duration)
|
||||||
|
segments.append({"start": round(start, 3), "end": round(end, 3)})
|
||||||
|
start = end
|
||||||
|
return segments
|
||||||
|
|
||||||
|
def transcribe_segmented_with_tempfiles(self, src_file: str, segments: list, backend: str = "faster-whisper", model: str = "base", compute_type: str = "int8", overlap: float = 0.2):
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for seg in segments:
|
||||||
|
start = max(0.0, float(seg["start"]) - overlap)
|
||||||
|
end = float(seg["end"]) + overlap
|
||||||
|
duration = end - start
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=True) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-ss",
|
||||||
|
str(start),
|
||||||
|
"-t",
|
||||||
|
str(duration),
|
||||||
|
"-i",
|
||||||
|
src_file,
|
||||||
|
"-ar",
|
||||||
|
"16000",
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
tmp_path,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
except Exception:
|
||||||
|
results.append({"start": seg["start"], "end": seg["end"], "text": ""})
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
if backend == "openai-whisper":
|
||||||
|
import whisper
|
||||||
|
|
||||||
|
m = whisper.load_model(model, device="cpu")
|
||||||
|
res = m.transcribe(tmp_path, fp16=False)
|
||||||
|
text = res.get("text", "")
|
||||||
|
elif backend == "transformers":
|
||||||
|
import torch
|
||||||
|
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline
|
||||||
|
|
||||||
|
torch_dtype = torch.float32
|
||||||
|
model_obj = AutoModelForSpeechSeq2Seq.from_pretrained(model, torch_dtype=torch_dtype, low_cpu_mem_usage=True)
|
||||||
|
model_obj.to("cpu")
|
||||||
|
processor = AutoProcessor.from_pretrained(model)
|
||||||
|
pipe = pipeline(
|
||||||
|
"automatic-speech-recognition",
|
||||||
|
model=model_obj,
|
||||||
|
tokenizer=processor.tokenizer,
|
||||||
|
feature_extractor=processor.feature_extractor,
|
||||||
|
device=-1,
|
||||||
|
)
|
||||||
|
out = pipe(tmp_path)
|
||||||
|
text = out["text"] if isinstance(out, dict) else str(out)
|
||||||
|
else:
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
|
wmodel = WhisperModel(model, device="cpu", compute_type=compute_type)
|
||||||
|
segs_gen, info = wmodel.transcribe(tmp_path, beam_size=5)
|
||||||
|
segs = list(segs_gen)
|
||||||
|
text = "".join([s.text for s in segs])
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
results.append({"start": seg["start"], "end": seg["end"], "text": text})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def tts_synthesize(self, text: str, out_path: str, model: str = "kokoro"):
|
||||||
|
try:
|
||||||
|
from TTS.api import TTS
|
||||||
|
|
||||||
|
tts = TTS(model_name=model, progress_bar=False, gpu=False)
|
||||||
|
tts.tts_to_file(text=text, file_path=out_path)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
import pyttsx3
|
||||||
|
|
||||||
|
engine = pyttsx3.init()
|
||||||
|
engine.save_to_file(text, out_path)
|
||||||
|
engine.runAndWait()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def ensure_tts_model(self, repo_id: str):
|
||||||
|
try:
|
||||||
|
from huggingface_hub import snapshot_download
|
||||||
|
|
||||||
|
try:
|
||||||
|
local_dir = snapshot_download(repo_id, repo_type="model")
|
||||||
|
except Exception:
|
||||||
|
local_dir = snapshot_download(repo_id)
|
||||||
|
return local_dir
|
||||||
|
except Exception:
|
||||||
|
return repo_id
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["TranscribeService"]
|
||||||
158
whisper_project/main.py
Normal file
158
whisper_project/main.py
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""CLI mínimo que expone el orquestador principal.
|
||||||
|
|
||||||
|
Este módulo proporciona la función `main()` que construye los adaptadores
|
||||||
|
por defecto e invoca `PipelineOrchestrator.run(...)`. Está diseñado para
|
||||||
|
reemplazar el antiguo `run_full_pipeline.py` como punto de entrada.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from whisper_project.usecases.orchestrator import PipelineOrchestrator
|
||||||
|
from whisper_project.infra.kokoro_adapter import KokoroHttpClient
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--video", required=True)
|
||||||
|
p.add_argument("--srt", help="SRT de entrada (opcional)")
|
||||||
|
p.add_argument(
|
||||||
|
"--kokoro-endpoint",
|
||||||
|
required=False,
|
||||||
|
default="https://kokoro.example/api/synthesize",
|
||||||
|
help=(
|
||||||
|
"Endpoint HTTP de Kokoro (por defecto: "
|
||||||
|
"https://kokoro.example/api/synthesize)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
p.add_argument("--kokoro-key", required=False)
|
||||||
|
p.add_argument("--voice", default="em_alex")
|
||||||
|
p.add_argument("--kokoro-model", default="model")
|
||||||
|
p.add_argument("--whisper-model", default="base")
|
||||||
|
p.add_argument(
|
||||||
|
"--translate-method",
|
||||||
|
choices=[
|
||||||
|
"local",
|
||||||
|
"gemini",
|
||||||
|
"argos",
|
||||||
|
"none",
|
||||||
|
],
|
||||||
|
default="local",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--gemini-key",
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"API key para Gemini (si eliges "
|
||||||
|
"--translate-method=gemini)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
p.add_argument("--mix", action="store_true")
|
||||||
|
p.add_argument("--mix-background-volume", type=float, default=0.2)
|
||||||
|
p.add_argument("--keep-chunks", action="store_true")
|
||||||
|
p.add_argument("--keep-temp", action="store_true")
|
||||||
|
p.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Simular pasos sin ejecutar",
|
||||||
|
)
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
video = os.path.abspath(args.video)
|
||||||
|
if not os.path.exists(video):
|
||||||
|
print("Vídeo no encontrado:", video, file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
workdir = tempfile.mkdtemp(prefix="full_pipeline_")
|
||||||
|
try:
|
||||||
|
# construir cliente Kokoro HTTP nativo e inyectarlo en el orquestador
|
||||||
|
kokoro_client = KokoroHttpClient(
|
||||||
|
args.kokoro_endpoint,
|
||||||
|
api_key=args.kokoro_key,
|
||||||
|
voice=args.voice,
|
||||||
|
model=args.kokoro_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
orchestrator = PipelineOrchestrator(
|
||||||
|
kokoro_endpoint=args.kokoro_endpoint,
|
||||||
|
kokoro_key=args.kokoro_key,
|
||||||
|
voice=args.voice,
|
||||||
|
kokoro_model=args.kokoro_model,
|
||||||
|
tts_client=kokoro_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = orchestrator.run(
|
||||||
|
video=video,
|
||||||
|
srt=args.srt,
|
||||||
|
workdir=workdir,
|
||||||
|
translate_method=args.translate_method,
|
||||||
|
gemini_api_key=args.gemini_key,
|
||||||
|
whisper_model=args.whisper_model,
|
||||||
|
mix=args.mix,
|
||||||
|
mix_background_volume=args.mix_background_volume,
|
||||||
|
keep_chunks=args.keep_chunks,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Si no es dry-run, crear una subcarpeta por proyecto en output/
|
||||||
|
# (output/<basename-of-video>) y mover allí los artefactos generados.
|
||||||
|
final_path = None
|
||||||
|
if (
|
||||||
|
not args.dry_run
|
||||||
|
and result
|
||||||
|
and getattr(result, "burned_video", None)
|
||||||
|
):
|
||||||
|
base = os.path.splitext(os.path.basename(video))[0]
|
||||||
|
project_out = os.path.join(os.getcwd(), "output", base)
|
||||||
|
try:
|
||||||
|
os.makedirs(project_out, exist_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Mover el vídeo principal
|
||||||
|
src = result.burned_video
|
||||||
|
dest = os.path.join(project_out, os.path.basename(src))
|
||||||
|
try:
|
||||||
|
if os.path.abspath(src) != os.path.abspath(dest):
|
||||||
|
shutil.move(src, dest)
|
||||||
|
final_path = dest
|
||||||
|
except Exception:
|
||||||
|
final_path = src
|
||||||
|
|
||||||
|
# También mover otros artefactos que empiecen por el basename
|
||||||
|
try:
|
||||||
|
pattern = os.path.join(os.getcwd(), f"{base}*")
|
||||||
|
for p in glob.glob(pattern):
|
||||||
|
# no mover el archivo fuente ya movido
|
||||||
|
if os.path.abspath(p) == os.path.abspath(final_path):
|
||||||
|
continue
|
||||||
|
# mover sólo ficheros regulares
|
||||||
|
try:
|
||||||
|
if os.path.isfile(p):
|
||||||
|
shutil.move(p, os.path.join(project_out, os.path.basename(p)))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# En dry-run o sin resultado, no movemos nada
|
||||||
|
final_path = getattr(result, "burned_video", None)
|
||||||
|
|
||||||
|
print("Flujo completado. Vídeo final:", final_path)
|
||||||
|
finally:
|
||||||
|
if not args.keep_temp:
|
||||||
|
try:
|
||||||
|
shutil.rmtree(workdir)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -1,179 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Procesamiento de vídeo: extrae audio, transcribe/traduce y
|
|
||||||
quema subtítulos.
|
|
||||||
|
|
||||||
Flujo:
|
|
||||||
- Extrae audio con ffmpeg (WAV 16k mono)
|
|
||||||
- Transcribe con faster-whisper o openai-whisper
|
|
||||||
(opción task='translate')
|
|
||||||
- Escribe SRT y lo incrusta en el vídeo con ffmpeg
|
|
||||||
|
|
||||||
Nota: requiere ffmpeg instalado y, para modelos, faster-whisper
|
|
||||||
o openai-whisper.
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from transcribe import write_srt
|
|
||||||
|
|
||||||
|
|
||||||
def extract_audio(video_path: str, out_audio: str):
|
|
||||||
cmd = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-i",
|
|
||||||
video_path,
|
|
||||||
"-vn",
|
|
||||||
"-acodec",
|
|
||||||
"pcm_s16le",
|
|
||||||
"-ar",
|
|
||||||
"16000",
|
|
||||||
"-ac",
|
|
||||||
"1",
|
|
||||||
out_audio,
|
|
||||||
]
|
|
||||||
subprocess.run(cmd, check=True)
|
|
||||||
|
|
||||||
|
|
||||||
def burn_subtitles(video_path: str, srt_path: str, out_video: str):
|
|
||||||
# Usar filtro subtitles de ffmpeg
|
|
||||||
cmd = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-i",
|
|
||||||
video_path,
|
|
||||||
"-vf",
|
|
||||||
f"subtitles={srt_path}",
|
|
||||||
"-c:a",
|
|
||||||
"copy",
|
|
||||||
out_video,
|
|
||||||
]
|
|
||||||
subprocess.run(cmd, check=True)
|
|
||||||
|
|
||||||
|
|
||||||
def transcribe_and_translate_faster(audio_path: str, model: str, target: str):
|
|
||||||
from faster_whisper import WhisperModel
|
|
||||||
|
|
||||||
wm = WhisperModel(model, device="cpu", compute_type="int8")
|
|
||||||
segments, info = wm.transcribe(
|
|
||||||
audio_path, beam_size=5, task="translate", language=target
|
|
||||||
)
|
|
||||||
return segments
|
|
||||||
|
|
||||||
|
|
||||||
def transcribe_and_translate_openai(audio_path: str, model: str, target: str):
|
|
||||||
import whisper
|
|
||||||
|
|
||||||
m = whisper.load_model(model, device="cpu")
|
|
||||||
result = m.transcribe(
|
|
||||||
audio_path, fp16=False, task="translate", language=target
|
|
||||||
)
|
|
||||||
return result.get("segments", None)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description=(
|
|
||||||
"Extraer, transcribir/traducir y quemar subtítulos en vídeo"
|
|
||||||
" (offline)"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--video", "-v", required=True, help="Ruta del archivo de vídeo"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--backend",
|
|
||||||
"-b",
|
|
||||||
choices=["faster-whisper", "openai-whisper"],
|
|
||||||
default="faster-whisper",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--model",
|
|
||||||
"-m",
|
|
||||||
default="base",
|
|
||||||
help="Modelo de whisper a usar (tiny, base, etc.)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--to", "-t", default="es", help="Idioma de destino para traducción"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--out",
|
|
||||||
"-o",
|
|
||||||
default=None,
|
|
||||||
help=(
|
|
||||||
"Ruta del vídeo de salida (si no se especifica,"
|
|
||||||
" se usa input_burned.mp4)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--srt",
|
|
||||||
default=None,
|
|
||||||
help=(
|
|
||||||
"Ruta SRT a escribir (si no se especifica,"
|
|
||||||
" se usa input.srt)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
video = Path(args.video)
|
|
||||||
if not video.exists():
|
|
||||||
print("Vídeo no encontrado", file=sys.stderr)
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
out_video = (
|
|
||||||
args.out
|
|
||||||
if args.out
|
|
||||||
else str(video.with_name(video.stem + "_burned.mp4"))
|
|
||||||
)
|
|
||||||
srt_path = args.srt if args.srt else str(video.with_suffix('.srt'))
|
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
|
||||||
audio_path = tmp.name
|
|
||||||
|
|
||||||
try:
|
|
||||||
print("Extrayendo audio con ffmpeg...")
|
|
||||||
extract_audio(str(video), audio_path)
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"Transcribiendo y traduciendo a '{args.to}'"
|
|
||||||
f" usando {args.backend}..."
|
|
||||||
)
|
|
||||||
if args.backend == "faster-whisper":
|
|
||||||
segments = transcribe_and_translate_faster(
|
|
||||||
audio_path, args.model, args.to
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
segments = transcribe_and_translate_openai(
|
|
||||||
audio_path, args.model, args.to
|
|
||||||
)
|
|
||||||
|
|
||||||
if not segments:
|
|
||||||
print(
|
|
||||||
"No se obtuvieron segmentos de la transcripción",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
sys.exit(3)
|
|
||||||
|
|
||||||
print(f"Escribiendo SRT en {srt_path}...")
|
|
||||||
write_srt(segments, srt_path)
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"Quemando subtítulos en el vídeo -> {out_video}"
|
|
||||||
f" (esto puede tardar)..."
|
|
||||||
)
|
|
||||||
burn_subtitles(str(video), srt_path, out_video)
|
|
||||||
|
|
||||||
print("Proceso completado.")
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
Path(audio_path).unlink()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -1,449 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# Orquesta: transcripción -> traducción -> síntesis por segmento -> reemplazo/mezcla -> quemado de subtítulos
|
"""Compatibility shim: run_full_pipeline
|
||||||
|
|
||||||
import argparse
|
This module forwards to `whisper_project.main:main` to preserve the
|
||||||
import os
|
historical CLI entrypoint name expected by tests and users.
|
||||||
import shlex
|
"""
|
||||||
import shutil
|
from __future__ import annotations
|
||||||
import subprocess
|
|
||||||
import sys
|
from whisper_project.main import main
|
||||||
import tempfile
|
|
||||||
|
|
||||||
|
|
||||||
def run(cmd, dry_run=False, env=None):
|
if __name__ == "__main__":
|
||||||
# Ejecuta un comando. Acepta str (ejecuta vía shell) o list (sin shell).
|
|
||||||
# Imprime el comando de forma segura para copiar/pegar. Si dry_run=True
|
|
||||||
# no ejecuta nada.
|
|
||||||
if isinstance(cmd, (list, tuple)):
|
|
||||||
printable = " ".join(shlex.quote(str(x)) for x in cmd)
|
|
||||||
else:
|
|
||||||
printable = cmd
|
|
||||||
print("+", printable)
|
|
||||||
if dry_run:
|
|
||||||
return 0
|
|
||||||
if isinstance(cmd, (list, tuple)):
|
|
||||||
return subprocess.run(cmd, shell=False, check=True, env=env)
|
|
||||||
return subprocess.run(cmd, shell=True, check=True, env=env)
|
|
||||||
|
|
||||||
|
|
||||||
def json_payload_template(model, voice):
|
|
||||||
# Payload JSON con {text} como placeholder que acepta srt_to_kokoro
|
|
||||||
return '{"model":"' + model + '","voice":"' + voice + '","input":"{text}","response_format":"wav"}'
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
p = argparse.ArgumentParser()
|
|
||||||
p.add_argument("--video", required=True, help="Vídeo de entrada")
|
|
||||||
p.add_argument(
|
|
||||||
"--srt",
|
|
||||||
help=("SRT de entrada (si ya existe). Si no, se transcribe del audio"),
|
|
||||||
)
|
|
||||||
p.add_argument("--kokoro-endpoint", required=True, help="URL del endpoint TTS")
|
|
||||||
p.add_argument("--kokoro-key", required=True, help="API key para Kokoro")
|
|
||||||
p.add_argument("--voice", default="em_alex", help="Nombre de voz (p.ej. em_alex)")
|
|
||||||
p.add_argument("--kokoro-model", default="model", help="ID del modelo Kokoro")
|
|
||||||
p.add_argument("--whisper-model", default="base", help="Modelo de Whisper para transcribir")
|
|
||||||
p.add_argument("--out", default=None, help="Vídeo de salida final (opcional)")
|
|
||||||
p.add_argument(
|
|
||||||
"--translate-method",
|
|
||||||
choices=["local", "gemini", "none"],
|
|
||||||
default="local",
|
|
||||||
help=(
|
|
||||||
"Método para traducir el SRT: 'local' (MarianMT), 'gemini' (API)"
|
|
||||||
" o 'none' (usar SRT proporcionado)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
p.add_argument("--gemini-key", default=None, help="API key para Gemini (si aplica)")
|
|
||||||
p.add_argument(
|
|
||||||
"--mix",
|
|
||||||
action="store_true",
|
|
||||||
help="Mezclar el audio sintetizado con la pista original en lugar de reemplazarla",
|
|
||||||
)
|
|
||||||
p.add_argument(
|
|
||||||
"--mix-background-volume",
|
|
||||||
type=float,
|
|
||||||
default=0.2,
|
|
||||||
help="Volumen de la pista original al mezclar (0.0-1.0)",
|
|
||||||
)
|
|
||||||
p.add_argument(
|
|
||||||
"--keep-chunks",
|
|
||||||
action="store_true",
|
|
||||||
help="Conservar los archivos de chunks generados por la síntesis (debug)",
|
|
||||||
)
|
|
||||||
p.add_argument(
|
|
||||||
"--keep-temp",
|
|
||||||
action="store_true",
|
|
||||||
help="No borrar el directorio temporal de trabajo al terminar",
|
|
||||||
)
|
|
||||||
p.add_argument("--dry-run", action="store_true", help="Solo mostrar comandos sin ejecutar")
|
|
||||||
args = p.parse_args()
|
|
||||||
|
|
||||||
video = os.path.abspath(args.video)
|
|
||||||
if not os.path.exists(video):
|
|
||||||
print("Vídeo no encontrado:", video, file=sys.stderr)
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
workdir = tempfile.mkdtemp(prefix="full_pipeline_")
|
|
||||||
try:
|
|
||||||
# 1) obtener SRT: si no se pasa, extraer audio y transcribir
|
|
||||||
if args.srt:
|
|
||||||
srt_in = os.path.abspath(args.srt)
|
|
||||||
print("Usando SRT proporcionado:", srt_in)
|
|
||||||
else:
|
|
||||||
audio_tmp = os.path.join(workdir, "extracted_audio.wav")
|
|
||||||
cmd_extract = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-i",
|
|
||||||
video,
|
|
||||||
"-vn",
|
|
||||||
"-acodec",
|
|
||||||
"pcm_s16le",
|
|
||||||
"-ar",
|
|
||||||
"16000",
|
|
||||||
"-ac",
|
|
||||||
"1",
|
|
||||||
audio_tmp,
|
|
||||||
]
|
|
||||||
run(cmd_extract, dry_run=args.dry_run)
|
|
||||||
|
|
||||||
# llamar al script transcribe.py para generar SRT
|
|
||||||
srt_in = os.path.join(workdir, "transcribed.srt")
|
|
||||||
cmd_trans = [
|
|
||||||
sys.executable,
|
|
||||||
"whisper_project/transcribe.py",
|
|
||||||
"--file",
|
|
||||||
audio_tmp,
|
|
||||||
"--backend",
|
|
||||||
"faster-whisper",
|
|
||||||
"--model",
|
|
||||||
args.whisper_model,
|
|
||||||
"--srt",
|
|
||||||
"--srt-file",
|
|
||||||
srt_in,
|
|
||||||
]
|
|
||||||
run(cmd_trans, dry_run=args.dry_run)
|
|
||||||
|
|
||||||
# 2) traducir SRT según método elegido
|
|
||||||
srt_translated = os.path.join(workdir, "translated.srt")
|
|
||||||
if args.translate_method == "local":
|
|
||||||
cmd_translate = [
|
|
||||||
sys.executable,
|
|
||||||
"whisper_project/translate_srt_local.py",
|
|
||||||
"--in",
|
|
||||||
srt_in,
|
|
||||||
"--out",
|
|
||||||
srt_translated,
|
|
||||||
]
|
|
||||||
run(cmd_translate, dry_run=args.dry_run)
|
|
||||||
elif args.translate_method == "gemini":
|
|
||||||
gem_key = args.gemini_key or os.environ.get("GEMINI_API_KEY")
|
|
||||||
if not gem_key:
|
|
||||||
print(
|
|
||||||
"--translate-method=gemini requiere --gemini-key o la var de entorno GEMINI_API_KEY",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
sys.exit(4)
|
|
||||||
cmd_translate = [
|
|
||||||
sys.executable,
|
|
||||||
"whisper_project/translate_srt_with_gemini.py",
|
|
||||||
"--in",
|
|
||||||
srt_in,
|
|
||||||
"--out",
|
|
||||||
srt_translated,
|
|
||||||
"--gemini-api-key",
|
|
||||||
gem_key,
|
|
||||||
]
|
|
||||||
run(cmd_translate, dry_run=args.dry_run)
|
|
||||||
else:
|
|
||||||
# none: usar SRT tal cual
|
|
||||||
srt_translated = srt_in
|
|
||||||
|
|
||||||
# 3) sintetizar por segmento con Kokoro, alinear, concatenar y
|
|
||||||
# reemplazar o mezclar audio en el vídeo
|
|
||||||
dub_wav = os.path.join(workdir, "dub_final.wav")
|
|
||||||
payload = json_payload_template(args.kokoro_model, args.voice)
|
|
||||||
synth_cmd = [
|
|
||||||
sys.executable,
|
|
||||||
"whisper_project/srt_to_kokoro.py",
|
|
||||||
"--srt",
|
|
||||||
srt_translated,
|
|
||||||
"--endpoint",
|
|
||||||
args.kokoro_endpoint,
|
|
||||||
"--payload-template",
|
|
||||||
payload,
|
|
||||||
"--api-key",
|
|
||||||
args.kokoro_key,
|
|
||||||
"--out",
|
|
||||||
dub_wav,
|
|
||||||
"--video",
|
|
||||||
video,
|
|
||||||
"--align",
|
|
||||||
]
|
|
||||||
if args.keep_chunks:
|
|
||||||
synth_cmd.append("--keep-chunks")
|
|
||||||
if args.mix:
|
|
||||||
synth_cmd += ["--mix-with-original", "--mix-background-volume", str(args.mix_background_volume)]
|
|
||||||
else:
|
|
||||||
synth_cmd.append("--replace-original")
|
|
||||||
|
|
||||||
run(synth_cmd, dry_run=args.dry_run)
|
|
||||||
|
|
||||||
# 4) quemar SRT en vídeo resultante
|
|
||||||
out_video = args.out if args.out else os.path.splitext(video)[0] + ".replaced_audio.subs.mp4"
|
|
||||||
replaced_src = os.path.splitext(video)[0] + ".replaced_audio.mp4"
|
|
||||||
# build filter string
|
|
||||||
vf = f"subtitles={srt_translated}:force_style='FontName=Arial,FontSize=24'"
|
|
||||||
cmd_burn = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-i",
|
|
||||||
replaced_src,
|
|
||||||
"-vf",
|
|
||||||
vf,
|
|
||||||
"-c:a",
|
|
||||||
"copy",
|
|
||||||
out_video,
|
|
||||||
]
|
|
||||||
run(cmd_burn, dry_run=args.dry_run)
|
|
||||||
|
|
||||||
print("Flujo completado. Vídeo final:", out_video)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if args.dry_run:
|
|
||||||
print("(dry-run) leaving workdir:", workdir)
|
|
||||||
else:
|
|
||||||
if not args.keep_temp:
|
|
||||||
try:
|
|
||||||
shutil.rmtree(workdir)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
#!/usr/bin/env python3
|
|
||||||
# run_full_pipeline.py
|
|
||||||
# Orquesta: transcripción -> traducción -> síntesis por segmento -> reemplazo/mezcla -> quemado de subtítulos
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import shlex
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
|
|
||||||
def run(cmd, dry_run=False, env=None):
|
|
||||||
# Ejecuta un comando. Acepta str (ejecuta vía shell) o list (sin shell).
|
|
||||||
# Imprime el comando de forma segura para copiar/pegar. Si dry_run=True
|
|
||||||
# no ejecuta nada.
|
|
||||||
if isinstance(cmd, (list, tuple)):
|
|
||||||
printable = " ".join(shlex.quote(str(x)) for x in cmd)
|
|
||||||
else:
|
|
||||||
printable = cmd
|
|
||||||
print("+", printable)
|
|
||||||
if dry_run:
|
|
||||||
return 0
|
|
||||||
if isinstance(cmd, (list, tuple)):
|
|
||||||
return subprocess.run(cmd, shell=False, check=True, env=env)
|
|
||||||
return subprocess.run(cmd, shell=True, check=True, env=env)
|
|
||||||
|
|
||||||
|
|
||||||
def json_payload_template(model, voice):
|
|
||||||
# Payload JSON con {text} como placeholder que acepta srt_to_kokoro
|
|
||||||
return '{"model":"' + model + '","voice":"' + voice + '","input":"{text}","response_format":"wav"}'
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
p = argparse.ArgumentParser()
|
|
||||||
p.add_argument("--video", required=True, help="Vídeo de entrada")
|
|
||||||
p.add_argument(
|
|
||||||
"--srt",
|
|
||||||
help=("SRT de entrada (si ya existe). Si no, se transcribe del audio"),
|
|
||||||
)
|
|
||||||
p.add_argument("--kokoro-endpoint", required=True, help="URL del endpoint TTS")
|
|
||||||
p.add_argument("--kokoro-key", required=True, help="API key para Kokoro")
|
|
||||||
p.add_argument("--voice", default="em_alex", help="Nombre de voz (p.ej. em_alex)")
|
|
||||||
p.add_argument("--kokoro-model", default="model", help="ID del modelo Kokoro")
|
|
||||||
p.add_argument("--whisper-model", default="base", help="Modelo de Whisper para transcribir")
|
|
||||||
p.add_argument("--out", default=None, help="Vídeo de salida final (opcional)")
|
|
||||||
p.add_argument(
|
|
||||||
"--translate-method",
|
|
||||||
choices=["local", "gemini", "none"],
|
|
||||||
default="local",
|
|
||||||
help=(
|
|
||||||
"Método para traducir el SRT: 'local' (MarianMT), 'gemini' (API)"
|
|
||||||
" o 'none' (usar SRT proporcionado)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
p.add_argument("--gemini-key", default=None, help="API key para Gemini (si aplica)")
|
|
||||||
p.add_argument(
|
|
||||||
"--mix",
|
|
||||||
action="store_true",
|
|
||||||
help="Mezclar el audio sintetizado con la pista original en lugar de reemplazarla",
|
|
||||||
)
|
|
||||||
p.add_argument(
|
|
||||||
"--mix-background-volume",
|
|
||||||
type=float,
|
|
||||||
default=0.2,
|
|
||||||
help="Volumen de la pista original al mezclar (0.0-1.0)",
|
|
||||||
)
|
|
||||||
p.add_argument(
|
|
||||||
"--keep-chunks",
|
|
||||||
action="store_true",
|
|
||||||
help="Conservar los archivos de chunks generados por la síntesis (debug)",
|
|
||||||
)
|
|
||||||
p.add_argument(
|
|
||||||
"--keep-temp",
|
|
||||||
action="store_true",
|
|
||||||
help="No borrar el directorio temporal de trabajo al terminar",
|
|
||||||
)
|
|
||||||
p.add_argument("--dry-run", action="store_true", help="Solo mostrar comandos sin ejecutar")
|
|
||||||
args = p.parse_args()
|
|
||||||
|
|
||||||
video = os.path.abspath(args.video)
|
|
||||||
if not os.path.exists(video):
|
|
||||||
print("Vídeo no encontrado:", video, file=sys.stderr)
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
workdir = tempfile.mkdtemp(prefix="full_pipeline_")
|
|
||||||
try:
|
|
||||||
# 1) obtener SRT: si no se pasa, extraer audio y transcribir
|
|
||||||
if args.srt:
|
|
||||||
srt_in = os.path.abspath(args.srt)
|
|
||||||
print("Usando SRT proporcionado:", srt_in)
|
|
||||||
else:
|
|
||||||
audio_tmp = os.path.join(workdir, "extracted_audio.wav")
|
|
||||||
cmd_extract = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-i",
|
|
||||||
video,
|
|
||||||
"-vn",
|
|
||||||
"-acodec",
|
|
||||||
"pcm_s16le",
|
|
||||||
"-ar",
|
|
||||||
"16000",
|
|
||||||
"-ac",
|
|
||||||
"1",
|
|
||||||
audio_tmp,
|
|
||||||
]
|
|
||||||
run(cmd_extract, dry_run=args.dry_run)
|
|
||||||
|
|
||||||
# llamar al script transcribe.py para generar SRT
|
|
||||||
srt_in = os.path.join(workdir, "transcribed.srt")
|
|
||||||
cmd_trans = [
|
|
||||||
sys.executable,
|
|
||||||
"whisper_project/transcribe.py",
|
|
||||||
"--file",
|
|
||||||
audio_tmp,
|
|
||||||
"--backend",
|
|
||||||
"faster-whisper",
|
|
||||||
"--model",
|
|
||||||
args.whisper_model,
|
|
||||||
"--srt",
|
|
||||||
"--srt-file",
|
|
||||||
srt_in,
|
|
||||||
]
|
|
||||||
run(cmd_trans, dry_run=args.dry_run)
|
|
||||||
|
|
||||||
# 2) traducir SRT según método elegido
|
|
||||||
srt_translated = os.path.join(workdir, "translated.srt")
|
|
||||||
if args.translate_method == "local":
|
|
||||||
cmd_translate = [
|
|
||||||
sys.executable,
|
|
||||||
"whisper_project/translate_srt_local.py",
|
|
||||||
"--in",
|
|
||||||
srt_in,
|
|
||||||
"--out",
|
|
||||||
srt_translated,
|
|
||||||
]
|
|
||||||
run(cmd_translate, dry_run=args.dry_run)
|
|
||||||
elif args.translate_method == "gemini":
|
|
||||||
gem_key = args.gemini_key or os.environ.get("GEMINI_API_KEY")
|
|
||||||
if not gem_key:
|
|
||||||
print(
|
|
||||||
"--translate-method=gemini requiere --gemini-key o la var de entorno GEMINI_API_KEY",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
sys.exit(4)
|
|
||||||
cmd_translate = [
|
|
||||||
sys.executable,
|
|
||||||
"whisper_project/translate_srt_with_gemini.py",
|
|
||||||
"--in",
|
|
||||||
srt_in,
|
|
||||||
"--out",
|
|
||||||
srt_translated,
|
|
||||||
"--gemini-api-key",
|
|
||||||
gem_key,
|
|
||||||
]
|
|
||||||
run(cmd_translate, dry_run=args.dry_run)
|
|
||||||
else:
|
|
||||||
# none: usar SRT tal cual
|
|
||||||
srt_translated = srt_in
|
|
||||||
|
|
||||||
# 3) sintetizar por segmento con Kokoro, alinear, concatenar y
|
|
||||||
# reemplazar o mezclar audio en el vídeo
|
|
||||||
dub_wav = os.path.join(workdir, "dub_final.wav")
|
|
||||||
payload = json_payload_template(args.kokoro_model, args.voice)
|
|
||||||
synth_cmd = [
|
|
||||||
sys.executable,
|
|
||||||
"whisper_project/srt_to_kokoro.py",
|
|
||||||
"--srt",
|
|
||||||
srt_translated,
|
|
||||||
"--endpoint",
|
|
||||||
args.kokoro_endpoint,
|
|
||||||
"--payload-template",
|
|
||||||
payload,
|
|
||||||
"--api-key",
|
|
||||||
args.kokoro_key,
|
|
||||||
"--out",
|
|
||||||
dub_wav,
|
|
||||||
"--video",
|
|
||||||
video,
|
|
||||||
"--align",
|
|
||||||
]
|
|
||||||
if args.keep_chunks:
|
|
||||||
synth_cmd.append("--keep-chunks")
|
|
||||||
if args.mix:
|
|
||||||
synth_cmd += ["--mix-with-original", "--mix-background-volume", str(args.mix_background_volume)]
|
|
||||||
else:
|
|
||||||
synth_cmd.append("--replace-original")
|
|
||||||
|
|
||||||
run(synth_cmd, dry_run=args.dry_run)
|
|
||||||
|
|
||||||
# 4) quemar SRT en vídeo resultante
|
|
||||||
out_video = args.out if args.out else os.path.splitext(video)[0] + ".replaced_audio.subs.mp4"
|
|
||||||
replaced_src = os.path.splitext(video)[0] + ".replaced_audio.mp4"
|
|
||||||
# build filter string
|
|
||||||
vf = f"subtitles={srt_translated}:force_style='FontName=Arial,FontSize=24'"
|
|
||||||
cmd_burn = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-i",
|
|
||||||
replaced_src,
|
|
||||||
"-vf",
|
|
||||||
vf,
|
|
||||||
"-c:a",
|
|
||||||
"copy",
|
|
||||||
out_video,
|
|
||||||
]
|
|
||||||
run(cmd_burn, dry_run=args.dry_run)
|
|
||||||
|
|
||||||
print("Flujo completado. Vídeo final:", out_video)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if args.dry_run:
|
|
||||||
print("(dry-run) leaving workdir:", workdir)
|
|
||||||
else:
|
|
||||||
if not args.keep_temp:
|
|
||||||
try:
|
|
||||||
shutil.rmtree(workdir)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
main()
|
||||||
@ -1,17 +1,26 @@
|
|||||||
import os, traceback
|
#!/usr/bin/env python3
|
||||||
from TTS.api import TTS
|
"""Shim: run_xtts_clone
|
||||||
|
|
||||||
out='whisper_project/dub_female_xtts_es.wav'
|
This script delegates to the example `examples/run_xtts_clone.py` or
|
||||||
speaker='whisper_project/ref_female_es.wav'
|
prints guidance if not available. Kept for backward compatibility.
|
||||||
text='Hola, esta es una prueba de clonación usando xtts_v2 en español latino.'
|
"""
|
||||||
model='tts_models/multilingual/multi-dataset/xtts_v2'
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
script = "examples/run_xtts_clone.py"
|
||||||
try:
|
try:
|
||||||
print('Cargando modelo:', model)
|
subprocess.run([sys.executable, script], check=True)
|
||||||
tts = TTS(model_name=model, progress_bar=True, gpu=False)
|
|
||||||
print('Llamando a tts_to_file con speaker_wav=', speaker)
|
|
||||||
tts.tts_to_file(text=text, file_path=out, speaker_wav=speaker, language='es')
|
|
||||||
print('Generado:', out, 'size=', os.path.getsize(out))
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print('Error durante la clonación:')
|
print("Error ejecutando run_xtts_clone ejemplo:", e, file=sys.stderr)
|
||||||
traceback.print_exc()
|
print("Ejecuta 'python examples/run_xtts_clone.py' para la demo.")
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,43 @@
|
|||||||
|
"""Funciones helper para sintetizar desde SRT.
|
||||||
|
|
||||||
|
Este módulo mantiene compatibilidad con la antigua utilidad `srt_to_kokoro.py`.
|
||||||
|
Contiene `parse_srt_file` y `synth_chunk` delegando a infra.kokoro_utils.
|
||||||
|
Se incluye una función `synthesize_from_srt` que documenta la compatibilidad
|
||||||
|
con `KokoroHttpClient` (nombre esperado por otros módulos).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from whisper_project.infra.kokoro_utils import parse_srt_file as _parse_srt_file, synth_chunk as _synth_chunk
|
||||||
|
|
||||||
|
|
||||||
|
def parse_srt_file(path: str):
|
||||||
|
"""Parsea un .srt y devuelve la lista de subtítulos.
|
||||||
|
|
||||||
|
Delegado a `whisper_project.infra.kokoro_utils.parse_srt_file`.
|
||||||
|
"""
|
||||||
|
return _parse_srt_file(path)
|
||||||
|
|
||||||
|
|
||||||
|
def synth_chunk(endpoint: str, text: str, headers: dict, payload_template: Any, timeout: int = 60) -> bytes:
|
||||||
|
"""Envía texto al endpoint y devuelve bytes de audio.
|
||||||
|
|
||||||
|
Delegado a `whisper_project.infra.kokoro_utils.synth_chunk`.
|
||||||
|
"""
|
||||||
|
return _synth_chunk(endpoint, text, headers, payload_template, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def synthesize_from_srt(srt_path: str, out_wav: str, endpoint: str = "", api_key: str = ""):
|
||||||
|
"""Compat layer: función con el nombre esperado por scripts legacy.
|
||||||
|
|
||||||
|
Nota: la implementación completa se encuentra ahora en `KokoroHttpClient`.
|
||||||
|
Esta función delega a `parse_srt_file` y `synth_chunk` si se necesita.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("Use KokoroHttpClient.synthesize_from_srt or the infra adapter instead")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["parse_srt_file", "synth_chunk", "synthesize_from_srt"]
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
srt_to_kokoro.py
|
srt_to_kokoro.py
|
||||||
@ -17,475 +57,66 @@ Ejemplos:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
try:
|
"""
|
||||||
import requests
|
Thin wrapper CLI que delega en `KokoroHttpClient.synthesize_from_srt`.
|
||||||
except Exception as e:
|
|
||||||
print("Este script requiere la librería 'requests'. Instálala con: pip install requests")
|
|
||||||
raise
|
|
||||||
|
|
||||||
try:
|
Conserva la interfaz CLI previa para compatibilidad, pero internamente usa
|
||||||
import srt
|
el cliente HTTP nativo definido en `whisper_project.infra.kokoro_adapter`.
|
||||||
except Exception:
|
"""
|
||||||
print("Este script requiere la librería 'srt'. Instálala con: pip install srt")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
def find_synthesis_endpoint(openapi_url: str) -> Optional[str]:
|
from whisper_project.infra.kokoro_adapter import KokoroHttpClient
|
||||||
"""Intento heurístico: baja openapi.json y busca paths con 'synth'|'tts'|'text' que soporten POST."""
|
|
||||||
try:
|
|
||||||
r = requests.get(openapi_url, timeout=20)
|
|
||||||
r.raise_for_status()
|
|
||||||
spec = r.json()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"No pude leer openapi.json desde {openapi_url}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
paths = spec.get("paths", {})
|
|
||||||
candidate = None
|
|
||||||
for path, methods in paths.items():
|
|
||||||
lname = path.lower()
|
|
||||||
if any(k in lname for k in ("synth", "tts", "text", "synthesize")):
|
|
||||||
for method, op in methods.items():
|
|
||||||
if method.lower() == "post":
|
|
||||||
# candidato
|
|
||||||
candidate = path
|
|
||||||
break
|
|
||||||
if candidate:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not candidate:
|
|
||||||
# fallback: scan operationId or summary
|
|
||||||
for path, methods in paths.items():
|
|
||||||
for method, op in methods.items():
|
|
||||||
meta = json.dumps(op).lower()
|
|
||||||
if any(k in meta for k in ("synth", "tts", "text", "synthesize")) and method.lower() == "post":
|
|
||||||
candidate = path
|
|
||||||
break
|
|
||||||
if candidate:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not candidate:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Construir base url desde openapi_url
|
|
||||||
from urllib.parse import urlparse, urljoin
|
|
||||||
p = urlparse(openapi_url)
|
|
||||||
base = f"{p.scheme}://{p.netloc}"
|
|
||||||
return urljoin(base, candidate)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_srt_file(path: str):
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
raw = f.read()
|
|
||||||
subs = list(srt.parse(raw))
|
|
||||||
return subs
|
|
||||||
|
|
||||||
|
|
||||||
def synth_chunk(endpoint: str, text: str, headers: dict, payload_template: Optional[str], timeout=60):
|
|
||||||
"""Envía la solicitud y devuelve bytes de audio. Maneja respuestas audio/* o JSON con campo base64."""
|
|
||||||
# Construir payload
|
|
||||||
if payload_template:
|
|
||||||
body = payload_template.replace("{text}", text)
|
|
||||||
try:
|
|
||||||
json_body = json.loads(body)
|
|
||||||
except Exception:
|
|
||||||
# enviar como texto plano
|
|
||||||
json_body = {"text": text}
|
|
||||||
else:
|
|
||||||
json_body = {"text": text}
|
|
||||||
|
|
||||||
# Realizar POST
|
|
||||||
r = requests.post(endpoint, json=json_body, headers=headers, timeout=timeout)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
ctype = r.headers.get("Content-Type", "")
|
|
||||||
if ctype.startswith("audio/"):
|
|
||||||
return r.content
|
|
||||||
# Si viene JSON con base64
|
|
||||||
try:
|
|
||||||
j = r.json()
|
|
||||||
# buscar campos con 'audio' o 'wav' o 'base64'
|
|
||||||
for k in ("audio", "wav", "data", "base64"):
|
|
||||||
if k in j:
|
|
||||||
val = j[k]
|
|
||||||
# si es base64
|
|
||||||
import base64
|
|
||||||
try:
|
|
||||||
return base64.b64decode(val)
|
|
||||||
except Exception:
|
|
||||||
# tal vez ya es bytes hex u otra cosa
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fallback: devolver raw bytes
|
|
||||||
return r.content
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_ffmpeg():
|
|
||||||
if shutil.which("ffmpeg") is None:
|
|
||||||
print("ffmpeg no está disponible en PATH. Instálalo para poder concatenar/convertir audios.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def convert_and_save(raw_bytes: bytes, target_path: str):
|
|
||||||
"""Guarda bytes a un archivo temporal y convierte a WAV PCM 16k mono usando ffmpeg."""
|
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".bin") as tmp:
|
|
||||||
tmp.write(raw_bytes)
|
|
||||||
tmp.flush()
|
|
||||||
tmp_path = tmp.name
|
|
||||||
|
|
||||||
# Convertir con ffmpeg a WAV 22050 Hz mono 16-bit
|
|
||||||
cmd = [
|
|
||||||
"ffmpeg", "-y", "-i", tmp_path,
|
|
||||||
"-ar", "22050", "-ac", "1", "-sample_fmt", "s16", target_path
|
|
||||||
]
|
|
||||||
try:
|
|
||||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"ffmpeg falló al convertir chunk: {e}")
|
|
||||||
# como fallback, escribir los bytes "crudos"
|
|
||||||
with open(target_path, "wb") as out:
|
|
||||||
out.write(raw_bytes)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.remove(tmp_path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def create_silence(duration: float, out_path: str, sr: int = 22050):
|
|
||||||
"""Create a silent wav of given duration (seconds) at sr and save to out_path."""
|
|
||||||
# use ffmpeg anullsrc
|
|
||||||
cmd = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-f",
|
|
||||||
"lavfi",
|
|
||||||
"-i",
|
|
||||||
f"anullsrc=channel_layout=mono:sample_rate={sr}",
|
|
||||||
"-t",
|
|
||||||
f"{duration}",
|
|
||||||
"-c:a",
|
|
||||||
"pcm_s16le",
|
|
||||||
out_path,
|
|
||||||
]
|
|
||||||
try:
|
|
||||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
# fallback: write tiny silence by creating zero bytes
|
|
||||||
try:
|
|
||||||
with open(out_path, "wb") as fh:
|
|
||||||
fh.write(b"\x00" * 1024)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def pad_or_trim_wav(in_path: str, out_path: str, target_duration: float, sr: int = 22050):
|
|
||||||
"""Pad with silence or trim input wav to match target_duration (seconds)."""
|
|
||||||
# get duration
|
|
||||||
try:
|
|
||||||
p = subprocess.run([
|
|
||||||
"ffprobe",
|
|
||||||
"-v",
|
|
||||||
"error",
|
|
||||||
"-show_entries",
|
|
||||||
"format=duration",
|
|
||||||
"-of",
|
|
||||||
"default=noprint_wrappers=1:nokey=1",
|
|
||||||
in_path,
|
|
||||||
], capture_output=True, text=True)
|
|
||||||
cur = float(p.stdout.strip())
|
|
||||||
except Exception:
|
|
||||||
cur = 0.0
|
|
||||||
|
|
||||||
if cur == 0.0:
|
|
||||||
shutil.copy(in_path, out_path)
|
|
||||||
return
|
|
||||||
|
|
||||||
if abs(cur - target_duration) < 0.02:
|
|
||||||
shutil.copy(in_path, out_path)
|
|
||||||
return
|
|
||||||
|
|
||||||
if cur > target_duration:
|
|
||||||
cmd = ["ffmpeg", "-y", "-i", in_path, "-t", f"{target_duration}", out_path]
|
|
||||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
return
|
|
||||||
|
|
||||||
# pad: create silence of missing duration and concat
|
|
||||||
pad = target_duration - cur
|
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as sil:
|
|
||||||
sil_path = sil.name
|
|
||||||
try:
|
|
||||||
create_silence(pad, sil_path, sr=sr)
|
|
||||||
# concat in_path + sil_path
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as listf:
|
|
||||||
listf.write(f"file '{os.path.abspath(in_path)}'\n")
|
|
||||||
listf.write(f"file '{os.path.abspath(sil_path)}'\n")
|
|
||||||
listname = listf.name
|
|
||||||
cmd2 = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", listname, "-c", "copy", out_path]
|
|
||||||
subprocess.run(cmd2, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.remove(sil_path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
os.remove(listname)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def concat_chunks(chunks: list, out_path: str):
|
|
||||||
# Crear lista para ffmpeg concat demuxer
|
|
||||||
ensure_ffmpeg()
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as listf:
|
|
||||||
for c in chunks:
|
|
||||||
listf.write(f"file '{os.path.abspath(c)}'\n")
|
|
||||||
listname = listf.name
|
|
||||||
|
|
||||||
cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", listname, "-c", "copy", out_path]
|
|
||||||
try:
|
|
||||||
subprocess.run(cmd, check=True)
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
# fallback: concatenar mediante reconversión
|
|
||||||
tmp_concat = out_path + ".tmp.wav"
|
|
||||||
cmd2 = ["ffmpeg", "-y", "-i", f"concat:{'|'.join(chunks)}", "-c", "copy", tmp_concat]
|
|
||||||
subprocess.run(cmd2)
|
|
||||||
shutil.move(tmp_concat, out_path)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.remove(listname)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
p = argparse.ArgumentParser()
|
p = argparse.ArgumentParser()
|
||||||
p.add_argument("--srt", required=True, help="Ruta al archivo .srt traducido")
|
p.add_argument("--srt", required=True, help="Ruta al archivo .srt traducido")
|
||||||
p.add_argument("--openapi", required=False, help="URL al openapi.json de Kokoro (intenta autodetectar endpoint)")
|
p.add_argument("--endpoint", required=False, help="URL directa del endpoint de síntesis (opcional)")
|
||||||
p.add_argument("--endpoint", required=False, help="URL directa del endpoint de síntesis (usa esto si autodetección falla)")
|
|
||||||
p.add_argument(
|
|
||||||
"--payload-template",
|
|
||||||
required=False,
|
|
||||||
help='Plantilla JSON para el payload con {text} como placeholder, ejemplo: "{\"text\": \"{text}\", \"voice\": \"alloy\"}"',
|
|
||||||
)
|
|
||||||
p.add_argument("--api-key", required=False, help="Valor para autorización (se envía como header Authorization: Bearer <key>)")
|
p.add_argument("--api-key", required=False, help="Valor para autorización (se envía como header Authorization: Bearer <key>)")
|
||||||
p.add_argument("--voice", required=False, help="Nombre de voz si aplica (se añade al payload si se usa template)")
|
p.add_argument("--voice", default="em_alex")
|
||||||
|
p.add_argument("--model", default="model")
|
||||||
p.add_argument("--out", required=True, help="Ruta de salida WAV final")
|
p.add_argument("--out", required=True, help="Ruta de salida WAV final")
|
||||||
p.add_argument(
|
p.add_argument("--video", required=False, help="Ruta al vídeo original (opcional)")
|
||||||
"--video",
|
p.add_argument("--align", action="store_true", help="Alinear segmentos con timestamps del SRT")
|
||||||
required=False,
|
p.add_argument("--keep-chunks", action="store_true")
|
||||||
help="Ruta al vídeo original (necesario si quieres mezclar el audio con la pista original).",
|
p.add_argument("--mix-with-original", action="store_true")
|
||||||
)
|
p.add_argument("--mix-background-volume", type=float, default=0.2)
|
||||||
p.add_argument(
|
p.add_argument("--replace-original", action="store_true")
|
||||||
"--mix-with-original",
|
|
||||||
action="store_true",
|
|
||||||
help="Mezclar el WAV generado con la pista de audio original del vídeo (usa --video).",
|
|
||||||
)
|
|
||||||
p.add_argument(
|
|
||||||
"--mix-background-volume",
|
|
||||||
type=float,
|
|
||||||
default=0.2,
|
|
||||||
help="Volumen de la pista original al mezclar (0.0-1.0), por defecto 0.2",
|
|
||||||
)
|
|
||||||
p.add_argument(
|
|
||||||
"--replace-original",
|
|
||||||
action="store_true",
|
|
||||||
help="Reemplazar la pista de audio del vídeo original por el WAV generado (usa --video).",
|
|
||||||
)
|
|
||||||
p.add_argument(
|
|
||||||
"--align",
|
|
||||||
action="store_true",
|
|
||||||
help="Generar silencios para alinear segmentos con los timestamps del SRT (inserta gaps entre segmentos).",
|
|
||||||
)
|
|
||||||
p.add_argument(
|
|
||||||
"--keep-chunks",
|
|
||||||
action="store_true",
|
|
||||||
help="Conservar los WAV de cada segmento en el directorio temporal (útil para debugging).",
|
|
||||||
)
|
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
|
|
||||||
headers = {"Accept": "*/*"}
|
# Construir cliente Kokoro HTTP y delegar la síntesis completa
|
||||||
if args.api_key:
|
endpoint = args.endpoint or os.environ.get("KOKORO_ENDPOINT")
|
||||||
headers["Authorization"] = f"Bearer {args.api_key}"
|
api_key = args.api_key or os.environ.get("KOKORO_API_KEY")
|
||||||
|
|
||||||
endpoint = args.endpoint
|
|
||||||
if not endpoint and args.openapi:
|
|
||||||
print("Intentando detectar endpoint desde openapi.json...")
|
|
||||||
endpoint = find_synthesis_endpoint(args.openapi)
|
|
||||||
if endpoint:
|
|
||||||
print(f"Usando endpoint detectado: {endpoint}")
|
|
||||||
else:
|
|
||||||
print("No se detectó endpoint automáticamente. Pasa --endpoint o --payload-template.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if not endpoint:
|
if not endpoint:
|
||||||
print("Debes proporcionar --endpoint o --openapi para que el script funcione.")
|
print("Debe proporcionar --endpoint o la variable de entorno KOKORO_ENDPOINT", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(2)
|
||||||
|
|
||||||
subs = parse_srt_file(args.srt)
|
|
||||||
tmpdir = tempfile.mkdtemp(prefix="srt_kokoro_")
|
|
||||||
chunk_files = []
|
|
||||||
|
|
||||||
print(f"Sintetizando {len(subs)} segmentos...")
|
|
||||||
prev_end = 0.0
|
|
||||||
for i, sub in enumerate(subs, start=1):
|
|
||||||
text = re.sub(r"\s+", " ", sub.content.strip())
|
|
||||||
if not text:
|
|
||||||
prev_end = sub.end.total_seconds()
|
|
||||||
continue
|
|
||||||
|
|
||||||
start_sec = sub.start.total_seconds()
|
|
||||||
end_sec = sub.end.total_seconds()
|
|
||||||
duration = end_sec - start_sec
|
|
||||||
|
|
||||||
# if align requested, insert silence for gap between previous end and current start
|
|
||||||
if args.align:
|
|
||||||
gap = start_sec - prev_end
|
|
||||||
if gap > 0.01:
|
|
||||||
sil_target = os.path.join(tmpdir, f"sil_{i:04d}.wav")
|
|
||||||
create_silence(gap, sil_target)
|
|
||||||
chunk_files.append(sil_target)
|
|
||||||
|
|
||||||
|
client = KokoroHttpClient(endpoint, api_key=api_key, voice=args.voice, model=args.model)
|
||||||
try:
|
try:
|
||||||
raw = synth_chunk(endpoint, text, headers, args.payload_template)
|
client.synthesize_from_srt(
|
||||||
except Exception as e:
|
srt_path=args.srt,
|
||||||
print(f"Error al sintetizar segmento {i}: {e}")
|
out_wav=args.out,
|
||||||
prev_end = end_sec
|
video=args.video,
|
||||||
continue
|
align=args.align,
|
||||||
|
keep_chunks=args.keep_chunks,
|
||||||
target = os.path.join(tmpdir, f"chunk_{i:04d}.wav")
|
mix_with_original=args.mix_with_original,
|
||||||
convert_and_save(raw, target)
|
mix_background_volume=args.mix_background_volume,
|
||||||
|
)
|
||||||
# If align: pad or trim to subtitle duration, otherwise keep raw chunk
|
|
||||||
if args.align:
|
|
||||||
aligned = os.path.join(tmpdir, f"chunk_{i:04d}.aligned.wav")
|
|
||||||
pad_or_trim_wav(target, aligned, duration)
|
|
||||||
# replace target with aligned file in list
|
|
||||||
chunk_files.append(aligned)
|
|
||||||
# remove original raw chunk unless keep-chunks
|
|
||||||
if not args.keep_chunks:
|
|
||||||
try:
|
|
||||||
os.remove(target)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
chunk_files.append(target)
|
|
||||||
|
|
||||||
prev_end = end_sec
|
|
||||||
print(f" - Segmento {i}/{len(subs)} -> {os.path.basename(chunk_files[-1])}")
|
|
||||||
|
|
||||||
if not chunk_files:
|
|
||||||
print("No se generaron fragmentos de audio. Abortando.")
|
|
||||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print("Concatenando fragments...")
|
|
||||||
concat_chunks(chunk_files, args.out)
|
|
||||||
print(f"Archivo final generado en: {args.out}")
|
print(f"Archivo final generado en: {args.out}")
|
||||||
|
except Exception as e:
|
||||||
# Si el usuario pidió mezclar con la pista original del vídeo
|
print(f"Error durante la síntesis desde SRT: {e}", file=sys.stderr)
|
||||||
if args.mix_with_original:
|
sys.exit(1)
|
||||||
if not args.video:
|
|
||||||
print("--mix-with-original requiere que pases --video con la ruta del vídeo original.")
|
|
||||||
else:
|
|
||||||
# extraer audio del vídeo original a wav temporal (mono 22050)
|
|
||||||
orig_tmp = os.path.join(tempfile.gettempdir(), f"orig_audio_{os.getpid()}.wav")
|
|
||||||
mixed_tmp = os.path.join(tempfile.gettempdir(), f"mixed_audio_{os.getpid()}.wav")
|
|
||||||
try:
|
|
||||||
cmd_ext = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-i",
|
|
||||||
args.video,
|
|
||||||
"-vn",
|
|
||||||
"-ar",
|
|
||||||
"22050",
|
|
||||||
"-ac",
|
|
||||||
"1",
|
|
||||||
"-sample_fmt",
|
|
||||||
"s16",
|
|
||||||
orig_tmp,
|
|
||||||
]
|
|
||||||
subprocess.run(cmd_ext, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
|
|
||||||
# Mezclar: new audio (args.out) en primer plano, original a volumen reducido
|
|
||||||
vol = float(args.mix_background_volume)
|
|
||||||
# construir filtro: [0:a]volume=1[a1];[1:a]volume=vol[a0];[a1][a0]amix=inputs=2:duration=first:weights=1 vol [mix]
|
|
||||||
filter_complex = f"[0:a]volume=1[a1];[1:a]volume={vol}[a0];[a1][a0]amix=inputs=2:duration=first:weights=1 {vol}[mix]"
|
|
||||||
# usar ffmpeg para mezclar y generar mixed_tmp
|
|
||||||
cmd_mix = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-i",
|
|
||||||
args.out,
|
|
||||||
"-i",
|
|
||||||
orig_tmp,
|
|
||||||
"-filter_complex",
|
|
||||||
f"[0:a]volume=1[a1];[1:a]volume={vol}[a0];[a1][a0]amix=inputs=2:duration=first:dropout_transition=0[mix]",
|
|
||||||
"-map",
|
|
||||||
"[mix]",
|
|
||||||
"-c:a",
|
|
||||||
"pcm_s16le",
|
|
||||||
mixed_tmp,
|
|
||||||
]
|
|
||||||
subprocess.run(cmd_mix, check=True)
|
|
||||||
|
|
||||||
# reemplazar args.out con mixed_tmp
|
|
||||||
shutil.move(mixed_tmp, args.out)
|
|
||||||
print(f"Archivo mezclado generado en: {args.out}")
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"Error al mezclar audio con la pista original: {e}")
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
if os.path.exists(orig_tmp):
|
|
||||||
os.remove(orig_tmp)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Si se solicita reemplazar la pista original en el vídeo
|
|
||||||
if args.replace_original:
|
|
||||||
if not args.video:
|
|
||||||
print("--replace-original requiere que pases --video con la ruta del vídeo original.")
|
|
||||||
else:
|
|
||||||
out_video = os.path.splitext(args.video)[0] + ".replaced_audio.mp4"
|
|
||||||
try:
|
|
||||||
cmd_rep = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-i",
|
|
||||||
args.video,
|
|
||||||
"-i",
|
|
||||||
args.out,
|
|
||||||
"-map",
|
|
||||||
"0:v:0",
|
|
||||||
"-map",
|
|
||||||
"1:a:0",
|
|
||||||
"-c:v",
|
|
||||||
"copy",
|
|
||||||
"-c:a",
|
|
||||||
"aac",
|
|
||||||
"-b:a",
|
|
||||||
"192k",
|
|
||||||
out_video,
|
|
||||||
]
|
|
||||||
subprocess.run(cmd_rep, check=True)
|
|
||||||
print(f"Vídeo con audio reemplazado generado: {out_video}")
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"Error al reemplazar audio en el vídeo: {e}")
|
|
||||||
|
|
||||||
# limpieza
|
|
||||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@ -1,890 +1,49 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Transcribe audio usando distintos backends de Whisper.
|
|
||||||
|
|
||||||
Soportados: openai-whisper, transformers, faster-whisper
|
"""Compat wrapper para transcripción.
|
||||||
|
|
||||||
|
Este módulo expone una clase ligera `FasterWhisperTranscriber` que
|
||||||
|
reutiliza la implementación del adaptador infra (`TranscribeService`).
|
||||||
|
También reexporta utilidades comunes como `write_srt` y
|
||||||
|
`dedupe_adjacent_segments` para mantener compatibilidad con código
|
||||||
|
legacy que importa estas funciones desde `whisper_project.transcribe`.
|
||||||
"""
|
"""
|
||||||
import argparse
|
from __future__ import annotations
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from typing import Optional
|
||||||
|
|
||||||
|
from whisper_project.infra.transcribe_adapter import TranscribeService
|
||||||
|
from whisper_project.infra.transcribe import (
|
||||||
|
write_srt,
|
||||||
|
dedupe_adjacent_segments,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def transcribe_openai_whisper(file: str, model: str):
|
class FasterWhisperTranscriber:
|
||||||
import whisper
|
"""Adaptador mínimo que expone la API esperada por código legacy.
|
||||||
|
|
||||||
print(f"Cargando openai-whisper modelo={model} en CPU...")
|
Internamente reutiliza `TranscribeService.transcribe_faster`.
|
||||||
m = whisper.load_model(model, device="cpu")
|
|
||||||
print("Transcribiendo...")
|
|
||||||
result = m.transcribe(file, fp16=False)
|
|
||||||
# openai-whisper devuelve 'segments' con start, end y text
|
|
||||||
segments = result.get("segments", None)
|
|
||||||
if segments:
|
|
||||||
for seg in segments:
|
|
||||||
print(seg.get("text", ""))
|
|
||||||
return segments
|
|
||||||
else:
|
|
||||||
print(result.get("text", ""))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def transcribe_transformers(file: str, model: str):
|
|
||||||
import torch
|
|
||||||
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline
|
|
||||||
|
|
||||||
device = "cpu"
|
|
||||||
torch_dtype = torch.float32
|
|
||||||
|
|
||||||
print(f"Cargando transformers modelo={model} en CPU...")
|
|
||||||
model_obj = AutoModelForSpeechSeq2Seq.from_pretrained(model, torch_dtype=torch_dtype, low_cpu_mem_usage=True)
|
|
||||||
model_obj.to(device)
|
|
||||||
processor = AutoProcessor.from_pretrained(model)
|
|
||||||
|
|
||||||
pipe = pipeline(
|
|
||||||
"automatic-speech-recognition",
|
|
||||||
model=model_obj,
|
|
||||||
tokenizer=processor.tokenizer,
|
|
||||||
feature_extractor=processor.feature_extractor,
|
|
||||||
device=-1,
|
|
||||||
)
|
|
||||||
|
|
||||||
print("Transcribiendo...")
|
|
||||||
result = pipe(file)
|
|
||||||
# result puede ser dict o str dependiendo de la versión
|
|
||||||
if isinstance(result, dict):
|
|
||||||
print(result.get("text", ""))
|
|
||||||
else:
|
|
||||||
print(result)
|
|
||||||
# transformers pipeline normalmente no devuelve segmentos temporales
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def transcribe_faster_whisper(file: str, model: str, compute_type: str = "int8"):
|
|
||||||
from faster_whisper import WhisperModel
|
|
||||||
|
|
||||||
print(f"Cargando faster-whisper modelo={model} en CPU compute_type={compute_type}...")
|
|
||||||
model_obj = WhisperModel(model, device="cpu", compute_type=compute_type)
|
|
||||||
print("Transcribiendo...")
|
|
||||||
segments_gen, info = model_obj.transcribe(file, beam_size=5)
|
|
||||||
# faster-whisper may return a generator; convert to list to allow multiple passes
|
|
||||||
segments = list(segments_gen)
|
|
||||||
text = "".join([seg.text for seg in segments])
|
|
||||||
print(text)
|
|
||||||
# segments es una lista de objetos con .start, .end, .text
|
|
||||||
return segments
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Transcribe audio usando Whisper (varios backends)"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--file", "-f", required=True, help="Ruta al archivo de audio"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--backend",
|
|
||||||
"-b",
|
|
||||||
choices=["openai-whisper", "transformers", "faster-whisper"],
|
|
||||||
default="faster-whisper",
|
|
||||||
help="Backend a usar",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--model",
|
|
||||||
"-m",
|
|
||||||
default="base",
|
|
||||||
help="Nombre del modelo (ej: tiny, base)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--compute-type",
|
|
||||||
"-c",
|
|
||||||
default="int8",
|
|
||||||
help="compute_type para faster-whisper",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--srt",
|
|
||||||
action="store_true",
|
|
||||||
help="Generar archivo SRT con timestamps (si el backend lo soporta)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--srt-file",
|
|
||||||
default=None,
|
|
||||||
help=(
|
|
||||||
"Ruta del archivo SRT de salida. Por defecto: mismo nombre"
|
|
||||||
" que el audio con extensión .srt"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--srt-fallback",
|
|
||||||
action="store_true",
|
|
||||||
help=(
|
|
||||||
"Generar SRT aproximado si backend no devuelve segmentos."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--segment-transcribe",
|
|
||||||
action="store_true",
|
|
||||||
help=(
|
|
||||||
"Cuando se usa --srt-fallback, transcribir cada segmento usando"
|
|
||||||
" archivos temporales para rellenar el texto"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--segment-overlap",
|
|
||||||
type=float,
|
|
||||||
default=0.2,
|
|
||||||
help=(
|
|
||||||
"Superposición en segundos entre segmentos al transcribir por"
|
|
||||||
" segmentos (por defecto: 0.2)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--srt-segment-seconds",
|
|
||||||
type=float,
|
|
||||||
default=10.0,
|
|
||||||
help=(
|
|
||||||
"Duración en segundos de cada segmento para el SRT de fallback."
|
|
||||||
" Por defecto: 10.0"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--tts",
|
|
||||||
action="store_true",
|
|
||||||
help="Generar audio TTS a partir del texto transcrito",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--tts-model",
|
|
||||||
default="kokoro",
|
|
||||||
help="Nombre del modelo TTS a usar (ej: kokoro)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--tts-model-repo",
|
|
||||||
default=None,
|
|
||||||
help=(
|
|
||||||
"Repo de Hugging Face para el modelo TTS (ej: user/kokoro)."
|
|
||||||
" Si se especifica, se descargará automáticamente."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--dub",
|
|
||||||
action="store_true",
|
|
||||||
help=(
|
|
||||||
"Generar pista doblada (por segmentos) a partir del texto transcrito"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--dub-out",
|
|
||||||
default=None,
|
|
||||||
help=("Ruta de salida para el audio doblado (WAV). Por defecto: mismo nombre + .dub.wav"),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--dub-mode",
|
|
||||||
choices=["replace", "mix"],
|
|
||||||
default="replace",
|
|
||||||
help=("Modo de doblaje: 'replace' reemplaza voz original por TTS; 'mix' mezcla ambas pistas"),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--dub-mix-level",
|
|
||||||
type=float,
|
|
||||||
default=0.75,
|
|
||||||
help=("Cuando --dub-mode=mix, nivel de volumen del TTS relativo (0-1)."),
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
path = Path(args.file)
|
|
||||||
if not path.exists():
|
|
||||||
print(f"Archivo no encontrado: {args.file}", file=sys.stderr)
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
# Shortcut: si el usuario solo quiere SRT de fallback sin transcribir
|
|
||||||
# por segmentos, no necesitamos cargar ningún backend (evita errores
|
|
||||||
# si faster-whisper/whisper no están instalados).
|
|
||||||
if args.srt and args.srt_fallback and not args.segment_transcribe:
|
|
||||||
duration = get_audio_duration(args.file)
|
|
||||||
if duration is None:
|
|
||||||
print(
|
|
||||||
"No se pudo obtener duración; no se puede generar SRT de fallback.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
sys.exit(4)
|
|
||||||
fallback_segments = make_uniform_segments(duration, args.srt_segment_seconds)
|
|
||||||
srt_file_arg = args.srt_file
|
|
||||||
srt_path = (
|
|
||||||
srt_file_arg
|
|
||||||
if srt_file_arg
|
|
||||||
else str(path.with_suffix('.srt'))
|
|
||||||
)
|
|
||||||
# crear segmentos vacíos
|
|
||||||
filled_segments = [
|
|
||||||
{"start": s["start"], "end": s["end"], "text": ""}
|
|
||||||
for s in fallback_segments
|
|
||||||
]
|
|
||||||
write_srt(filled_segments, srt_path)
|
|
||||||
print(f"SRT de fallback guardado en: {srt_path}")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
try:
|
|
||||||
segments = None
|
|
||||||
if args.backend == "openai-whisper":
|
|
||||||
segments = transcribe_openai_whisper(args.file, args.model)
|
|
||||||
elif args.backend == "transformers":
|
|
||||||
segments = transcribe_transformers(args.file, args.model)
|
|
||||||
else:
|
|
||||||
segments = transcribe_faster_whisper(
|
|
||||||
args.file, args.model, compute_type=args.compute_type
|
|
||||||
)
|
|
||||||
|
|
||||||
# Si se pide SRT y tenemos segmentos, escribir archivo SRT
|
|
||||||
if args.srt:
|
|
||||||
if segments:
|
|
||||||
# determinar nombre del srt
|
|
||||||
# determinar nombre del srt
|
|
||||||
srt_file_arg = args.srt_file
|
|
||||||
srt_path = (
|
|
||||||
srt_file_arg
|
|
||||||
if srt_file_arg
|
|
||||||
else str(path.with_suffix('.srt'))
|
|
||||||
)
|
|
||||||
segments_to_write = dedupe_adjacent_segments(segments)
|
|
||||||
write_srt(segments_to_write, srt_path)
|
|
||||||
print(f"SRT guardado en: {srt_path}")
|
|
||||||
else:
|
|
||||||
if args.srt_fallback:
|
|
||||||
# intentar generar SRT aproximado
|
|
||||||
duration = get_audio_duration(args.file)
|
|
||||||
if duration is None:
|
|
||||||
print(
|
|
||||||
"No se pudo obtener duración;"
|
|
||||||
" no se puede generar SRT de fallback.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
sys.exit(4)
|
|
||||||
fallback_segments = make_uniform_segments(
|
|
||||||
duration, args.srt_segment_seconds
|
|
||||||
)
|
|
||||||
# Para cada segmento intentamos obtener transcripción
|
|
||||||
# parcial.
|
|
||||||
filled_segments = []
|
|
||||||
if args.segment_transcribe:
|
|
||||||
# extraer cada segmento a un archivo temporal
|
|
||||||
# y transcribir
|
|
||||||
filled = transcribe_segmented_with_tempfiles(
|
|
||||||
args.file,
|
|
||||||
fallback_segments,
|
|
||||||
backend=args.backend,
|
|
||||||
model=args.model,
|
|
||||||
compute_type=args.compute_type,
|
|
||||||
overlap=args.segment_overlap,
|
|
||||||
)
|
|
||||||
filled_segments = filled
|
|
||||||
else:
|
|
||||||
for seg in fallback_segments:
|
|
||||||
seg_obj = {
|
|
||||||
"start": seg["start"],
|
|
||||||
"end": seg["end"],
|
|
||||||
"text": "",
|
|
||||||
}
|
|
||||||
filled_segments.append(seg_obj)
|
|
||||||
srt_file_arg = args.srt_file
|
|
||||||
srt_path = (
|
|
||||||
srt_file_arg
|
|
||||||
if srt_file_arg
|
|
||||||
else str(path.with_suffix('.srt'))
|
|
||||||
)
|
|
||||||
segments_to_write = dedupe_adjacent_segments(
|
|
||||||
filled_segments
|
|
||||||
)
|
|
||||||
write_srt(segments_to_write, srt_path)
|
|
||||||
print(f"SRT de fallback guardado en: {srt_path}")
|
|
||||||
print(
|
|
||||||
"Nota: para SRT con texto, habilite transcripción"
|
|
||||||
" por segmento o use un backend que devuelva"
|
|
||||||
" segmentos."
|
|
||||||
)
|
|
||||||
sys.exit(0)
|
|
||||||
else:
|
|
||||||
print(
|
|
||||||
"El backend elegido no devolvió segmentos temporales;"
|
|
||||||
" no se puede generar SRT.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
sys.exit(3)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error durante la transcripción: {e}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Bloque TTS: sintetizar texto completo si se solicitó
|
|
||||||
if args.tts:
|
|
||||||
# si se especificó un repo, asegurar modelo descargado
|
|
||||||
if args.tts_model_repo:
|
|
||||||
model_path = ensure_tts_model(args.tts_model_repo)
|
|
||||||
# usar la ruta local como modelo
|
|
||||||
args.tts_model = model_path
|
|
||||||
|
|
||||||
all_text = None
|
|
||||||
if segments:
|
|
||||||
all_text = "\n".join(
|
|
||||||
[
|
|
||||||
s.get("text", "") if isinstance(s, dict) else s.text
|
|
||||||
for s in segments
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if all_text:
|
|
||||||
tts_out = str(path.with_suffix(".tts.wav"))
|
|
||||||
ok = tts_synthesize(
|
|
||||||
all_text, tts_out, model=args.tts_model
|
|
||||||
)
|
|
||||||
if ok:
|
|
||||||
print(f"TTS guardado en: {tts_out}")
|
|
||||||
else:
|
|
||||||
print(
|
|
||||||
"Error al sintetizar TTS; comprueba dependencias.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
sys.exit(5)
|
|
||||||
|
|
||||||
# Bloque de doblaje por segmentos: sintetizar cada segmento y generar
|
|
||||||
# un archivo WAV concatenado con la pista doblada. El audio resultante
|
|
||||||
# mantiene la duración de los segmentos originales (paddings/recortes
|
|
||||||
# simples) para poder reemplazar o mezclar con la pista original.
|
|
||||||
if args.dub:
|
|
||||||
# decidir ruta de salida
|
|
||||||
dub_out = (
|
|
||||||
args.dub_out
|
|
||||||
if args.dub_out
|
|
||||||
else str(Path(args.file).with_suffix(".dub.wav"))
|
|
||||||
)
|
|
||||||
|
|
||||||
# si no tenemos segmentos, intentar fallback con transcripción por segmentos
|
|
||||||
use_segments = segments
|
|
||||||
if not use_segments:
|
|
||||||
duration = get_audio_duration(args.file)
|
|
||||||
if duration is None:
|
|
||||||
print(
|
|
||||||
"No se pudo obtener la duración del audio; no se puede doblar.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
sys.exit(6)
|
|
||||||
fallback_segments = make_uniform_segments(duration, args.srt_segment_seconds)
|
|
||||||
if args.segment_transcribe:
|
|
||||||
print("Obteniendo transcripciones por segmento para doblaje...")
|
|
||||||
use_segments = transcribe_segmented_with_tempfiles(
|
|
||||||
args.file,
|
|
||||||
fallback_segments,
|
|
||||||
backend=args.backend,
|
|
||||||
model=args.model,
|
|
||||||
compute_type=args.compute_type,
|
|
||||||
overlap=args.segment_overlap,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# crear segmentos vacíos (no tiene texto)
|
|
||||||
use_segments = [
|
|
||||||
{"start": s["start"], "end": s["end"], "text": ""}
|
|
||||||
for s in fallback_segments
|
|
||||||
]
|
|
||||||
|
|
||||||
# asegurar modelo TTS local si se indicó repo
|
|
||||||
if args.tts_model_repo:
|
|
||||||
model_path = ensure_tts_model(args.tts_model_repo)
|
|
||||||
args.tts_model = model_path
|
|
||||||
|
|
||||||
ok = synthesize_dubbed_audio(
|
|
||||||
src_audio=args.file,
|
|
||||||
segments=use_segments,
|
|
||||||
tts_model=args.tts_model,
|
|
||||||
out_path=dub_out,
|
|
||||||
mode=args.dub_mode,
|
|
||||||
mix_level=args.dub_mix_level,
|
|
||||||
)
|
|
||||||
if ok:
|
|
||||||
print(f"Audio doblado guardado en: {dub_out}")
|
|
||||||
else:
|
|
||||||
print("Error generando audio doblado.", file=sys.stderr)
|
|
||||||
sys.exit(7)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _format_timestamp(seconds: float) -> str:
|
|
||||||
"""Formatea segundos en timestamp SRT hh:mm:ss,mmm"""
|
|
||||||
millis = int((seconds - int(seconds)) * 1000)
|
|
||||||
h = int(seconds // 3600)
|
|
||||||
m = int((seconds % 3600) // 60)
|
|
||||||
s = int(seconds % 60)
|
|
||||||
return f"{h:02d}:{m:02d}:{s:02d},{millis:03d}"
|
|
||||||
|
|
||||||
|
|
||||||
def write_srt(segments, out_path: str):
|
|
||||||
"""Escribe una lista de segmentos en formato SRT.
|
|
||||||
|
|
||||||
segments: iterable de objetos o dicts con .start, .end y .text
|
|
||||||
"""
|
"""
|
||||||
lines = []
|
|
||||||
for i, seg in enumerate(segments, start=1):
|
|
||||||
# soportar objetos con atributos o dicts
|
|
||||||
if hasattr(seg, "start"):
|
|
||||||
start = float(seg.start)
|
|
||||||
end = float(seg.end)
|
|
||||||
text = seg.text if hasattr(seg, "text") else str(seg)
|
|
||||||
else:
|
|
||||||
start = float(seg.get("start", 0.0))
|
|
||||||
end = float(seg.get("end", 0.0))
|
|
||||||
text = seg.get("text", "")
|
|
||||||
|
|
||||||
start_ts = _format_timestamp(start)
|
def __init__(
|
||||||
end_ts = _format_timestamp(end)
|
self, model: str = "base", compute_type: str = "int8"
|
||||||
lines.append(str(i))
|
) -> None:
|
||||||
lines.append(f"{start_ts} --> {end_ts}")
|
self._svc = TranscribeService(
|
||||||
# normalize text newlines
|
model=model, compute_type=compute_type
|
||||||
for line in str(text).strip().splitlines():
|
)
|
||||||
lines.append(line)
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
Path(out_path).write_text("\n".join(lines), encoding="utf-8")
|
def transcribe(
|
||||||
|
self, file: str, *, srt: bool = False, srt_file: Optional[str] = None
|
||||||
|
|
||||||
def dedupe_adjacent_segments(segments):
|
|
||||||
"""Eliminar duplicados simples entre segmentos adyacentes.
|
|
||||||
|
|
||||||
Estrategia simple: si el final de un segmento y el inicio del
|
|
||||||
siguiente comparten una secuencia de palabras, eliminamos la
|
|
||||||
duplicación del inicio del siguiente.
|
|
||||||
"""
|
|
||||||
if not segments:
|
|
||||||
return segments
|
|
||||||
|
|
||||||
# Normalize incoming segments to a list of dicts with keys start,end,text
|
|
||||||
norm = []
|
|
||||||
for s in segments:
|
|
||||||
if hasattr(s, "start"):
|
|
||||||
norm.append({"start": float(s.start), "end": float(s.end), "text": getattr(s, "text", "")})
|
|
||||||
else:
|
|
||||||
# assume mapping-like
|
|
||||||
norm.append({"start": float(s.get("start", 0.0)), "end": float(s.get("end", 0.0)), "text": s.get("text", "")})
|
|
||||||
|
|
||||||
out = [norm[0].copy()]
|
|
||||||
for seg in norm[1:]:
|
|
||||||
prev = out[-1]
|
|
||||||
a = (prev.get("text") or "").strip()
|
|
||||||
b = (seg.get("text") or "").strip()
|
|
||||||
if not a or not b:
|
|
||||||
out.append(seg.copy())
|
|
||||||
continue
|
|
||||||
|
|
||||||
# tokenizar en palabras (espacios) y buscar la mayor superposición
|
|
||||||
a_words = a.split()
|
|
||||||
b_words = b.split()
|
|
||||||
max_ol = 0
|
|
||||||
max_k = min(len(a_words), len(b_words), 10)
|
|
||||||
for k in range(1, max_k + 1):
|
|
||||||
if a_words[-k:] == b_words[:k]:
|
|
||||||
max_ol = k
|
|
||||||
|
|
||||||
if max_ol > 0:
|
|
||||||
# quitar las primeras max_ol palabras de b
|
|
||||||
new_b = " ".join(b_words[max_ol:]).strip()
|
|
||||||
new_seg = seg.copy()
|
|
||||||
new_seg["text"] = new_b
|
|
||||||
out.append(new_seg)
|
|
||||||
else:
|
|
||||||
out.append(seg.copy())
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def get_audio_duration(file_path: str):
|
|
||||||
"""Obtiene la duración del audio en segundos usando ffprobe.
|
|
||||||
|
|
||||||
Devuelve float (segundos) o None si no se puede obtener.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
cmd = [
|
|
||||||
"ffprobe",
|
|
||||||
"-v",
|
|
||||||
"error",
|
|
||||||
"-show_entries",
|
|
||||||
"format=duration",
|
|
||||||
"-of",
|
|
||||||
"default=noprint_wrappers=1:nokey=1",
|
|
||||||
file_path,
|
|
||||||
]
|
|
||||||
out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL)
|
|
||||||
return float(out.strip())
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def make_uniform_segments(duration: float, seg_seconds: float):
|
|
||||||
"""Genera una lista de segmentos uniformes [{start, end}, ...]."""
|
|
||||||
segments = []
|
|
||||||
if duration <= 0 or seg_seconds <= 0:
|
|
||||||
return segments
|
|
||||||
start = 0.0
|
|
||||||
idx = 0
|
|
||||||
while start < duration:
|
|
||||||
end = min(start + seg_seconds, duration)
|
|
||||||
segments.append({"start": round(start, 3), "end": round(end, 3)})
|
|
||||||
idx += 1
|
|
||||||
start = end
|
|
||||||
return segments
|
|
||||||
|
|
||||||
|
|
||||||
def transcribe_segmented_with_tempfiles(
|
|
||||||
src_file: str,
|
|
||||||
segments: list,
|
|
||||||
backend: str = "faster-whisper",
|
|
||||||
model: str = "base",
|
|
||||||
compute_type: str = "int8",
|
|
||||||
overlap: float = 0.2,
|
|
||||||
):
|
):
|
||||||
"""Recorta `src_file` en segmentos y transcribe cada uno.
|
segments = self._svc.transcribe_faster(file)
|
||||||
|
if srt and srt_file and segments:
|
||||||
|
write_srt(segments, srt_file)
|
||||||
|
return segments
|
||||||
|
|
||||||
Retorna lista de dicts {'start','end','text'} para cada segmento.
|
|
||||||
"""
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
results = []
|
__all__ = [
|
||||||
for seg in segments:
|
"FasterWhisperTranscriber",
|
||||||
start = max(0.0, float(seg["start"]) - overlap)
|
"TranscribeService",
|
||||||
end = float(seg["end"]) + overlap
|
"write_srt",
|
||||||
duration = end - start
|
"dedupe_adjacent_segments",
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=True) as tmp:
|
|
||||||
tmp_path = tmp.name
|
|
||||||
cmd = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-ss",
|
|
||||||
str(start),
|
|
||||||
"-t",
|
|
||||||
str(duration),
|
|
||||||
"-i",
|
|
||||||
src_file,
|
|
||||||
"-ar",
|
|
||||||
"16000",
|
|
||||||
"-ac",
|
|
||||||
"1",
|
|
||||||
tmp_path,
|
|
||||||
]
|
]
|
||||||
try:
|
|
||||||
subprocess.run(
|
|
||||||
cmd,
|
|
||||||
check=True,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
# si falla el recorte, dejar texto vacío
|
|
||||||
results.append(
|
|
||||||
{"start": seg["start"], "end": seg["end"], "text": ""}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# transcribir tmp_path con el backend
|
|
||||||
try:
|
|
||||||
if backend == "openai-whisper":
|
|
||||||
import whisper
|
|
||||||
|
|
||||||
m = whisper.load_model(model, device="cpu")
|
|
||||||
res = m.transcribe(tmp_path, fp16=False)
|
|
||||||
text = res.get("text", "")
|
|
||||||
elif backend == "transformers":
|
|
||||||
# pipeline de transformers
|
|
||||||
import torch
|
|
||||||
from transformers import (
|
|
||||||
AutoModelForSpeechSeq2Seq,
|
|
||||||
AutoProcessor,
|
|
||||||
pipeline,
|
|
||||||
)
|
|
||||||
|
|
||||||
torch_dtype = torch.float32
|
|
||||||
model_obj = AutoModelForSpeechSeq2Seq.from_pretrained(
|
|
||||||
model, torch_dtype=torch_dtype, low_cpu_mem_usage=True
|
|
||||||
)
|
|
||||||
model_obj.to("cpu")
|
|
||||||
processor = AutoProcessor.from_pretrained(model)
|
|
||||||
pipe = pipeline(
|
|
||||||
"automatic-speech-recognition",
|
|
||||||
model=model_obj,
|
|
||||||
tokenizer=processor.tokenizer,
|
|
||||||
feature_extractor=processor.feature_extractor,
|
|
||||||
device=-1,
|
|
||||||
)
|
|
||||||
out = pipe(tmp_path)
|
|
||||||
text = out["text"] if isinstance(out, dict) else str(out)
|
|
||||||
else:
|
|
||||||
# faster-whisper
|
|
||||||
from faster_whisper import WhisperModel
|
|
||||||
|
|
||||||
wmodel = WhisperModel(
|
|
||||||
model, device="cpu", compute_type=compute_type
|
|
||||||
)
|
|
||||||
segs_gen, info = wmodel.transcribe(tmp_path, beam_size=5)
|
|
||||||
segs = list(segs_gen)
|
|
||||||
text = "".join([s.text for s in segs])
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
text = ""
|
|
||||||
|
|
||||||
results.append(
|
|
||||||
{"start": seg["start"], "end": seg["end"], "text": text}
|
|
||||||
)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def tts_synthesize(text: str, out_path: str, model: str = "kokoro"):
|
|
||||||
"""Sintetiza `text` a `out_path` usando Coqui TTS si está disponible,
|
|
||||||
o pyttsx3 como fallback simple.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Intentar Coqui TTS
|
|
||||||
from TTS.api import TTS
|
|
||||||
|
|
||||||
# El usuario debe tener el modelo descargado o especificar el id
|
|
||||||
tts = TTS(model_name=model, progress_bar=False, gpu=False)
|
|
||||||
tts.tts_to_file(text=text, file_path=out_path)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
# Fallback a pyttsx3 (menos natural, offline)
|
|
||||||
import pyttsx3
|
|
||||||
|
|
||||||
engine = pyttsx3.init()
|
|
||||||
engine.save_to_file(text, out_path)
|
|
||||||
engine.runAndWait()
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_tts_model(repo_id: str):
|
|
||||||
"""Descarga un repo de Hugging Face y devuelve la ruta local.
|
|
||||||
|
|
||||||
Usa huggingface_hub.snapshot_download. Si la descarga falla, devuelve
|
|
||||||
el repo_id tal cual (se intentará usar como id remoto).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from huggingface_hub import snapshot_download
|
|
||||||
|
|
||||||
print(f"Descargando modelo TTS desde: {repo_id} ...")
|
|
||||||
try:
|
|
||||||
# intentar descarga explícita como 'model' (útil para ids con '/').
|
|
||||||
local_dir = snapshot_download(repo_id, repo_type="model")
|
|
||||||
except Exception:
|
|
||||||
# fallback al comportamiento por defecto
|
|
||||||
local_dir = snapshot_download(repo_id)
|
|
||||||
print(f"Modelo descargado en: {local_dir}")
|
|
||||||
return local_dir
|
|
||||||
except Exception as e:
|
|
||||||
print(f"No se pudo descargar el modelo {repo_id}: {e}")
|
|
||||||
return repo_id
|
|
||||||
|
|
||||||
|
|
||||||
def _pad_or_trim_wav(in_path: str, out_path: str, target_duration: float):
|
|
||||||
"""Pad or trim `in_path` WAV to `target_duration` seconds using ffmpeg.
|
|
||||||
|
|
||||||
Creates `out_path` with exactly target_duration seconds. If input is
|
|
||||||
shorter, pads with silence; if longer, trims.
|
|
||||||
"""
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
# ffmpeg -y -i in.wav -af apad=pad_dur=...,atrim=duration=... -ar 16000 -ac 1 out.wav
|
|
||||||
try:
|
|
||||||
# Use apad then atrim to ensure exact duration
|
|
||||||
cmd = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-i",
|
|
||||||
in_path,
|
|
||||||
"-af",
|
|
||||||
f"apad=pad_dur={max(0, target_duration)}",
|
|
||||||
"-t",
|
|
||||||
f"{target_duration}",
|
|
||||||
"-ar",
|
|
||||||
"16000",
|
|
||||||
"-ac",
|
|
||||||
"1",
|
|
||||||
out_path,
|
|
||||||
]
|
|
||||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def synthesize_segment_tts(text: str, model: str, dur: float, out_wav: str) -> bool:
|
|
||||||
"""Sintetiza `text` en `out_wav` y ajusta su duración a `dur` segundos.
|
|
||||||
|
|
||||||
- Primero genera un WAV temporal con `tts_synthesize`.
|
|
||||||
- Luego lo pad/recorta a `dur` usando ffmpeg.
|
|
||||||
"""
|
|
||||||
import tempfile
|
|
||||||
import os
|
|
||||||
|
|
||||||
try:
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
|
||||||
tmp_path = tmp.name
|
|
||||||
|
|
||||||
ok = tts_synthesize(text, tmp_path, model=model)
|
|
||||||
if not ok:
|
|
||||||
# cleanup
|
|
||||||
try:
|
|
||||||
os.remove(tmp_path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
|
|
||||||
# ajustar duración
|
|
||||||
adjusted = _pad_or_trim_wav(tmp_path, out_wav, target_duration=dur)
|
|
||||||
try:
|
|
||||||
os.remove(tmp_path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return adjusted
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def synthesize_dubbed_audio(
|
|
||||||
src_audio: str,
|
|
||||||
segments: list,
|
|
||||||
tts_model: str,
|
|
||||||
out_path: str,
|
|
||||||
mode: str = "replace",
|
|
||||||
mix_level: float = 0.75,
|
|
||||||
):
|
|
||||||
"""Genera una pista doblada a partir de `segments` y el audio fuente.
|
|
||||||
|
|
||||||
- segments: lista de dicts con 'start','end','text' (en segundos).
|
|
||||||
- mode: 'replace' (devuelve solo TTS concatenado) o 'mix' (mezcla TTS y original).
|
|
||||||
- mix_level: volumen relativo del TTS cuando se mezcla (0-1).
|
|
||||||
|
|
||||||
Retorna True si se generó correctamente `out_path`.
|
|
||||||
"""
|
|
||||||
import tempfile
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
# Normalizar segmentos a lista de dicts {'start','end','text'}
|
|
||||||
norm_segments = []
|
|
||||||
for s in segments:
|
|
||||||
if hasattr(s, "start"):
|
|
||||||
norm_segments.append({"start": float(s.start), "end": float(s.end), "text": getattr(s, "text", "")})
|
|
||||||
else:
|
|
||||||
norm_segments.append({"start": float(s.get("start", 0.0)), "end": float(s.get("end", 0.0)), "text": s.get("text", "")})
|
|
||||||
|
|
||||||
# crear carpeta temporal para segmentos TTS
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
tts_segment_paths = []
|
|
||||||
for i, seg in enumerate(norm_segments):
|
|
||||||
start = float(seg.get("start", 0.0))
|
|
||||||
end = float(seg.get("end", start))
|
|
||||||
dur = max(0.001, end - start)
|
|
||||||
text = (seg.get("text") or "").strip()
|
|
||||||
|
|
||||||
out_seg = os.path.join(tmpdir, f"seg_{i:04d}.wav")
|
|
||||||
|
|
||||||
if not text:
|
|
||||||
# crear silencio de duración dur
|
|
||||||
try:
|
|
||||||
cmd = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-f",
|
|
||||||
"lavfi",
|
|
||||||
"-i",
|
|
||||||
f"anullsrc=channel_layout=mono:sample_rate=16000",
|
|
||||||
"-t",
|
|
||||||
f"{dur}",
|
|
||||||
"-ar",
|
|
||||||
"16000",
|
|
||||||
"-ac",
|
|
||||||
"1",
|
|
||||||
out_seg,
|
|
||||||
]
|
|
||||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
tts_segment_paths.append(out_seg)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
continue
|
|
||||||
|
|
||||||
ok = synthesize_segment_tts(text, tts_model, dur, out_seg)
|
|
||||||
if not ok:
|
|
||||||
return False
|
|
||||||
tts_segment_paths.append(out_seg)
|
|
||||||
|
|
||||||
# crear lista de concatenación
|
|
||||||
concat_list = os.path.join(tmpdir, "concat.txt")
|
|
||||||
with open(concat_list, "w", encoding="utf-8") as f:
|
|
||||||
for p in tts_segment_paths:
|
|
||||||
f.write(f"file '{p}'\n")
|
|
||||||
|
|
||||||
# concatenar segmentos en un WAV final temporal
|
|
||||||
final_tmp = os.path.join(tmpdir, "tts_full.wav")
|
|
||||||
try:
|
|
||||||
cmd = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-f",
|
|
||||||
"concat",
|
|
||||||
"-safe",
|
|
||||||
"0",
|
|
||||||
"-i",
|
|
||||||
concat_list,
|
|
||||||
"-c",
|
|
||||||
"copy",
|
|
||||||
final_tmp,
|
|
||||||
]
|
|
||||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# si el modo es replace, mover final_tmp a out_path (con conversión si es necesario)
|
|
||||||
try:
|
|
||||||
if mode == "replace":
|
|
||||||
# convertir a WAV 16k mono si no lo está
|
|
||||||
cmd = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-i",
|
|
||||||
final_tmp,
|
|
||||||
"-ar",
|
|
||||||
"16000",
|
|
||||||
"-ac",
|
|
||||||
"1",
|
|
||||||
out_path,
|
|
||||||
]
|
|
||||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
return True
|
|
||||||
|
|
||||||
# modo mix: mezclar pista TTS con la original en out_path
|
|
||||||
# ajustar volumen del TTS
|
|
||||||
# ffmpeg -i original -i tts -filter_complex "[1:a]volume=LEVEL[a1];[0:a][a1]amix=inputs=2:normalize=0[out]" -map "[out]" out.wav
|
|
||||||
tts_level = float(max(0.0, min(1.0, mix_level)))
|
|
||||||
cmd = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-i",
|
|
||||||
src_audio,
|
|
||||||
"-i",
|
|
||||||
final_tmp,
|
|
||||||
"-filter_complex",
|
|
||||||
f"[1:a]volume={tts_level}[a1];[0:a][a1]amix=inputs=2:duration=longest:dropout_transition=0",
|
|
||||||
"-ar",
|
|
||||||
"16000",
|
|
||||||
"-ac",
|
|
||||||
"1",
|
|
||||||
out_path,
|
|
||||||
]
|
|
||||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
||||||
|
|||||||
@ -1,84 +1,42 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""translate_srt_argos.py
|
"""Shim: translate_srt_argos
|
||||||
Traduce un .srt localmente usando Argos Translate (más ligero que transformers/torch).
|
|
||||||
Instala automáticamente el paquete en caso de no existir.
|
|
||||||
|
|
||||||
Uso:
|
Delegates to `whisper_project.infra.argos_adapter.ArgosTranslator.translate_srt`
|
||||||
source .venv/bin/activate
|
if available; otherwise runs `examples/translate_srt_argos.py` as fallback.
|
||||||
python3 whisper_project/translate_srt_argos.py --in in.srt --out out.srt
|
|
||||||
|
|
||||||
Requisitos: argostranslate (el script intentará instalarlo si no está presente)
|
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import srt
|
import subprocess
|
||||||
import tempfile
|
import sys
|
||||||
import os
|
|
||||||
|
|
||||||
try:
|
|
||||||
from argostranslate import package, translate
|
|
||||||
except Exception:
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_en_es_package():
|
def main():
|
||||||
installed = package.get_installed_packages()
|
p = argparse.ArgumentParser(prog="translate_srt_argos")
|
||||||
for p in installed:
|
p.add_argument("--in", dest="in_srt", required=True)
|
||||||
if p.from_code == 'en' and p.to_code == 'es':
|
p.add_argument("--out", dest="out_srt", required=True)
|
||||||
return True
|
|
||||||
# Si no está instalado, buscar disponible y descargar
|
|
||||||
avail = package.get_available_packages()
|
|
||||||
for p in avail:
|
|
||||||
if p.from_code == 'en' and p.to_code == 'es':
|
|
||||||
print('Descargando paquete Argos en->es...')
|
|
||||||
download_path = tempfile.mktemp(suffix='.zip')
|
|
||||||
try:
|
|
||||||
import requests
|
|
||||||
|
|
||||||
with requests.get(p.download_url, stream=True, timeout=60) as r:
|
|
||||||
r.raise_for_status()
|
|
||||||
with open(download_path, 'wb') as fh:
|
|
||||||
for chunk in r.iter_content(chunk_size=8192):
|
|
||||||
if chunk:
|
|
||||||
fh.write(chunk)
|
|
||||||
# instalar desde el zip descargado
|
|
||||||
package.install_from_path(download_path)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error descargando/instalando paquete Argos: {e}")
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
if os.path.exists(download_path):
|
|
||||||
os.remove(download_path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def translate_srt(in_path: str, out_path: str):
|
|
||||||
with open(in_path, 'r', encoding='utf-8') as fh:
|
|
||||||
subs = list(srt.parse(fh.read()))
|
|
||||||
|
|
||||||
# Asegurar paquete en->es
|
|
||||||
ok = ensure_en_es_package()
|
|
||||||
if not ok:
|
|
||||||
raise SystemExit('No se encontró paquete Argos en->es y no se pudo descargar')
|
|
||||||
|
|
||||||
for i, sub in enumerate(subs, start=1):
|
|
||||||
text = sub.content.strip()
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
tr = translate.translate(text, 'en', 'es')
|
|
||||||
sub.content = tr
|
|
||||||
print(f'Translated {i}/{len(subs)}')
|
|
||||||
|
|
||||||
with open(out_path, 'w', encoding='utf-8') as fh:
|
|
||||||
fh.write(srt.compose(subs))
|
|
||||||
print(f'Wrote translated SRT to: {out_path}')
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
p = argparse.ArgumentParser()
|
|
||||||
p.add_argument('--in', dest='in_srt', required=True)
|
|
||||||
p.add_argument('--out', dest='out_srt', required=True)
|
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
translate_srt(args.in_srt, args.out_srt)
|
|
||||||
|
try:
|
||||||
|
from whisper_project.infra.argos_adapter import ArgosTranslator
|
||||||
|
|
||||||
|
t = ArgosTranslator()
|
||||||
|
t.translate_srt(args.in_srt, args.out_srt)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
script = "examples/translate_srt_argos.py"
|
||||||
|
cmd = [sys.executable, script, "--in", args.in_srt, "--out", args.out_srt]
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
print("Error: no se pudo ejecutar Argos Translate:", e, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main() or 0)
|
||||||
|
|
||||||
|
# The deprecated block has been removed.
|
||||||
|
# Use whisper_project.infra.argos_adapter for programmatic access.
|
||||||
|
|
||||||
|
|||||||
@ -1,57 +1,41 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""translate_srt_local.py
|
"""Shim: translate_srt_local
|
||||||
Traduce un .srt localmente usando MarianMT (Helsinki-NLP/opus-mt-en-es).
|
|
||||||
|
|
||||||
Uso:
|
Delegates to `whisper_project.infra.marian_adapter.MarianTranslator.translate_srt`
|
||||||
source .venv/bin/activate
|
if available; otherwise falls back to running the script in `examples/`.
|
||||||
python3 whisper_project/translate_srt_local.py --in path/to/in.srt --out path/to/out.srt
|
|
||||||
|
|
||||||
Requisitos: transformers, sentencepiece, srt
|
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import srt
|
import subprocess
|
||||||
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def translate_srt(in_path: str, out_path: str, model_name: str = "Helsinki-NLP/opus-mt-en-es", batch_size: int = 8):
|
|
||||||
with open(in_path, "r", encoding="utf-8") as f:
|
|
||||||
subs = list(srt.parse(f.read()))
|
|
||||||
|
|
||||||
# Cargar modelo y tokenizador
|
|
||||||
tok = AutoTokenizer.from_pretrained(model_name)
|
|
||||||
model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
|
|
||||||
|
|
||||||
texts = [sub.content.strip() for sub in subs]
|
|
||||||
translated = []
|
|
||||||
|
|
||||||
for i in range(0, len(texts), batch_size):
|
|
||||||
batch = texts[i:i+batch_size]
|
|
||||||
# tokenizar
|
|
||||||
enc = tok(batch, return_tensors="pt", padding=True, truncation=True)
|
|
||||||
outs = model.generate(**enc, max_length=512)
|
|
||||||
outs_decoded = tok.batch_decode(outs, skip_special_tokens=True)
|
|
||||||
translated.extend(outs_decoded)
|
|
||||||
|
|
||||||
# Asignar traducidos
|
|
||||||
for sub, t in zip(subs, translated):
|
|
||||||
sub.content = t.strip()
|
|
||||||
|
|
||||||
with open(out_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(srt.compose(subs))
|
|
||||||
|
|
||||||
print(f"SRT traducido guardado en: {out_path}")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
p = argparse.ArgumentParser()
|
p = argparse.ArgumentParser(prog="translate_srt_local")
|
||||||
p.add_argument("--in", dest="in_srt", required=True)
|
p.add_argument("--in", dest="in_srt", required=True)
|
||||||
p.add_argument("--out", dest="out_srt", required=True)
|
p.add_argument("--out", dest="out_srt", required=True)
|
||||||
p.add_argument("--model", default="Helsinki-NLP/opus-mt-en-es")
|
|
||||||
p.add_argument("--batch-size", dest="batch_size", type=int, default=8)
|
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
|
|
||||||
translate_srt(args.in_srt, args.out_srt, model_name=args.model, batch_size=args.batch_size)
|
try:
|
||||||
|
# Prefer the infra adapter when available
|
||||||
|
from whisper_project.infra.marian_adapter import MarianTranslator
|
||||||
|
|
||||||
|
t = MarianTranslator()
|
||||||
|
t.translate_srt(args.in_srt, args.out_srt)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
# Fallback: run the examples script if present
|
||||||
|
try:
|
||||||
|
script = "examples/translate_srt_local.py"
|
||||||
|
cmd = [sys.executable, script, "--in", args.in_srt, "--out", args.out_srt]
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
print("Error: no se pudo ejecutar la traducción local:", e, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
main()
|
sys.exit(main() or 0)
|
||||||
|
|
||||||
|
|||||||
@ -1,139 +1,42 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""translate_srt_with_gemini.py
|
"""Shim: translate_srt_with_gemini
|
||||||
Lee un .srt, traduce cada bloque de texto con Gemini (Google Generative API) y
|
|
||||||
escribe un nuevo .srt manteniendo índices y timestamps.
|
|
||||||
|
|
||||||
Uso:
|
Delegates to `whisper_project.infra.gemini_adapter.GeminiTranslator.translate_srt`
|
||||||
export GEMINI_API_KEY="..."
|
or falls back to `examples/translate_srt_with_gemini.py`.
|
||||||
.venv/bin/python whisper_project/translate_srt_with_gemini.py \
|
|
||||||
--in whisper_project/dailyrutines.kokoro.dub.srt \
|
|
||||||
--out whisper_project/dailyrutines.kokoro.dub.es.srt \
|
|
||||||
--model gemini-2.5-flash
|
|
||||||
|
|
||||||
Si no pasas --gemini-api-key, se usará la variable de entorno GEMINI_API_KEY.
|
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import subprocess
|
||||||
import os
|
import sys
|
||||||
import time
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
import requests
|
|
||||||
import srt
|
|
||||||
# Intentar usar la librería oficial si está instalada (mejor compatibilidad)
|
|
||||||
try:
|
|
||||||
import google.generativeai as genai # type: ignore
|
|
||||||
except Exception:
|
|
||||||
genai = None
|
|
||||||
|
|
||||||
|
|
||||||
def translate_text_google_gl(text: str, api_key: str, model: str = "gemini-2.5-flash") -> str:
|
|
||||||
"""Llamada a la API Generative Language de Google (generateContent).
|
|
||||||
Devuelve el texto traducido (o el texto original si falla).
|
|
||||||
"""
|
|
||||||
if not api_key:
|
|
||||||
raise ValueError("gemini api key required")
|
|
||||||
# Si la librería oficial está disponible, usarla (maneja internamente los endpoints)
|
|
||||||
if genai is not None:
|
|
||||||
try:
|
|
||||||
genai.configure(api_key=api_key)
|
|
||||||
model_obj = genai.GenerativeModel(model)
|
|
||||||
# la librería acepta un prompt simple o lista; pedimos texto traducido explícitamente
|
|
||||||
prompt = f"Traduce al español el siguiente texto y devuelve solo el texto traducido:\n\n{text}"
|
|
||||||
resp = model_obj.generate_content(prompt, generation_config={"max_output_tokens": 1024, "temperature": 0.0})
|
|
||||||
# resp.text está disponible en la respuesta wrapper
|
|
||||||
if hasattr(resp, "text") and resp.text:
|
|
||||||
return resp.text.strip()
|
|
||||||
# fallback: revisar candidates
|
|
||||||
if hasattr(resp, "candidates") and resp.candidates:
|
|
||||||
c = resp.candidates[0]
|
|
||||||
if hasattr(c, "content") and hasattr(c.content, "parts"):
|
|
||||||
parts = [p.text for p in c.content.parts if getattr(p, "text", None)]
|
|
||||||
if parts:
|
|
||||||
return "\n".join(parts).strip()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Warning: genai library translate failed: {e}")
|
|
||||||
|
|
||||||
# Fallback HTTP (legacy/path-variant). Intentamos v1 y v1beta2 según disponibilidad.
|
|
||||||
for prefix in ("v1", "v1beta2"):
|
|
||||||
endpoint = (
|
|
||||||
f"https://generativelanguage.googleapis.com/{prefix}/models/{model}:generateContent?key={api_key}"
|
|
||||||
)
|
|
||||||
body = {
|
|
||||||
"prompt": {"text": f"Traduce al español el siguiente texto y devuelve solo el texto traducido:\n\n{text}"},
|
|
||||||
"maxOutputTokens": 1024,
|
|
||||||
"temperature": 0.0,
|
|
||||||
"candidateCount": 1,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
r = requests.post(endpoint, json=body, timeout=30)
|
|
||||||
r.raise_for_status()
|
|
||||||
j = r.json()
|
|
||||||
# buscar candidatos
|
|
||||||
if isinstance(j, dict) and "candidates" in j and isinstance(j["candidates"], list) and j["candidates"]:
|
|
||||||
first = j["candidates"][0]
|
|
||||||
if isinstance(first, dict):
|
|
||||||
if "content" in first and isinstance(first["content"], str):
|
|
||||||
return first["content"].strip()
|
|
||||||
if "output" in first and isinstance(first["output"], str):
|
|
||||||
return first["output"].strip()
|
|
||||||
if "content" in first and isinstance(first["content"], list):
|
|
||||||
parts = []
|
|
||||||
for c in first["content"]:
|
|
||||||
if isinstance(c, dict) and isinstance(c.get("text"), str):
|
|
||||||
parts.append(c.get("text"))
|
|
||||||
if parts:
|
|
||||||
return "\n".join(parts).strip()
|
|
||||||
for key in ("output_text", "text", "response", "translated_text"):
|
|
||||||
if key in j and isinstance(j[key], str):
|
|
||||||
return j[key].strip()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Warning: GL translate failed ({prefix}): {e}")
|
|
||||||
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def translate_srt_file(in_path: str, out_path: str, api_key: str, model: str):
|
|
||||||
with open(in_path, "r", encoding="utf-8") as fh:
|
|
||||||
subs = list(srt.parse(fh.read()))
|
|
||||||
|
|
||||||
for i, sub in enumerate(subs, start=1):
|
|
||||||
text = sub.content.strip()
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
# llamar a la API
|
|
||||||
try:
|
|
||||||
translated = translate_text_google_gl(text, api_key, model=model)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Warning: translate failed for index {sub.index}: {e}")
|
|
||||||
translated = text
|
|
||||||
# asignar traducido
|
|
||||||
sub.content = translated
|
|
||||||
# pequeño delay para no golpear la API demasiado rápido
|
|
||||||
time.sleep(0.15)
|
|
||||||
print(f"Translated {i}/{len(subs)}")
|
|
||||||
|
|
||||||
out_s = srt.compose(subs)
|
|
||||||
with open(out_path, "w", encoding="utf-8") as fh:
|
|
||||||
fh.write(out_s)
|
|
||||||
print(f"Wrote translated SRT to: {out_path}")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
p = argparse.ArgumentParser()
|
p = argparse.ArgumentParser(prog="translate_srt_with_gemini")
|
||||||
p.add_argument("--in", dest="in_srt", required=True)
|
p.add_argument("--in", dest="in_srt", required=True)
|
||||||
p.add_argument("--out", dest="out_srt", required=True)
|
p.add_argument("--out", dest="out_srt", required=True)
|
||||||
p.add_argument("--gemini-api-key", default=None)
|
p.add_argument("--gemini-api-key", dest="gemini_api_key", required=False, default=None)
|
||||||
p.add_argument("--model", default="gemini-2.5-flash")
|
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
|
|
||||||
key = args.gemini_api_key or os.environ.get("GEMINI_API_KEY")
|
try:
|
||||||
if not key:
|
from whisper_project.infra.gemini_adapter import GeminiTranslator
|
||||||
print("Provide --gemini-api-key or set GEMINI_API_KEY env var", flush=True)
|
|
||||||
raise SystemExit(2)
|
|
||||||
|
|
||||||
translate_srt_file(args.in_srt, args.out_srt, key, args.model)
|
g = GeminiTranslator(api_key=args.gemini_api_key)
|
||||||
|
g.translate_srt(args.in_srt, args.out_srt)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
script = "examples/translate_srt_with_gemini.py"
|
||||||
|
cmd = [sys.executable, script, "--in", args.in_srt, "--out", args.out_srt]
|
||||||
|
if args.gemini_api_key:
|
||||||
|
cmd += ["--gemini-api-key", args.gemini_api_key]
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
print("Error: no se pudo ejecutar la traducción con Gemini:", e, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
main()
|
sys.exit(main() or 0)
|
||||||
|
|
||||||
|
|||||||
3
whisper_project/usecases/__init__.py
Normal file
3
whisper_project/usecases/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from . import orchestrator
|
||||||
|
|
||||||
|
__all__ = ["orchestrator"]
|
||||||
BIN
whisper_project/usecases/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
whisper_project/usecases/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
362
whisper_project/usecases/orchestrator.py
Normal file
362
whisper_project/usecases/orchestrator.py
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
"""Orquestador que compone los adaptadores infra para ejecutar el pipeline.
|
||||||
|
|
||||||
|
Proporciona una clase `Orchestrator` con método `run` y soporta modo dry-run
|
||||||
|
para inspección sin ejecutar los pasos pesados.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from whisper_project.infra import process_video, transcribe
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Orchestrator:
|
||||||
|
"""Orquesta: extracción audio -> transcripción -> TTS por segmento -> reemplazo audio -> quemar subtítulos.
|
||||||
|
|
||||||
|
Nota: los pasos concretos se delegan a los adaptadores en `whisper_project.infra`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, dry_run: bool = False, tts_model: str = "kokoro", verbose: bool = False):
|
||||||
|
self.dry_run = dry_run
|
||||||
|
self.tts_model = tts_model
|
||||||
|
if verbose:
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
|
def run(self, src_video: str, out_dir: str, translate: bool = False) -> dict:
|
||||||
|
"""Ejecuta el pipeline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
src_video: ruta al vídeo de entrada.
|
||||||
|
out_dir: carpeta donde escribir resultados intermedios/finales.
|
||||||
|
translate: si True, intentará traducir SRT (delegado a futuras implementaciones).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
diccionario con resultados y rutas generadas.
|
||||||
|
"""
|
||||||
|
src = Path(src_video)
|
||||||
|
out = Path(out_dir)
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"input_video": str(src.resolve()),
|
||||||
|
"out_dir": str(out.resolve()),
|
||||||
|
"steps": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1) Extraer audio
|
||||||
|
audio_wav = out / f"{src.stem}.wav"
|
||||||
|
step = {"name": "extract_audio", "out": str(audio_wav)}
|
||||||
|
result["steps"].append(step)
|
||||||
|
if self.dry_run:
|
||||||
|
logger.info("[dry-run] extraer audio: %s -> %s", src, audio_wav)
|
||||||
|
else:
|
||||||
|
logger.info("extraer audio: %s -> %s", src, audio_wav)
|
||||||
|
process_video.extract_audio(str(src), str(audio_wav))
|
||||||
|
|
||||||
|
# 2) Transcribir (segmentado si es necesario)
|
||||||
|
srt_path = out / f"{src.stem}.srt"
|
||||||
|
step = {"name": "transcribe", "out": str(srt_path)}
|
||||||
|
result["steps"].append(step)
|
||||||
|
if self.dry_run:
|
||||||
|
logger.info("[dry-run] transcribir audio -> %s", srt_path)
|
||||||
|
segments = []
|
||||||
|
else:
|
||||||
|
logger.info("transcribir audio -> %s", srt_path)
|
||||||
|
# usamos la función delegante que el proyecto expone
|
||||||
|
segments = transcribe.transcribe_segmented_with_tempfiles(str(audio_wav), [])
|
||||||
|
transcribe.write_srt(segments, str(srt_path))
|
||||||
|
|
||||||
|
# 3) (Opcional) traducir SRT — placeholder
|
||||||
|
if translate:
|
||||||
|
step = {"name": "translate", "out": str(srt_path)}
|
||||||
|
result["steps"].append(step)
|
||||||
|
if self.dry_run:
|
||||||
|
logger.info("[dry-run] traducir SRT: %s", srt_path)
|
||||||
|
else:
|
||||||
|
logger.info("traducir SRT: %s (funcionalidad no implementada en orquestador)", srt_path)
|
||||||
|
|
||||||
|
# 4) Generar TTS segmentado en un WAV final (dub)
|
||||||
|
dubbed_wav = out / f"{src.stem}.dub.wav"
|
||||||
|
step = {"name": "tts_and_stitch", "out": str(dubbed_wav)}
|
||||||
|
result["steps"].append(step)
|
||||||
|
if self.dry_run:
|
||||||
|
logger.info("[dry-run] synthesize TTS por segmento -> %s (modelo=%s)", dubbed_wav, self.tts_model)
|
||||||
|
else:
|
||||||
|
logger.info("synthesize TTS por segmento -> %s (modelo=%s)", dubbed_wav, self.tts_model)
|
||||||
|
# por ahora usamos la función helper de transcribe para síntesis (si existe)
|
||||||
|
try:
|
||||||
|
# `segments` viene de la transcripción previa
|
||||||
|
transcribe.tts_synthesize(" ".join([s.get("text", "") for s in segments]), str(dubbed_wav), model=self.tts_model)
|
||||||
|
except Exception:
|
||||||
|
# Fallback simple: crear un silencio (no romper)
|
||||||
|
logger.exception("TTS falló, creando archivo vacío como fallback")
|
||||||
|
try:
|
||||||
|
process_video.pad_or_trim_wav(0.0, str(dubbed_wav))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("No se pudo crear WAV de fallback")
|
||||||
|
|
||||||
|
# 5) Reemplazar audio en el vídeo
|
||||||
|
dubbed_video = out / f"{src.stem}.dub.mp4"
|
||||||
|
step = {"name": "replace_audio_in_video", "out": str(dubbed_video)}
|
||||||
|
result["steps"].append(step)
|
||||||
|
if self.dry_run:
|
||||||
|
logger.info("[dry-run] reemplazar audio en video: %s -> %s", src, dubbed_video)
|
||||||
|
else:
|
||||||
|
logger.info("reemplazar audio en video: %s -> %s", src, dubbed_video)
|
||||||
|
process_video.replace_audio_in_video(str(src), str(dubbed_wav), str(dubbed_video))
|
||||||
|
|
||||||
|
# 6) Quemar subtítulos en vídeo final
|
||||||
|
burned = out / f"{src.stem}.burned.mp4"
|
||||||
|
step = {"name": "burn_subtitles", "out": str(burned)}
|
||||||
|
result["steps"].append(step)
|
||||||
|
if self.dry_run:
|
||||||
|
logger.info("[dry-run] quemar subtítulos: %s + %s -> %s", dubbed_video, srt_path, burned)
|
||||||
|
else:
|
||||||
|
logger.info("quemar subtítulos: %s + %s -> %s", dubbed_video, srt_path, burned)
|
||||||
|
process_video.burn_subtitles(str(dubbed_video), str(srt_path), str(burned))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["Orchestrator"]
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..core.models import PipelineResult
|
||||||
|
from ..infra import ffmpeg_adapter
|
||||||
|
from ..infra.kokoro_adapter import KokoroHttpClient
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineOrchestrator:
|
||||||
|
"""Use case class that coordinates the high-level steps of the pipeline.
|
||||||
|
|
||||||
|
Esta clase mantiene la lógica de orquestación en métodos pequeños y
|
||||||
|
testables, y depende de adaptadores infra para las operaciones I/O.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
kokoro_endpoint: str,
|
||||||
|
kokoro_key: Optional[str] = None,
|
||||||
|
voice: Optional[str] = None,
|
||||||
|
kokoro_model: Optional[str] = None,
|
||||||
|
transcriber=None,
|
||||||
|
translator=None,
|
||||||
|
tts_client=None,
|
||||||
|
audio_processor=None,
|
||||||
|
):
|
||||||
|
# Si no se inyectan adaptadores, crear implementaciones por defecto
|
||||||
|
# Sólo importar adaptadores pesados si no se inyectan implementaciones.
|
||||||
|
if transcriber is None:
|
||||||
|
try:
|
||||||
|
from ..infra.faster_whisper_adapter import FasterWhisperTranscriber
|
||||||
|
|
||||||
|
self.transcriber = FasterWhisperTranscriber()
|
||||||
|
except Exception:
|
||||||
|
# dejar como None para permitir fallback a subprocess en tiempo de ejecución
|
||||||
|
self.transcriber = None
|
||||||
|
else:
|
||||||
|
self.transcriber = transcriber
|
||||||
|
|
||||||
|
if translator is None:
|
||||||
|
try:
|
||||||
|
from ..infra.marian_adapter import MarianTranslator
|
||||||
|
|
||||||
|
self.translator = MarianTranslator()
|
||||||
|
except Exception:
|
||||||
|
self.translator = None
|
||||||
|
else:
|
||||||
|
self.translator = translator
|
||||||
|
|
||||||
|
if tts_client is None:
|
||||||
|
try:
|
||||||
|
from ..infra.kokoro_adapter import KokoroHttpClient
|
||||||
|
|
||||||
|
self.tts_client = KokoroHttpClient(kokoro_endpoint, api_key=kokoro_key, voice=voice, model=kokoro_model)
|
||||||
|
except Exception:
|
||||||
|
self.tts_client = None
|
||||||
|
else:
|
||||||
|
self.tts_client = tts_client
|
||||||
|
|
||||||
|
if audio_processor is None:
|
||||||
|
try:
|
||||||
|
from ..infra.ffmpeg_adapter import FFmpegAudioProcessor
|
||||||
|
|
||||||
|
self.audio_processor = FFmpegAudioProcessor()
|
||||||
|
except Exception:
|
||||||
|
self.audio_processor = None
|
||||||
|
else:
|
||||||
|
self.audio_processor = audio_processor
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
video: str,
|
||||||
|
srt: Optional[str],
|
||||||
|
workdir: str,
|
||||||
|
translate_method: str = "local",
|
||||||
|
gemini_api_key: Optional[str] = None,
|
||||||
|
whisper_model: str = "base",
|
||||||
|
mix: bool = False,
|
||||||
|
mix_background_volume: float = 0.2,
|
||||||
|
keep_chunks: bool = False,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> PipelineResult:
|
||||||
|
"""Run the pipeline.
|
||||||
|
|
||||||
|
When dry_run=True the orchestrator will only print planned actions
|
||||||
|
instead of executing subprocesses or ffmpeg commands.
|
||||||
|
"""
|
||||||
|
# 0) prepare paths
|
||||||
|
if dry_run:
|
||||||
|
print("[dry-run] workdir:", workdir)
|
||||||
|
|
||||||
|
# 1) extraer audio
|
||||||
|
audio_tmp = os.path.join(workdir, "extracted_audio.wav")
|
||||||
|
if dry_run:
|
||||||
|
print(f"[dry-run] ffmpeg extract audio -> {audio_tmp}")
|
||||||
|
else:
|
||||||
|
self.audio_processor.extract_audio(video, audio_tmp, sr=16000)
|
||||||
|
|
||||||
|
# 2) transcribir si es necesario
|
||||||
|
if srt:
|
||||||
|
srt_in = srt
|
||||||
|
else:
|
||||||
|
srt_in = os.path.join(workdir, "transcribed.srt")
|
||||||
|
cmd_trans = [
|
||||||
|
sys.executable,
|
||||||
|
"whisper_project/transcribe.py",
|
||||||
|
"--file",
|
||||||
|
audio_tmp,
|
||||||
|
"--backend",
|
||||||
|
"faster-whisper",
|
||||||
|
"--model",
|
||||||
|
whisper_model,
|
||||||
|
"--srt",
|
||||||
|
"--srt-file",
|
||||||
|
srt_in,
|
||||||
|
]
|
||||||
|
if dry_run:
|
||||||
|
print("[dry-run] ", " ".join(cmd_trans))
|
||||||
|
else:
|
||||||
|
# Use injected transcriber when possible
|
||||||
|
try:
|
||||||
|
self.transcriber.transcribe(audio_tmp, srt_in)
|
||||||
|
except Exception:
|
||||||
|
# Fallback to subprocess if adapter not available
|
||||||
|
subprocess.run(cmd_trans, check=True)
|
||||||
|
|
||||||
|
# 3) traducir
|
||||||
|
srt_translated = os.path.join(workdir, "translated.srt")
|
||||||
|
if translate_method == "local":
|
||||||
|
cmd_translate = [
|
||||||
|
sys.executable,
|
||||||
|
"whisper_project/translate_srt_local.py",
|
||||||
|
"--in",
|
||||||
|
srt_in,
|
||||||
|
"--out",
|
||||||
|
srt_translated,
|
||||||
|
]
|
||||||
|
if dry_run:
|
||||||
|
print("[dry-run] ", " ".join(cmd_translate))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self.translator.translate_srt(srt_in, srt_translated)
|
||||||
|
except Exception:
|
||||||
|
subprocess.run(cmd_translate, check=True)
|
||||||
|
elif translate_method == "gemini":
|
||||||
|
# preferir adaptador inyectado que soporte Gemini, sino usar el local wrapper
|
||||||
|
cmd_translate = [
|
||||||
|
sys.executable,
|
||||||
|
"whisper_project/translate_srt_with_gemini.py",
|
||||||
|
"--in",
|
||||||
|
srt_in,
|
||||||
|
"--out",
|
||||||
|
srt_translated,
|
||||||
|
]
|
||||||
|
if gemini_api_key:
|
||||||
|
cmd_translate += ["--gemini-api-key", gemini_api_key]
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print("[dry-run] ", " ".join(cmd_translate))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# intentar usar adaptador Gemini si está disponible
|
||||||
|
if self.translator and getattr(self.translator, "__class__", None).__name__ == "GeminiTranslator":
|
||||||
|
self.translator.translate_srt(srt_in, srt_translated)
|
||||||
|
else:
|
||||||
|
# intentar importar adaptador local
|
||||||
|
from ..infra.gemini_adapter import GeminiTranslator
|
||||||
|
|
||||||
|
gem = GeminiTranslator(api_key=gemini_api_key)
|
||||||
|
gem.translate_srt(srt_in, srt_translated)
|
||||||
|
except Exception:
|
||||||
|
subprocess.run(cmd_translate, check=True)
|
||||||
|
elif translate_method == "argos":
|
||||||
|
cmd_translate = [
|
||||||
|
sys.executable,
|
||||||
|
"whisper_project/translate_srt_argos.py",
|
||||||
|
"--in",
|
||||||
|
srt_in,
|
||||||
|
"--out",
|
||||||
|
srt_translated,
|
||||||
|
]
|
||||||
|
if dry_run:
|
||||||
|
print("[dry-run] ", " ".join(cmd_translate))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
if self.translator and getattr(self.translator, "__class__", None).__name__ == "ArgosTranslator":
|
||||||
|
self.translator.translate_srt(srt_in, srt_translated)
|
||||||
|
else:
|
||||||
|
from ..infra.argos_adapter import ArgosTranslator
|
||||||
|
|
||||||
|
a = ArgosTranslator()
|
||||||
|
a.translate_srt(srt_in, srt_translated)
|
||||||
|
except Exception:
|
||||||
|
subprocess.run(cmd_translate, check=True)
|
||||||
|
elif translate_method == "none":
|
||||||
|
srt_translated = srt_in
|
||||||
|
else:
|
||||||
|
raise ValueError("translate_method not supported in this orchestrator")
|
||||||
|
|
||||||
|
# 4) sintetizar por segmento
|
||||||
|
dub_wav = os.path.join(workdir, "dub_final.wav")
|
||||||
|
if dry_run:
|
||||||
|
print(f"[dry-run] synthesize from srt {srt_translated} -> {dub_wav} (align={True} mix={mix})")
|
||||||
|
else:
|
||||||
|
# Use injected tts_client
|
||||||
|
self.tts_client.synthesize_from_srt(
|
||||||
|
srt_translated,
|
||||||
|
dub_wav,
|
||||||
|
video=video,
|
||||||
|
align=True,
|
||||||
|
keep_chunks=keep_chunks,
|
||||||
|
mix_with_original=mix,
|
||||||
|
mix_background_volume=mix_background_volume,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5) reemplazar audio en vídeo
|
||||||
|
replaced = os.path.splitext(video)[0] + ".replaced_audio.mp4"
|
||||||
|
if dry_run:
|
||||||
|
print(f"[dry-run] replace audio in video -> {replaced}")
|
||||||
|
else:
|
||||||
|
self.audio_processor.replace_audio_in_video(video, dub_wav, replaced)
|
||||||
|
|
||||||
|
# 6) quemar subtítulos
|
||||||
|
burned = os.path.splitext(video)[0] + ".replaced_audio.subs.mp4"
|
||||||
|
if dry_run:
|
||||||
|
print(f"[dry-run] burn subtitles {srt_translated} into -> {burned}")
|
||||||
|
else:
|
||||||
|
self.audio_processor.burn_subtitles(replaced, srt_translated, burned)
|
||||||
|
|
||||||
|
return PipelineResult(
|
||||||
|
workdir=workdir,
|
||||||
|
dub_wav=dub_wav,
|
||||||
|
replaced_video=replaced,
|
||||||
|
burned_video=burned,
|
||||||
|
)
|
||||||
Loading…
x
Reference in New Issue
Block a user