| # subtify |
|
|
| Genera subtítulos a partir de vídeos: descarga, extrae audio, transcribe, diariza, |
| traduce e incrusta los subtítulos. Corre como Space de Hugging Face |
| (`Maximofn/subtify`) sobre ZeroGPU. |
|
|
| ## Norma de documentación |
|
|
| **Todo aprendizaje se documenta en este fichero.** Si hay que investigar algo —una |
| API, por qué falla un modelo, el formato raro que devuelve una librería— la |
| conclusión se apunta aquí antes de seguir. El objetivo es no volver a investigar |
| dos veces lo mismo. |
|
|
| Aplica también a los callejones sin salida: saber que algo *no* funciona y por qué |
| ahorra tanto tiempo como saber qué funciona. |
|
|
| ## Objetivo del producto |
|
|
| Cuando hay varios hablantes, transcribir a cada uno por separado y pintar sus |
| subtítulos en un color distinto. |
|
|
| A futuro, sobre esa misma base: traducir la transcripción, clonar la voz de cada |
| hablante, generar TTS y hacer lipsync — es decir, a partir de un vídeo en un |
| idioma, producir el vídeo en otro. |
|
|
| Por eso hacen falta **timestamps por palabra**, no solo por frase. |
|
|
| ## Arquitectura de modelos |
|
|
| Dos familias de modelos con papeles distintos: |
|
|
| - **STT** — transcribe con timestamps de palabra. Es la rejilla fina que necesitan |
| la traducción, el TTS y el lipsync. |
| - **STT+d** — transcribe y diariza a la vez, devolviendo turnos de habla con |
| etiqueta de hablante. Es lo que permite colorear por hablante. |
|
|
| Las dos salidas se cruzan por solapamiento temporal: cada palabra cae dentro del |
| turno que la contiene. |
|
|
| Para la diarización no se confiará en un solo modelo: la idea es combinar varios |
| métodos y contrastarlos. |
|
|
| ### Elección actual |
|
|
| | Papel | Modelo | Por qué | |
| |---|---|---| |
| | STT principal | `nvidia/parakeet-tdt-0.6b-v3` | 3-5× más rápido que Whisper turbo, hasta 5× menos VRAM, timestamps de palabra | |
| | STT fallback | `openai/whisper-large-v3` | Cubre los 99 idiomas; se usa cuando el idioma no está entre los 25 de Parakeet | |
| | Detección de idioma | `openai/whisper-large-v3` | Solo cuando el selector de idioma se deja vacío | |
| | STT+d | `OpenMOSS-Team/MOSS-Transcribe-Diarize` | Transcribe y diariza en una pasada con 2,4 GB | |
|
|
| ## Benchmark de modelos STT (agosto 2026) |
|
|
| Medido sobre los vídeos del repo en ZeroGPU (H200). Spaces: |
| `Maximofn/subtify-stt-benchmark` y `Maximofn/subtify-stt-benchmark-qwen`. |
|
|
| Podcast en español, 327 s, dos hablantes: |
|
|
| | Modelo | Tiempo | RTFx | VRAM | Timestamps | Diariza | |
| |---|---|---|---|---|---| |
| | Cohere Transcribe | 2,3 s | 144× | 4,9 GB | no | no | |
| | Parakeet TDT v3 | 22,1 s | 14,8× | 4,6 GB | palabra | no | |
| | Voxtral Mini 3B | 24,9 s | 13,1× | 9,5 GB | no (ver abajo) | no | |
| | Qwen3-ASR 1.7B | 35,9 s | 9,1× | 5,7 GB | palabra | no | |
| | MOSS-Transcribe-Diarize | 46,4 s | 7,0× | 2,4 GB | frase | **sí** | |
| | Whisper large-v3-turbo | 64,3 s | 5,1× | 24,2 GB | palabra | no | |
| | distil-whisper large-v3 | 66,5 s | 4,9× | 24,2 GB | palabra | no | |
| | Granite Speech 4.1 Plus | 199,1 s | 1,6× | 4,3 GB | palabra | **sí** | |
|
|
| Vídeo en inglés de 598 s: Parakeet 22,8 s frente a los 112,9 s de Whisper turbo — |
| la ventaja crece con la duración, porque en clips cortos domina el arranque en frío. |
|
|
| **`distil-whisper/distil-large-v3` es solo inglés.** Era el transcriptor por defecto |
| y sobre audio en español no transcribía: traducía, y mal. |
|
|
| ## Trampas de cada modelo |
|
|
| Todas comprobadas en el benchmark; el código que las resuelve está en los Spaces. |
|
|
| ### Parakeet TDT v3 |
| - El pipeline genérico de `transformers` **no devuelve timestamps**: es un |
| transducer, no un seq2seq. Hay que usar `AutoModelForTDT` y |
| `processor.decode(sequences, durations=output.durations)`. |
| - Los timestamps son de **subtoken BPE**, no de palabra: `'Lo'`, `'ok'`, `'ing'`. |
| Hay que reagruparlos recorriendo el texto y consumiendo subtokens por longitud. |
| - **Detecta el idioma automáticamente y no acepta que se lo fuercen.** |
| - Cubre 25 idiomas europeos, no los 99 de Whisper. |
|
|
| ### MOSS-Transcribe-Diarize |
| - Su prompt por defecto **está en chino**. Sin pasarlo, el modelo no transcribe: |
| se pone a traducir el audio a otro idioma. |
| - Sin `add_generation_prompt=True` ignora la instrucción de emitir timestamps y |
| devuelve solo la etiqueta de hablante. |
| - Aguanta 90 minutos de una pasada. **No trocear**: al hacerlo, `[S01]` deja de |
| referirse a la misma persona entre ventanas. |
| - Formato de salida: `[inicio][Sxx]texto[fin]`. |
|
|
| ### Granite Speech 4.1 Plus |
| - En modo timestamps devuelve todo en minúsculas y sin puntuación. Hacen falta dos |
| pasadas (una para el texto, otra para los tiempos) alineadas con `SequenceMatcher`. |
| - Los tiempos vienen en centisegundos **módulo 1000**: el contador se reinicia cada |
| 10 segundos y hay que desenrollar el rollover. |
|
|
| ### Voxtral |
| - El **modelo abierto** vía `transformers` no da timestamps. |
| - La **API de Mistral sí los da** con `timestamp_granularities=["word"]`, aunque |
| entonces no admite fijar `language` a la vez. Son dos productos distintos. |
| - Hay una implementación funcionando en el proyecto del podcast |
| (`Welcome to La Secta`, `src/transcription/stt.py`, clase `VoxtralTranscriber`). |
|
|
| ### Qwen3-ASR |
| - Su paquete `qwen-asr` fija `transformers==4.57.6`, **incompatible** con el 5.x que |
| necesitan Parakeet y Cohere. Requiere un entorno aparte. |
| - El forced aligner devuelve objetos `ForcedAlignItem`, que no son indexables. |
| - El aligner cubre hasta 5 minutos: en audios más largos pierde palabras. |
|
|
| ### Descartados |
| - **Canary-1B-v2**: solo NeMo, dependencias incompatibles. |
| - **Cohere Transcribe**: el mejor texto y rapidísimo, pero sin timestamps. |
| - **ARK-ASR-3B** y **Granite 4.1 2B** a secas: sin timestamps. |
|
|
| ## ZeroGPU |
|
|
| - La GPU se presta solo mientras corre una función `@spaces.GPU`. **El modelo hay |
| que cargarlo dentro de la función**, no al importar el módulo. |
| - **Reserva crédito según el `duration` declarado**, no según el uso real. Declarar |
| 900 s aparta 900 s de cuota aunque se usen 20. |
| - **No admite ejecuciones concurrentes**: dos tareas a la vez agotan la asignación |
| y fallan con «too many ZeroGPU credits allocated to running tasks». |
| - El wrapper cruza un proceso y **solo propaga el nombre de la clase de excepción**, |
| sin traceback. Hay que capturar los errores dentro de la función decorada. |
| - El filesystem **no sobrevive a un rebuild**: los resultados hay que guardarlos fuera. |
| - Los modelos gated necesitan un secret `HF_TOKEN` en el Space. |
| - **El decorador va en la función que calcula, no en la que construye la interfaz.** |
| En `app.py` estaba sobre `subtify()`, que solo monta los componentes de Gradio: la |
| GPU se pedía al arrancar y se soltaba enseguida, así que las transcripciones |
| corrían sin GPU. Ahora está sobre `diarize()` y `trascribe_audio()`. |
|
|
| ## Descarga de vídeos |
|
|
| `download.py` importa `pytube`, que **lleva años roto** para YouTube. El módulo que |
| funciona es `youtube_download.py`, con `yt-dlp`, y no siempre a la primera: |
|
|
| - El cliente por defecto puede dar `403 Forbidden`. |
| - `web`, `ios` y `tv` a veces listan formatos que luego no sirven. |
| - Ningún cliente funciona siempre: en una prueba tiró `mweb` y en la siguiente hubo |
| que caer a `android`. Por eso se recorre una lista de clientes en cascada. |
| - Para algunos vídeos solo queda el formato 18 (mp4 360p con audio incrustado); hay |
| que bajarlo y extraer el audio con ffmpeg. |
|
|
| **Desde el Space no se puede descargar de YouTube.** Responde «Sign in to confirm |
| you're not a bot»: es el bloqueo antibot contra IPs de centro de datos. La misma URL |
| se descarga sin problema desde un ordenador de casa. En el Space, la vía que funciona |
| es subir el fichero; la UI lo dice cuando detecta ese error. |
|
|
| ## Gradio |
|
|
| - El Space corre la versión que declara `sdk_version` en el README, no la de |
| `requirements.txt`. Estaba en **5.13.2** (enero de 2025). |
| - Esa versión tiene un bug al convertir el esquema de los componentes: da |
| `TypeError: argument of type 'bool' is not iterable`, tumba el endpoint `/info` y |
| entonces **todos** los botones responden «No API found», aunque el evento esté |
| bien registrado y la función jamás llegue a ejecutarse. |
| - `app.py` lo parchea en `_patch_gradio_schema_bug()`. La solución de fondo es subir |
| `sdk_version`, y entonces el parche sobra. |
|
|
| ## Subida de ficheros al Space |
|
|
| Por API solo se aceptan ficheros **`.py`**. Los `.md` y `.txt` generan el commit pero |
| llegan vacíos, sin aviso de error: por eso `requirements.txt` y este `CLAUDE.md` hay |
| que editarlos a mano desde la web de Hugging Face. |
|
|
| ## Pendientes de investigar |
|
|
| - **Modelo dedicado de identificación de idioma.** Ahora se usa Whisper para |
| detectar, lo que obliga a cargarlo (6-8 s) aunque luego transcriba Parakeet. |
| Alternativa: `speechbrain/lang-id-voxlingua107-ecapa` (107 idiomas, ~25 M |
| parámetros, milisegundos). Revisar cuando el pipeline esté funcionando. |
|
|
| - **Inconsistencia de timestamps entre motores, para el lipsync.** Whisper y |
| Parakeet marcan el inicio de palabra de forma distinta: para «OpenAI», Whisper |
| daba 0,00 s y Parakeet 0,16 s. Diferencias de 100-300 ms. |
| Para subtítulos es irrelevante, pero el desfase audio-vídeo se percibe a partir de |
| ~125 ms. Como un vídeo en español usaría Parakeet y uno en japonés Whisper, el |
| sync tendría carácter distinto según el idioma, y una calibración del lipsync solo |
| valdría para uno de los dos. Medir contra referencia antes de construir el doblaje. |
|
|
| - **Diarización con más de dos hablantes.** MOSS solo se ha probado con dos voces. |
| Validar antes de apostar por él para sustituir a pyannote. |
|
|