arrow_back Tous les articles
8 min de lecture

Comment on a construit une app de dictée vocale qui tape dans n'importe quelle fenêtre

Comment on a construit une app de bureau pour la transcription vocale en temps réel, capable de fonctionner hors ligne, en Python. Elle injecte les frappes directement dans la fenêtre active.

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

La plupart des outils de dictée écrivent le texte dans leur propre fenêtre. Tu transcris, puis tu fais un copier-coller ailleurs. Ça casse ton rythme. Tacet écoute ton micro et tape directement dans l’app qui a le focus. VS Code, Slack, ton navigateur, un terminal. Pas de presse-papiers, pas de plugins, pas de changement de fenêtre.

Les solutions existantes te bloquent dans un écosystème (Apple Dictation, Google Voice Typing) ou demandent des intégrations spécifiques à chaque app. On voulait quelque chose qui marche partout, qui tourne hors ligne pour la vie privée, et qui n’a besoin ni de connexion internet ni de clé API. Rien sur le marché ne faisait les trois.

Qui utilise ça concrètement

Les gens qui tapent beaucoup et préfèrent parler. Les rédacteurs qui écrivent du contenu long. Les développeurs qui veulent dicter des commentaires ou de la documentation sans quitter leur éditeur. Les personnes avec des troubles musculosquelettiques ou des besoins d’accessibilité qui ne peuvent pas rester au clavier toute la journée. Les gens soucieux de leur vie privée qui ne veulent pas que leurs données vocales transitent par un serveur.

La stack technique

Python 3.9+ avec CustomTkinter pour l’interface de bureau. faster-whisper pour la transcription locale (tourne sur CTranslate2). sounddevice pour la capture micro en temps réel. pynput pour les raccourcis globaux et l’injection de frappes. numpy pour le traitement du signal audio. pystray pour l’icône dans la barre système. OpenAI API et Deepgram API comme fournisseurs cloud optionnels.

Rien de compliqué. On a choisi Python parce que l’écosystème AI/ML est là. faster-whisper est une implémentation C++ de Whisper avec des bindings Python, donc la vitesse de transcription n’est pas un problème lié à Python. L’interface est la seule partie qui est du “Python lent”, et elle n’a pas besoin d’être rapide.

Le plus dur : l’aperçu en direct

Quand tu parles, Tacet affiche un aperçu en temps réel de ce qu’il pense que tu dis. Whisper re-transcrit la même fenêtre audio toutes les 900ms. L’approche naïve : effacer l’ancien aperçu, taper le nouveau. Ça provoque un scintillement visible parce que tu supprimes et retapes plus de 40 caractères plusieurs fois par seconde.

On a résolu ça avec du stable-prefix diffing. À chaque mise à jour, on trouve le plus long préfixe commun entre l’ancien texte et le nouveau. Ensuite, on n’efface que le suffixe qui a changé et on tape le nouveau suffixe. Si Whisper passe de “I want to” à “I want to talk about”, on ne touche pas les 10 premiers caractères. On ajoute juste ” 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)

Ça a réduit le nombre de frappes visibles d’environ 80 % et l’aperçu est devenu fluide au lieu de saccadé.

L’autre casse-tête, c’était la détection d’activité vocale. Un seuil d’énergie fixe ne marche pas parce que chaque micro a un bruit de fond différent. On a fini par suivre un plancher de bruit avec une moyenne mobile exponentielle, puis on définit les seuils parole/silence comme des multiplicateurs au-dessus de ce plancher. Une pièce calme et un café bruyant fonctionnent tous les deux sans que l’utilisateur touche aux réglages.

# 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

Comment les données circulent

Trois threads. Le thread principal fait tourner l’interface. Un thread en arrière-plan capture l’audio du micro et gère la détection d’activité vocale, en découpant l’audio par les pauses de silence. Quand un morceau est prêt, il va dans une file d’attente. Deux threads de transcription tirent depuis cette file et passent l’audio dans le moteur choisi (Whisper local, OpenAI API ou Deepgram API).

Le texte transcrit passe par un pipeline de traitement en cinq étapes : commandes vocales (“period” devient ”.”), horodatages, expansion de templates, capitalisation automatique, et remplacement de mots. Le texte final est injecté sous forme de frappes via pynput dans la fenêtre active.

Le moteur et l’interface sont découplés par des callbacks. Le moteur n’importe rien de l’interface. Il appelle juste on_status_change, on_preview_update, on_final_text. L’interface connecte ça à sa file de mise à jour. Tu pourrais faire tourner le moteur sans interface sans changer une seule ligne.

Ce que ça sait faire

Fonctionne avec n’importe quelle application

Tacet tape directement dans la fenêtre qui a le focus. Il utilise pynput pour injecter des frappes au niveau du système, donc ça marche avec n’importe quelle app qui accepte la saisie clavier. Pas d’extensions navigateur, pas d’intégrations API, pas de plugins. Ouvre ton email, commence à dicter, et les mots apparaissent. Passe sur Slack, continue de parler. L’app gère les caractères spéciaux comme les retours à la ligne et les tabulations en appuyant sur les vraies touches (Enter, Tab) au lieu d’essayer de taper des séquences d’échappement.

Détection adaptative de l’activité vocale

L’app détecte automatiquement quand tu commences et arrêtes de parler. Elle suit les niveaux de bruit ambiant avec une moyenne mobile exponentielle, puis définit les seuils de détection de parole comme des multiplicateurs au-dessus de ce plancher de bruit. Ça marche dans une pièce calme ou un environnement bruyant sans calibration manuelle. Elle garde aussi 300ms d’audio en mémoire tampon avant la détection de parole (preroll), pour ne jamais perdre la première syllabe d’une phrase.

Hors ligne d’abord, cloud en secours

Le moteur par défaut est faster-whisper qui tourne en local sur ton CPU. Pas de clés API, pas d’internet, pas de données qui quittent ta machine. Au premier lancement, Tacet teste la vitesse du modèle sélectionné. Si la transcription d’une seconde d’audio prend plus de 5 secondes, il passe automatiquement au modèle “tiny” et sauvegarde cette préférence. Si tu veux plus de précision et que le cloud ne te dérange pas, tu peux passer à OpenAI ou Deepgram en changeant un seul paramètre.

Commandes vocales et traitement de texte personnalisables

Dis “period” et ça tape ”.”. Dis “new line” et ça appuie sur Enter. Dis “delete that” et ça efface le dernier morceau. Tout est configurable. Tu peux ajouter tes propres commandes, désactiver celles par défaut, ou mettre en place des remplacements de mots (“btw” devient “by the way”). Le texte passe par un pipeline en cinq étapes : commandes vocales, horodatages, templates, capitalisation automatique, et remplacements. Chaque étape est indépendante et peut être activée ou désactivée.

Les compromis qu’on a faits

On a choisi CustomTkinter plutôt qu’Electron. Electron nous aurait donné une interface plus jolie avec moins d’effort, mais ça embarque un navigateur Chromium entier. Pour une app qui reste tranquille dans la barre système et utilise peu de ressources, ça ne collait pas. CustomTkinter est plus léger, démarre plus vite, et garde la taille d’installation petite. Le compromis, c’est moins de composants UI et moins de finition visuelle.

On a opté pour du traitement de texte basé sur des règles au lieu d’utiliser un LLM pour le post-traitement. Un LLM pourrait corriger la grammaire, ajouter une ponctuation plus intelligente, peut-être même restructurer les phrases. Mais ça ajouterait de la latence, nécessiterait soit un appel cloud soit un second modèle local, et rendrait la sortie imprévisible. Quand quelqu’un dicte dans une fenêtre de chat, il veut que ses mots soient tapés maintenant, pas corrigés deux secondes plus tard. Les règles sont rapides, prévisibles et gratuites.

On n’a pas fait de transcription en streaming temps réel pour le moteur local. Whisper fonctionne sur des morceaux d’audio complets, pas des flux. On aurait pu construire un pipeline de streaming avec des fenêtres qui se chevauchent, mais la complexité n’en valait pas la peine. À la place, on a ajusté le découpage (détection de silence, preroll buffering, durée minimale des morceaux) pour que ça reste réactif. La plupart des énoncés sont traités en moins d’une seconde sur un CPU correct.

On a aussi choisi d’injecter des frappes au lieu d’utiliser le presse-papiers. L’injection via le presse-papiers serait plus simple et plus fiable entre les plateformes, mais ça écrase ce que l’utilisateur a copié en dernier. C’est rédhibitoire pour la plupart des workflows.

Ce qu’on ferait différemment

Le système de configuration a commencé comme un fichier JSON plat et a grandi de manière organique. On s’est retrouvé avec du code de migration de format legacy pour les remplacements de mots parce que l’interface avait besoin d’une structure différente de la configuration d’origine. Si on recommençait, on définirait un schéma propre avec du versioning dès le départ au lieu de bricoler de la logique de migration après coup.

Le modèle de threading fonctionne mais il est fait à la main avec des locks, des events, et des utterance IDs pour la détection de données obsolètes. Une bibliothèque comme concurrent.futures ou une approche asynchrone avec asyncio aurait été plus propre. On envisagerait aussi de séparer le moteur dans son propre processus pour éviter complètement le GIL, puisque la capture audio et la transcription sont toutes les deux gourmandes en CPU.

On aurait aussi investi dans les tests automatisés plus tôt. L’architecture basée sur les callbacks rend le moteur testable en théorie, mais on n’a pas de suite de tests qui exerce le pipeline complet. C’est de la dette technique qu’on porte.

Tags

tacet desktop ai open-source python

Plus du blog