Estaba experimentando con Qwen3.5-9B a través de llama.cpp hace poco. Su rendimiento de razonamiento es excepcional para un modelo de este tamaño, y es nativamente multimodal. Principalmente utilizo modelos basados en la nube como Gemini o Claude porque priorizo la calidad del razonamiento por encima de la velocidad. Pero ver esta cosa volar a 60 tokens por segundo en mi máquina se sentía bastante bien. Lo tenía abierto en un panel de terminal, escribiendo preguntas rápidas para generar comandos bash o texto formateado. Era genial, pero cambiar de contexto para escribir en una ventana de terminal anula el propósito de un modelo local rápido. Solo necesito la respuesta donde ya está mi cursor.

Al mismo tiempo, estaba haciendo una refactorización aburrida en Parakeet STT, mi herramienta de dictado para Linux totalmente local y push-to-talk.

Entonces se me ocurrió una idea obvia: ¿por qué estoy escribiendo? ¿Y si pudiera canalizar mi voz al modelo STT, tomar esa transcripción, canalizarla directamente al LLM local e insertar la respuesta de la IA exactamente donde está mi cursor?

Unas horas más tarde, lo tenía funcionando. Mantén pulsada la tecla Ctrl derecho para dictar texto sin formato. Mantén pulsadas las teclas Mayús + Ctrl derecho para consultar el LLM. Funciona en terminales, IDE, navegadores y cualquier superficie que acepte texto pegado.

La arquitectura

Parakeet STT ya estaba dividido en un demonio Python (que ejecuta NeMo STT de NVIDIA) y una interfaz Rust (que gestiona las teclas de acceso rápido, la superposición Wayland y la inyección uinput).

En lugar de introducir la lógica LLM en el demonio STT de Python, el cliente Rust actúa como coordinador.

[ evdev Hotkey ] ---> [ Rust Client ] <---> [ Python STT Daemon ]
                              |
                              v
                      [ llama-server ]

Intención a nivel del núcleo

El sistema necesita distinguir entre un dictado estándar (Ctrl derecho) y una consulta LLM (Mayús + Ctrl derecho).

Si se confía en comprobar el estado del modificador después de que se haya realizado el habla, se introducen condiciones de carrera (¿qué pasa si el usuario suelta Mayús un milisegundo antes de soltar Ctrl derecho?). En su lugar, la intención se captura al pulsar la tecla de acceso rápido mediante la introspección del flujo de eventos evdev:

enum HotkeyIntent {
    Dictate,     // Solo Ctrl derecho → transcripción sin formato
    LlmQuery,    // Ctrl derecho + Mayús → consulta LLM
}

Dado que inicializamos el estado de las teclas modificadoras directamente desde el núcleo cuando el oyente se conecta, si Mayús ya está pulsada cuando se inicia el sistema, la primera expresión se enruta correctamente.

Transmisión de SSE directamente a la superposición de la interfaz de usuario

La integración de LLM utiliza eventos enviados por el servidor (SSE) a través de reqwest para transmitir la respuesta. Evité explícitamente reutilizar el WebSocket de STT para esto. El demonio STT no debe conocer ni preocuparse por el LLM.

async fn fetch_llm_streamed_answer(
    llm: &LlmRuntimeConfig,
    session_id: Uuid,
    transcript: &str,
    progress_tx: &mpsc::UnboundedSender<LlmProgress>,
) -> Result<String> {
    // POST estándar compatible con OpenAI
    let request_body = json!({
        "model": llm.model,
        "stream": true,
        "messages": [
            {"role": "system", "content": llm.system_prompt},
            {"role": "user", "content": transcript},
        ],
    });

    // ... bucle de análisis SSE ...
    if let Some(delta) = extract_delta_content(&parsed) {
        assembled.push_str(delta);
        if llm.overlay_stream {
            progress_tx.send(LlmProgress::Delta {
                session_id, delta: delta.to_string(),
            });
        }
    }
}

El progreso de la transmisión se alimenta directamente a la misma infraestructura de enrutamiento de superposición Wayland que se utiliza para los resultados provisionales del habla. Se obtiene una experiencia de usuario unificada: la superposición muestra el habla transcrita y, a continuación, pasa inmediatamente a la transmisión de la respuesta del LLM, utilizando el mismo seguimiento de números de secuencia y animaciones de fundido escalonadas de 16 ms.

Orquestación sin bloqueos

El bucle de eventos WebSocket nunca se bloquea esperando al LLM. Cuando el demonio Python devuelve un FinalResult, el bucle Rust marca llm_busy = true, genera una tarea Tokio independiente para la solicitud HTTP del LLM y vuelve inmediatamente a escuchar las teclas de acceso rápido.

Si intentas activar otro dictado mientras el LLM está ocupado pensando, el sistema lo rechaza explícitamente y actualiza la superposición:

if llm_busy {
    warn!("ignoring hotkey down while LLM response is in progress");
    overlay_router.route_interim_state(
        /* ... */
        "LLM ocupado; espera a que termine la respuesta actual".to_string(),
    );
    continue;
}

Filosofías de diseño minimalistas

Sin pérdida de datos

Los LLM se bloquean. Los servidores se quedan sin memoria. Las ventanas de contexto se superan.

Si la solicitud del LLM falla por cualquier motivo, el sistema recurre instantáneamente a inyectar la transcripción sin procesar de tu discurso.

let response_text = match result {
    Ok(answer) => sanitize_model_answer(&answer),
    Err(err) => {
        warn!(%err, "llm query failed, falling back to raw transcript");
        fallback_transcript.clone()
    }
};

Se trata de un contrato de fiabilidad estricto: tu voz nunca se descarta. En el peor de los casos, obtendrás exactamente lo que dijiste, en lugar de la respuesta de la IA.

El subsistema de inyección de texto (que utiliza uinput para la suplantación de pulsaciones de teclas a nivel del núcleo) es completamente independiente del origen. Recibe una estructura InjectionJob. No le importa si ese texto proviene de una transcripción de voz sin procesar o de una respuesta LLM. El origen se utiliza estrictamente para el registro y la observabilidad (origin=llm_answer frente a origin=raw_final_result), manteniendo la lógica del portapapeles y los acordes completamente aislada de la orquestación de la IA.

Sin dependencias de la nube

Todo se ejecuta localmente. La configuración predeterminada apunta a http://127.0.0.1:8080.

Para garantizar que el LLM no desactive el reconocimiento de voz si entra en pánico, llama-server se gestiona mediante un script auxiliar que lo genera en una sesión tmux completamente separada (parakeet-llm). Si el LLM falla, Parakeet STT simplemente registra el error HTTP, coloca la transcripción sin procesar en el editor y sigue escuchando.

La experiencia vivida

La prueba real está en el uso diario. Utilizar la voz para consultar instantáneamente Qwen3.5-9B y ver cómo la respuesta se materializa en mis notas, terminal o navegador, exactamente donde está mi cursor, resulta refrescante en comparación con los días en los que tenía que cambiar de pestaña para buscar o chatear con la IA. Sigo utilizándolos, pero solo cuando son realmente necesarios.

Incluso con los pequeños modelos «tontos» actuales, la disponibilidad y la velocidad en contexto lo hacen increíblemente útil para plantillas, comandos de shell o recordar sintaxis. Al ver lo rápido que han mejorado los modelos locales en solo un año, este tipo de canalización local instantánea y sin latencia solo va a ser cada vez más potente.

Ver el código en GitHub