¿Qué hace wjs-transcripción-de-audio?
Audio hablado de entrada → SRT con marca de tiempo en el mismo idioma de salida. Este skill se detiene en el SRT en el idioma de origen. La traducción a otro idioma es el siguiente skill (/wjs-traducción-de-subtítulos).
Cuándo usar
- El usuario proporciona un archivo de video o audio y desea una transcripción / SRT en el idioma de origen.
- El usuario ya tiene un SRT traducido y falta el SRT de origen.
- El usuario pide "hacer SRT" / "crear subtítulos" / "generar transcripción" sin que se haya solicitado aún la traducción.
Cuándo NO usar
- El SRT en el idioma de origen ya existe → saltar directamente a
/wjs-traducción-de-subtítulos. - El usuario desea la transcripción en un idioma diferente al hablado → ejecutar este skill primero, luego
/wjs-traducción-de-subtítulos. - El usuario desea solo el doblaje o la incrustación de subtítulos → si el SRT existe, saltar; de lo contrario, ejecutar este primero.
Enrutamiento: qué motor
| Idioma de origen | Motor predeterminado | Por qué |
|---|---|---|
| Chino (zh-CN, zh-HK, zh-TW) | Volcano (豆包) ASR | Precisión materialmente mejor que Whisper para chino — preferencia permanente del usuario |
| Cualquier otro (es, en, pt, fr, it, ja, ko, …) | API de OpenAI Whisper con granularidad a nivel de palabra | Whisper es fuerte en multilingüe; las marcas de tiempo de palabras nos permiten ensamblar señales nosotros mismos |
| Sin conexión / sin acceso a API | openai-whisper local (medium) | Calidad base; los mismos modos de fallo de bucle/bloque se aplican |
Para chino, no usar Whisper por defecto a menos que el usuario lo solicite explícitamente o Volcano no esté disponible. Esta es una decisión de enrutamiento deliberada — ver la memoria del usuario sobre la prioridad de ASR en chino.
Ruta de la API de OpenAI Whisper (no chino, y fallback chino)
El principio clave: no solicitar response_format=srt. La segmentación de señales de Whisper falla en monólogos largos (señales de 30 segundos de bloque) y en silencios (alucinaciones de bucle). Solicitar marcas de tiempo a nivel de palabra y ensamblar las señales usted mismo — el post-procesamiento es determinista y gratuito.
Por qué no response_format=srt
Dos modos de fallo que arruinan la salida SRT de whisper-1 en contenido largo:
- Señales de 30 segundos de bloque. En monólogos largos,
whisper-1conresponse_format=srtemite una señal que cubre toda la ventana decondition_on_previous_textde 30s. La transcripción está bien; el tiempo no es útil para la lectura en pantalla. - Alucinación de bucle en colas silenciosas.
temperature=0codiciosa en audio de baja energía produce "你如果不把拥抱浪费写在这上面,你很难的" repetido 50 veces.
Ambos provienen de dejar que Whisper decida los límites de las señales. Solución: marcas de tiempo a nivel de palabra + su propio ensamblador basado en puntuación.
Llamando a la API
# 1. Comprimir para subir — MP3 mono a 64kbps es suficiente para voz.
# El límite de OpenAI es 25 MB por solicitud; dividir en fragmentos de 10 minutos
# (≈4.5MB a 64kbps) para robustez bajo proxies inestables.
ffmpeg -hide_banner -loglevel error -y \
-ss <inicio> -t 600 -i input.mp4 \
-vn -ac 1 -ar 16000 -c:a libmp3lame -b:a 64k chunk.mp3
# 2. Solicitar marcas de tiempo a nivel de palabra. NO solicitar response_format=srt.
import httpx, os
data = {
"model": "whisper-1",
"language": "es", # fijar idioma de origen; nunca auto-detectar
"response_format": "verbose_json",
"timestamp_granularities[]": "word", # ← el flag crítico
"temperature": "0.2", # habilitar cadena de fallback (anti-bucle)
}
with open("chunk.mp3", "rb") as f:
r = httpx.post(
"https://api.openai.com/v1/audio/transcriptions",
headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"},
data=data,
files={"file": ("chunk.mp3", f, "audio/mpeg")},
timeout=600.0,
)
r.raise_for_status()
j = r.json()
palabras = j["words"] # [{"word": "hola", "start": 0.12, "end": 0.34}, ...]
segmentos = j["segments"] # ver la sorpresa abajo
Sorpresa: words[] no tiene puntuación, segments[] es inconsistente
El array words[] de Whisper típicamente no tiene puntuación en word["word"] — cada entrada es un token simple como "做", "个", "测", "试". La puntuación, cuando existe, solo reside en el campo text de segments[].
Peor aún, el texto de segments[] está inconsistentemente puntuado a través de fragmentos del mismo archivo: el fragmento 0 de un podcast de 79 min podría emitir 285 segmentos simples ("做个测试" "你在" "呵呵") de 1-2s cada uno sin puntuación; el fragmento 7 podría emitir 34 segmentos de 14-30s cada uno con puntuación. Ambos comportamientos se envían en la misma respuesta de la API.
Entonces la receta correcta combina ambos: usar segments[] para límites de pausa naturales (ya alineados con la respiración), pero tratarlos como entrada sin procesar para su propio ensamblador de señales, que utiliza marcas de tiempo de palabras para dividir en cualquier lugar donde los segmentos sean demasiado largos.
Receta de ensamblaje de señales
DUR_OBJETIVO = 3.0 # intentar que las señales tengan esta duración
DUR_MAX_SEÑAL = 5.0 # nunca exceder
MAX_CARACTERES = 18 # ~una línea con tamaño de fuente 14 en vertical 1080
GAP_MAX = 1.0 # umbral de silencio → forzar límite de señal
MIN_PIEZA = 0.3 # por debajo de esto, fusionar con el vecino
SET_PUNTUACION = set(",。!?;,.;!?")
# Paso A: fusionar segmentos[] cortos hacia DUR_OBJETIVO (usar segmentos,
# no palabras — los límites de segmentos de Whisper ya están
# alineados con pausas).
def ensamblar(segmentos, desplazamiento):
señales, buf = [], []
def vaciar():
if buf:
señales.append((buf[0]["start"]+desplazamiento, buf[-1]["end"]+desplazamiento,
"".join(s["text"].strip() for s in buf)))
buf.clear()
for s in segmentos:
dur = s["end"] - s["start"]
# Segmento único largo CON puntuación interna → dividir independiente
if dur > DUR_MAX_SEÑAL and any(c in s["text"] for c in SET_PUNTUACION):
vaciar(); señales.extend(dividir_segmento_largo(s, desplazamiento)); continue
if not buf: buf.append(s); continue
if (s["start"] - buf[-1]["end"]) >= GAP_MAX \
or (buf[-1]["end"] - buf[0]["start"]) >= DUR_OBJETIVO \
or (s["end"] - buf[0]["start"]) > DUR_MAX_SEÑAL:
vaciar()
buf.append(s)
vaciar(); return señales
# Paso B: paso final — dividir cada coma/punto interno en su propia señal
# (marcas de tiempo proporcionales por posición de carácter). Fusionar piezas
# más cortas que MIN_PIEZA hacia adelante.
# Paso C: cualquier señal que aún supere MAX_CARACTERES se divide en el mayor
# espacio entre palabras usando marcas de tiempo de words[]. Recursivo hasta
# estar por debajo del límite.
Ajustar DUR_OBJETIVO y MAX_CARACTERES al ritmo de lectura de la plataforma. El límite de 18 caracteres es importante para la incrustación en vertical 1080×1920 con Tamaño_de_fuente=14 — más largo se envuelve en múltiples líneas ilegibles.
Detalles operativos
- Autenticación: las credenciales viven en
~/code/.env. Cargar conset -a; source ~/code/.env; set +aantes de invocar. - Proxy SOCKS en esta máquina:
httpxnecesita el extrasocksio— usaruvx --with httpx --with socksio python ...(sin él obtienesImportError: Using SOCKS proxy, but the 'socksio' package is not installed). - Dividir en fragmentos: piezas de 10 minutos a 64kbps mono MP3 (~4.5MB cada una) es el punto óptimo de fiabilidad. Fragmentos de 20 minutos (~9MB) a veces causan RST bajo proxies inestables. Concurrencia
max_workers=2es más fiable que4. - Reintento: cada llamada a la API envuelta en 5× retroceso exponencial (
time.sleep(min(2**n, 30))) —RemoteProtocolError: Server disconnectedes común y transitorio. - Costura de desplazamiento: las palabras de cada fragmento vuelven con marcas de tiempo relativas a ese fragmento. Al fusionar, agregar el desplazamiento de inicio absoluto del fragmento a cada
start/endde palabra antes de ensamblar las señales. - Protección de bucle (cinturón y tirantes): incluso con
temperature=0.2, ocasionalmente un sub-fragmento aún hace bucle. Después del ensamblaje, ejecutar un detector de bucle en el texto de cada señal — si cualquier frase de 8–40 caracteres de longitud se repite 3+ veces consecutivas, descartar la señal.
Anti-patrones (no hacer)
- ❌ No solicitar
response_format=srtpara contenido de más de ~2 minutos. - ❌ No "arreglar" señales malas con una segunda llamada a la API. Si obtuviste señales de bloque o alucinaciones de bucle de tu primera llamada, rehacer con granularidad a nivel de palabra una vez — no volver a transcribir solo el sub-rango roto.
- ❌ No usar
temperature=0en audio potencialmente silencioso (yoga, contenido espiritual, finales de podcast). La decodificación codiciosa hace bucles.0.2habilita la cadena de fallback. - ❌ No omitir
language=.... La detección automática ocasionalmente cambia chino→japonés o español→portugués en los primeros 30 segundos y toda la transcripción se vuelve incorrecta.
Ruta de Volcano (豆包) ASR — preferida para chino
Volcano ASR supera rutinariamente a Whisper en precisión de mandarín (tasa de reconocimiento, puntuación, entidades nombradas). Usar este como predeterminado para fuente zh-*.
⛔ NUNCA usar 飞书妙记 / lark-minutes para ASR. Solo proporciona marcas de tiempo a nivel de turno (turno de hablante), no tiempo por palabra — los subtítulos construidos a partir de él se desvían por segundos y se rompen en lugares antinaturales. La regla permanente del usuario: "以后不要用飞书妙记来做ASR,不能作为SRT使用,记住". No hay fallback con 飞书妙记. Si Volcano no está disponible, recurrir a la ruta de nivel de palabra de OpenAI Whisper anterior (fijar
language=zh).
Usar la API WebSocket STREAMING — envía bytes, NO necesita URL pública
Las APIs de archivo / 录音文件识别 / MediaKit requieren todas una URL HTTP(S) de audio públicamente accesible. El usuario rechazó el alojamiento de URL ("不要再用什么从服务器端去 download 的这 mp3 这样的模式" — los túneles fallan en su punto de acceso). La API 大模型流式语音识别 (bigmodel streaming) evita la URL por completo al enviar bytes PCM sin procesar a través de un WebSocket. Esta es la ruta de trabajo. Devuelve marcas de tiempo de palabras en ms.
Scripts empaquetados (usar estos — son la ruta de trabajo verificada):
# 1. Transcribir: envía bytes PCM mono de 16k a través de WebSocket → JSON ASR
# (decodifica cualquier entrada a través de ffmpeg; .pcm pasa directamente)
export VOLC_ASR_APPID=… VOLC_ASR_ACCESS_TOKEN=… # las credenciales viven con el usuario
python3 scripts/volc_asr_stream.py <clip.mp4|wav|mp3|pcm> <out.asr.json>
# 2. Construir un SRT limpio y con marcas de tiempo de palabras a partir del JSON ASR
python3 scripts/build_srt_from_asr.py <out.asr.json> <out.srt> [max_chars=18]
# Ajustes de segmentación (opcional):
# --max-chars N aumentar para mantener las señales completas / dividir en puntuación, no a mitad de oración
# --soft-min N caracteres mínimos antes de que un ,、; vacíe una señal (predeterminado 8)
# --strip-punct eliminar TODA la puntuación del texto mostrado (aspecto limpio de subtítulo)
- Punto final:
wss://openspeech.bytedance.com/api/v3/sauc/bigmodel - Cabeceras:
X-Api-App-Key:{appid},X-Api-Access-Key:{token},X-Api-Resource-Id:volc.bigasr.sauc.duration,X-Api-Connect-Id:{uuid} - Tramado v3 binario: cabecera de 4 bytes
[(ver<<4)|hdrsize, (msgtype<<4)|flags, (ser<<4)|comp, 0]. Full-client(0x1)+POS_SEQ envía JSON de configuración comprimido con gzip; paquetes de audio(0x2) envían fragmentos PCM comprimidos con gzip (200ms = 6400 bytes a 16k mono s16le); el último paquete usa NEG_WITH_SEQ(0x3) con secuencia negativa. La respuesta full del servidor(0x9) devuelve JSON comprimido con gzip. - JSON de configuración:
{user:{uid}, audio:{format:"pcm",rate:16000,bits:16,channel:1,codec:"raw"}, request:{model_name:"bigmodel", enable_punc:true, enable_itn:true, show_utterances:true}}. NO establecerresult_type:"single"— eso devuelve solo la última oración en cada trama; el modo predeterminado acumula todas las emisiones. - Forma del resultado:
result.utterances[], cada una context(puntuado) +words[](token +start_time/end_timeen ms). Los tokens latinos como "AI" vuelven constart=end=0—build_srt_from_asr.pylos rellena hacia adelante/atrás desde los vecinos. - build_srt_from_asr.py también: divide las señales en puntuación (vacía DURO en
。!?; vacía SUAVE en,、;después de--soft-mincaracteres; límite duro--max-chars), opcionalmente elimina toda la puntuación de la visualización (--strip-punct), descarga rellenos 呃/嗯/唉 y colapsa tokens cortos duplicados inmediatos (才才→才). Cada señal está temporizada por su primera/última palabra para que se asiente exactamente en el audio hablado — sin desviación. Para evitar rupturas a mitad de oración, aumentar--max-charspara que los límites caigan en puntuación en lugar del límite de caracteres. - Transcripción por clip: transcribir el propio audio de cada clip (PCM mono de 16k) para que las marcas de tiempo del SRT salgan relativas al clip.
- Credenciales: viven con el usuario (豆包语音引擎). Nombres de entorno aceptados por el script:
VOLC_ASR_APPID/VOLC_APPIDyVOLC_ASR_ACCESS_TOKEN/VOLC_TOKEN;FFMPEG_BINopcional.- El ASR y TTS de 火山 de este usuario COMPARTEN UN único conjunto de credenciales — el mismo appid + access token funcionan para ambos servicios. Están almacenados en
~/code/.envcomoVOLC_TTS_APPID/VOLC_TTS_ACCESS_TOKEN(además deVOLC_APPIDen~/.zshrc), con aliasVOLC_ASR_APPID/VOLC_ASR_ACCESS_TOKENadjuntos que apuntan a los mismos valores. Así queset -a; source ~/code/.env; set +aes suficiente — no hay token ASR separado que buscar. Si los alias alguna vez faltan, recrearlos a partir de los valores de TTS (son el mismo secreto).
- El ASR y TTS de 火山 de este usuario COMPARTEN UN único conjunto de credenciales — el mismo appid + access token funcionan para ambos servicios. Están almacenados en
- Callejones sin salida (no reintentar): MediaKit asr-subtitles (doc 6448/2381968) aún necesita una URL pública y una clave API de MediaKit separada. La API de archivo 录音文件识别 necesita una URL pública.
Whisper local como último recurso
Solo cuando no hay conexión, la cuota de API está agotada, o para borradores ultra baratos. La calidad es materialmente más baja para chino; los mismos modos de fallo de bloque/bucle se aplican; Whisper local no expone marcas de tiempo a nivel de palabra a través de la CLI, por lo que la solución de principios no está disponible.
ffmpeg -i input.mp4 -vn -ac 1 -ar 16000 -c:a pcm_s16le _audio.wav -y
uvx --from openai-whisper whisper _audio.wav \
--language zh --task transcribe \
--model medium --output_format srt --output_dir .
rm _audio.wav
medium es el mínimo práctico para precisión en chino; small solo es aceptable para inglés de estudio limpio. Whisper escribe . milisegundos; el archivo sigue siendo SRT válido. Si regeneras el SRT, siempre emite , ms.
Pasada de pulido AI — corregir errores tipográficos evidentes (paso final, siempre ejecutar para chino)
Incluso el Volcano ASR envía errores de homófonos claros que se leen mal en pantalla — observado: 「总数」→「意数」, 「需求」→「虚求」, 「程序员」→「成员」. El texto ASR sin procesar es el piso, no el techo. Después de ensamblar el SRT, hacer una pasada de pulido de Claude sobre el SRT completo para corregir errores evidentes usando el contexto de la oración.
Esta pasada se realiza en sesión por Claude leyendo el SRT — sin llamada a API externa. Reescribir el .srt en el lugar (o a <stem>.polished.srt).
Reglas estrictas — esto es corrección, no edición:
- ✅ Corregir solo errores claros de homófono / tipográficos donde la palabra intencionada es inequívoca a partir del contexto (意数→总数, 虚求→需求).
- ⛔ NUNCA cambiar marcas de tiempo, señales de salida, o eliminar contenido. Si no estás seguro, dejar intacto.
- ⛔ No reformular oraciones, acortar, o "mejorar" el estilo. El objetivo es precisión léxica, no elocuencia.
- ⛔ No eliminar repeticiones intencionales (como "一个一个") o partículas únicas de habla.
- 🔲 Si todo es correcto, no escribir ningún archivo — solo confirmar que no hay cambios.
El paso de pulido se omite para SRTs no chinos (Whisper tiene pocos errores de homófonos en fuentes con ortografía fonética).