Transcrição de áudio#
Speech-to-text em painel: um único POST manda o mesmo áudio para 2–4 modelos acústicos em paralelo, cada um preparado com o vocabulário que o falante já ensinou; as leituras são comparadas palavra a palavra; onde divergem, um modelo de texto — o árbitro — decide com o léxico e o tema na mão; e cada termo que o veredito aprendeu vira memória para o próximo áudio. Você recebe o texto final e a história inteira: o que cada modelo ouviu, onde discordaram, como foi decidido, o que ficou em dúvida e quanto custou.
Medido (bancada de 2026-09-12): vocabulário no prompt corta o erro (WER) pela metade em todo modelo —
groq/whisper-large-v3foi de 18,6% para 5,1%.gpt-transcribeé o mais robusto com ruído (20,3% no conjunto difícil, contra 33,1% dogpt-4o-transcribea 5 dB). É por isso que o léxico existe: quanto mais a sua conta transcreve e corrige, melhor ela transcreve.
O que a API garante:
- Consenso barato primeiro. Modelos que concordam não acionam o árbitro
(a menos que
learnesteja ligado, quando ele roda só para extrair termos). Sem árbitro disponível no ambiente, a transcrição ainda sai — por voto de maioria — e o registro diz isso (source: "majority",arbiter_used: false). O árbitro é ogemini-3.8-flashcom raciocíniolow; se ele estiver indisponível, um modelo reserva responde earbiter_modeldiz qual. Um áudio sem fala (silêncio, um tom, um ruído) não é erro: a resposta é200comfinal_text: ""esource: "silence"— nenhum árbitro é chamado para inventar palavras. - Uma hipótese nunca vira fato sozinha — e nunca entra num prompt. Um
termo que os modelos ouviram fica como
hypothesis(confiança limitada a0.7) numa fila de revisão, com as formas ouvidas emaliases, até você confirmar (POST …/confirm, opcionalmente sob a grafia certa:{"surface":"catcher-agents"}), rejeitar, ensinar ou corrigir o texto. Motivo medido: modelos erram um nome do mesmo jeito toda vez — confirmar por concordância aprendeu grafias erradas e piorou o acerto de nomes de 58% para 19%. Os nomes dos seus agentes, skills e ferramentas (o glossário do workspace) já entram confirmados, sem clique. Um termo que você rejeitou nunca volta por evidência de modelo — só você pode reabri-lo. - O áudio nunca é armazenado. O registro guarda o que foi ouvido (textos, prompts, custos), não a voz.
Chaves: resolvidas por provedor, por conta — a sua credencial no cofre
do Console (OPENAI_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY,
XAI_API_KEY) tem precedência sobre a chave de plataforma. Um modelo cujo
provedor não tem chave nunca é chamado: aparece em models_unavailable, e o
painel padrão troca a voz que faltou pela próxima que pode rodar. Sem nenhuma
chave utilizável → 400 TRANSCRIPTION_KEY_REQUIRED, antes de gastar.
Permissão: transcrever, apagar histórico, corrigir e escrever no léxico
exigem papel owner ou admin (cada transcrição gasta crédito de provedor, e o
léxico muda o que TODO próximo áudio recebe). Catálogo, histórico e leitura do
léxico são liberados para qualquer membro autenticado.
GETCatálogo de modelos#
GET /v1/transcriptions/models — o catálogo com disponibilidade para a sua
conta: monte o seletor daqui e você nunca oferece uma voz que responderia
400. Resposta 200:
{
"models": [
{ "id": "gpt-transcribe", "label": "OpenAI gpt-transcribe", "provider": "openai",
"prompt_style": "prompt", "prompt_max_bytes": 900,
"formats": ["wav", "mp3", "ogg", "webm", "m4a", "flac"],
"priced": true, "cost_per_minute_usd": 0.0045, "flat_rate": false,
"default": true, "panel": true, "streaming": false,
"notes": "Bancada 2026-09-12: 1,0% WER com prompt …",
"available": true, "key_source": "vault" },
{ "id": "xai/grok-stt", "available": false, "unavailable_reason": "no XAI_API_KEY credential" }
],
"default_panel": ["gpt-transcribe", "whisper-1", "groq/whisper-large-v3"],
"effective_panel": ["gpt-transcribe", "whisper-1", "groq/whisper-large-v3"],
"panel_available": true,
"max_models": 4,
"arbiter": { "model": "gemini-3.8-flash", "available": true },
"second_pass": ["auto", "always", "never"],
"formats": ["wav", "mp3", "ogg", "webm", "m4a", "flac"],
"max_audio_bytes": 26214400,
"learning": { "confirmation": "user_or_glossary", "model_evidence_cap": 0.7, "prompt": "confirmed_only", "glossary": true }
}
| id | provedor | formatos | preço | o que foi medido |
|---|---|---|---|---|
gpt-transcribe (padrão, painel) |
openai | todos | US$ 0,0045/min | o mais robusto com ruído; 1,0% WER com vocabulário |
gpt-4o-transcribe |
openai | todos | US$ 0,006/min | 4,2% com vocabulário; degrada a 33,1% com ruído forte |
gpt-4o-mini-transcribe |
openai | todos | US$ 0,003/min | o mais rápido da OpenAI (0,56 s); o mais fraco com ruído |
whisper-1 (painel) |
openai | todos | US$ 0,006/min | preserva apelidos e o que foi dito literalmente |
groq/whisper-large-v3 (painel) |
groq | todos | US$ 0,111/h | o mais rápido do painel (0,42 s); prompt limitado a 896 bytes |
openrouter/google/gemini-3-flash-preview |
openrouter | wav, mp3 | custo reportado pelo provedor | 6,5% com contexto |
openrouter/thinkingmachines/inkling |
openrouter | wav, mp3 | custo reportado pelo provedor | 5,1% com contexto |
xai/grok-stt |
xai | wav (PCM 16 kHz) | não precificado | 21,0% → 6,3% com até 100 keyterm |
effective_panel é o painel padrão depois da troca por chave; notes traz
o número medido de cada modelo, não um rótulo de marketing.
POSTTranscrever#
POST /v1/transcriptions — owner/admin. Aceita multipart (a parte
file com o áudio) ou JSON (audio_b64). Até 25 MB; wav, mp3,
ogg/opus, webm, m4a ou flac — o formato vem do Content-Type/mime_type, senão
dos bytes, senão da extensão do filename. Ajuste o timeout do seu cliente para
pelo menos 120 s: um painel de 3 modelos com árbitro e reescuta leva alguns
segundos por minuto de áudio.
curl -X POST https://agents-api.catcher.one/v1/transcriptions \
-H "X-API-Key: $CTC_KEY" \
-F "file=@nota.ogg;type=audio/ogg" \
-F "speaker_id=wa:5511999990000" \
-F "topics=cmux,catcher-agents" \
-F "language=pt"
curl -X POST https://agents-api.catcher.one/v1/transcriptions \
-H "X-API-Key: $CTC_KEY" -H "Content-Type: application/json" \
-d '{
"audio_b64": "'"$(base64 < nota.wav)"'",
"mime_type": "audio/wav",
"models": ["gpt-transcribe", "whisper-1"],
"speaker_id": "wa:5511999990000",
"topics": ["cmux", "catcher-agents"],
"speaker_profile": "desenvolvedor, fala de projetos Go e de cmux",
"learn": true,
"second_pass": "auto"
}'
| Campo | Regra |
|---|---|
file (multipart) / audio_b64 (JSON; data-URI aceito) |
obrigatório; até 25 MB (413 TRANSCRIPTION_AUDIO_TOO_LARGE) |
mime_type / filename |
ajudam a identificar o formato; opcionais quando os bytes se identificam sozinhos |
models |
1 a 4 ids do catálogo (multipart: CSV ou campo repetido). Vazio → painel padrão. Desconhecido ou acima de 4 → 400 |
language |
ISO-639-1, padrão pt |
speaker_id |
quem está falando — o léxico é por falante (um remetente de WhatsApp, um usuário seu). Sem ele, o falante é o membro da conta que chamou |
topics |
até 8 dicas de tema; entram no prompt como Contexto: … |
speaker_profile |
uma linha (até 500 chars) que só o árbitro vê |
learn |
padrão true. false = este áudio não ensina nada ao léxico |
second_pass |
auto (padrão) | always | never. Em auto o áudio é reouvido só quando a primeira passada rodou sem vocabulário e descobriu contexto, ou quando o árbitro pediu com dúvidas abertas |
Resposta 200 — o registro persistido (o mesmo shape de
GET /v1/transcriptions/{id}):
{
"id": "<uuid>",
"final_text": "Subi o catcher-agents no cmux e o Jô revisou.",
"corrected_text": "", "corrected_at": null,
"source": "arbiter",
"agreement": 0.83, "passes": 2, "divergence_count": 2,
"arbiter_used": true, "arbiter_model": "gemini-3.8-flash", "arbiter_error": "",
"models": ["gpt-transcribe", "whisper-1", "groq/whisper-large-v3"],
"learned": [ { "id": "<uuid>", "surface": "cmux", "aliases": ["c-mux"], "status": "hypothesis",
"confidence": 0.5, "evidence": [ { "kind": "arbiter", "source": "t:<uuid>", "agreeing": 2, "at": "…" } ] } ],
"learned_count": 1,
"topics": ["cmux", "catcher-agents"],
"speaker_scope": "speaker:wa:5511999990000",
"language": "pt", "filename": "nota.ogg", "audio_mime": "audio/ogg", "audio_bytes": 481324,
"duration_seconds": 15.04, "cost_usd": 0.0038, "cost_known": true, "latency_ms": 2210,
"created_at": "2026-09-12T18:00:00Z",
"divergences": [ { "position": 3, "pivot": "catcher agents",
"variants": { "catcher agents": 1, "catcher-agents": 2 },
"by_model": { "gpt-transcribe": "catcher-agents", "whisper-1": "catcher-agents", "groq/whisper-large-v3": "catcher agents" },
"majority": "catcher-agents", "context": "subi o catcher agents no" } ],
"decisions": [ { "span": "c-mux", "chosen": "cmux", "reason": "termo confirmado do léxico", "confidence": 0.9 } ],
"doubts": [ { "span": "Jô", "options": ["Jô", "Ju"], "reason": "apelido; o áudio decide" } ],
"prompt_by_model": { "gpt-transcribe": "Vocabulário: cmux, catcher-agents. Contexto: cmux." },
"terms_used": ["cmux", "catcher-agents"],
"models_unavailable": [ { "model": "xai/grok-stt", "reason": "no XAI_API_KEY credential" } ],
"candidates": [
{ "pass": 1, "model": "gpt-transcribe", "provider": "openai", "text": "…", "prompt": "…",
"latency_ms": 840, "cost_usd": 0.0011, "cost_known": true, "error": "", "dropped_reason": "", "wer_vs_final": 0.04 }
]
}
O que ler primeiro:
source— quem produziufinal_text:single(um só modelo votou),agreement(todos ouviram o mesmo),majority(voto — o árbitro não era necessário ou estava indisponível; vejaarbiter_error),arbiter.doubts— o que o texto sozinho não resolve. O árbitro não chuta: mostre as opções ao usuário ou deixe o áudio decidir.learned— o que este áudio ensinou ao léxico, já com o status DEPOIS dele (hypothesisouconfirmed).cost_usd/cost_known—cost_known: falsesignifica que algum modelo não pôde ser precificado (provedor sem custo reportado, duração desconhecida): o número é parcial e declarado assim, nunca um0.00silencioso.candidates[]— cada leitura, inclusive as que falharam (error) e as descartadas como alucinação (dropped_reason), comwer_vs_final: o número que diz, com o tempo, qual modelo merece a cadeira para ESTE falante.
Erros: 400 TRANSCRIPTION_INVALID_REQUEST (a mensagem nomeia o campo),
400 TRANSCRIPTION_KEY_REQUIRED, 413 TRANSCRIPTION_AUDIO_TOO_LARGE,
502 TRANSCRIPTION_FAILED (todo modelo falhou; o corpo traz failures[] com
{model, provider, class, error}), 501 TRANSCRIPTION_DISABLED.
POSTTranscrever (streaming)#
POST /v1/transcriptions/stream — owner/admin, SSE. O mesmo corpo, as
mesmas validações, o mesmo custo — só o transporte muda: você vê quem está
ouvindo, o que cada um ouviu, onde discordaram, o veredito e a reescuta
enquanto acontecem. Tudo que pode falhar com um status (credencial, corpo,
modelo, chave, tamanho) responde antes de o stream abrir — cheque res.ok
antes de ler; depois do 200 só existe o evento error.
Cada data: é { stage, pass?, model?, message?, candidate?, data? }:
| Evento | Quando | data |
|---|---|---|
context |
antes de cada passada | { terms, topics, models }; na passada 2, { terms, topics, reason } (cold_start_context_discovered | doubts_pending | requested) |
listening |
um modelo começou a ouvir | pass, model |
heard |
um modelo terminou (ou falhou) | pass, model, candidate |
compared |
as leituras foram alinhadas | { agreement, divergences, voting } |
arbitrating |
o árbitro foi chamado | { effort, divergences } |
verdict |
o árbitro respondeu | { final_text, topics, decisions, learned_terms, doubts, needs_audio_pass } |
arbiter_failed |
o árbitro caiu; a maioria assumiu | message |
second_pass_failed |
a reescuta não rendeu; o veredito da passada 1 fica | message |
done |
o registro foi persistido | idêntico à resposta do endpoint JSON (com id) |
error |
falha depois do 200 | { status, error_code, message } |
Como consumir: o mesmo fetch + reader (ou requests com stream=True) da
seção Geração de imagens — EventSource não serve, porque não envia corpo nem
X-API-Key.
GETHistórico#
GET /v1/transcriptions?page=1&limit=20 — as transcrições da conta, mais
recente primeiro: { transcriptions: [...], total, page, limit } (limit até
100). Filtre por falante com speaker_id=…. Os itens vêm no shape curto
(sem candidates, divergences, decisions, doubts, prompt_by_model).
GET /v1/transcriptions/{id} — o shape completo. Id inexistente ou de outra
conta → 404 TRANSCRIPTION_NOT_FOUND — indistinguíveis de propósito.
DELETE /v1/transcriptions/{id} — owner/admin, 204. Apaga o registro e
as leituras; não toca o léxico (o histórico é do áudio, a memória é do
falante).
POSTCorrigir#
POST /v1/transcriptions/{id}/correct — owner/admin. A edição do usuário é
a evidência mais forte que existe: corpo { "text": "…" } (até 200k chars).
Cada trecho alterado vira um termo confirmado com a grafia certa, levando a
grafia errada como alias — o próximo áudio já sabe que "c-mux" é "cmux";
palavras removidas são rejeitadas. Resposta 200 com o shape completo mais
learned_from_correction: [ { surface, aliases } ] (vazio se o texto não
mudou). Texto vazio → 400.
GETLéxico#
GET /v1/transcriptions/lexicon?speaker_id=… — os termos do falante mais os
da conta inteira, com status, confiança e a trilha de evidência (por que
cada termo está onde está). Sem speaker_id, o escopo do próprio membro.
{
"scope": "speaker:wa:5511999990000",
"terms": [
{ "id": "<uuid>", "surface": "catcher-agents", "key": "catcher-agents",
"aliases": ["catcher agents"], "topics": ["catcher-agents"], "meaning": "o produto",
"status": "confirmed", "confidence": 1, "occurrences": 4,
"evidence": [ { "kind": "arbiter", "source": "t:<uuid>", "agreeing": 3, "at": "…" },
{ "kind": "user_taught", "source": "user:7", "at": "…" } ],
"first_seen_at": "…", "last_seen_at": "…", "scope": "company" }
],
"total": 1,
"counts": { "confirmed": 1, "hypothesis": 0, "rejected": 0 }
}
status ∈ hypothesis | confirmed | rejected; evidence[].kind ∈
arbiter | majority | user_taught | user_confirmed | user_correction |
user_rejected; scope é company (todo falante herda) ou o escopo do falante.
PUTEnsinar uma palavra#
PUT /v1/transcriptions/lexicon/terms — owner/admin. Corpo:
{ "surface": "cmux", "aliases": ["c-mux", "cê mux"], "topics": ["cmux"],
"meaning": "multiplexador de terminais", "scope": "speaker", "speaker_id": "wa:5511999990000" }
surface obrigatório (até 200 bytes); aliases até 12; topics até 8;
meaning até 300 chars; scope = company (padrão — todo falante da conta
herda) ou speaker (com speaker_id). Ensinar de novo a mesma palavra mescla
aliases/temas e mantém a sua grafia (acentos incluídos) como canônica.
Resposta 200 com a linha do termo (status: "confirmed", confidence: 1).
POSTConfirmar ou rejeitar#
POST /v1/transcriptions/lexicon/terms/{id}/confirm · /reject —
owner/admin. Aceitar uma hipótese (confirmed, confidence: 1) ou
riscá-la (rejected, confidence: 0 — sai de todo prompt, e só um confirm
seu a traz de volta). Resposta 200 com a linha; termo inexistente ou de outra
conta → 404 TRANSCRIPTION_NOT_FOUND.
DELETE /v1/transcriptions/lexicon/terms/{id} — owner/admin, 204;
inexistente ou de outra conta → 404 TRANSCRIPTION_TERM_NOT_FOUND.