subtify / CLAUDE.md
Maximofn's picture
Subir gradio a 5.49.1, documentar aprendizajes y dejar de ignorar requirements.txt
fe5c50b
|
Raw
History Blame Contribute Delete
9.52 kB
# 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.