IntegraçõesASR — Ditado em tempo real

LeoRad ASR

v3

Reconhecimento de fala próprio da LeoRad para ditado médico em português, com formatação de laudo embutida.

URL base:https://asr.leorad.com.br

#Visão geral

O LeoRad ASR é o serviço de reconhecimento de fala da própria LeoRad, treinado em ditado médico em português do Brasil. Ele recebe trechos curtos de áudio e devolve, na mesma resposta, a transcrição crua e o texto já formatado segundo as normas do laudo.

É a integração indicada para ditado em tempo real: o áudio é enviado em trechos enquanto o médico fala e cada resposta chega em menos de um segundo. Para transcrever um arquivo de áudio inteiro, de uma vez só, use o endpoint POST /speech da API de IA.

Como o texto é produzido

text
áudio WAV 16 kHz mono
   │
   ▼
modelo de fala (fine-tuning em ditado médico)  ──▶  verbatim
   │      "análise dois pontos parágrafo nível l quatro l cinco ..."
   ▼
normas do laudo (números, datas, níveis, medidas)
   │      "análise dois pontos parágrafo nível L4-L5 ..."
   ▼
formatação (pontuação, parágrafos, cabeçalhos)  ──▶  formatado
          "Análise:\nNível L4-L5 ..."

Rotas disponíveis

O modelo transcreve verbatim: comandos ditados como "vírgula" ou "ponto parágrafo" saem como palavras no campo verbatim e são convertidos em pontuação no campo formatado. Renderize o formatado e guarde o verbatim se precisar aplicar o dicionário do próprio usuário.

#Autenticação

O ASR não tem login próprio: ele aceita um token temporário emitido pela plataforma a partir do seu Token de API. O token vale 30 minutos e é o que o navegador envia em cada trecho de áudio, sem nunca carregar credenciais de longa duração.

  1. 1
    Token de API: Obtenha o Token de API da conta como descrito na documentação da API REST. É um JWT que identifica o usuário da LeoRad.
  2. 2
    Token do ASR: Troque o Token de API por um token temporário em POST /realtime-v3/get-token.
  3. 3
    Chamada ao ASR: Envie o áudio para https://asr.leorad.com.br com o cabeçalho Authorization: Bearer <token>.

Obtendo o token temporário

A emissão acontece na API de IA, não no ASR. Autentique com o Token de API no cabeçalho Authorization:

POSThttps://api-ai.leorad.com.br/realtime-v3/get-token
curl
curl -X POST "https://api-ai.leorad.com.br/realtime-v3/get-token" \
  -H "Authorization: Bearer <SEU_TOKEN_DE_API>"

Exemplo de resposta

json
{
  "token": "eyJzdWIiOiIxMjM0IiwiaWF0IjoxNzY4...",
  "expires_at": "2026-09-11T18:42:07.000Z"
}

Utilizando o token do ASR

O token devolvido vai no cabeçalho Authorization de toda requisição ao ASR:

http
Authorization: Bearer eyJzdWIiOiIxMjM0IiwiaWF0IjoxNzY4...
Renove o token em background, antes de vencer, para que o clique no microfone não espere a emissão. Se receber 401, descarte o token em cache, peça outro e repita a requisição uma única vez.
O ASR também aplica CORS por allowlist: o domínio do seu front precisa ser incluído na lista de origens permitidas antes de funcionar no navegador. Solicite a inclusão pelo contato no fim desta página. Chamadas a partir do seu servidor não passam por essa restrição, mas continuam exigindo o token.

#Rotas

Duas rotas, ambas sob https://asr.leorad.com.br. /transcrever exige o token temporário; /saude é pública.

#POST /transcrever

Recebe um trecho de áudio em multipart/form-data e devolve a transcrição em duas formas: verbatim (texto cru, comandos como palavras) e formatado (pontuação, parágrafos, medidas e cabeçalhos já aplicados).

POSThttps://asr.leorad.com.br/transcrever
ParâmetroTipoObrigatórioDescrição
arquivobinarySimTrecho de áudio. O caminho rápido é WAV PCM 16 kHz mono; outros formatos são convertidos no servidor. Corpo limitado a 2 MB.
seqintegerNãoNúmero do trecho na sessão de ditado. É devolvido intacto na resposta, o que permite remontar os trechos na ordem certa mesmo com requisições em paralelo.
audiobinaryNãoNome alternativo do campo arquivo, mantido por compatibilidade. Prefira arquivo em integrações novas.
Trechos considerados silêncio não vão ao modelo: a resposta volta com verbatim e formatado vazios e inferencia_ms igual a zero. É uma defesa contra alucinação em silêncio — trate a string vazia como "nada a inserir".

Resposta

json
{
  "seq": 1,
  "verbatim": "análise dois pontos parágrafo nível l quatro l cinco dois pontos abaulamento discal medindo três vírgula cinco por dois centímetros",
  "formatado": "Análise:\nNível L4-L5: abaulamento discal medindo 3,5 x 2 cm",
  "duracao_audio_ms": 7320,
  "inferencia_ms": 284
}

Exemplo de requisição (JavaScript)

javascript
const formData = new FormData();
formData.append('arquivo', wavBlob, 'trecho-1.wav'); // WAV 16 kHz mono
formData.append('seq', '1');

const response = await fetch('https://asr.leorad.com.br/transcrever', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer <SEU_TOKEN_DO_ASR>',
  },
  body: formData,
});

if (!response.ok) {
  throw new Error(`ASR respondeu ${response.status}`);
}

const { seq, verbatim, formatado } = await response.json();
console.log(seq, formatado);
A inferência tem timeout de 10 segundos; acima disso a resposta é 504. Trechos de 5 a 15 segundos são o ponto de equilíbrio entre latência e contexto para o modelo.

#GET /saude

Verificação mínima e pública, sem token. Responde 200 com ok: true quando o modelo está carregado e pronto. Use no carregamento da tela para habilitar o botão do microfone.

GEThttps://asr.leorad.com.br/saude

Resposta

json
{ "ok": true }

Exemplo de requisição (JavaScript)

javascript
const disponivel = await fetch('https://asr.leorad.com.br/saude')
  .then((r) => r.ok)
  .catch(() => false);

// Use o resultado para habilitar (ou não) o botão do microfone.
setMicrofoneHabilitado(disponivel);
Depois de uma atualização do serviço, o modelo leva cerca de 90 segundos para recarregar. Nesse intervalo esta rota pode responder 502 ou ok: false — trate como indisponibilidade temporária.

#Formato do áudio

O modelo trabalha em 16 kHz mono. Enviar o áudio já nesse formato evita uma conversão no servidor e tira algumas dezenas de milissegundos da latência de cada trecho.

PropriedadeValor
Formato preferidoWAV PCM 16 bits
Taxa de amostragem16 kHz
CanaisMono (estéreo é mixado no servidor)
Tamanho máximo do corpo2 MB (cerca de 60 s de WAV 16 kHz)
Duração recomendada5 a 15 segundos por trecho
Outros formatoswebm, ogg, mp3 e afins são convertidos por ffmpeg no servidor

Convertendo antes de enviar

O áudio que o navegador produz com MediaRecorder costuma ser webm ou ogg. Se o seu fluxo não gera WAV diretamente, converta antes de enviar:

bash
ffmpeg -i entrada.webm -ac 1 -ar 16000 saida.wav
O áudio não sai da infraestrutura da LeoRad: a inferência roda em GPU dedicada no Brasil, sem envio a provedores externos de reconhecimento de fala.

#Ditado ao vivo

O padrão validado em produção é o commit por pausa: em vez de manter uma conexão aberta, o cliente acumula áudio e fecha um trecho quando detecta uma pausa na fala. Cada trecho é uma requisição independente, o que simplifica a reconexão e mantém a resposta abaixo de um segundo.

  1. 1
    Capture PCM em 16 kHz: Abra o microfone com getUserMedia e leia as amostras em um AudioWorklet ou ScriptProcessor.
  2. 2
    Detecte a pausa: Acompanhe o RMS de cada quadro. Silêncio contínuo por cerca de 600 ms fecha o trecho; um limite de duração (10 a 15 s) fecha trechos de fala ininterrupta.
  3. 3
    Envie com seq crescente: Codifique o trecho em WAV e envie com seq incremental. Não espere a resposta anterior para começar a gravar o próximo trecho.
  4. 4
    Insira na ordem do seq: Use o seq devolvido para inserir o texto na posição certa do editor, mesmo que as respostas cheguem fora de ordem.

Exemplo completo (JavaScript)

javascript
// Ditado ao vivo com commit por pausa.
// Captura PCM em 16 kHz, fecha um trecho quando o médico faz uma pausa
// e envia cada trecho ao ASR com um "seq" crescente.

const TAXA = 16000;
const LIMIAR_RMS = 0.005;      // abaixo disso consideramos silêncio
const PAUSA_MS = 600;          // silêncio contínuo que fecha o trecho
const TRECHO_MAX_MS = 15000;   // fecha o trecho mesmo sem pausa

const contexto = new AudioContext({ sampleRate: TAXA });
const fonte = contexto.createMediaStreamSource(
  await navigator.mediaDevices.getUserMedia({ audio: true }),
);
const no = contexto.createScriptProcessor(4096, 1, 1);

let buffer = [];
let amostrasSilencio = 0;
let seq = 0;

no.onaudioprocess = (evento) => {
  const quadro = new Float32Array(evento.inputBuffer.getChannelData(0));
  buffer.push(quadro);

  const rms = Math.sqrt(quadro.reduce((s, v) => s + v * v, 0) / quadro.length);
  amostrasSilencio = rms < LIMIAR_RMS ? amostrasSilencio + quadro.length : 0;

  const amostras = buffer.reduce((s, q) => s + q.length, 0);
  const pausa = amostrasSilencio > (PAUSA_MS / 1000) * TAXA;
  const cheio = amostras > (TRECHO_MAX_MS / 1000) * TAXA;

  if ((pausa && amostras > TAXA) || cheio) {
    enviarTrecho(paraWav(buffer, TAXA), ++seq);
    buffer = [];
    amostrasSilencio = 0;
  }
};

fonte.connect(no);
no.connect(contexto.destination);

async function enviarTrecho(wav, seq) {
  const corpo = new FormData();
  corpo.append('arquivo', wav, `trecho-${seq}.wav`);
  corpo.append('seq', String(seq));

  const resposta = await fetch('https://asr.leorad.com.br/transcrever', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${await obterToken()}` },
    body: corpo,
  });

  const { formatado } = await resposta.json();
  if (formatado) inserirNoEditor(formatado); // na ordem do seq
}

// Float32 mono -> WAV PCM 16 bits.
function paraWav(quadros, taxa) {
  const total = quadros.reduce((s, q) => s + q.length, 0);
  const buffer = new ArrayBuffer(44 + total * 2);
  const view = new DataView(buffer);
  const texto = (pos, s) => [...s].forEach((c, i) => view.setUint8(pos + i, c.charCodeAt(0)));

  texto(0, 'RIFF');
  view.setUint32(4, 36 + total * 2, true);
  texto(8, 'WAVEfmt ');
  view.setUint32(16, 16, true);
  view.setUint16(20, 1, true);
  view.setUint16(22, 1, true);
  view.setUint32(24, taxa, true);
  view.setUint32(28, taxa * 2, true);
  view.setUint16(32, 2, true);
  view.setUint16(34, 16, true);
  texto(36, 'data');
  view.setUint32(40, total * 2, true);

  let pos = 44;
  for (const quadro of quadros) {
    for (const amostra of quadro) {
      const v = Math.max(-1, Math.min(1, amostra));
      view.setInt16(pos, v < 0 ? v * 0x8000 : v * 0x7fff, true);
      pos += 2;
    }
  }

  return new Blob([view], { type: 'audio/wav' });
}
Dicionários e substituições por usuário ficam no seu cliente. O servidor aplica apenas as regras gerais do laudo, então aplique os seus replaces sobre o campo formatado antes de exibir.

#Comandos e normas

O campo formatado é o verbatim depois de duas camadas de regras: os comandos de ditado viram pontuação e as normas do laudo padronizam números, medidas, datas e níveis vertebrais.

Comandos de ditado

O médico falaSai como
vírgula,
ponto.
ponto final.
ponto e vírgula;
dois pontos:
parágrafoquebra de parágrafo
ponto parágrafo. + quebra de parágrafo
nova linhaquebra de linha
abre parênteses / fecha parênteses( )
interrogação?
reticências...

Normas do laudo

Além da pontuação, o serviço padroniza os elementos numéricos do laudo. Cabeçalhos de seção ditados com "dois pontos" (Análise, Técnica, Conclusão, Impressão diagnóstica, entre outros) vão para uma linha própria com inicial maiúscula.

O médico falaSai como
três vírgula cinco3,5
três por dois centímetros3 x 2 cm
cinquenta por cento50%
nível l quatro l cincoL4-L5
cinco de outubro de dois mil e vinte e três05/10/2023
sete centímetros7 cm

Exemplo

verbatim (como o modelo transcreve)

text
análise dois pontos parágrafo nível l quatro l cinco dois pontos abaulamento discal medindo três vírgula cinco por dois centímetros vírgula sem compressão radicular ponto parágrafo observação dois pontos controle em seis meses ponto

formatado (o que você renderiza)

text
Análise:
Nível L4-L5: abaulamento discal medindo 3,5 x 2 cm, sem compressão radicular.

Observação:
Controle em seis meses.
As regras rodam no servidor e valem para todos os usuários, o que mantém o laudo padronizado. Novas normas entram acompanhadas de teste automatizado, então o comportamento do campo formatado é estável entre versões.

#Erros

Os erros chegam como código HTTP; quando há detalhe, ele vem no corpo em detail. O tratamento importa no ditado ao vivo, onde uma falha isolada não deve derrubar a sessão.

CódigoSignificado
400Campo arquivo ausente, áudio vazio ou em formato ilegível.
401Token ausente, inválido ou vencido. Emita outro e repita a requisição.
413Corpo acima de 2 MB. Divida o áudio em trechos menores.
500Falha interna na inferência. Repita uma vez antes de mostrar erro ao usuário.
503Modelo ainda carregando, normalmente logo após uma atualização do serviço. Aguarde e tente de novo.
504A inferência passou de 10 segundos. Reenvie um trecho menor.

Política de retentativa recomendada

Uma tentativa extra por motivo, nunca em cascata — é o comportamento adotado no cliente oficial da plataforma:

401
Invalide o token em cache, peça outro e repita a requisição uma vez.
Falha de rede ou 5xx
Repita a requisição uma vez.
Demais 4xx
Erro imediato, sem retentativa: o problema está no corpo ou no contrato.
Se o usuário interromper a gravação, cancele as requisições em voo com AbortController. Um cancelamento proposital não deve ser tratado como falha nem gerar retentativa.

#Suporte

Dúvidas sobre a integração, liberação do seu domínio no CORS ou acesso ao serviço de ditado.

Suporte técnico

Fale com o time de engenharia da LeoRad. Inclua o domínio do seu front e, se possível, o horário e o código de erro observado.

contato@leorad.com.br