arrow_back Todos los articulos
8 min de lectura

Hicimos una app de dictado por voz que escribe en cualquier ventana

Creamos una app de escritorio para transcripción en tiempo real, capaz de funcionar sin internet, hecha en Python, que inyecta pulsaciones de teclado directamente en la ventana donde estés trabajando.

R
Revolt Bots Team
Tacet main window showing live transcription preview while dictating

La mayoría de las herramientas de dictado mandan el texto a su propia ventana. Transcribís, después copiás y pegás en otro lado. Eso te corta el flujo. Tacet escucha tu micrófono y escribe directamente en la app que tengas enfocada. VS Code, Slack, tu navegador, una terminal. Sin portapapeles, sin plugins, sin cambiar de ventana.

Las opciones que existen o te encierran en un ecosistema (Apple Dictation, Google Voice Typing) o necesitan integraciones específicas por app. Queríamos algo que funcione en todos lados, que corra offline si querés privacidad, y que no necesite conexión a internet ni API key para funcionar. Nada en el mercado hacía las tres cosas.

Quién usa esto en la vida real

Gente que escribe mucho y prefiere hablar. Escritores redactando contenido largo. Desarrolladores que quieren dictar comentarios o documentación sin salir de su editor. Cualquiera con RSI o necesidades de accesibilidad que no puede estar en el teclado todo el día. Personas que se preocupan por la privacidad y no quieren que sus datos de voz lleguen a un servidor.

El stack técnico

Python 3.9+ con CustomTkinter para la interfaz de escritorio. faster-whisper para transcripción local (corre sobre CTranslate2). sounddevice para captura de micrófono en tiempo real. pynput para hotkeys globales e inyección de pulsaciones. numpy para procesamiento de señal de audio. pystray para el ícono en la bandeja del sistema. OpenAI API y Deepgram API como proveedores de nube opcionales.

Nada del otro mundo. Elegimos Python porque el ecosistema de AI/ML está ahí. faster-whisper es una implementación de Whisper en C++ con bindings de Python, así que la velocidad de transcripción no es un problema de Python. La interfaz es la única parte que es “Python lento”, y no necesita ser rápida.

La parte más difícil: la vista previa en vivo

Cuando estás hablando, Tacet muestra una vista previa en tiempo real de lo que cree que estás diciendo. Whisper re-transcribe la misma ventana de audio cada 900ms. El enfoque ingenuo es: borrar la vista previa vieja, escribir la nueva. Eso causa parpadeo visible porque estás borrando y reescribiendo 40+ caracteres varias veces por segundo.

Lo resolvimos con diffing de prefijo estable. En cada actualización, buscamos el prefijo común más largo entre el texto viejo y el nuevo. Después solo borramos el sufijo que cambió y escribimos el sufijo nuevo. Si Whisper pasa de “I want to” a “I want to talk about”, no tocamos los primeros 10 caracteres. Solo agregamos ” 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)

Esto redujo el cambio visible de pulsaciones en un 80% aproximadamente e hizo que la vista previa se sienta fluida en vez de temblorosa.

El otro dolor de cabeza fue la detección de actividad de voz. Un umbral de energía fijo no funciona porque cada micrófono tiene distinto ruido de fondo. Terminamos rastreando un piso de ruido usando un promedio móvil exponencial, y después definiendo los umbrales de habla/silencio como multiplicadores sobre ese piso. Un cuarto silencioso y un café ruidoso funcionan igual sin que el usuario toque nada.

# 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

Cómo fluyen los datos

Tres hilos. El hilo principal corre la interfaz. Un hilo en segundo plano captura audio del micrófono y ejecuta la detección de actividad de voz, cortando el audio por pausas de silencio. Cuando un fragmento está listo, va a una cola. Dos hilos de transcripción sacan de esa cola y pasan el audio por el motor seleccionado (Whisper local, OpenAI API o Deepgram API).

El texto transcrito pasa por un pipeline de procesamiento de cinco etapas: comandos de voz (“period” se convierte en ”.”), timestamps, expansión de plantillas, capitalización automática y reemplazos de palabras. El texto final se inyecta como pulsaciones de teclado vía pynput en la ventana que esté activa.

El motor y la interfaz están desacoplados mediante callbacks. El motor no importa nada de la interfaz. Solo llama a on_status_change, on_preview_update, on_final_text. La interfaz conecta esos callbacks a su cola de actualizaciones. Podrías correr el motor sin interfaz gráfica sin cambiar una línea.

Qué puede hacer en concreto

Funciona con cualquier aplicación

Tacet escribe directamente en la ventana que tengas enfocada. Usa pynput para inyectar pulsaciones a nivel del sistema operativo, así que funciona con cualquier app que acepte entrada de teclado. Sin extensiones de navegador, sin integraciones de API, sin plugins. Abrí tu email, empezá a dictar y las palabras aparecen. Cambiá a Slack, seguí hablando. Maneja caracteres especiales como saltos de línea y tabulaciones presionando las teclas reales (Enter, Tab) en vez de intentar escribir secuencias de escape.

Detección adaptativa de actividad de voz

La app detecta automáticamente cuándo empezás y dejás de hablar. Rastrea los niveles de ruido ambiental usando un promedio móvil exponencial, y después define los umbrales de detección como multiplicadores sobre ese piso de ruido. Funciona en un cuarto silencioso o en un ambiente ruidoso sin calibración manual. También guarda 300ms de audio antes de que se detecte el habla (preroll), así que nunca perdés la primera sílaba de una oración.

Offline primero, con fallback a la nube

El motor por defecto es faster-whisper corriendo localmente en tu CPU. Sin API keys, sin internet, sin datos saliendo de tu máquina. En la primera ejecución, Tacet hace un test de velocidad con el modelo seleccionado. Si tarda más de 5 segundos en transcribir 1 segundo de audio, baja automáticamente al modelo “tiny” y guarda esa preferencia. Si querés más precisión y no te importa la nube, podés cambiar a OpenAI o Deepgram con un solo cambio de configuración.

Comandos de voz y procesamiento de texto configurables

Decí “period” y escribe ”.”. Decí “new line” y presiona Enter. Decí “delete that” y borra el último fragmento. Todo esto es configurable. Podés agregar tus propios comandos, desactivar los que vienen incluidos, o definir reemplazos de palabras (“btw” se convierte en “by the way”). El texto pasa por un pipeline de cinco etapas: comandos de voz, timestamps, plantillas, capitalización automática y reemplazos. Cada etapa es independiente y se puede activar o desactivar.

Decisiones que tomamos

Elegimos CustomTkinter en vez de Electron. Electron nos habría dado una interfaz más linda con menos esfuerzo, pero incluye un navegador Chromium completo. Para una app que se queda quieta en la bandeja del sistema y usa pocos recursos, eso no tenía sentido. CustomTkinter es más liviano, inicia más rápido y mantiene el tamaño de instalación chico. La contrapartida es menos componentes de interfaz y menos pulido visual.

Fuimos con procesamiento de texto basado en reglas en vez de usar un LLM para post-procesamiento. Un LLM podría corregir gramática, agregar puntuación más inteligente, quizás hasta reestructurar oraciones. Pero agregaría latencia, requeriría o una llamada a la nube o un segundo modelo local, y haría la salida impredecible. Cuando alguien dicta en una ventana de chat, quiere que sus palabras aparezcan ya, no corregidas dos segundos después. Las reglas son rápidas, predecibles y gratis.

No implementamos transcripción en streaming en tiempo real para el motor local. Whisper trabaja con fragmentos de audio completos, no con streams. Podríamos haber armado un pipeline de streaming con ventanas superpuestas, pero la complejidad no valía la pena. En cambio, ajustamos el chunking (detección de silencio, buffering de preroll, duración mínima del fragmento) para que se sienta lo suficientemente responsivo. La mayoría de las frases se procesan en menos de un segundo en una CPU decente.

También elegimos inyectar pulsaciones de teclado en vez de usar el portapapeles. La inyección por portapapeles sería más simple y más confiable entre plataformas, pero sobrescribe lo último que el usuario copió. Eso es inaceptable para la mayoría de los flujos de trabajo.

Qué haríamos distinto

El sistema de configuración empezó como un archivo JSON plano y creció de forma orgánica. Terminamos con código de migración de formato legacy para reemplazos de palabras porque la interfaz necesitaba una estructura distinta a la del config original. Si empezáramos de nuevo, definiríamos un schema con versionado desde el día uno en vez de agregar lógica de migración después.

El modelo de threading funciona pero está armado a mano con locks, events y utterance IDs para detección de datos obsoletos. Una librería como concurrent.futures o un enfoque async con asyncio habría sido más limpio. También consideraríamos separar el motor en su propio proceso para evitar el GIL por completo, ya que la captura de audio y la transcripción usan mucho CPU.

También invertiríamos en testing automatizado antes. La arquitectura basada en callbacks hace que el motor sea testeable en teoría, pero no tenemos una suite de tests que ejercite el pipeline completo. Eso es deuda técnica que venimos arrastrando.

Etiquetas

tacet desktop ai open-source python

Mas del blog