Communitygithub.com

wjs-transcripción-de-audio

Convierte audio/vídeo en un SRT en el mismo idioma de origen usando Whisper. Es el primer paso del conjunto de localización jianshuo.

¿Qué es wjs-transcripción-de-audio?

Este skill, `wjs-transcribing-audio`, es un micrófono de entrada para el conjunto de localización jianshuo. Su trabajo es transformar archivos de audio o video hablados en un archivo de subtítulos SRT con marca de tiempo, exactamente en el mismo idioma en que se habla (el idioma de origen). No traduce a otro idioma, para eso está el skill complementario `wjs-translating-subtitles`. Es útil para creadores de contenido, editores de video, traductores y cualquier persona que necesite una transcripción precisa con marcas de tiempo para generar subtítulos. Soporta múltiples plataformas de transcripción, seleccionando la más adecuada según el idioma de origen: para chino (zh-CN, zh-HK, zh-TW) utiliza de forma predeterminada la API de Volcano ASR (豆包) por su precisión superior, y para otros idiomas (es, en, pt, fr, etc.) utiliza la API de OpenAI Whisper. También ofrece un modo offline con Whisper local como último recurso. El skill implementa un proceso robusto para generar SRT optimizados, evitando los modos de fallo comunes de Whisper (segmentos de 30 segundos o alucinaciones de bucle) mediante el uso de marcas de tiempo a nivel de palabra y un ensamblador de señales basado en puntuación. Incluye un paso de 'pulido AI' opcional para corregir errores de homófonos en el texto chino, y maneja la autenticación, el proxy SOCKS, la división en fragmentos, y la concatenación de tiempos de manera transparente.

Compatible con✓Claude Code✓Codex CLI~Cursor
npx skills add https://github.com/jianshuo/claude-skills/tree/main/wjs-transcribing-audio

Preguntar en tu IA favorita

Abre un nuevo chat con esta habilidad de agente ya precargada.

Documentación

¿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 origenMotor predeterminadoPor qué
Chino (zh-CN, zh-HK, zh-TW)Volcano (豆包) ASRPrecisió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 palabraWhisper es fuerte en multilingüe; las marcas de tiempo de palabras nos permiten ensamblar señales nosotros mismos
Sin conexión / sin acceso a APIopenai-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:

  1. Señales de 30 segundos de bloque. En monólogos largos, whisper-1 con response_format=srt emite una señal que cubre toda la ventana de condition_on_previous_text de 30s. La transcripción está bien; el tiempo no es útil para la lectura en pantalla.
  2. Alucinación de bucle en colas silenciosas. temperature=0 codiciosa 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 con set -a; source ~/code/.env; set +a antes de invocar.
  • Proxy SOCKS en esta máquina: httpx necesita el extra socksio — usar uvx --with httpx --with socksio python ... (sin él obtienes ImportError: 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=2 es más fiable que 4.
  • Reintento: cada llamada a la API envuelta en 5× retroceso exponencial (time.sleep(min(2**n, 30))) — RemoteProtocolError: Server disconnected es 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/end de 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=srt para 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=0 en audio potencialmente silencioso (yoga, contenido espiritual, finales de podcast). La decodificación codiciosa hace bucles. 0.2 habilita 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 establecer result_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 con text (puntuado) + words[] (token + start_time/end_time en ms). Los tokens latinos como "AI" vuelven con start=end=0 — build_srt_from_asr.py los 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-min caracteres; 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-chars para 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_APPID y VOLC_ASR_ACCESS_TOKEN/VOLC_TOKEN; FFMPEG_BIN opcional.
    • 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/.env como VOLC_TTS_APPID / VOLC_TTS_ACCESS_TOKEN (además de VOLC_APPID en ~/.zshrc), con alias VOLC_ASR_APPID / VOLC_ASR_ACCESS_TOKEN adjuntos que apuntan a los mismos valores. Así que set -a; source ~/code/.env; set +a es 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).
  • 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).

Individual skills in this repo

This repo contains 5 individual skills — each has its own dedicated page.

Skills relacionados