Yago Marinho
Voltar para os artigos
  • NodeJS
  • OpenAI
  • Socket.IO
  • FFmpeg
  • NextJS

Davna AI: construindo um parceiro de conversa para treinar inglês

Construí uma IA que conversa comigo em inglês por voz, corrige meus erros com paciência e traduz cada mensagem — e no caminho aprendi muito sobre áudio no navegador, FFmpeg e o custo real de cada fala.

Foto de Yago MarinhoPor: Yago MarinhoÚltima atualização realizada em:
💡
Você pode conferir o projeto completo no repositório do projeto e testar a demo no ar.
Existe uma diferença enorme entre entender inglês e conversar em inglês. Leio documentação, assisto palestras e acompanho discussões técnicas sem grandes dificuldades. Mas quando chega a hora de abrir a boca — numa entrevista, numa daily ou numa call com um cliente de fora —, o vocabulário some e a frase sai pela metade.
O motivo é simples: falta prática. E prática de conversação é justamente a mais difícil de conseguir. Aulas particulares custam caro, aplicativos de idioma treinam palavras soltas e não diálogos, e conversar com alguém nativo exige uma disponibilidade (e uma coragem) que nem sempre temos.
Foi aí que surgiu a pergunta que deu origem ao Davna: e se eu tivesse uma professora disponível a qualquer hora, que conversasse comigo por voz, corrigisse meus erros sem julgamento e ainda me mostrasse a tradução de tudo o que foi dito?
Os modelos de IA atuais tornam isso possível. Mas, como sempre, a distância entre "possível" e "funcionando de verdade" é onde mora a engenharia.

Tabela de conteúdos

O que é o Davna?

O Davna é uma aplicação de prática de conversação em inglês com uma professora virtual. A experiência é parecida com mandar áudios no WhatsApp:
  1. O aluno grava um áudio falando em inglês;
  1. A mensagem aparece no histórico com a transcrição e a tradução para o português;
  1. A professora responde por voz, também com transcrição e tradução;
  1. Se o aluno cometeu algum erro gramatical, ela aponta com gentileza e mostra a forma correta.
Ou seja: não é um chatbot de texto com uma camada de voz por cima. A voz é a interface principal — e isso muda completamente os desafios técnicos.

Stack e arquitetura

O projeto foi organizado em um monorepo TypeScript, separando aplicações, módulos de domínio e código compartilhado:
  • apps/client.app → frontend em Next.js, hospedado na Vercel, com rotas de API que funcionam como BFF (Backend for Frontend);
  • apps/monolith.api → API em Node.js com Express e Socket.IO;
  • apps/multimedia.processing → microsserviço isolado com FFmpeg e ffprobe;
  • services/ffmpeg → SDK HTTP que a API usa para falar com o microsserviço;
  • modules/ → domínios da aplicação: sala de aula, identidade e acesso, monitoramento, insights de clientes;
  • shared/ → kernel funcional (Either, Reader), contratos de repositório e implementações de infraestrutura (MongoDB, S3, OpenAI, Google Sheets).
Quem acompanha o blog vai reconhecer várias ideias que apresentei na refatoração do notion-blog: handlers puros, injeção de dependências via Reader, erros como resultados explícitos e o padrão Repository. O Davna foi o primeiro projeto que nasceu com essa base desde o primeiro commit — e isso fez toda a diferença quando a complexidade apareceu.

Anatomia de uma fala: o fluxo completo de um turno

Creio que a melhor forma de entender o Davna é acompanhar o caminho de uma única fala do aluno, desde o microfone até a resposta da professora. Por trás de um simples "apertar e falar", acontecem pelo menos duas passagens pelo FFmpeg e quatro chamadas à OpenAI.

#Etapa 1: Capturando o áudio no navegador

Tudo começa com a API MediaRecorder do navegador. O primeiro cuidado foi lidar com uma realidade pouco comentada: cada navegador grava em um formato diferente. O Chrome prefere WebM com Opus, o Safari prefere MP4. Por isso, o gravador testa uma lista de formatos em ordem de preferência e usa o primeiro que o navegador suportar:
export async function startRecorder( fn: (chunk: Blob) => any, ): Promise<Recorder> { const preferred = [ 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg', 'audio/mp4', ] const supported = preferred.find( t => MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(t), ) || 'audio/webm' const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) const mediaRecorder = new MediaRecorder(stream, { mimeType: supported }) mediaRecorder.ondataavailable = e => { if (e.data && e.data.size > 0) fn(e.data) } // ... }
💡
Você pode conferir o gravador completo no repositório do github
Durante a gravação, os pedaços de áudio são coletados a cada 250 ms e, ao final, unidos em um único Blob. Antes de enviar, o aluno pode ouvir o que gravou, pausar e retomar — um detalhe pequeno de UX, mas que reduz bastante a ansiedade de "falar errado".
O envio acontece por HTTP multipart, passando primeiro pela rota de API do Next.js. Esse desvio é intencional: o token de acesso do usuário fica em um cookie httpOnly e nunca chega ao JavaScript do navegador. Quem conversa com a API é o servidor do Next.js, usando o token e uma chave de API.

#Etapa 2: O problema da duração e o microsserviço de FFmpeg

Aqui veio o primeiro problema real do projeto. Eu precisava saber quanto tempo durava cada áudio — não por curiosidade, mas porque a duração é a base do controle de uso (falo mais sobre isso adiante).
O problema é que o WebM gerado pelo MediaRecorder frequentemente chega sem a duração no cabeçalho. O arquivo toca normalmente, mas qualquer ferramenta que tente ler os metadados recebe um valor vazio ou inválido. Além disso, um áudio WebM gravado no Chrome nem sempre toca bem no Safari, e vice-versa.
A solução foi padronizar: todo áudio que chega à API é convertido para AAC antes de ser armazenado. E, como o FFmpeg é um binário pesado e consome bastante CPU, decidi isolá-lo em um microsserviço próprio, com seu próprio container:
const DEFAULT_TIMEOUT_MS = 10_000 const args = ( useFilePath ? ['-i', input as string, '-vn', '-c:a', 'aac', '-b:a', '128k'] : ['-i', 'pipe:0', '-vn', '-c:a', 'aac', '-b:a', '128k'] ).concat(outputPath ? [outputPath] : ['-f', 'mp4', 'pipe:1']) const p = spawn(path, args, { stdio: ['pipe', 'pipe', 'pipe'] })
💡
Você pode conferir a conversão completa no repositório do github
Alguns detalhes dessa implementação:
  • Tudo em memória, via pipe → o áudio entra pelo stdin e sai pelo stdout, sem arquivos temporários;
  • Timeout de 10 segundos com SIGKILL → um áudio corrompido não pode travar o serviço;
  • Metadados via ffprobe → depois da conversão, o ffprobe lê a duração real em milissegundos, que volta para a API nos cabeçalhos da resposta (x-file-duration, x-file-codec, x-file-format).
Principais pontos positivos dessa decisão:
  • Duração confiável → o controle de uso passa a se basear em um número real;
  • Formato único → o mesmo áudio toca em qualquer navegador;
  • Isolamento de recursos → a CPU consumida pelo FFmpeg não disputa espaço com a API;
  • Contrato explícito → a API conhece apenas o SDK; trocar o FFmpeg por um serviço gerenciado no futuro não afeta o domínio.
Depois de convertido, o áudio é salvo no AWS S3 através de um provider de storage plugável — o mesmo contrato também tem uma implementação com GridFS do MongoDB.

#Etapa 3: Transcrever, traduzir, responder e falar

Com o áudio armazenado, começa o trabalho da IA. Toda a comunicação com a OpenAI fica concentrada em um único provider, que expõe três capacidades: responder, transcrever e sintetizar voz.
const response = await openai.responses.create({ model: 'o4-mini', input: [{ role: 'system', content: instruction }, ...input], }) // ... const response = await openai.audio.transcriptions.create({ file: createReadStream(path), model: 'gpt-4o-transcribe', }) // ... const response = await openai.audio.speech.create({ model: 'gpt-4o-mini-tts', response_format: 'aac', voice: 'alloy', input: text, })
💡
Você pode conferir o provider da OpenAI no repositório do github
A partir dessas três capacidades, um turno completo segue esta sequência:
  1. Transcrição da fala do aluno com o gpt-4o-transcribe;
  1. Tradução da transcrição para o português, com o o4-mini;
  1. Resposta da professora, gerada pelo o4-mini a partir de todo o histórico da conversa;
  1. Tradução da resposta para o português;
  1. Síntese de voz da resposta com o gpt-4o-mini-tts, já no formato AAC;
  1. Leitura dos metadados do áudio gerado pelo FFmpeg e armazenamento no S3.
Um detalhe que gosto bastante: a voz da professora já é gerada em AAC. Assim, os áudios do aluno e da IA seguem exatamente o mesmo formato, passam pelo mesmo storage e são exibidos pelo mesmo componente no histórico. Para o sistema, uma mensagem é uma mensagem — não importa quem falou.

#Etapa 4: Tempo real com Socket.IO

Um turno completo leva alguns segundos. Se o aluno ficasse olhando para uma tela parada durante esse tempo, a experiência pareceria quebrada. Por isso, a conversa acontece sobre WebSockets com Socket.IO, e o servidor vai avisando o cliente a cada passo.
O handler que orquestra o turno deixa esse fluxo bem visível:
export const appendAndReplyHandler = Handler<Env, Data, Metadata>( request => async ({ emitter, /* ...dependências */ }) => { emitter.emit('classroom:replying', { classroom_id, participant_id }) const result = await transcribeAndAppend({ audio, classroom_id, participant_id })(env) if (isLeft(result)) { emitter.emit('error:service', { status: 'error', message: result.value.message }) return Response.metadata({ status: 'error' }) } emitter.emit('classroom:updated', { remainingConsumption: remainingConsumption(result.value.consume), classroom: result.value.classroom, message: result.value.message, }) emitter.emit('classroom:replying', { classroom_id, participant_id: teacher_id }) const result2 = await teacherGeneratesResponse({ classroom, teacher_id })(env) // ... mesmo tratamento: erro → error:service, sucesso → classroom:updated }, )
💡
Você pode conferir o handler completo no repositório do github
Na prática, o aluno vê:
  • "Digitando..." enquanto sua fala é transcrita;
  • Sua mensagem aparecer com transcrição e tradução;
  • "Any está respondendo..." enquanto a IA pensa e gera a voz;
  • A resposta da professora pronta para tocar.
Repare que o handler continua seguindo o mesmo padrão funcional dos outros projetos: as dependências chegam pelo Reader, os erros são valores (Left) e não exceções, e o emitter é só mais uma dependência injetada. Isso significa que todo esse fluxo pode ser testado sem abrir um único socket de verdade.
Além disso, o cliente cuida dos casos em que a conexão cai: um banner avisa o aluno e oferece reconexão, com até cinco tentativas.

Dando personalidade à professora

Toda essa infraestrutura não valeria nada se a conversa fosse fria ou robótica. E aqui está um aprendizado importante: a personalidade da IA é definida em uma única instrução de sistema — e cada palavra dela importa.
const transcription = await gpt.respond({ instruction: 'You are a conversational assistant with the tone and clarity of a supportive teacher. Continue the conversation naturally, always aiming to educate and guide the user. Whenever the user makes linguistic or grammatical mistakes, gently point them out and provide the correct form—always with empathy and encouragement. Your explanations should be clear, constructive, and never condescending. Maintain a warm, patient, and respectful tone at all times. For your first message, introduce yourself briefly as a teacher named Any, greet the user warmly, and ask what topic they would like to talk about today.', input, })
Cada trecho dessa instrução tem um propósito:
  • "Continue the conversation naturally" → a prioridade é a conversa fluir, não virar uma aula expositiva;
  • "gently point them out and provide the correct form" → corrigir é parte do objetivo, mas sem interromper o diálogo;
  • "never condescending" → quem está aprendendo já se sente exposto; a IA não pode piorar isso;
  • "For your first message, introduce yourself" → quando o aluno entra numa sala vazia, é a professora quem quebra o gelo e pergunta sobre o que ele quer conversar.
Esse último ponto merece destaque. Ao entrar na sala, se o histórico está vazio, a Any fala primeiro. Parece um detalhe, mas resolve um problema real: a tela em branco é intimidadora, principalmente para quem já tem receio de falar.
As traduções seguem a mesma lógica, com instruções próprias em português pedindo uma tradução fiel e com a linguagem mais neutra possível.

Controlando o custo: o limite de uso diário

Quando cada fala dispara quatro chamadas a modelos pagos, a conta chega rápido. Uma aplicação aberta ao público sem nenhum controle de uso é, na prática, um cartão de crédito exposto na internet.
A primeira versão do controle foi propositalmente simples: cada usuário tem até 1 hora de áudio por dia, somando a duração de todas as mensagens desde a meia-noite:
const actualDay = new Date(new Date().toDateString()) const actuals = msgs.filter(msg => msg.created_at >= actualDay) const consume = actuals .map(msg => msg.data.duration) .reduce((val, curr) => val + curr, 0) if (consume >= 60 * 60 * 1000 /* 1h */) return Left({ status: 'error', message: "This user can't consume more today", })
É aqui que a etapa do FFmpeg mostra o seu valor: sem uma duração confiável, esse limite simplesmente não funcionaria. O tempo restante é devolvido ao cliente a cada mensagem, e o aluno acompanha um relógio com quanto ainda pode praticar no dia.
Medir em minutos de áudio, e não em tokens, foi uma escolha consciente. Para o aluno, "você ainda tem 40 minutos hoje" é muito mais compreensível do que qualquer número de tokens — e, para mim, a duração do áudio é um bom indicador do custo de transcrição e síntese.

Uma IA falsa para não pagar pela IA de verdade

Logo nos primeiros dias, percebi um problema curioso: desenvolver o Davna custava dinheiro. Cada teste manual da interface, cada ajuste de layout no histórico, cada reconexão disparava chamadas reais à OpenAI.
A solução foi criar uma FakeAI: uma implementação do mesmo contrato do provider da OpenAI, que devolve respostas fixas e um áudio WAV de dois segundos de silêncio. A escolha entre a IA real e a falsa é feita por configuração.
Esse é um daqueles momentos em que a arquitetura se paga. Como o domínio depende apenas do contrato GPTModel, e não da OpenAI diretamente, trocar a implementação não exigiu mudar nenhuma linha dos handlers. A mesma FakeAI também serve aos testes automatizados, que não fazem nenhuma chamada externa.

Deploy: Docker, EC2 e um runner self-hosted

A infraestrutura de produção ficou dividida assim:
  • Vercel → frontend Next.js;
  • AWS EC2 → API, microsserviço de FFmpeg e MongoDB, orquestrados com Docker Compose;
  • AWS S3 → armazenamento dos áudios.
Os Dockerfiles usam build em dois estágios com node:22-alpine. Os testes rodam dentro do estágio de build: se algum teste falha, a imagem nem chega a ser gerada. Em execução, o container roda com um usuário sem privilégios de root.
O pipeline no GitHub Actions identificava o que mudou (API ou FFmpeg), construía as imagens, rodava testes de integração com Docker Compose e publicava as imagens no Docker Hub. O deploy em si rodava em um runner self-hosted na própria instância EC2, que atualizava apenas o serviço alterado.
E sim: chegar a esse pipeline custou cerca de vinte commits com mensagens como "deploy v2", "deploy v3" e "fixing dockerfile". Acredito que todo desenvolvedor já passou por isso — e quem diz que não, provavelmente está mentindo.

Desafios e soluções encontradas

1. Formatos de áudio diferentes em cada navegador
O MediaRecorder não grava no mesmo formato em todos os navegadores. A solução foi negociar o formato no cliente e padronizar tudo em AAC no servidor.
2. Áudios sem duração
O WebM gravado no navegador frequentemente não traz a duração nos metadados. A conversão via FFmpeg, seguida da leitura com ffprobe, resolveu o problema e tornou possível o limite de uso.
3. Latência de um turno completo
Um turno passa por quatro chamadas de IA e duas passagens pelo FFmpeg, todas em sequência. Não dá para esconder esse tempo, mas dá para torná-lo transparente: os eventos do Socket.IO mostram ao aluno exatamente o que está acontecendo.
4. Custo durante o desenvolvimento
A FakeAI eliminou o custo de testar a interface e os fluxos, sem nenhuma mudança no domínio.
5. Segurança das credenciais
O token do usuário fica em cookie httpOnly, a API exige chave de acesso em toda requisição, e as conexões de WebSocket são autenticadas já no handshake.

Roadmap e próximos passos

A primeira versão do Davna cumpriu o seu papel: provar que a experiência funciona. Mas ela também deixou claro o seu limite. Hoje, uma mensagem é um registro "gordo", que carrega áudio, transcrição e tradução juntos. Cada novo recurso — um resumo da aula, uma análise dos erros mais frequentes, um feedback de pronúncia — exigiria mais campos e mais duplicação.
Por isso, já comecei a próxima etapa:
  • Modelar a sala de aula como um grafo → mensagens, áudios, textos e participantes viram nós conectados por arestas. Uma transcrição, uma tradução ou um resumo passam a ser representações derivadas de um conteúdo, e não campos fixos;
  • Políticas de uso flexíveis → limites por dia, semana ou mês, em minutos ou em tokens, concedidos por planos;
  • Transações com Saga → operações que envolvem vários repositórios ganham compensações automáticas em caso de falha;
  • Um orquestrador dedicado para a IA → tirar o turno da conversa de dentro do handler do socket e levá-lo para um serviço próprio;
  • Paralelizar etapas independentes → a tradução da fala do aluno, por exemplo, não precisa esperar pela resposta da professora.
Cada um desses pontos rende um artigo próprio — e é exatamente o que pretendo fazer.

Conclusão

Construir o Davna me mostrou que integrar uma IA a um produto vai muito além de chamar uma API. A chamada ao modelo é, talvez, a parte mais simples. O desafio real está em tudo o que acontece ao redor dela: capturar áudio em navegadores diferentes, garantir metadados confiáveis, manter o usuário informado enquanto a resposta não chega, controlar custos e, principalmente, fazer com que a experiência pareça natural.
Também foi o projeto que validou, na prática, a arquitetura que venho construindo nos últimos artigos. Handlers puros, contratos explícitos e dependências injetadas não são preciosismo técnico: foram eles que me permitiram trocar a OpenAI por uma IA falsa em minutos, testar o fluxo completo sem abrir um socket e isolar o FFmpeg sem tocar no domínio.
E, de quebra, ganhei uma professora que está sempre disponível, nunca perde a paciência e não se importa de corrigir o mesmo erro pela décima vez.
Afinal, como venho repetindo por aqui: o melhor jeito de aprender é construindo — e, neste caso, construir foi também uma forma de aprender inglês.
💡
Você pode conferir o projeto completo no repositório do projeto
 
Foto de Yago Marinho
Yago MarinhoEngenheiro de Software Sênior. Construo produtos com IA, do design ao deploy.
Ver portfólio ↗