Dicter à la machine : construire une transcription vocale temps réel, en local, sans GPU
← Retour
Informatique

Dicter à la machine : construire une transcription vocale temps réel, en local, sans GPU

Cédrix · 25/06/2026
Modifié le 27 juin 2026 à 16h22

Comment transformer un modèle conçu pour le traitement par lots en un outil de dictée qui écrit au fil de la voix — avec Whisper, un peu de WebSocket et une bonne dose de pragmatisme.


Le problème, en une phrase

On veut parler à son ordinateur et voir le texte s’écrire au fur et à mesure, sans envoyer sa voix dans le cloud, et sans carte graphique dédiée. Trois contraintes qui, mises bout à bout, dessinent un projet bien plus intéressant qu’il n’y paraît.

La première — « au fur et à mesure » — entre frontalement en collision avec la nature même de Whisper, le modèle de reconnaissance vocale d’OpenAI. Whisper n’est pas un modèle streaming. Il a été pensé pour ingérer un fichier audio complet et en restituer la transcription. Lui demander d’écrire en direct, c’est lui demander quelque chose pour quoi il n’a pas été conçu. Tout l’enjeu de cet article tient dans la manière de contourner cette tension.

La deuxième contrainte — le local — relève de la souveraineté des données. Une dictée, c’est souvent des notes médicales, des comptes rendus de réunion, des idées en gestation. Les faire transiter par une API tierce n’est ni neutre ni anodin.

La troisième — pas de GPU — est la plus terre à terre, et la plus structurante. Notre machine de test est un AMD Ryzen 7 5700G : seize threads, 27 Go de RAM, et un GPU intégré inutilisable pour de l’inférence en pratique. C’est cette absence de carte NVIDIA qui va dicter, dès la première décision, toute l’architecture.


Premier arbitrage : oublier l’implémentation officielle

Le réflexe naturel serait d’installer openai-whisper, le paquet de référence. C’est aussi la première erreur à éviter. Cette implémentation s’appuie sur PyTorch et, sur CPU, elle est lente — trop lente pour viser le temps réel.

Le facteur temps réel (real-time factor, RTF), c’est-à-dire le rapport entre la durée de calcul et la durée de l’audio, y dépasse souvent 1 sur un processeur grand public. Autrement dit : transcrire dix secondes de parole prend plus de dix secondes. Rédhibitoire pour de la dictée.

La solution : faster-whisper

La bonne réponse s’appelle faster-whisper. C’est une réimplémentation de l’inférence Whisper bâtie sur CTranslate2, un moteur d’exécution optimisé pour les modèles Transformer.

Mêmes poids, mêmes modèles entraînés par OpenAI, mais un runtime qui exploite :

  • La quantification entière (int8)
  • Des noyaux de calcul vectorisés
  • Une gestion mémoire bien plus frugale

Le gain sur CPU est de l’ordre d’un facteur quatre, sans PyTorch ni ses centaines de mégaoctets de dépendances.

Quantification int8

La quantification int8 consiste à représenter les poids du réseau sur huit bits entiers plutôt qu’en virgule flottante. On perd une fraction infime de précision, on gagne énormément en vitesse et en empreinte mémoire. Sur un CPU sans unités de calcul flottant massivement parallèles, c’est exactement le bon compromis.

Exemple de code

from faster_whisper import WhisperModel

model = WhisperModel("small", device="cpu", compute_type="int8", cpu_threads=8)
segments, _ = model.transcribe(audio, language="fr", beam_size=1, vad_filter=True)

Trois détails importants :

  • beam_size=1 : désactive la recherche en faisceau. On accepte un décodage glouton, moins précis sur le papier mais bien plus rapide — un arbitrage assumé pour le temps réel.

  • vad_filter=True : active un détecteur d’activité vocale (Voice Activity Detection) qui élague les silences avant transcription. Cela évite à Whisper d’« halluciner » du texte sur du vide — un travers bien connu du modèle.

  • condition_on_previous_text=False : empêche le décodeur de se nourrir de ses propres sorties précédentes. Sans cette précaution, la moindre erreur peut s’auto-entretenir et provoquer des boucles de répétition.


Le cœur du sujet : simuler le streaming

Whisper transcrit des blocs. Pour donner l’illusion d’une écriture continue, on triche — élégamment. L’idée tient en deux temps :

  1. Un texte provisoire qui se réécrit
  2. Un texte figé qui ne bouge plus

Le mécanisme

Le serveur conserve, pour chaque connexion, un tampon audio correspondant à la phrase en cours — ce qu’on appellera une utterance.

Toutes les six dixièmes de seconde, il retranscrit l’intégralité de ce tampon et renvoie le résultat au navigateur sous l’étiquette interim. Ce texte provisoire, affiché en gris, se précise à chaque passe :

« Bonjour ceci »
→ « Bonjour ceci est un test »
→ « Bonjour ceci est un test de dictée »

L’utilisateur voit littéralement la phrase se construire.

Le figement (commit)

Lorsque le serveur détecte un silence suffisamment long — par défaut huit dixièmes de seconde — il considère la phrase terminée. Il envoie alors le texte sous l’étiquette final, l’affiche en blanc, vide le tampon, et recommence à zéro pour la phrase suivante. Le texte figé ne sera plus jamais retouché ; le provisoire ne concerne que la phrase en cours.

Détection de silence

La détection de silence repose sur une mesure d’énergie toute simple — la valeur efficace (RMS) du signal — comparée à un seuil. À chaque bloc audio reçu, on regarde s’il contient de la voix ; on mémorise l’horodatage de la dernière activité ; et le « silence de fin » est simplement le temps écoulé depuis.

Un garde-fou complète le dispositif : si une phrase dépasse vingt secondes sans pause, on la fige de force, pour éviter qu’un tampon ne grossisse indéfiniment et ralentisse chaque passe.

should_commit = (utt.trailing_silence >= silence_commit
                 or utt.duration >= MAX_UTTERANCE_S)

text = await loop.run_in_executor(None, transcribe, model, audio, language)

if should_commit:
    await ws.send_json({"type": "final", "text": text})
    utt.reset()
elif text != last_sent:
    await ws.send_json({"type": "interim", "text": text})

Architecture asynchrone

La transcription est une opération bloquante et gourmande en CPU. La lancer directement dans la boucle d’événements asynchrone figerait tout le serveur. On la délègue donc à un thread pool via run_in_executor, et un simple drapeau busy garantit qu’on ne lance jamais deux transcriptions simultanées.

Si une passe prend plus de temps que l’intervalle de six dixièmes de seconde, on saute simplement le tic suivant. Le système s’auto-régule : sur un modèle lent ou une longue phrase, le texte provisoire se rafraîchit juste un peu moins souvent.


La chaîne audio, du micro au modèle

Reste à acheminer la voix jusqu’au serveur. Le navigateur capte le micro via l’API Web Audio. Mais un piège technique attend ici l’imprudent : la fréquence d’échantillonnage.

Les cartes son fonctionnent généralement à 44,1 ou 48 kHz, tandis que Whisper attend du 16 kHz mono. On effectue donc le rééchantillonnage côté client, par moyennage de fenêtres, avant de convertir le signal flottant en entiers 16 bits (PCM) et de l’envoyer en binaire sur le WebSocket.

Avantages du rééchantillonnage côté client

Ce choix — downsampler dans le navigateur — n’est pas anodin :

  • Il divise par trois le volume de données transmises
  • Il évite au serveur d’avoir à décoder un flux compressé (Opus, WebM) avec ses latences propres
  • Le serveur reçoit du PCM brut, prêt à l’emploi, qu’il n’a plus qu’à empiler dans le tampon

La capture audio

La capture elle-même s’appuie sur un AudioWorklet, l’API moderne qui fait tourner le traitement audio dans un thread dédié, à l’écart du fil principal de l’interface. C’est la garantie que la collecte des échantillons ne sera pas perturbée par le rendu de la page — et inversement.

Schéma de la chaîne audio

Navigateur (micro)
  ↓
  Web Audio API : capture → rééchantillonnage 16 kHz → PCM Int16
  ↓
  WebSocket binaire
  ↓
Serveur (FastAPI + faster-whisper)
  ↓
  Tampon audio + détection de silence
  ↓
  Retranscription en boucle (thread pool)
  ↓
Retour JSON : {"type":"interim"} puis {"type":"final"}

Choisir son modèle : le benchmark qui surprend

Whisper se décline en plusieurs tailles : tiny, base, small, medium, large-v3. L’intuition voudrait qu’on choisisse le plus petit qu’on puisse tolérer côté précision. Les chiffres, mesurés sur notre Ryzen 7 avec un échantillon de 4,9 secondes, racontent une histoire plus nuancée.

Modèle Chargement Transcription RTF
base 3,0 s 2,48 s 0,50×
small 0,7 s 1,50 s 0,30×
medium 19,7 s 3,95 s 0,80×

Le verdict contre-intuitif

small s’est révélé à la fois plus rapide et plus précis que base. La logique « plus petit = plus rapide » se brise ici, probablement à cause des spécificités de la quantification et des chemins de code internes à CTranslate2 pour chaque taille de modèle.

medium, lui, est environ 2,6 fois plus lent que small : avec un RTF de 0,80, une phrase de dix secondes demande huit secondes de calcul — bien trop pour rafraîchir un texte « en direct ».

Leçons

  1. Un benchmark sur la machine cible vaut tous les a priori : nous aurions pu choisir base par raisonnement et nous tromper.

  2. small s’impose comme le point d’équilibre pour cet usage — assez rapide pour le temps réel, assez précis pour être agréable.

  3. medium et large-v3 restent là pour qui privilégie la justesse à la réactivité, par exemple pour de la transcription différée.

Note méthodologique

Ces tests ont été menés sur une voix synthétique (le moteur espeak-ng), à la prononciation robotique. La qualité du texte produit n’y est donc pas représentative — une voix humaine donne de bien meilleurs résultats. Seuls les temps de calcul, eux, sont pleinement significatifs.


Mettre les réglages dans les mains de l’utilisateur

Une première version exposait modèle, langue et délai de silence via des variables d’environnement. Fonctionnel, mais aride : changer de modèle imposait de relancer le serveur. La bonne décision a été de remonter ces réglages dans l’interface.

Le mécanisme

Le mécanisme est simple et robuste. À l’ouverture du WebSocket, le navigateur envoie un premier message JSON — {model, language, silence} — avant le moindre octet d’audio. Le serveur le lit, charge le modèle demandé, applique les paramètres, puis bascule en mode écoute.

Pour éviter de recharger un modèle déjà connu, on maintient un cache : le premier choix de medium coûte ses vingt secondes de chargement, les suivants sont instantanés.

_models = {}

def get_model(size: str) -> WhisperModel:
    with _model_lock:
        if size not in _models:
            _models[size] = WhisperModel(size, device="cpu", compute_type="int8")
        return _models[size]

Interface utilisateur

Côté interface :

  • Les choix sont mémorisés dans le navigateur (localStorage)
  • Ils sont verrouillés pendant l’enregistrement — on ne change pas de modèle au milieu d’une phrase
  • Le curseur de « délai avant figement » rend tangible un paramètre autrement abstrait
    • Le raccourcir : figement nerveux, idéal pour des phrases courtes
    • L’allonger : laisse le temps de respirer entre deux idées sans couper la transcription

C’est le genre de réglage qu’on n’ajuste bien qu’à l’usage, avec sa propre voix — raison de plus pour le sortir du fichier de configuration.


Ce que ce projet enseigne

Au-delà de la dictée, cette petite application condense quelques principes qui dépassent largement son cadre.

1. Connaître la nature de son modèle

La moitié du travail a consisté à composer avec le fait que Whisper n’est pas un modèle streaming. Le motif « provisoire qui s’affine, figé sur silence » n’est pas un détail d’implémentation : c’est la réponse architecturale à cette contrainte. On ne plie pas le modèle à notre désir ; on conçoit autour de ses propriétés.

2. Le bon runtime change tout

Passer de PyTorch à CTranslate2, c’est la différence entre un projet infaisable et un projet fluide, sans changer une ligne de la logique métier. Sur CPU, le choix du moteur d’inférence pèse souvent plus lourd que celui du modèle.

3. Mesurer plutôt que supposer

Le benchmark small contre base est le rappel salutaire qu’une intuition d’ingénieur, même raisonnable, ne remplace pas trois minutes de mesure sur la machine réelle.

4. Rendre les arbitrages visibles

En exposant le compromis vitesse/précision sous forme d’un menu déroulant, on transfère la décision à celui qui sait ce qu’il veut : l’utilisateur. La meilleure valeur par défaut reste une valeur par défaut — pas une vérité.


Conclusion

L’ensemble tient en quelques fichiers : un serveur Python, une page web, un worklet audio.

https://git.abonnel.fr/cedricAbonnel/speech-to-text-whisper

Pas de cloud, pas de GPU, pas de compte à créer. Juste une voix, un processeur, et un modèle qu’on a appris à apprivoiser.

Partager : ✉ Mail X in 🐘
Commentaires

Aucun commentaire pour l'instant. Soyez le premier !

Laisser un commentaire
Un code de vérification sera envoyé à votre adresse email.