Je m’amusais récemment avec Qwen3.5-9B via llama.cpp. Ses performances de raisonnement sont exceptionnelles pour un modèle de cette taille, et il est nativement multimodal. J’utilise principalement des modèles cloud comme Gemini ou Claude parce que j’optimise la qualité du raisonnement plutôt que la vitesse. Mais voir cette chose tourner à 60 tokens par seconde sur ma machine, c’était plutôt satisfaisant. Je l’avais ouvert dans un panneau de terminal, tapant des questions rapides pour générer des commandes bash ou du texte formaté. C’était génial, mais changer de contexte pour taper dans une fenêtre de terminal va à l’encontre de l’objectif d’un modèle local rapide. J’ai juste besoin de la réponse là où se trouve déjà mon curseur.
En même temps, je faisais une refonte ennuyeuse de Parakeet STT, mon outil de dictée Linux entièrement local et push-to-talk.
Puis une idée évidente m’est venue : pourquoi est-ce que je tape ? Et si je pouvais transférer ma parole dans le modèle STT, prendre cette transcription, la transférer directement dans le LLM local et insérer la réponse de l’IA exactement là où se trouve mon curseur ?
Quelques heures plus tard, j’avais réussi à le faire fonctionner. Maintenez la touche Ctrl droite enfoncée pour dicter du texte brut. Maintenez les touches Maj + Ctrl droite enfoncées pour interroger le LLM. Cela fonctionne dans les terminaux, les IDE, les navigateurs, toute surface qui accepte le texte collé.
L’architecture
Parakeet STT était déjà divisé en un démon Python (exécutant NeMo STT de NVIDIA) et une interface Rust (gérant les raccourcis clavier, la superposition Wayland et l’injection uinput).
Au lieu d’intégrer la logique LLM dans le démon Python STT, le client Rust agit comme un orchestrateur.
[ evdev Hotkey ] ---> [ Rust Client ] <---> [ Python STT Daemon ]
|
v
[ llama-server ]
Intention au niveau du noyau
Le système doit faire la distinction entre une dictée standard (Ctrl droite) et une requête LLM (Maj + Ctrl droite).
Si vous vous fiez à la vérification de l’état des modificateurs après la fin de la dictée, vous introduisez des conditions de concurrence (que se passe-t-il si l’utilisateur relâche Maj une milliseconde avant de relâcher Ctrl droite ?). Au lieu de cela, l’intention est capturée lors de l’activation du raccourci clavier en introspectant le flux d’événements evdev :
enum HotkeyIntent {
Dictate, // Ctrl droite seul → transcription brute
LlmQuery, // Ctrl droite + Maj → requête LLM
}
Comme nous initialisons l’état des touches modificatrices directement à partir du noyau lorsque l’écouteur se connecte, si Maj est déjà enfoncé au démarrage du système, la toute première énonciation est acheminée correctement.
Diffusion en continu des SSE directement vers la superposition de l’interface utilisateur
L’intégration LLM utilise les événements envoyés par le serveur (SSE) via reqwest pour diffuser la réponse. J’ai explicitement évité de réutiliser le WebSocket STT pour cela. Le démon STT ne doit pas connaître ni se soucier du LLM.
async fn fetch_llm_streamed_answer(
llm: &LlmRuntimeConfig,
session_id: Uuid,
transcript: &str,
progress_tx: &mpsc::UnboundedSender<LlmProgress>,
) -> Result<String> {
// POST standard compatible OpenAI
let request_body = json!({
"model": llm.model,
"stream": true,
"messages": [
{"role": "system", "content": llm.system_prompt},
{"role": "user", "content": transcript},
],
});
// ... boucle d'analyse 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(),
});
}
}
}
La progression du streaming alimente directement la même infrastructure de routage Wayland overlay utilisée pour les résultats vocaux intermédiaires. Vous obtenez une expérience utilisateur unifiée : l’overlay affiche votre transcription vocale, puis passe immédiatement à la réponse du LLM, en utilisant le même suivi des numéros de séquence et les mêmes animations de fondu enchaîné de 16 ms.
Orchestration non bloquante
La boucle d’événements WebSocket ne bloque jamais en attendant le LLM. Lorsque le démon Python renvoie un FinalResult, la boucle Rust marque llm_busy = true, génère une tâche Tokio détachée pour la requête HTTP LLM et revient immédiatement à l’écoute des raccourcis clavier.
Si vous essayez de déclencher une autre dictée pendant que le LLM est occupé à réfléchir, le système la rejette explicitement et met à jour la superposition :
if llm_busy {
warn!("ignoring hotkey down while LLM response is in progress");
overlay_router.route_interim_state(
/* ... */
"LLM occupé ; attendez la fin de la réponse en cours".to_string(),
);
continue;
}
Philosophies de conception minimalistes
Aucune perte de données
Les LLM plantent. Les serveurs sont en manque de mémoire. Les fenêtres contextuelles sont dépassées.
Si la requête LLM échoue pour une raison quelconque, le système revient instantanément à l’injection de votre transcription brute.
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()
}
};
Il s’agit d’un contrat de fiabilité strict : votre discours n’est jamais perdu. Dans le pire des cas, vous obtenez exactement ce que vous avez dit, plutôt que la réponse de l’IA.
Le sous-système d’injection de texte (qui utilise uinput pour l’usurpation de frappes au niveau du noyau) est totalement indépendant de l’origine. Il reçoit une structure InjectionJob. Il ne se soucie pas de savoir si ce texte provient d’une transcription brute de la parole ou d’une réponse LLM. L’origine est strictement utilisée à des fins de journalisation et d’observabilité (origin=llm_answer vs origin=raw_final_result), ce qui permet de garder la logique du presse-papiers et des accords complètement isolée de l’orchestration de l’IA.
Aucune dépendance au cloud
Tout fonctionne localement. La configuration par défaut pointe vers http://127.0.0.1:8080.
Pour s’assurer que le LLM ne perturbe pas la reconnaissance vocale en cas de panique, llama-server est géré par un script d’aide qui le lance dans une session tmux complètement séparée (parakeet-llm). Si le LLM tombe en panne, Parakeet STT se contente d’enregistrer l’erreur HTTP, de déposer votre transcription brute dans votre éditeur et de continuer à écouter.
L’expérience vécue
La véritable preuve réside dans l’utilisation quotidienne. Utiliser la voix pour interroger instantanément Qwen3.5-9B et voir la réponse s’afficher dans mes notes, mon terminal ou mon navigateur, exactement à l’endroit où se trouve mon curseur, est très agréable par rapport à l’époque où je devais changer d’onglet pour effectuer une recherche ou discuter avec l’IA. Je continue à les utiliser, mais uniquement lorsque cela est vraiment nécessaire.
Même avec les petits modèles « basiques » actuels, la disponibilité et la rapidité dans le contexte les rendent incroyablement utiles pour les modèles standard, les commandes shell ou le rappel de syntaxe. Au vu de la rapidité avec laquelle les modèles locaux se sont améliorés au cours de la dernière année, ce type de pipeline local instantané et sans latence ne va faire que gagner en puissance.