Die lokale Spracherkennung von OnType wird von mlx-swift-asr angetrieben, einer Open-Source-Swift-Bibliothek, die wir gebaut haben, um Qwen3-ASR nativ auf Apple Silicon auszuführen. Keine Python-Runtime, kein Brückenbau über Subprozesse, keine Cloud - nur Swift -> MLX -> Neural Engine.
In diesem Beitrag geht es darum, wie die Inferenz-Pipeline funktioniert, welches Latenz-Engineering dafür sorgt, dass sie sich sofort anfühlt, und welche nicht offensichtlichen Stolperfallen wir unterwegs getroffen haben.
Qwen3-ASR ist ein Spracherkennungsmodell vom Qwen-Team von Alibaba. Wir liefern die Variante mit 0,6 Milliarden Parametern aus, quantisiert auf 6 Bit - etwa 400 MB auf der Festplatte. Es nutzt einen Whisper-ähnlichen Audio-Encoder (Conv2d -> Transformer), der in einen Qwen3-Textdecoder mit Grouped Query Attention, RoPE und SwiGLU-Aktivierungen einspeist.
Apples MLX -Framework ist gezielt für ML-Inferenz auf Apple Silicon gebaut. Der wichtigste Vorteil gegenüber allgemeinen Runtimes: MLX versteht die Unified-Memory- Architektur der M-Serie. CPU, GPU und Neural Engine teilen sich denselben Speicherpool - es gibt also kein Kopieren zwischen Host- und Gerätespeicher. Für eine Echtzeit-ASR-Pipeline, in der Audio kontinuierlich einströmt und Zwischenrepräsentationen sofort verbraucht werden, entfernt das eine ganze Klasse von Latenz-Overhead.
Wir verwenden mlx-swift, also die nativen Swift-Bindings, damit der gesamte Pfad in Apples Ökosystem bleibt: Swift -> MLX -> Metal -> Neural Engine. Kein Python-Interpreter in der Schleife.
Der Pfad von Audio zu Text in mlx-swift-asr besteht aus fünf Phasen:
Roh-Audio mit 16 kHz wird in ein 128-Bin-Log-Mel-Spektrogramm umgewandelt. Die Parameter entsprechen exakt dem WhisperFeatureExtractor: FFT-Fenster mit 400 Samples (25 ms), Hop mit 160 Samples (10 ms), Mel-Filterbank-Normalisierung nach Slaney.
Die Hann-Window- und Mel-Filterbank-Matrizen werden beim Start einmal berechnet und als statische Eigenschaften gecacht. Das ist nur eine kleine Optimierung - rund 10 ms pro Transkription - aber in einer Streaming-Pipeline, in der jeder Chunk diesen Pfad durchläuft, summiert es sich.
Ein nicht offensichtliches Kompatibilitätsdetail: Wir verwerfen den letzten STFT-Frame, um das Verhalten von torch.stft(center=True) in PyTorch nachzubilden. Ein Off-by-one bei der Anzahl der Frames führt im Encoder zu Dimensionsfehlern - die Art Bug, die Stunden kostet, weil das Modell nicht abstürzt, sondern einfach Müll ausgibt.
Das Mel-Spektrogramm wird in den Audio-Tower eingespeist - drei Conv2d-Schichten mit Stride 2, gefolgt von einem Transformer-Encoder. Dadurch schrumpft die Zeitdimension um etwa das Achtfache und die Audiomerkmale werden in die Hidden-Dimension des Textdecoders projiziert.
Eine Feinheit: Die Conv2d-Gewichte müssen beim Laden des Modells vom PyTorch-Layout OIHW in das MLX-Layout OHWI transponiert werden. Der Weight-Sanitization-Schritt übernimmt das automatisch.
Die codierten Audio-Features werden gemäß dem Chat-Template-Format von Qwen3 in einen Text-Prompt eingebettet. Audio-Platzhalter-Tokens im Prompt werden über ein cumsum-basiertes Indexing-Schema durch die tatsächlichen Embeddings des Encoder-Outputs ersetzt. Genau diese Architektur nutzen auch multimodale LLMs für Bild-Tokens.
Hier liegt das interessante Performance-Engineering. Autoregressives Decoding erzeugt ein Token nach dem anderen. Der naive Ansatz - Forward Pass, Token extrahieren, nächster Forward Pass - ist seriell. Die GPU rechnet, während die CPU wartet, dann extrahiert die CPU das Token, während die GPU untätig herumsteht.
Wir verwenden ein Double-Buffer-asyncEval-Muster, um Arbeit von GPU und CPU zu überlappen:
item() aufrufen, um die Token-ID zu extrahieren - das erzwingt zwar eine GPU-Synchronisation, aber parallel berechnet die GPU bereits die nächsten LogitsDer Code liest sich kontraintuitiv: Sie bereiten die Input-Embeddings des nächsten Schritts vor, queuen den Forward Pass und asyncEval und extrahieren erst danach das aktuelle Token. Aber genau dieses Pipelining versteckt die Synchronisationslatenz, die sonst die Decode-Zeit dominieren wuerde.
Generierte Token-IDs werden über BPE decodiert und durch ein Output-Parsing geleitet, das Spezial-Tokens entfernt und die erkannte Sprache extrahiert. Der bereinigte Text geht anschliessend durch unsere Engine für Inverse Text Normalization, bevor er den Cursor erreicht.
MLX kompiliert Metal-Compute-Kernel zur Laufzeit - JIT-Kompilierung. Die erste Transkription nach dem App-Start zieht deshalb rund 5 Sekunden Shader-Kompilierungsaufwand nach sich. Alle folgenden Transkriptionen sind schnell.
Das ist die mit Abstand folgenreichste Performance-Falle in der gesamten Bibliothek. Ohne Warmup dauert die erste echte Transkription für 8 Sekunden Audio 5 bis 8 Sekunden, also etwa 0,5x Echtzeit. Mit Warmup läuft jede Transkription mit 4x bis 6x Echtzeitgeschwindigkeit.
Unsere Warmup-Strategie hat einige nicht offensichtliche Details:
temperature=0 emittiert das Modell auf Rausch-Eingaben sofort EOS - es werden also null Tokens generiert und die Kernel des autoregressiven Decode-Loops bleiben unkompiliert. Mit temperature=1.0 erzwingen wir Token-Generierung und damit die komplette Decode-Pipeline.Qwen3-ASR 0.6B mit 6-Bit-Quantisierung auf einem M1 Pro. Das sind reine Inferenzzahlen, ohne Modell-Load und Metal-Warmup:
| Audio-Dauer | Inferenzzeit | RTF | Geschwindigkeit | | ----------- | ------------ | ----- | --------------- | | 1.58s | 0.27s | 0.172 | 5.8x Echtzeit | | 2.56s | 0.41s | 0.159 | 6.3x Echtzeit | | 0.98s | 0.26s | 0.264 | 3.8x Echtzeit | | 1.19s | 0.29s | 0.239 | 4.2x Echtzeit |
Der typische RTF-Bereich liegt bei 0.15 bis 0.27, also 3,7x bis 6,3x Echtzeit. Das ist konkurrenzfähig mit der Python-Implementierung von MLX und läuft trotzdem als native Swift-Bibliothek ohne Python-Overhead.
Für den Echtzeit-Spracheingabe-Anwendungsfall von OnType bedeutet das: Die ASR-Engine verarbeitet Sprache schneller, als sie hereinkommt. In dem Moment, in dem der Nutzer den Hotkey loslaesst, ist der Großteil des Audio bereits transkribiert. Nur der letzte Chunk muss noch verarbeitet werden - und das dauert unter 300 ms.
Ein paar Sicherheitsmechanismen, die wir auf die harte Tour gelernt haben:
ceil(audioDuration × 20) + 64 Tokens begrenzt. Ohne diese Grenze können pathologische Eingaben dazu führen, dass das Modell Tausende Tokens erzeugt, ohne EOS zu senden, und die GPU endlos weiterläuft.fatalError. Wir kapseln kritische Pfade in withError, um daraus Swift-throws zu machen, damit die App im HUD einen Fehler anzeigen kann, statt abzustuerzen.mlx-swift-asr ist MIT-lizenziert und verfügbar unter github.com/ontypehq/mlx-swift-asr. Es ist ein eigenständiges Swift-Paket - fügen Sie es zu Ihrer Package.swift hinzu und Sie bekommen lokale Spracherkennung mit einer API aus drei Zeilen:
let stt = try await Qwen3ASRSTT.loadWithWarmup(from: modelDirectory)
let result = try await stt.transcribe(file: audioURL)
print(result.text) // "Hallo, Welt."
print(result.rtf) // 0.17 (5.8x Echtzeit)
Wenn Sie irgendetwas bauen, das Spracherkennung auf Apple Silicon braucht - Spracheingabe, Transkription, Accessibility-Tools -, probieren Sie es aus. Benchmarks lassen sich über swift test --filter Benchmark reproduzieren.
OnType ausprobieren, um mlx-swift-asr in Aktion zu sehen - Taste halten, sprechen, loslassen, tippen. Lokal, in Echtzeit und privat.