Vext 1.1 salió hace tres semanas. Vext 1.2.0 llega hoy.
getvext.app · 1.1.0 → 1.2.0
El anuncio de cara al usuario de la versión 1.2.0 cubre las novedades en la app — una pestaña de Hablantes, interfaz multiidioma completa, transcripciones de reuniones más nítidas. Este post es la contrapartida técnica: los problemas que costó de verdad sacar adelante, y cómo quedaron en el código.
La diarización en streaming no es suficiente — ni cuando "funciona"
La transcripción de reuniones en tiempo real tiene un grado de libertad que no podemos recuperar después: ve el audio una vez, en orden. El diarizador en streaming asigna a cada chunk VAD una sola etiqueta de hablante usando un embedding por chunk. Es una buena aproximación cuando la gente se turna. Colapsa los intercambios rápidos en la etiqueta equivocada cuando no lo hacen.
Dos hablantes pisándose durante diez segundos no debería convertirse en "Hablante 1 durante diez segundos."
La versión 1.2.0 mantiene la pasada en streaming — las transcripciones de reuniones siguen apareciendo en vivo, chunk a chunk — pero tras terminar la reunión y guardarse la transcripción provisional, Vext ejecuta una segunda pasada de diarización sobre los archivos WAV por stream. El pipeline offline es:
- pyannote Community-1 para segmentación
- WeSpeaker embeddings con enmascaramiento de frames solapados
- VBx refinamiento bayesiano para consolidar clusters
La pasada offline reatribuye cada chunk a su cluster correcto de forma global. Cuando reconoce a un hablante conocido, su embedding se actualiza en la base de datos — la próxima reunión lo identificará más rápido. Vext elimina los archivos WAV temporales cuando el refinamiento termina.
Ves el resultado en streaming durante la reunión. Lees el resultado refinado después. No son el mismo artefacto — y eso es deliberado.
Chunks con múltiples hablantes: segmentar en lugar de etiquetar
La otra mitad de la historia de diarización ocurre dentro de un único chunk.
Sortformer emite una línea de tiempo de hablantes por frame. Si dentro de un chunk VAD aparecen dos o más índices de hablante distintos, transcribirlo como un bloque único fuerza al modelo a atribuirlo todo a una sola voz. Un chunk de 30 segundos con un intercambio rápido se convierte en un bloque transcrito con una sola etiqueta — dos voces colapsadas en una.
La versión 1.2.0 segmenta el audio en los puntos de cambio de hablante y transcribe cada turno de forma independiente. Un chunk entra, N chunks salen — cada uno con su propia etiqueta de hablante, cada uno transcrito como un enunciado discreto.
Un detalle que llevó más tiempo del que debería: Sortformer genera parpadeos ruidosos por debajo de 300ms — un solo frame atribuido a otro hablante en mitad de un enunciado. Segmentar en cada parpadeo fragmenta la transcripción y produce turnos fantasma. Las regiones por debajo de 300ms se absorben ahora en la secuencia adyacente más larga antes de que corra el segmentador, así que los cortes que hacemos son los cortes que existen.
El bug del envenenamiento del micrófono
setVoiceProcessingEnabled de Apple en AVAudioInputNode hace lo que dice: AGC, supresión de ruido, cancelación de eco. También hace algo que la documentación no enfatiza — muta el estado HAL compartido del dispositivo de entrada.
Activarlo en Vext, y cualquier otra app que lea el mismo micrófono — Zoom, FaceTime, OBS, cualquier grabador — ve AGC y supresión de ruido aplicados a su feed también. La voz del usuario suena lejana y con la ganancia reducida en la llamada en la que de verdad está. Desactivarlo, y obtienes el mismo problema al revés la próxima vez que otra app lo activa.
El instinto es pelear con la API — forzarla a off, luego a on, mantener un lock, restaurar estado. La respuesta correcta es "no usarla aquí en absoluto." Vext captura a los participantes de la reunión mediante un tap de audio del sistema separado, no por la ruta del micrófono. El stream del micrófono y el stream del sistema son físicamente distintos. La cancelación de eco entre ellos nunca fue necesaria; estaba resolviendo un problema que no existe en esta arquitectura.
La versión 1.2.0 elimina la llamada. El estado HAL compartido ya no se ve perturbado.
Un event tap que miente
El event tap global de teclado — lo que hace que mantener una tecla y hablar funcione — tiene un modo de fallo que merece describirse porque tardó un tiempo en rastrearse.
Tras el reposo de pantalla, reposo del sistema o cambio rápido de usuario, el mach port que respalda el tap puede quedar obsoleto. CGEventTapIsEnabled sigue devolviendo true. Los eventos se descartan en silencio. El usuario mantiene la tecla; nada ocurre. Reiniciar la app lo soluciona. Nada en los logs explica por qué.
La versión 1.2.0 se autocura:
- Ahora nos suscribimos a
NSWorkspace.didWakeNotification,screensDidWakeNotificationysessionDidBecomeActiveNotification. Cada uno dispara una reinstalación completa del tap — no un re-enable, una recreación. - Cuando el sistema lanza
tapDisabledByTimeout, verificamos que el re-enable realmente surtió efecto. Si no fue así, se ejecuta la misma reinstalación completa. - El temporizador de health-check pasó a modos de run-loop
.common— lo movimos porque antes se bloqueaba durante el tracking del menú y las operaciones de arrastrar, exactamente las ventanas en las que es más probable que el tap obsoleto sea la siguiente interacción del usuario.
No es código glamuroso. Es el tipo de código que decide si la app es lo suficientemente fiable para vivir en tu barra de menú.
Un único camino de disparo
Vext puede iniciar dictado, una nota o una reunión desde un atajo de teclado o desde la barra de menú. En la versión 1.1, esos eran dos caminos de código. Habían divergido.
El camino del hotkey pasaba por el coordinador: burbuja de cursor mostrada, licencia verificada, callbacks de estado disparados, serialización del pegado aplicada. El camino de la barra de menú se saltaba la mayor parte de eso y llamaba directamente a la capa de grabación. Aparecieron bugs sutiles — una sesión que arrancaba desde el menú con estado de licencia obsoleto, una burbuja de cursor ausente que dejaba a los usuarios preguntándose si se estaba grabando algo.
La versión 1.2.0 enruta las acciones del menú por el mismo camino de código que el hotkey, con una diferencia declarada: el dictado iniciado desde el menú se trata como manos libres (toggle para empezar, toggle para parar), ya que no hay tecla física que mantener. Todo lo demás es idéntico porque es la misma llamada a función.
Etiquetas de hablante que no derivan
Dos caminos estaban produciendo nombres para el mismo audio. La instantánea de hablantes de la reunión — la lista de voces y sus nombres asignados que ves en el detalle de la reunión — solía reconstruirse desde DiarizerManager.getSpeakerList() tras terminar la reunión, y luego mapearse a nombres de visualización por separado de las etiquetas de los chunks.
La versión 1.2.0 construye la instantánea progresivamente durante la grabación mediante liveSnapshot, usando la misma llamada diarizeSpeaker() que etiqueta los chunks. Misma fuente de verdad, por construcción. Los hablantes ya presentes en el KnownSpeakerRepository global se excluyen de la instantánea por reunión, ya que sus embeddings viven globalmente y no necesitan re-listarse por reunión.
Cinco idiomas, una tabla
La historia de localización es corta porque la implementación es aburrida a propósito.
Cada cadena visible por el usuario — etiquetas del sidebar, ítems del menú, estados vacíos, prompts de onboarding, descripciones de permisos, tooltips de la barra de herramientas, texto del About, etiquetas del selector de modelos — pasa por una tabla de traducción centralizada. Cinco idiomas: inglés, español, ruso, hindi, tailandés. Las claves que faltan caen silenciosamente al inglés.
El selector de idioma (Ajustes → General, y en el onboarding) tiene una opción AUTO que sigue el locale del sistema de macOS. Elegir un idioma concreto cambia sin reiniciar — sin relanzar la app, sin recargar la vista, sin parpadeo. Eso es posible por la misma razón por la que la tabla es barata de ampliar: cada cadena visible lee de la tabla en el momento del render, no al arrancar la app.
Si añadimos un sexto idioma, el trabajo es traducción, no ingeniería.
Cómo actualizar
Si estás en 1.1.0:
brew upgrade muvon/tap/vext
O descarga el DMG desde getvext.app/download.
Si eres nuevo:
brew install muvon/tap/vext
Los hablantes, dictados, notas y reuniones existentes se conservan. La primera reunión grabada con 1.2.0 es la primera con la diarización en dos pasadas en efecto.
Notas completas de Vext 1.2.0 →
— Don



