El punto de partida de este proyecto fue la necesidad de seguir una entrevista por videollamada leyendo en vivo lo que se decía. Los subtítulos automáticos suelen resolverse en la aplicación/servicio usado para la videollamada, pero esto no siempre es así. La alternativa es hacer la transcripción localmente y en tiempo real sobre lo que se escucha en el equipo: por ejemplo, la voz remota de una llamada. De esta manera se obtiene una transcripcion, sin depender de la aplicación puntual usada para la conversación.

QRTWhisper es una aplicación de escritorio que captura la salida de audio del sistema (o un micrófono) y la transcribe en vivo con Whisper.cpp, utilizando la GPU.
El caso de uso central es la conversación en tiempo real —videollamada, reunión, entrevista—, donde la demora entre el habla y el subtítulo debe ser mínima y el texto resultante, preciso.
Este artículo detalla y fundamenta la motivación, los atributos de calidad, las decisiones arquitectónicas y de diseño y las tecnologías usadas. A diferencia de lo que planteé en el post de QLogueLibrarian, aquí la complejidad no está en la gestión de un dominio de archivos, sino en el tiempo real, la GPU y el audio del sistema.
Motivación técnica
- El audio a transcribir no siempre es un micrófono. En una videollamada, lo que interesa leer es la voz remota, que llega por la salida de audio del sistema. Capturarla exige crear un micrófono virtual que monitoree esa salida; un
SDL_OpenAudioDevicesobre un micrófono físico no alcanza. - La transcripción debe ser local. La inferencia en el propio equipo evita enviar audio a servicios externos y permite controlar la latencia.
- La latencia y la precisión son requisitos de primer orden. El tamaño de la ventana de audio, el tiempo de inferencia y el modelo elegido determinan si el texto es utilizable durante una conversación.
- La gestión del modelo es parte del problema. Seleccionar un modelo adecuado, verificar su integridad y descargarlo son operaciones que la aplicación debe resolver sin depender de la terminal.
Whisper.cpp incluye examples/stream, que implementa transcripción por streaming por línea de comandos. Es una base técnica sólida, pero le falta la capa que hace utilizable esa tecnología durante una conversación: una interfaz que no interfiera con el uso del teclado, un micrófono virtual autogestionado y la posibilidad de descargar y verificar el modelo desde la propia aplicación.
Atributos de calidad
Los atributos de calidad que definen este proyecto están dominados por la performance. A diferencia de un dominio de archivos, donde predominan la usabilidad y la extensibilidad, aquí el orden es otro: la baja latencia, el rendimiento y la precisión condicionan el resto de las decisiones.
Baja latencia (atributo dominante)
La latencia extremo a extremo es la demora entre el momento en que se produce el habla y el momento en que se muestra el subtítulo. Se compone de la ventana de audio acumulada, el tiempo de inferencia del modelo y la actualización de la interfaz. Diseñar para baja latencia condiciona decisiones concretas: el tamaño de la ventana de procesamiento, que la inferencia no compita con la UI y que el resultado se publique sin pasos intermedios innecesarios.
Rendimiento (atributo dominante)
El rendimiento es la capacidad de sostener la transcripción en tiempo real sin degradar el uso. El audio debe procesarse a la misma velocidad a la que se produce —sin acumularse ni descartarse— y la interfaz debe permanecer operable durante toda la sesión. Se observa en la cadencia de segmentos publicados y en la ausencia de cortes o pérdida de audio, y debe mantenerse estable aun cuando cambien el modelo y la longitud de la ventana de procesamiento. Es un atributo en tensión con la precisión: más calidad de transcripción exige más cómputo, y el equilibrio entre ambos debe poder ajustarse sin comprometer la continuidad del flujo.
Precisión de la transcripción (atributo dominante)
La utilidad del subtítulo depende de la calidad del texto producido. La precisión se ve afectada por el modelo elegido (el catálogo va de tiny a large-v3), por la integridad del archivo del modelo y por la segmentación del audio. La aplicación gestiona la descarga de modelos con verificación de checksum y permite elegirlos desde la interfaz.
Fluidez y continuidad
El audio se captura sin cortes y se transcribe en segmentos incrementales. La transición entre segmentos no debe producir silencios perceptibles ni textos superpuestos.
Usabilidad
En el flujo de captura el usuario no interviene: la aplicación crea el micrófono virtual, lo detecta y lo selecciona. El usuario elige la fuente (salida del sistema o micrófono real) y el método de presentación. La presentación de la transcripción se realiza de forma de no entorpecer la visibilidad de la pantalla ni el flujo del usuario.
Extensibilidad y estabilidad estructural
La vista no conoce al modelo; los backends de micrófono virtual son intercambiables detrás de una interfaz; la gestión de modelos es un servicio independiente. Esto permite incorporar cambios sin reescribir el núcleo.
Portabilidad y distribución
La aplicación se distribuye como AppImage autónomo, con los modelos alojados fuera de la imagen y el driver de GPU provisto por el sistema operativo.
Funcionalidad
La aplicación expone un flujo orientado a conversaciones en vivo:
- Micrófono virtual autogestionado: crea un source de audio que monitorea la salida elegida (por defecto, la activa) y lo destruye al salir. Las fuentes son alternativas y seleccionables: salida del sistema (voz remota) o micrófono real (voz propia).
- Subtítulos en tiempo real: el texto se presenta como notificación de sistema o como subtítulo en una ventana translúcida sobre la pantalla.
- Gestión y descarga de modelos: un diálogo lista el catálogo, muestra el estado (descargado/disponible), descarga con barra de progreso y cancelación, verifica el checksum y permite eliminar modelos.
- Detección de dispositivos: enumera los dispositivos de captura y auto-selecciona el micrófono virtual generado.
- Tray y cierre ordenado: la aplicación reside en la bandeja durante la transcripción y, al salir, detiene el orquestador y descarga el módulo de audio en orden.
Elección de tecnologías: Qt6, CMake y whisper.cpp
Se eligió Qt6 por ofrecer widgets de escritorio, notificaciones de sistema, integración con tray y ventanas translúcidas siempre-encima sobre un framework estable. En este proyecto se utilizó el stack de Widgets (no QML); la justificación se desarrolla en la sección de arquitectura, porque el paradigma de la interfaz condiciona el patrón arquitectónico.
CMake es el sistema de construcción estándar de Qt6 y del ecosistema C++ actual. Permite además modelar decisiones relevantes del proyecto: qué backend de micrófono virtual compilar (QRTWHISPER_MIC_BACKEND) y qué arquitecturas CUDA incluir (CMAKE_CUDA_ARCHITECTURES).
whisper.cpp se integra como submódulo de Git, fijando una versión reproducible del motor y de ggml. La captura de audio utiliza SDL2 (el mismo mecanismo de whisper.cpp/examples/stream), y la creación del micrófono virtual se resuelve con libpulse (API C de PulseAudio) o, de forma alternativa, invocando pactl. La GPU/CUDA es el medio por el cual la transcripción en tiempo real resulta viable con modelos como medium.en.
Decisiones arquitectónicas
Arquitectura general: la vista no conoce al modelo
El patrón base es el mismo que expliqué para QLogueLibrarian: MVC aplicado con disciplina, donde los nombres de las clases no bastan si las responsabilidades quedan acopladas. La variante elegida aquí es la que en el post anterior describí como MVP: el Controller/Presenter es el único mediador entre el modelo y la vista, y la vista desconoce la existencia del modelo.
classDiagram
class View {
MainWidget / TextRender / Tray
<<interfaz de usuario>>
}
class Controller {
<<mediador / presentador>>
lógica de presentación
}
class Model {
estado + orquestación
}
class TranscriptionWorker {
<<orquestador / QThread>>
ventana → transcribir → publicar
}
class ITranscriptionEngine {
<<interfaz>>
load / transcribe
}
class WhisperEngine {
whisper_full (GPU)
}
class IAudioStream {
<<interfaz>>
start / nextWindow
}
class SdlAudioStream {
SDL2: captura + segmentación
}
class ModelManager {
catálogo / descarga / checksum
}
class VirtualMic {
<<interfaz>>
create / destroy
}
class PulseAudioVirtualMic {
libpulse (API C)
}
class PactlVirtualMic {
QProcess + pactl
}
View --> Controller : acciones del usuario
Controller --> Model : startTranscription(...)
Controller --> ModelManager : gestionar modelos
Controller --> VirtualMic : crear/destruir mic virtual
VirtualMic <|-- PulseAudioVirtualMic
VirtualMic <|-- PactlVirtualMic
Model --> TranscriptionWorker : inicia el hilo
TranscriptionWorker --> IAudioStream
TranscriptionWorker --> ITranscriptionEngine
IAudioStream <|-- SdlAudioStream
ITranscriptionEngine <|-- WhisperEngine
TranscriptionWorker ..> Model : segmentTranscribed(segmento)
Model ..> Controller : transcriptionReady()
Controller ..> View : subtítulo / notificaciónEl flujo de datos es unidireccional: el orquestador emite segmentos, el Model los acumula y notifica mediante una señal Qt, el Controller determina cómo presentarlos y la vista solo los muestra. La vista no consulta al modelo; un cambio interno del modelo no afecta a la interfaz.
Transcripción desacoplada: orquestador, motor y flujo de audio
La transcripción es la operación más pesada de la aplicación y no debe ejecutarse en el hilo de la UI. El primer diseño concentraba en un único Worker la captura SDL, la segmentación, la carga del modelo, la inferencia, el formateo y el ciclo de vida. Esa acumulación de responsabilidades se separó en tres piezas con contratos explícitos:
- Flujo de audio (
IAudioStream/SdlAudioStream): captura, ventana deslizante (step/length/keep), política de VAD y estado temporal del stream. Decide cuándo y qué audio transcribir. - Motor de transcripción (
ITranscriptionEngine/WhisperEngine): carga del modelo, parámetros de inferencia, ejecución dewhisper_fully contexto entre llamadas. Es el único componente que conoce la API de whisper. - Orquestador (
TranscriptionWorker): el bucle delgado sobreQThread(ventana → transcribir → publicar), con el flag de stop y las señales; no conoce SDL ni whisper.
sequenceDiagram
participant SDL as SdlAudioStream
participant W as TranscriptionWorker
participant E as WhisperEngine
participant M as Model
participant C as Controller
participant V as View
loop por cada ventana de audio
SDL->>W: ventana PCM
W->>E: transcribe(ventana)
E-->>W: segmentos (GPU)
W-->>M: segmentTranscribed(segmento)
M-->>C: transcriptionReady()
C-->>V: subtítulo / notificación
endEl desacople sigue el mismo criterio de seams que el resto del proyecto y aporta tres cosas. Primero, una única razón de cambio por pieza: afinar la latencia o la segmentación no toca el motor, y actualizar whisper.cpp no toca la captura. Segundo, aísla dependencias: whisper.h queda en un solo componente y SDL en otro, lo que habilita incorporar un backend de captura nativo (PipeWire) o un motor alternativo sin modificar el orquestador. Tercero, los errores dejan de viajar como texto: cada componente devuelve un código de error con un detalle técnico, y la presentación decide cómo decirlo (ver Gestión de idiomas).
Variantes MVC consideradas: Widgets/MVP-lean vs QML/bindings
En QLogueLibrarian elegí QML y una variante en la que la vista lee el modelo directamente mediante bindings declarativos. Esa decisión no era una receta general: era la consecuencia de un paradigma declarativo y de un dominio de archivos con estado relativamente estable.
En QRTWhisper el contexto es distinto. El flujo es imperativo y dirigido por eventos: iniciar la captura, crear un micrófono virtual, procesar un stream continuo, responder a errores y cerrar ordenadamente. Los widgets clásicos y las señales/slots de Qt expresan ese flujo de forma directa. Ocultar el modelo detrás del Controller no implica duplicar propiedades de UI (como habría ocurrido en QML); la vista recibe un conjunto acotado de operaciones y el Controller traduce los eventos del modelo. La vista queda desacoplada, y el costo de esa indirección es menor que el acoplamiento que introduciría exponer el modelo directamente.
La conclusión es que la variante de MVC debe desprenderse del paradigma de la interfaz y del dominio del problema: en una interfaz declarativa con bindings, la lectura directa del modelo es razonable; en un flujo imperativo de tiempo real, un mediador que aísla la vista del modelo resulta más apropiado.
El micrófono virtual como servicio intercambiable
El problema técnico específico es transcribir la voz remota de una conversación. Esa voz llega por la salida de audio del sistema; para capturarla es necesario crear un source de audio que monitoree esa salida. En el entorno PulseAudio/PipeWire esto se resuelve con el módulo module-remap-source sobre el monitor del sink elegido (master=<sink>.monitor). Una vez creado, el dispositivo aparece en la lista de captura que enumera SDL, identificado por su descripción (QRTWhisper_Virtual_Mic), y la aplicación lo detecta y selecciona automáticamente.
Crear y destruir ese micrófono virtual es un servicio con dos implementaciones posibles, por lo que quedó detrás de una interfaz (VirtualMic) y de una fábrica:
PactlVirtualMic: invocapactlcomo proceso externo (QProcess). No agrega dependencias de compilación.PulseAudioVirtualMic: utiliza la API C de PulseAudio (libpulse) con el patrón síncronopa_threaded_mainloop, sin ejecutar comandos externos y con manejo de errores nativo (pa_strerror).
La interfaz garantiza paridad de artefacto: ambas implementaciones crean el mismo módulo con el mismo source_name, limpian restos dejados por el otro backend y exponen el mismo token de detección. La selección es build-time mediante la opción de CMake QRTWHISPER_MIC_BACKEND (default libpulse), resuelta por VirtualMicFactory.
| Ventajas | Desventajas |
|---|---|
| Migrar del comando externo a libpulse no afecta a otros módulos: la vista y el controller solo conocen la interfaz. | La variante libpulse requiere libpulse-dev para compilar (documentado). |
Mantener pactl como backend alternativo permite volver al enfoque anterior mediante un cambio de build mientras se valida el nuevo. | El backend se elige en tiempo de compilación; no hay selección en runtime. |
| La verificación de integridad y el manejo de errores del módulo pasan a la API nativa. | En un servidor PipeWire sin capa de compatibilidad de PulseAudio, ninguna de las dos variantes funciona (tampoco la captura vía SDL). |
El mismo seam permite incorporar en el futuro un backend nativo PipeWire (libpipewire) sin modificar el resto de la aplicación: bastaría implementar la interfaz y seleccionarla en la fábrica.
Gestión y descarga de modelos
La precisión depende del modelo, por lo que su gestión forma parte del producto. ModelManager mantiene un catálogo curado (variantes .en y multilingües, de tiny a large-v3-turbo) y resuelve en runtime dónde se almacenan los modelos. La descarga se realiza desde HuggingFace con Qt Network, y la integridad se verifica contra el checksum sha256 que expone la API del repositorio (campo lfs.sha256), sin mantener una tabla de hashes en el código:
- se obtiene la metadata del repositorio;
- se descarga el modelo a un archivo temporal
.partcalculando el hash de forma incremental; - al finalizar se compara el sha256 y, si coincide, el archivo se renombra a
ggml-<modelo>.bin.
La cancelación descarta el .part. El diálogo de gestión (QListWidget + QProgressBar) permite consultar el estado, descargar, cancelar y eliminar modelos. La carpeta de modelos se resuelve con una prioridad relevante para la distribución: override explícito (QRTWHISPER_MODELS_DIR), la carpeta junto al AppImage (dirname($APPIMAGE)) y, en desarrollo, la carpeta junto al ejecutable.
Gestión de idiomas
La gestión de idiomas añade valor para una aplicación cuyo objetivo es la transcripción entre lenguajes. Hay tres requerimientos para esta funcionalidad:
- cambio de idioma sin reiniciar;
- preferencia persistente;
- encapsulamiento.
La solución propuesta utiliza los mecanismos estándar de Qt, adaptándolos a la arquitectura del proyecto.
Principios
- Los textos pertenecen a la capa que los emite. Las vistas son dueñas de sus textos.
- La infraestructura no produce texto para el usuario. Los backends y el motor devuelven un código de error con un detalle técnico no traducible; la presentación decide cómo decirlo.
- Se traducen textos de aplicación (etiquetas, acciones, estados); no se traducen datos (dispositivos, modelos, sinks, transcripción).
- La vista no conoce el catálogo ni el traductor: solo expresa la intención de cambio.
Abstracciones de Qt y responsabilidad
| Abstracción | Responsabilidad |
|---|---|
Marcado de textos (tr()) | Identidad de cada cadena por contexto de origen; cada capa es dueña de sus textos. |
QTranslator | Proveedor de traducción intercambiable; instalarlo/removerlo habilita el cambio en caliente. |
QLocale | Política por defecto (idioma del sistema) cuando no hay preferencia. |
QEvent::LanguageChange | Notificación a las vistas abiertas para que reconstruyan sus textos. |
QSettings | Persistencia de la preferencia de idioma. |
Cambio en caliente
- El cambio se propaga como evento, no por recreación de la interfaz.
- Regla: un único método por vista reconstruye sus textos y se invoca también en la construcción; el constructor solo compone widgets. Fuente única de verdad para el estado inicial y el cambio de idioma.
- Los elementos de contenido variable (listas, selectores) se reconstruyen preservando el estado del usuario.
- Los objetos que no son vistas (por ejemplo, la acción del tray) no reciben el evento y se actualizan por notificación.
Reparto de responsabilidades
| Rol | Responsabilidad |
|---|---|
| Vista | Expresar la intención de cambio; reconstruir sus propios textos. |
| Mediador | Aplicar la política, persistir la preferencia y notificar; único punto de entrada. |
| Preferencias | Conservar la preferencia entre ejecuciones. |
| Política por defecto | Idioma del sistema si no hay preferencia; respaldo en inglés. |
Se preserva el principio general del proyecto: la vista no conoce al modelo ni a los servicios; la localización no es una excepción.
Herencia y extensibilidad
- Herencia acotada: una base común para vistas retraducibles centraliza la reacción al evento; cada subclase aporta solo sus textos. No organiza contenido ni lógica. Los objetos no-vista se resuelven por composición/observación, no por herencia.
- Agregar un idioma es agregar un catálogo; el mecanismo no cambia. Catálogos y recursos viven en directorios propios, manteniendo el empaquetado autocontenido.
- Seam: si la lógica crece (muchos idiomas, variantes regionales), pasa a servicio explícito —con la misma forma que los demás servicios del sistema— sin tocar las vistas.
Reutilización de componentes de Qt
Se mantiene el principio de reutilizar las capacidades que Qt ya provee antes de escribir código propio o incorporar dependencias:
QThread+ señales/slots — mueve la inferencia de Whisper fuera de la UI sin bloqueos manuales.QNetworkAccessManager+QJsonDocument/QJsonArray— descarga y parseo de la metadata de HuggingFace sin dependencias externas de red o JSON.QCryptographicHash— verificación sha256 incremental durante la descarga del modelo.QSystemTrayIcon+QMenu— la aplicación reside en la bandeja durante la transcripción y ofrece un cierre ordenado.QListWidget,QProgressBar,QMessageBox— el diálogo de gestión de modelos y la presentación de errores, con widgets estándar.QDir,QFileInfoy la variableAPPIMAGE— resolución de la carpeta de modelos según el contexto de ejecución.QProcess(backendpactl) frente a la API C de libpulse — la misma funcionalidad detrás de la interfazVirtualMic, intercambiable en tiempo de compilación.
¿Sobrediseño o inversión estratégica?
En el post de QLogueLibrarian planteé si tanta arquitectura estaba justificada para una aplicación que ejecuta un comando externo. En QRTWhisper esa pregunta tiene otra respuesta, porque la complejidad es inherente al problema: transcripción en tiempo real, inferencia con GPU en un hilo separado, captura del audio del sistema con un source creado y destruido por la aplicación, descarga verificada de modelos de gran tamaño y empaquetado autónomo. La arquitectura no anticipa un escenario futuro: organiza problemas que ya están presentes.
Gestión del proyecto y buenas prácticas
whisper.cpp como submódulo de Git
La dependencia crítica, el motor de transcripción, está fijada como submódulo: garantiza reproducibilidad (todo clon usa la misma versión) y mantiene el historial separado. Al anclar la versión se evita que un cambio aguas arriba rompa la compilación de forma silenciosa.
CMake: decisiones de configuración
El CMakeLists.txt modela dos decisiones configurables del proyecto:
QRTWHISPER_MIC_BACKEND(libpulse|pactl): determina qué implementación del micrófono virtual se compila.CMAKE_CUDA_ARCHITECTURES: determina qué arquitecturas CUDA se incluyen. El default de desarrollo es75(GTX 16xx) para compilar rápido; para una distribución multi-GPU se compila75;86;89(y120con CUDA ≥ 12.8).
CUDA fat binaries: un binario para varias GPUs
No es necesario seleccionar la GPU en runtime. CUDA compila fat binaries con un cubin por arquitectura dentro del mismo .so; al ejecutar, el driver selecciona el cubin que corresponde al hardware presente. Un binario compilado con 75;86;89 funciona en una GTX 16xx (sm_75), una RTX 30xx (sm_86) y una RTX 40xx (sm_89) sin detección propia. Las arquitecturas 86/89 sin sufijo incluyen además PTX, que el driver puede JIT-compilar para GPUs más nuevas.
Distribución
La distribución para Linux se realiza mediante AppImage, generado con build-appimage.sh (linuxdeploy + linuxdeploy-plugin-qt), que empaqueta el binario junto con Qt y las demás dependencias en un único archivo ejecutable.
Dos decisiones de distribución son relevantes:
- Los modelos residen fuera del AppImage. La imagen se monta como un squashfs de solo lectura en tiempo de ejecución, por lo que no es posible escribir modelos en su interior. La aplicación detecta la variable
APPIMAGE, toma la carpeta del archivo y utiliza<dir_del_AppImage>/models, una carpeta escribible y portable junto al ejecutable. La variableQRTWHISPER_MODELS_DIRpermite elegir otra ubicación. - El driver CUDA proviene del host.
libcudano se empaqueta en el AppImage; el driver debe estar instalado en el sistema, y los fat binaries resuelven la selección de arquitectura. Lo mismo aplica al backend de audio: la aplicación se comunica con el servidor PulseAudio/PipeWire del sistema.
Recursos
Video de demostración
Repositorio con instrucciones de instalación: https://github.com/deibit-dev/qrtwhisper
Lectura relacionada: QLogueLibrarian: una interfaz gráfica multiplataforma para logue-cli

Dejá una respuesta