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.
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.
Le chemin audio-vers-texte dans mlx-swift-asr comporte cinq phases:
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.
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.
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.
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:
item() pour extraire l'identifiant du token — cela force une synchronisation GPU, mais le GPU calcule déjà les logits suivants en parallèleLe 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.
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.
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:
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 audio | Temps d'inférence | RTF | Vitesse | | ----------- | ----------------- | ----- | --------------- | | 1.58s | 0.27s | 0.172 | 5.8x temps réel | | 2.56s | 0.41s | 0.159 | 6.3x temps réel | | 0.98s | 0.26s | 0.264 | 3.8x temps réel | | 1.19s | 0.29s | 0.239 | 4.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.
Quelques mécanismes de sécurité que nous avons appris à ajouter à la dure:
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.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.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é.