← Ruta Perico
Etapa 05

Transcribir con Whisper lo que no tiene subtítulos

2ª categoría Publicada ~7 h desatendidas
05

El inventario dejó dos agujeros. Uno evidente: 33 vídeos sin subtítulos automáticos, 17,3 horas de material invisible. Y otro que solo apareció al medir la calidad de lo que ya teníamos.

El agujero que no se ve: la mitad estaba corrupta

Los subtítulos automáticos de YouTube no tienen la misma calidad en todos los vídeos. Los recientes traen puntuación y mayúsculas; los antiguos son un chorro en minúscula lleno de errores. Se distinguen con una heurística de dos líneas. Una heurística es una regla aproximada: no demuestra nada y se equivoca de vez en cuando, pero acierta lo suficiente como para no tener que abrir los 111 ficheros a mano. Aquí, contar puntos y mayúsculas basta:

python
punctuation = (text.count(".") + text.count(",")) / max(len(text.split()), 1)
capitals = sum(1 for c in text if c.isupper()) / max(len(text), 1)

is_bad = punctuation < 0.02 and capitals < 0.01
CalidadVídeosHoras
Buena (puntuadas)2917,1 h
Mala (sin puntuar)4919,4 h

Y no eran los descartables: entre las malas estaban las conferencias de una hora y los podcasts largos, o sea el material de mayor densidad de estilo. Por la regla que fijamos en la etapa 3 — una frase que hay que reparar ya no es suya — esas 19,4 horas eran casi inservibles.

Total de trabajo: 36,7 horas de audio, no las 17 que parecían.

Whisper en local, gratis

whisper.cpp corre Whisper — el modelo de transcripción de OpenAI: le das audio y te devuelve el texto de lo que se dice — en tu propia máquina. En un Apple Silicon tira de GPU vía Metal y va rápido. Nada sube a ningún servidor y no cuesta dinero.

terminal
brew install whisper-cpp ffmpeg

# el modelo grande, ~3 GB, se baja una vez
mkdir -p ~/.cache/whisper && cd ~/.cache/whisper
curl -L -O https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin

large-v3 y no medium: en español coloquial la diferencia se nota, y aquí la calidad de transcripción es el proyecto.

Prueba uno antes de lanzar ochenta

Un vídeo de diez minutos, para verificar que funciona y medir la velocidad real:

terminal
# solo el audio, a 16 kHz mono, que es lo que Whisper espera
yt-dlp -x --audio-format wav --postprocessor-args "-ar 16000 -ac 1" \
       -o "wav/%(id)s.%(ext)s" -- VIDEO_ID

whisper-cli -m ~/.cache/whisper/ggml-large-v3.bin \
            -l es -np -nt -f wav/VIDEO_ID.wav \
            -otxt -of out/VIDEO_ID

Las opciones: -l es fija el idioma (sin esto lo adivina y a veces falla), -np quita las barras de progreso, -nt quita las marcas de tiempo, -otxt escribe texto plano.

Medido: 1 min 42 s para un vídeo de 10 minutos, o sea ~6× tiempo real. De ahí sale la estimación de 36,7 h ÷ 6 ≈ 6 horas para el lote entero.

La diferencia de calidad

Mismo vídeo, dos transcripciones. Los subtítulos automáticos de YouTube:

«...tuviste que aprender francés efe a leches... los triunfos de la unsj amstrong...»

Era «a hachazos» y «Lance Armstrong»

Whisper large-v3:

«Yo hay una cosa que hoy en día me cabrea y mucho, ¿no? Es que todo es ganar. Ser segundo, ser tercero, hacer una plaza de honor. No se le da importancia.»

Puntuación correcta, signos de apertura, y los tics intactos

La segunda es citable tal cual. La primera hay que reconstruirla, y reconstruir significa inventar.

El lote: reanudable por diseño

Seis horas de proceso van a interrumpirse. Portátil que se duerme, reinicio, lo que sea. El script se escribe para que eso no importe:

transcribe.sh
#!/bin/zsh
MODEL=~/.cache/whisper/ggml-large-v3.bin
mkdir -p wav out

while read id; do
  # ya hecho: saltar. Esto es lo que hace el script reanudable
  [ -s "out/$id.txt" ] && continue

  yt-dlp -x --audio-format wav --postprocessor-args "-ar 16000 -ac 1" \
         -o "wav/%(id)s.%(ext)s" -- "$id" >/dev/null 2>&1
  [ ! -f "wav/$id.wav" ] && { echo "$id NO AUDIO"; continue; }

  whisper-cli -m $MODEL -l es -np -nt -f "wav/$id.wav" \
              -otxt -of "out/$id" >/dev/null 2>&1

  rm -f "wav/$id.wav"          # borrar el wav: 36 h de audio son ~4 GB
  echo "$id  $(wc -w < out/$id.txt) palabras"
done < pending.txt

Tres decisiones que parecen menores:

  • [ -s "out/$id.txt" ] && continue — salta lo ya hecho. Cortar y reanudar no pierde nada. El -s además exige que el fichero no esté vacío, así que un fallo a medias se reintenta.
  • rm -f "wav/$id.wav" — borra el audio en cuanto se usa. Sin esto acumulas 4 GB de WAV.
  • Una línea de log por vídeo — es lo único que te dirá dónde iba cuando vuelvas.
A este script le falta una bandera

Tal como está aquí, el lote reproduce un fallo que tardamos en descubrir: Whisper se engancha repitiendo una frase y se come el resto del fichero. La bandera que lo evita es -mc 0; el porqué está en la etapa 8.

Si vas a lanzar el lote de verdad, añádela antes de empezar.

El error que casi cuesta seis horas

El lote escribía en un directorio temporal del sistema. Si ese directorio se limpia — y los temporales se limpian — se pierde todo el cómputo.

La solución no fue mover la salida (habría obligado a reiniciar el lote), sino un segundo proceso copiando al repositorio cada 30 segundos:

sync.sh
while true; do
  cp -n "$TMP"/out/*.txt "$REPO"/data/archive/whisper/ 2>/dev/null
  pgrep -f transcribe.sh >/dev/null || break   # el lote acabo: salimos
  sleep 30
done

El -n de cp es «no sobrescribas»: copiar lo mismo cien veces no hace daño. Así, como mucho se pierde el vídeo que estuviera a medias.

Regla general

Todo proceso largo debe poder (1) reanudarse sin perder trabajo y (2) dejar su salida en sitio seguro según la produce, no al final. Las dos cosas cuestan tres líneas y se agradecen la primera vez que algo se corta.

Un bug de una línea que dejó dos vídeos fuera

La lista de pendientes se construyó juntando dos ficheros:

terminal
cat no_subs.txt bad_quality.txt | sort -u > pending.txt

Uno de los dos ficheros no acababa en salto de línea, así que su última línea y la primera del otro se pegaron en una sola:

salida
[81/81] zu4eA36KaYM0D8UjQviFWs SIN AUDIO
        └─────────┘└─────────┘
         video 1     video 2

El log lo cantó, pero solo porque el script imprime una línea por vídeo. Uno de los dos era un documental de 88 minutos. Se relanzaron aparte.

Resultado

OrigenVídeosPalabras
Whisper large-v382217.103
Subtítulos automáticos (los que ya eran buenos)29157.112
Corpus consolidado111374.215

Al consolidar, cuando un vídeo tiene las dos versiones gana la de Whisper. Esos 49 no suman palabras: sustituyen texto corrupto por texto limpio, que es donde estaba el valor.

De hecho Whisper devuelve menos palabras que los subtítulos automáticos sobre el mismo vídeo — 9.469 frente a 10.417 en uno medido, un 10% menos — y eso es una mejora, no una pérdida. Lo que sobra en los automáticos es ruido transcrito como si fuera habla, muletillas duplicadas y relleno. Menos palabras y más señal, que en un corpus es exactamente el cambio que interesa.

Coste en dinero: cero. Coste en tiempo: seis horas de máquina desatendida.

Semanas después: el script que se evaporó

transcribe.sh vivía en el directorio temporal de una sesión de trabajo, igual que su lista de entrada y sus logs. Cuando ese directorio se limpió, el proyecto perdió la capacidad de regenerar el 90 % de su corpus. Los datos seguían en el repositorio, pero la receta que los había producido no.

Se reconstruyó como módulo del paquete, perico.sources.transcribe, con todas las lecciones dentro: 16 kHz mono, -l es, -mc 0, reintentos con espera creciente y la salida directa al repositorio. La regla que deja es sencilla: un script que produce datos que se guardan es parte del proyecto, aunque solo se vaya a ejecutar una vez.