Retour au blog
·Ingénierie

Construire une reconnaissance vocale sur l’appareil avec MLX sur Apple Silicon


La reconnaissance vocale locale d'OnType repose sur mlx-swift-asr, une bibliothèque Swift open source que nous avons créée pour exécuter Qwen3-ASR nativement sur Apple Silicon. Pas de runtime Python, pas de pont via des sous-processus, pas de cloud — seulement Swift → MLX → Neural Engine.

Cet article explique le fonctionnement de la chaîne d'inférence, l'ingénierie de latence qui la rend quasi instantanée, ainsi que les pièges non évidents rencontrés en chemin.

Pourquoi Qwen3-ASR, pourquoi MLX

Qwen3-ASR est un modèle de reconnaissance vocale de l'équipe Qwen d'Alibaba. Nous distribuons la variante à 0,6 milliard de paramètres, quantifiée sur 6 bits — soit environ 400 Mo sur disque. Il utilise un encodeur audio de type Whisper (Conv2d → Transformer) qui alimente ensuite un décodeur texte Qwen3 avec Grouped Query Attention, RoPE et activations SwiGLU.

Le framework MLX d'Apple est conçu spécifiquement pour l'inférence ML sur Apple Silicon. Son principal avantage par rapport aux runtimes généralistes : MLX comprend l'architecture mémoire unifiée des puces de série M. CPU, GPU et Neural Engine partagent le même pool mémoire — aucune copie de données entre mémoire hôte et mémoire périphérique. Pour une chaîne ASR temps réel où l'audio circule en continu et où les représentations intermédiaires sont consommées immédiatement, cela supprime toute une classe de surcoûts de latence.

Nous utilisons mlx-swift, les bindings Swift natifs, afin que tout le chemin reste dans l'écosystème Apple : Swift → MLX → Metal → Neural Engine. Aucun interpréteur Python dans la boucle.

La chaîne d'inférence

Le chemin audio-vers-texte dans mlx-swift-asr comporte cinq phases :

Phase 1 : spectrogramme mel

L'audio brut en 16 kHz est converti en spectrogramme log-mel à 128 bandes. Les paramètres correspondent exactement à WhisperFeatureExtractor : fenêtre FFT de 400 échantillons (25 ms), pas de 160 échantillons (10 ms), normalisation de la banque de filtres mel de type Slaney.

La fenêtre de Hann et les matrices de la banque de filtres mel sont calculées une seule fois au démarrage puis mises en cache comme propriétés statiques. C'est une petite optimisation (environ 10 ms gagnées par transcription), mais dans une chaîne de streaming où chaque segment passe par là, cela s'additionne.

Un détail de compatibilité peu évident : nous supprimons la dernière trame STFT pour reproduire le comportement de torch.stft(center=True) dans PyTorch. Un décalage d'une trame suffit à provoquer des incompatibilités dimensionnelles dans l'encodeur — le genre de bug qui vous fait perdre des heures parce que le modèle ne plante pas, il produit simplement n'importe quoi.

Phase 2 : encodage audio

Le spectrogramme mel alimente la tour audio — trois couches Conv2d de stride 2 suivies d'un encodeur Transformer. Cela compresse la dimension temporelle d'environ 8x et projette les caractéristiques audio dans la dimension cachée du décodeur texte.

Subtilité : les poids Conv2d doivent être transposés de la disposition OIHW de PyTorch vers la disposition OHWI de MLX lors du chargement du modèle. L'étape de sanitation des poids gère cela automatiquement.

Phase 3 : construction du prompt

Les caractéristiques audio encodées sont fusionnées dans un prompt texte suivant le format de template de chat de Qwen3. Les tokens placeholders audio du prompt sont remplacés par les véritables embeddings de sortie de l'encodeur à l'aide d'un schéma d'indexation basé sur un cumul. C'est la même architecture que celle utilisée par les LLM multimodaux pour les tokens image.

Phase 4 : génération de tokens avec double buffering

C'est ici que se trouve l'ingénierie de performance intéressante. Le décodage auto-régressif génère un token à la fois. L'approche naïve — passe avant, extraction du token, nouvelle passe avant — est sérielle. Le GPU calcule pendant que le CPU attend, puis le CPU extrait le token pendant que le GPU reste inactif.

Nous utilisons un modèle asyncEval à double tampon pour superposer le travail du GPU et du CPU :

  1. Mettre en file la passe avant suivante avantd'extraire le token courant
  2. Appeler item() pour extraire l'identifiant du token — cela force une synchronisation GPU, mais le GPU calcule déjà les logits suivants en parallèle
  3. Au moment où nous avons besoin des logits suivants, ils sont souvent déjà matérialisés

Le code se lit de manière contre-intuitive : vous préparez les embeddings d'entrée de l'étape suivante, vous mettez en file la passe avant et asyncEval, puis seulement ensuite vous extrayez le token courant. Mais ce pipeline masque la latence de synchronisation qui dominerait sinon le temps de décodage.

Phase 5 : décodage des tokens et nettoyage

Les identifiants de tokens générés sont décodés via BPE puis passent par un parseur de sortie qui retire les tokens spéciaux et extrait la langue détectée. Le texte nettoyé passe ensuite dans notre moteur de normalisation textuelle inverse avant d'atteindre le curseur.

Warmup Metal : le démarrage à froid de 5 secondes

MLX compile les kernels de calcul Metal à l'exécution — compilation JIT. La première transcription après le lancement de l'application subit donc environ 5 secondes de surcoût de compilation des shaders. Les transcriptions suivantes sont rapides.

C'est le piège de performance le plus impactant de toute la bibliothèque. Sans warmup, la première vraie transcription prend entre 5 et 8 secondes pour 8 secondes d'audio (environ 0,5x le temps réel). Avec warmup, chaque transcription tourne entre 4x et 6x le temps réel.

Notre stratégie de warmup comporte quelques détails non évidents :

  • Utiliser du bruit, pas du silence. Le silence produit des valeurs mel proches de zéro qui n'activent pas forcément tous les chemins des kernels. Un bruit aléatoire de faible amplitude garantit des calculs variés sur toute la chaîne.
  • Utiliser une durée audio réaliste. Nous chauffons le modèle avec 8 secondes d'audio afin que l'encodeur Conv2d batché traite environ 8 segments, soit des dimensions proches de la réalité. Avec seulement 2 secondes (environ 2 segments), la première vraie transcription déclencherait encore de la compilation supplémentaire d'états de pipeline Metal pour des batches plus grands.
  • Utiliser une température non nulle au premier passage.Avec un décodage glouton (temperature=0), le modèle émet EOS immédiatement sur une entrée bruitée — zéro token généré, ce qui laisse les kernels de la boucle auto-régressive non compilés. Temperature=1.0 force la génération de tokens et exerce tout le chemin de décodage.
  • Deux passes de warmup. Une première avec échantillonnage par température (compiler tous les kernels), une seconde avec décodage glouton (valider le fast path). Puis on vide le cache mémoire tout en conservant le cache de shaders.

Chiffres de benchmark

Qwen3-ASR 0.6B en quantification 6 bits sur un M1 Pro. Il s'agit de chiffres d'inférence uniquement, hors chargement du modèle et warmup Metal :

Durée audioTemps d'inférenceRTFVitesse
1.58s0.27s0.1725.8x temps réel
2.56s0.41s0.1596.3x temps réel
0.98s0.26s0.2643.8x temps réel
1.19s0.29s0.2394.2x temps réel

Plage RTF typique : 0.15–0.27 (3.7x à 6.3x le temps réel). C'est compétitif avec l'implémentation Python MLX tout en tournant comme bibliothèque Swift native, sans surcoût Python.

Pour le cas d'usage de saisie vocale temps réel d'OnType, cela signifie que le moteur ASR traite la parole plus vite qu'elle n'arrive. Au moment où l'utilisateur relâche le raccourci, la plus grande partie de l'audio a déjà été transcrite. Seul le dernier segment reste à traiter, ce qui prend moins de 300 ms.

Garde-fous

Quelques mécanismes de sécurité que nous avons appris à ajouter à la dure :

  • Plafond de tokens basé sur la durée. La longueur maximale de génération est plafonnée à ceil(audioDuration × 20) + 64 tokens. Sans cela, certaines entrées pathologiques peuvent amener le modèle à générer des milliers de tokens sans jamais émettre EOS, ce qui fait tourner le GPU indéfiniment.
  • Détection de répétition. Si le même token se répète 10 fois de suite, nous arrêtons. Cela capture les sorties dégénérées où le modèle se bloque dans une boucle.
  • Longueur audio minimale. Un audio plus court qu'une fenêtre FFT (400 échantillons = 25 ms) renvoie immédiatement une chaîne vide. Sans ce contrôle, le padding par réflexion dans la STFT plante avec un intervalle invalide.
  • Conversion des erreurs MLX. Par défaut, MLX utilise fatalError pour les erreurs GPU. Nous enveloppons les chemins critiques dans withError pour les convertir en throws Swift, afin que l'application puisse afficher une erreur dans le HUD au lieu de planter.

Open source

mlx-swift-asr est sous licence MIT et disponible sur github.com/ontypehq/mlx-swift-asr. C'est un package Swift autonome — ajoutez-le à votre Package.swift et vous obtenez la reconnaissance vocale sur l'appareil avec une API en trois lignes :

let stt = try await Qwen3ASRSTT.loadWithWarmup(from: modelDirectory)
let result = try await stt.transcribe(file: audioURL)
print(result.text)  // "Hello, world."
print(result.rtf)   // 0.17 (5.8x real-time)

Si vous construisez quoi que ce soit nécessitant de la reconnaissance vocale sur Apple Silicon — saisie vocale, transcription, outils d'accessibilité — essayez-le. Les benchmarks sont reproductibles via swift test --filter Benchmark.

Essayez OnType pour voir mlx-swift-asr en action — maintenez une touche, parlez, relâchez pour saisir. Sur l'appareil, en temps réel, privé.