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:
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| Calidad | Vídeos | Horas |
|---|---|---|
| Buena (puntuadas) | 29 | 17,1 h |
| Mala (sin puntuar) | 49 | 19,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.
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.binlarge-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:
# 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_IDLas 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 intactosLa 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:
#!/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.txtTres decisiones que parecen menores:
[ -s "out/$id.txt" ] && continue— salta lo ya hecho. Cortar y reanudar no pierde nada. El-sademá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.
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 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:
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
doneEl -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.
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:
cat no_subs.txt bad_quality.txt | sort -u > pending.txtUno 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:
[81/81] zu4eA36KaYM0D8UjQviFWs SIN AUDIO
└─────────┘└─────────┘
video 1 video 2El 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
| Origen | Vídeos | Palabras |
|---|---|---|
| Whisper large-v3 | 82 | 217.103 |
| Subtítulos automáticos (los que ya eran buenos) | 29 | 157.112 |
| Corpus consolidado | 111 | 374.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.
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.