O que wjs-transcrição-áudio faz?
Áudio falado → SRT com timestamps no mesmo idioma. Este skill para no SRT do idioma de origem. A tradução para outro idioma é o próximo skill (/wjs-translating-subtitles).
Quando usar
- O usuário fornece um arquivo de vídeo ou áudio e deseja uma transcrição / SRT no idioma de origem.
- O usuário já tem um SRT traduzido, mas o SRT original está faltando.
- O usuário pede "fazer SRT" / "criar legendas" / "出逐字稿" sem solicitar tradução.
Quando NÃO usar
- O SRT no idioma de origem já existe → pule direto para
/wjs-translating-subtitles. - O usuário quer a transcrição em um idioma diferente do falado → execute este skill primeiro, depois
/wjs-translating-subtitles. - O usuário quer apenas dublagem ou queima de legenda → se o SRT existir, pule; caso contrário, execute este primeiro.
Roteamento: qual mecanismo
| Idioma de origem | Mecanismo padrão | Por quê |
|---|---|---|
| Chinês (zh-CN, zh-HK, zh-TW) | Volcano (豆包) ASR | Precisão materialmente melhor que Whisper para chinês — preferência constante do usuário |
| Qualquer outro (es, en, pt, fr, it, ja, ko, …) | API OpenAI Whisper com granularidade de palavra | Whisper multilíngue é robusto; timestamps de palavras permitem montar as legendas manualmente |
| Offline / sem acesso a API | openai-whisper local (medium) | Qualidade mínima; mesmos modos de falha de blob/loop se aplicam |
Para chinês, não use Whisper como padrão, a menos que o usuário peça explicitamente ou Volcano esteja indisponível. Esta é uma decisão de roteamento deliberada — veja a memória do usuário sobre prioridade de ASR chinês.
Caminho da API OpenAI Whisper (não-chinês e fallback chinês)
O princípio fundamental: não solicite response_format=srt. A segmentação de legendas do Whisper falha em monólogos longos (legendas blob de 30 segundos) e em trechos silenciosos (alucinações em loop). Solicite timestamps no nível de palavra e monte as legendas você mesmo — o pós-processamento é determinístico e gratuito.
Por que não usar response_format=srt
Dois modos de falha que prejudicam a saída SRT do whisper-1 em conteúdo longo:
- Legendas blob de 30 segundos. Em monólogos longos,
whisper-1comresponse_format=srtemite uma única legenda cobrindo toda a janela de 30s decondition_on_previous_text. A transcrição está correta; o tempo é inutilizável para leitura na tela. - Alucinação em loop em caudas silenciosas.
temperature=0ganancioso em áudio de baixa energia produz "você não pode colocar esse abraço desperdiçado aqui, é muito difícil" repetido 50 vezes.
Ambos decorrem de deixar o Whisper decidir os limites das legendas. Solução: timestamps no nível de palavra + seu próprio montador com reconhecimento de pontuação.
Chamando a API
# 1. Comprimir para upload — MP3 mono 64 kbps é suficiente para fala.
# Limite da OpenAI é 25MB por requisição; divida em pedaços de 10 min
# (≈4.5MB a 64kbps) para resiliência sob proxies instáveis.
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 timestamps no nível de palavra. NÃO solicite response_format=srt.
import httpx, os
data = {
"model": "whisper-1",
"language": "es", # fixe o idioma de origem; nunca auto-detectar
"response_format": "verbose_json",
"timestamp_granularities[]": "word", # ← a flag crítica
"temperature": "0.2", # habilita cadeia de fallback (anti-loop)
}
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()
words = j["words"] # [{"word": "olá", "start": 0.12, "end": 0.34}, ...]
segments = j["segments"] # veja surpresa abaixo
Surpresa: words[] não tem pontuação, segments[] é inconsistente
O array words[] do Whisper tipicamente não tem pontuação em word["word"] — cada entrada é um token puro como "fazer", "um", "teste". Pontuação, quando presente, vive apenas no campo text de segments[].
Pior, o texto de segments[] é inconsistentemente pontuado entre pedaços do mesmo arquivo: o pedaço 0 de um podcast de 79 min pode emitir 285 segmentos nus ("fazendo um teste" "você" "hehe") de 1-2s cada sem pontuação; o pedaço 7 pode emitir 34 segmentos de 14-30s cada com pontuação. Ambos os comportamentos são enviados na mesma resposta da API.
Portanto, a receita correta combina ambos: use segments[] para limites naturais de pausa (já alinhados à respiração), mas trate-os como entrada bruta para seu próprio montador de legendas, que usa timestamps de palavra para dividir onde os segmentos são muito longos.
Receita de montagem de legendas
DUR_ALVO = 3.0 # tente fazer as legendas deste tamanho
DUR_MAX_LEGENDA = 5.0 # nunca exceda
MAX_CARACT = 18 # ~uma linha na Fonte 14 em 1080-wide vertical
MAX_LACUNA = 1.0 # limite de silêncio → força limite de legenda
MIN_PECA = 0.3 # abaixo disso, mesclar com vizinho
PONT_DIVISAO = set(",。!?;,.;!?")
# Passo A: mesclar segments[] curtos em direção a DUR_ALVO (use segments,
# não words — os limites de segmento do Whisper já estão
# alinhados a pausas).
def montar(segments, offset):
legendas, buf = [], []
def descarregar():
if buf:
legendas.append((buf[0]["start"]+offset, buf[-1]["end"]+offset,
"".join(s["text"].strip() for s in buf)))
buf.clear()
for s in segments:
dur = s["end"] - s["start"]
# Segmento único longo COM pontuação interna → dividir isoladamente
if dur > DUR_MAX_LEGENDA and any(c in s["text"] for c in PONT_DIVISAO):
descarregar(); legendas.extend(quebrar_segmento_longo(s, offset)); continue
if not buf: buf.append(s); continue
if (s["start"] - buf[-1]["end"]) >= MAX_LACUNA \
or (buf[-1]["end"] - buf[0]["start"]) >= DUR_ALVO \
or (s["end"] - buf[0]["start"]) > DUR_MAX_LEGENDA:
descarregar()
buf.append(s)
descarregar(); return legendas
# Passo B: passada final — dividir cada vírgula/ponto interno em sua própria legenda
# (temporizações proporcionais por posição do caractere). Juntar peças
# mais curtas que MIN_PECA para frente.
# Passo C: qualquer legenda ainda > MAX_CARACT é dividida no maior espaço
# entre palavras usando timestamps de words[]. Recursivo até abaixo do limite.
Ajuste DUR_ALVO e MAX_CARACT para o ritmo de leitura da plataforma. O limite de 18 caracteres é importante para queima de legenda em vertical 1080×1920 em Fontsize=14 — mais longo quebra em múltiplas linhas ilegíveis.
Detalhes operacionais
- Autenticação: credenciais estão em
~/code/.env. Carregue comset -a; source ~/code/.env; set +aantes de invocar. - Proxy SOCKS nesta máquina:
httpxprecisa do extrasocksio— useuvx --with httpx --with socksio python ...(sem isso você obtémImportError: Usando proxy SOCKS, mas o pacote 'socksio' não está instalado). - Divisão em pedaços: pedaços de 10 min a 64kbps MP3 mono (~4.5MB cada) são o ponto ideal de confiabilidade. Pedaços de 20 min (~9MB) às vezes sofrem RST sob proxies instáveis. Concorrência
max_workers=2é mais confiável que4. - Repetição: toda chamada de API envolta em backoff exponencial de 5× (
time.sleep(min(2**n, 30))) —RemoteProtocolError: Servidor desconectadoé comum e transitório. - Junção de deslocamento: as palavras de cada pedaço vêm com timestamps relativos àquele pedaço. Ao mesclar, adicione o deslocamento inicial absoluto do pedaço a cada
start/endda palavra antes de montar as legendas. - Proteção de loop (cinto + suspensórios): mesmo com
temperature=0.2, ocasionalmente um sub-pedaço ainda entra em loop. Após a montagem, execute um detector de loop no texto de cada legenda — se qualquer frase de comprimento 8–40 caracteres repetir 3+ vezes consecutivamente, descarte a legenda.
Anti-padrões (não faça)
- ❌ Não solicite
response_format=srtpara conteúdo com mais de ~2 minutos. - ❌ Não "corrija" legendas ruins com uma segunda chamada de API. Se você obteve legendas blob ou alucinações em loop da sua primeira chamada, refaça com granularidade de palavra uma vez — não retranscreva apenas o sub-intervalo quebrado.
- ❌ Não use
temperature=0em áudio potencialmente silencioso (yoga, conteúdo espiritual, encerramentos de podcast). Decodificação gananciosa entra em loop.0.2habilita a cadeia de fallback. - ❌ Não pule
language=.... A detecção automática ocasionalmente troca Chinês→Japonês ou Espanhol→Português nos primeiros 30 segundos e toda a transcrição fica errada.
Caminho Volcano (豆包) ASR — preferido para chinês
O Volcano ASR rotineiramente supera o Whisper na precisão do Mandarim (taxa de reconhecimento, pontuação, entidades nomeadas). Use este como padrão para fonte zh-*.
⛔ NUNCA use 飞书妙记 / lark-minutes para ASR. Ele fornece apenas timestamps em nível de turno (fala do falante), não timing por palavra — legendas construídas a partir dele flutuam por segundos e quebram em lugares não naturais. A regra permanente do usuário: "以后不要用飞书妙记来做ASR,不能作为SRT使用,记住". Não há fallback de 飞书妙记. Se Volcano estiver indisponível, caia para o caminho OpenAI Whisper de nível de palavra acima (fixe
language=zh).
Use a API WebSocket STREAMING — envia bytes, NÃO precisa de URL pública
As APIs de arquivo / 录音文件识别 / MediaKit todas exigem uma URL HTTP(S) de áudio publicamente acessível. O usuário rejeitou hospedagem de URL ("não use mais esse modelo de baixar mp3 do servidor" — túneis falham no ponto de acesso deles). A API 大模型流式语音识别 (streaming bigmodel) contorna a URL completamente enviando bytes PCM brutos por um WebSocket. Este é o caminho funcional. Ela retorna timestamps de palavra em ms.
Scripts inclusos (use estes — são o caminho funcional verificado):
# 1. Transcrever: envia bytes PCM mono de 16k por WebSocket → JSON ASR
# (decodifica qualquer entrada via ffmpeg; .pcm passa direto)
export VOLC_ASR_APPID=… VOLC_ASR_ACCESS_TOKEN=… # credenciais estão com o usuário
python3 scripts/volc_asr_stream.py <clip.mp4|wav|mp3|pcm> <out.asr.json>
# 2. Construir um SRT limpo com timing de palavra a partir do JSON ASR
python3 scripts/build_srt_from_asr.py <out.asr.json> <out.srt> [max_chars=18]
# Parâmetros de segmentação (opcionais):
# --max-chars N aumente para manter legendas inteiras / quebrar na pontuação, não no meio da frase
# --soft-min N min caracteres antes de uma ,、; descarregar uma legenda (padrão 8)
# --strip-punct remover TODA pontuação do texto exibido (aparência limpa de legenda)
- Endpoint:
wss://openspeech.bytedance.com/api/v3/sauc/bigmodel - Headers:
X-Api-App-Key:{appid},X-Api-Access-Key:{token},X-Api-Resource-Id:volc.bigasr.sauc.duration,X-Api-Connect-Id:{uuid} - Frame v3 binário: cabeçalho de 4 bytes
[(ver<<4)|hdrsize, (msgtype<<4)|flags, (ser<<4)|comp, 0]. Full-client(0x1)+POS_SEQ envia JSON config gzipado; pacotes de áudio(0x2) enviam chunks PCM gzipados (200ms = 6400 bytes @16k mono s16le); último pacote usa NEG_WITH_SEQ(0x3) com seq negativo. Full-response(0x9) do servidor retorna JSON gzipado. - JSON de configuração:
{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}}. NÃO definaresult_type:"single"— isso retorna apenas a última frase a cada quadro; o modo padrão acumula todas as falas. - Forma do resultado:
result.utterances[], cada um comtext(pontuado) +words[](token +start_time/end_timeem ms). Tokens latinos como "AI" vêm comstart=end=0—build_srt_from_asr.pypreenche para frente/trás a partir dos vizinhos. - build_srt_from_asr.py também: divide legendas na pontuação (DESCARREGAMENTO FORTE em
。!?; DESCARREGAMENTO SUAVE em,、;após--soft-mincaracteres; limite máximo--max-chars), opcionalmente remove toda a pontuação da exibição (--strip-punct), descarta preenchimentos 呃/嗯/唉, e colapsa tokens curtos duplicados imediatos (才才→才). Cada legenda é temporizada pela sua primeira/última palavra para que se assente exatamente no áudio falado — sem flutuação. Para evitar quebras no meio da frase, aumente--max-charspara que os limites caiam na pontuação em vez do limite de caracteres. - Transcrição por clipe: transcreva o próprio áudio de cada clipe (PCM mono de 16k) para que os timestamps SRT saiam relativos ao clipe.
- Credenciais: estão com o usuário (豆包语音引擎). Nomes de env aceitos pelo script:
VOLC_ASR_APPID/VOLC_APPIDeVOLC_ASR_ACCESS_TOKEN/VOLC_TOKEN;FFMPEG_BINopcional.- O ASR e TTS 火山 deste usuário COMPARTILHAM UM conjunto de credenciais — o mesmo appid + access token funcionam para ambos os serviços. Eles estão armazenados em
~/code/.envcomoVOLC_TTS_APPID/VOLC_TTS_ACCESS_TOKEN(maisVOLC_APPIDem~/.zshrc), com aliasesVOLC_ASR_APPID/VOLC_ASR_ACCESS_TOKENanexados apontando para os mesmos valores. Entãoset -a; source ~/code/.env; set +aé suficiente — nenhum token ASR separado para procurar. Se os aliases estiverem faltando, recrie-os a partir dos valores TTS (eles são o mesmo segredo).
- O ASR e TTS 火山 deste usuário COMPARTILHAM UM conjunto de credenciais — o mesmo appid + access token funcionam para ambos os serviços. Eles estão armazenados em
- Caminhos sem saída (não tente novamente): MediaKit asr-subtitles (doc 6448/2381968) ainda precisa de uma URL pública e uma chave de API MediaKit separada. A API de arquivo 录音文件识别 precisa de uma URL pública.
Whisper Local como último recurso
Apenas quando offline, a cota da API estiver esgotada, ou para rascunhos ultra-baratos. Qualidade é materialmente menor para chinês; os mesmos modos de falha de blob/loop se aplicam; Whisper local não expõe timestamps no nível de palavra via CLI, portanto a correção baseada em princípios não está disponível.
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 é o piso prático para precisão em chinês; small é aceitável apenas para inglês limpo de estúdio. Whisper escreve . milissegundos; o arquivo ainda é SRT válido. Se você regenerar o SRT, sempre emita , ms.
Passo de polimento com IA — corrigir erros óbvios de digitação (etapa final, sempre executar para chinês)
Mesmo o Volcano ASR produz erros homofônicos claros que são lidos incorretamente na tela — observados: 「总数」→「意数」, 「需求」→「虚求」, 「程序员」→「成员」. O texto ASR bruto é o piso, não o teto. Após o SRT ser montado, faça uma passada de polimento com Claude sobre o SRT completo para corrigir erros óbvios usando o contexto da frase.
Esta passada é feita na sessão por Claude lendo o SRT — nenhuma chamada de API externa. Reescreva o .srt no local (ou para <nome>.polished.srt).
Regras rígidas — isso é correção, não edição:
- ✅ Corrija apenas erros claros de homófono / digitação onde a palavra pretendida é inequívoca pelo contexto (意数→总数, 虚求→需求).
- ⛔ **NUNCA mude timestamps,