# 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.