A gente criou um app de ditado por voz que digita em qualquer janela
Como a gente criou um app desktop de speech-to-text em tempo real, que funciona offline, feito em Python. Ele injeta teclas direto na janela que você estiver usando.
A maioria dos apps de ditado joga o texto numa janela própria. Você transcreve, depois copia e cola em outro lugar. Isso quebra seu fluxo. O Tacet ouve seu microfone e digita direto no app que estiver em foco. VS Code, Slack, navegador, terminal. Sem clipboard, sem plugins, sem trocar de janela.
As opções existentes ou te prendem num ecossistema (Apple Dictation, Google Voice Typing) ou precisam de integrações específicas por app. A gente queria algo que funciona em qualquer lugar, roda offline se você quer privacidade e não precisa de internet nem chave de API. Nada no mercado fazia os três.
Quem realmente usa isso
Pessoas que digitam muito e preferem falar. Escritores rascunhando textos longos. Desenvolvedores que querem ditar comentários ou documentação sem sair do editor. Qualquer pessoa com LER ou necessidades de acessibilidade que não consegue ficar no teclado o dia todo. Pessoas que se preocupam com privacidade e não querem seus dados de voz indo pra um servidor.
A stack técnica
Python 3.9+ com CustomTkinter pro GUI desktop. faster-whisper pra transcrição local (roda em CTranslate2). sounddevice pra captura de mic em tempo real. pynput pra hotkeys globais e injeção de teclas. numpy pra processamento de sinal de áudio. pystray pro ícone na bandeja do sistema. OpenAI API e Deepgram API como provedores cloud opcionais.
Nada de especial. A gente escolheu Python porque o ecossistema de AI/ML está lá. faster-whisper é uma implementação C++ do Whisper com bindings pra Python, então a velocidade de transcrição não é um problema do Python. O GUI é a única parte que é “Python lento”, e ele não precisa ser rápido.
A parte mais difícil: preview em tempo real
Quando você está falando, o Tacet mostra um preview em tempo real do que ele acha que você está dizendo. O Whisper re-transcreve a mesma janela de áudio a cada 900ms. A abordagem ingênua é: apagar o preview antigo com backspace, digitar o novo. Isso causa uma tremida visível porque você está deletando e redigitando 40+ caracteres várias vezes por segundo.
A gente resolveu com diffing de prefixo estável. A cada atualização, a gente encontra o maior prefixo comum entre o texto antigo e o novo. Depois só apaga o sufixo que mudou e digita o novo sufixo. Se o Whisper vai de “I want to” pra “I want to talk about”, a gente não mexe nos primeiros 10 caracteres. Só adiciona ” talk about”.
# Instead of backspacing the entire preview and retyping,
# we find what's already correct and only fix the tail.
common_len = 0
limit = min(len(old_text), len(new_text))
while common_len < limit and old_text[common_len] == new_text[common_len]:
common_len += 1
# Only delete what changed
old_suffix_len = len(old_text) - common_len
backspace(self.kb, old_suffix_len)
# Only type what's new
new_suffix = new_text[common_len:]
if new_suffix:
self._safe_type(new_suffix)
Isso reduziu a agitação visível de teclas em uns 80% e fez o preview parecer suave em vez de travado.
A outra dor de cabeça foi a detecção de atividade de voz. Um limiar fixo de energia não funciona porque cada microfone tem um ruído de fundo diferente. A gente acabou rastreando um piso de ruído usando uma média móvel exponencial, depois definindo limiares de fala/silêncio como multiplicadores acima desse piso. Um quarto silencioso e um café barulhento funcionam sem o usuário mexer em nenhuma configuração.
# Fixed thresholds break in noisy rooms. Instead, we track
# the ambient noise level and set thresholds relative to it.
if noise_floor < 1e-6:
noise_floor = rms
else:
noise_floor = noise_alpha * rms + (1 - noise_alpha) * noise_floor
speech_threshold = max(self.energy_threshold, noise_floor * noise_speech_mult)
silence_threshold = max(self.energy_threshold, noise_floor * noise_silence_mult)
if rms >= speech_threshold:
self._speaking = True
Como os dados fluem
Três threads. A thread principal roda o GUI. Uma thread em background captura áudio do mic e roda detecção de atividade de voz, fatiando o áudio por pausas de silêncio. Quando um pedaço fica pronto, ele vai pra uma fila. Duas threads de transcrição puxam dessa fila e passam o áudio pelo engine selecionado (Whisper local, OpenAI API ou Deepgram API).
O texto transcrito passa por um pipeline de processamento de cinco estágios: comandos de voz (“period” vira ”.”), timestamps, expansão de templates, auto-capitalização e substituição de palavras. O texto final é injetado como teclas via pynput na janela que estiver ativa.
O engine e o GUI são desacoplados via callbacks. O engine não importa nada do GUI. Ele só chama on_status_change, on_preview_update, on_final_text. O GUI conecta esses callbacks na sua fila de atualização. Dá pra rodar o engine headless sem mudar uma linha.
O que ele realmente consegue fazer
Funciona com qualquer aplicativo
O Tacet digita direto na janela que estiver em foco. Ele usa pynput pra injetar teclas no nível do sistema operacional, então funciona com qualquer app que aceite entrada de teclado. Sem extensões de navegador, sem integrações de API, sem plugins. Abre seu email, começa a ditar, e as palavras aparecem. Troca pro Slack, continua falando. Ele lida com caracteres especiais como quebras de linha e tabs pressionando as teclas reais (Enter, Tab) em vez de tentar digitar sequências de escape.
Detecção adaptativa de atividade de voz
O app detecta automaticamente quando você começa e para de falar. Ele rastreia níveis de ruído ambiente usando uma média móvel exponencial, depois define limiares de detecção de fala como multiplicadores acima desse piso de ruído. Funciona num quarto silencioso ou num ambiente barulhento sem calibração manual. Ele também guarda 300ms de áudio antes de detectar fala (preroll), então você nunca perde a primeira sílaba de uma frase.
Offline primeiro, com fallback na nuvem
O engine padrão é o faster-whisper rodando localmente na sua CPU. Sem chaves de API, sem internet, sem dados saindo da sua máquina. Na primeira execução, o Tacet faz um teste de velocidade no modelo selecionado. Se demora mais de 5 segundos pra transcrever 1 segundo de áudio, ele automaticamente rebaixa pro modelo “tiny” e salva essa preferência. Se você quer mais precisão e não se importa com a nuvem, pode trocar pra OpenAI ou Deepgram com uma mudança de config.
Comandos de voz e processamento de texto configuráveis
Diga “period” e ele digita ”.”. Diga “new line” e ele aperta Enter. Diga “delete that” e ele apaga o último trecho com backspace. Tudo isso é configurável. Você pode adicionar seus próprios comandos, desativar os padrão ou configurar substituições de palavras (“btw” vira “by the way”). O texto passa por um pipeline de cinco estágios: comandos de voz, timestamps, templates, auto-capitalização e substituições. Cada estágio é independente e pode ser ligado ou desligado.
Decisões que a gente tomou
A gente escolheu CustomTkinter em vez de Electron. Electron teria dado um visual mais bonito com menos esforço, mas ele embarca um navegador Chromium inteiro. Pra um app que fica quieto na bandeja do sistema usando poucos recursos, isso não fazia sentido. CustomTkinter é mais leve, inicia mais rápido e mantém o tamanho da instalação pequeno. O trade-off é menos componentes de UI e menos polimento visual.
A gente foi com processamento de texto baseado em regras em vez de usar um LLM pra pós-processamento. Um LLM poderia corrigir gramática, adicionar pontuação mais inteligente, talvez até reestruturar frases. Mas isso adicionaria latência, exigiria uma chamada na nuvem ou um segundo modelo local, e tornaria a saída imprevisível. Quando alguém dita numa janela de chat, a pessoa quer suas palavras digitadas agora, não corrigidas dois segundos depois. Regras são rápidas, previsíveis e grátis.
A gente pulou transcrição por streaming em tempo real pro engine local. O Whisper trabalha com pedaços completos de áudio, não streams. A gente poderia ter construído um pipeline de streaming com janelas sobrepostas, mas a complexidade não valia a pena. Em vez disso, a gente ajustou o fatiamento (detecção de silêncio, buffering de preroll, duração mínima do pedaço) pra parecer responsivo o bastante. A maioria das falas processa em menos de um segundo numa CPU razoável.
A gente também escolheu injetar teclas em vez de usar o clipboard. Injeção via clipboard seria mais simples e mais confiável entre plataformas, mas ela sobrescreve o que o usuário copiou por último. Isso é inaceitável pra maioria dos fluxos de trabalho.
O que a gente faria diferente
O sistema de config começou como um arquivo JSON simples e cresceu organicamente. A gente acabou com código de migração de formato legado pra substituições de palavras porque o GUI precisava de uma estrutura diferente do config original. Se a gente começasse de novo, definiria um schema com versionamento desde o primeiro dia em vez de adicionar lógica de migração depois.
O modelo de threading funciona, mas é feito na mão com locks, events e IDs de utterance pra detecção de dados obsoletos. Uma biblioteca como concurrent.futures ou uma abordagem async com asyncio teria sido mais limpa. A gente também consideraria separar o engine num processo próprio pra evitar o GIL completamente, já que captura de áudio e transcrição são ambas CPU-bound.
A gente também investiria em testes automatizados mais cedo. A arquitetura baseada em callbacks torna o engine testável em teoria, mas a gente não tem uma suíte de testes que exercita o pipeline completo. Isso é dívida técnica que a gente está carregando.