No ar: transkriptorsec.vercel.app — o
modelo é baixado na primeira transcrição, então o primeiro uso demora; prefira
tiny ou base para experimentar.
Transcritor de áudio que roda o modelo dentro do navegador. O arquivo não é enviado para nenhuma API de IA: o Whisper é baixado uma vez, fica no cache do navegador e a partir daí a transcrição funciona até offline.
Em cima disso há um CRUD completo — as transcrições ficam salvas em SQLite, com edição, anotações, reprodução do áudio original sincronizada com o texto e exportação de legendas.
- Transcrição no cliente — Whisper via
transformers.js, acelerado por WebGPU quando disponível, com fallback automático para WASM (CPU). - Quatro modelos — de
tiny(~45 MB) alarge-v3-turbo(~800 MB), escolhidos na interface. - Player com forma de onda — a silhueta do áudio é extraída do PCM durante a transcrição e desenhada em SVG; dá para clicar para navegar, usar as setas do teclado e mudar a velocidade de reprodução.
- Timestamps por trecho — clicar num trecho leva o player até aquele ponto, e o trecho em reprodução fica destacado.
- Separação de falantes —
pyannote-segmentation-3.0em janelas sobrepostas. Funciona mecanicamente, mas acerta perto do acaso: veja os números medidos em Decisões antes de contar com ela. - Exportação — SRT, VTT, texto corrido e texto com marcação de tempo.
- Processamento em lote — vários arquivos entram numa fila e são transcritos em sequência.
- CRUD — listar, abrir, renomear, anotar e excluir; o áudio guardado é apagado junto.
| Camada | Escolha |
|---|---|
| Framework | Next.js 16 (App Router) + React 19 + TypeScript |
| Estilo | Tailwind CSS v4 |
| Banco | SQLite via Prisma 7 (driver adapter libSQL: arquivo local ou Turso) |
| IA | @huggingface/transformers v4 (ONNX Runtime Web) num Web Worker |
| Testes | test runner nativo do Node, sem dependências extras |
Requer Node 20+ (desenvolvido no Node 24).
npm install
npx prisma migrate dev # cria prisma/dev.db
npm run dev # http://localhost:3000Não é preciso configurar nada antes: o banco padrão é um SQLite dentro do
próprio repositório. Para apontar para outro, copie .env.example para .env.
Para a transcrição acelerada, use um navegador com WebGPU (Chrome ou Edge
recentes). Sem WebGPU tudo continua funcionando em WASM, só mais devagar —
convém escolher o modelo tiny ou base nesse caso.
npm test # 36 testes das funções puras
npm run build # build de produção
npm run db:studio # inspecionar o banco
npm run eval:audio # baixa vozes públicas e monta os diálogos de teste
npm run eval:diar # mede o acerto da separação de falantes- docs/ARQUITETURA.md — como as peças se encaixam, o fluxo de uma transcrição, o algoritmo de diarização e suas limitações, o modelo de dados e como estender o projeto.
- docs/API.md — referência dos endpoints REST, com os formatos de requisição e resposta.
- docs/DEPLOY.md — como publicar na Vercel com Turso e, opcionalmente, Vercel Blob para o áudio.
app/
page.tsx lista de transcrições
nova/page.tsx upload e fila
transcricoes/[id]/page.tsx detalhe
api/transcriptions/ REST: list, create, read, update, delete, áudio
components/
TranscriberPanel.tsx fila em lote e diálogo com o worker
TranscriptDetail.tsx transcrição sincronizada, edição e exportação
AudioPlayer.tsx player com forma de onda e faixa de falantes
WaveformStrip.tsx forma de onda estática da listagem
lib/
exporters.ts SRT, VTT e texto (funções puras, testadas)
diarization.ts costura de janelas e atribuição de falantes
audio.ts decodificação e extração dos picos
time.ts, models.ts, speakers.ts utilidades
storage.ts, records.ts arquivos de áudio e serialização
workers/
transcriber.worker.ts Whisper + pyannote fora da thread principal
O trabalho pesado fica todo no Web Worker, então a interface continua responsiva enquanto um áudio longo é processado.
Por que no navegador, e não numa API. Uma API de transcrição custa por minuto de áudio e exige chave. Rodando no cliente o custo de operação é zero, o áudio não sai da máquina do usuário e o projeto pode ser publicado sem nenhum segredo configurado. O preço é o download inicial do modelo e a dependência do hardware de quem acessa.
Por que o áudio é decodificado fora do worker. A Web Audio API não existe dentro de um Worker. O arquivo é decodificado para PCM mono a 16 kHz na thread principal e o buffer é transferido (não copiado) para o worker, o que evita duplicar dezenas de MB.
Como a diarização funciona — e onde ela falha. O pyannote-segmentation-3.0
foi treinado em janelas de 10 segundos e numera os falantes localmente: o
"falante 0" de uma janela não é necessariamente o mesmo da janela seguinte. O
áudio é percorrido em janelas de 10 s com 2 s de sobreposição, e as identidades
são costuradas comparando quem fala na região sobreposta (lib/diarization.ts).
Medi, e o resultado é ruim. Com dois áudios de teste montados a partir de vozes humanas reais e gabarito exato, o acerto por tempo de fala ficou em 57,2% e 33,9% — sendo que o acaso, com duas pessoas, é 50%. O modo de falhar é fragmentar: para duas pessoas ele chegou a criar cinco identidades, e como cada uma sobrando conta como erro, o resultado desce abaixo do acaso.
Testei sete combinações de janela e sobreposição; nenhuma foi boa nos dois áudios ao mesmo tempo. Não é questão de ajuste de parâmetro — falta a etapa que os sistemas sérios têm: embeddings de voz com clusterização global. Costurar identidades apenas pela sobreposição entre janelas vizinhas não propaga identidade ao longo do tempo.
Os números saem de npm run eval:diar, e a metodologia com suas ressalvas está
em docs/ARQUITETURA.md. Trate a separação de falantes
como uma demonstração da mecânica, não como recurso confiável.
O modelo também tem classes próprias para fala sobreposta (duas pessoas ao mesmo tempo); nesses trechos atribuímos o primeiro falante do par, porque manter a continuidade da conversa importa mais do que acertar quem falou por cima de quem. A classe de silêncio é descartada — sem isso o silêncio viraria um falante.
Por que o onnxruntime-web está preso por overrides. A criação da sessão
ONNX passou a falhar em produção com Missing required scale, e o build
continuava verde — o erro só aparecia no navegador de quem tentava transcrever.
A causa está numa regressão do ONNX Runtime 1.25/1.26: ao fundir
DequantizeLinear em MatMulNBits, dois nós que compartilham o mesmo
initializer quebram a criação da sessão. O Whisper cai exatamente nisso, porque
decoder.embed_tokens.weight é uma tied embedding — a mesma matriz serve à
entrada do decoder e à projeção de saída. Foi corrigido no
onnxruntime#28326,
liberado na 1.27.0.
Só que o @huggingface/transformers v4 fixa o onnxruntime-web numa versão
exata de pré-lançamento, não num intervalo — então atualizar o pacote de cima
não resolveria. Daí o overrides no package.json, que sai quando o
transformers subir a dependência.
A verificação foi feita criando a sessão sobre os dois decoders que o app
usa, porque dtypeFor() escolhe quantizações diferentes por dispositivo: o q8
do caminho WASM e o q4 do caminho WebGPU. Testar só um deixaria metade dos
usuários sem cobertura.
Por que os exportadores são funções puras. O Whisper às vezes devolve o
tempo final de um trecho como null, e legenda com tempo inválido quebra
qualquer player. Toda a normalização (tempos ausentes, ordem, duração zero) fica
isolada em lib/exporters.ts e é coberta por testes — é o tipo de código onde
um erro só aparece no arquivo que o usuário baixou.
Por que a forma de onda é extraída na hora da transcrição. O PCM decodificado só existe naquele momento — logo depois o buffer é transferido para o worker e fica inacessível. Extrair ali custa uma passada linear e evita ter que baixar e decodificar o áudio inteiro de novo toda vez que a página abre. São guardados 180 picos por arquivo (algumas centenas de bytes), usando o máximo de cada intervalo e não a média: a média achata a fala num traço quase reto, enquanto o pico preserva a silhueta que torna a onda reconhecível.
Por que o progresso da fila é medido, e não animado. A barra durante a transcrição vem do tempo do trecho que o Whisper está decodificando, e a da diarização vem da janela atual. Nos estágios que realmente não têm como medir (ler o arquivo, salvar), a barra é indeterminada de propósito — uma barra determinada que não corresponde a nada é pior do que nenhuma.
Por que a rota de áudio implementa Range. Sem responder 206 Partial Content o elemento <audio> do Chrome não consegue nem ler a duração do
arquivo, e arrastar a barra de progresso não funciona. Requisições com Range
são respondidas em pedaços de no máximo 4 MB; sem Range, o arquivo é servido
por um stream pull-based. Nos dois casos um áudio de uma hora nunca precisa
caber inteiro na memória.
- O primeiro uso de cada modelo baixa os pesos; em conexão lenta isso demora.
- Em WASM (sem WebGPU) os modelos maiores ficam impraticáveis.
- A separação de falantes acerta perto do acaso (57,2% e 33,9% nos dois áudios de teste, contra 50% de acaso). Não use para nada que dependa dela.
- Em serverless sem Vercel Blob configurado, o áudio não é guardado: a transcrição, a forma de onda e os falantes funcionam, mas não há reprodução.
- O áudio guardado tem teto de 4 MB, por causa do limite de corpo de requisição das funções serverless.
MIT.
Os modelos usados em tempo de execução têm licença própria e são baixados do Hugging Face pelo navegador: Whisper (MIT) e pyannote/segmentation-3.0 (MIT, com termos de uso que exigem aceite na página do modelo).