Lokale Spracherkennung mit MLX auf Apple Silicon entwickeln
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.
Warum Qwen3-ASR, warum MLX
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.
Die Inferenz-Pipeline
Der Pfad von Audio zu Text in mlx-swift-asr besteht aus fünf Phasen:
Phase 1: Mel-Spektrogramm
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.
Phase 2: Audio-Encoding
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.
Phase 3: Prompt-Konstruktion
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.
Phase 4: Token-Generierung mit Double Buffering
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:
- Den nächsten Forward Pass vor der Extraktion des aktuellen Tokens in die Queue stellen
item()aufrufen, um die Token-ID zu extrahieren - das erzwingt zwar eine GPU-Synchronisation, aber parallel berechnet die GPU bereits die nächsten Logits- Wenn wir die nächsten Logits brauchen, sind sie häufig schon materialisiert
Der 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.
Phase 5: Token-Decoding und Bereinigung
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.
Metal-Warmup: der 5-Sekunden-Kaltstart
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:
- Rauschen statt Stille. Stille produziert nahezu null Mel-Werte und kann dazu führen, dass nicht alle Kernelpfade ausgeübt werden. Zufallsrauschen mit geringer Amplitude sorgt für vielfältige Berechnungen über die gesamte Pipeline.
- Realistische Audiolänge. Wir wärmen mit 8 Sekunden Audio auf, damit der gebatchte Conv2d-Encoder etwa 8 Chunks verarbeitet und damit realistische Batch-Dimensionen sieht. Mit nur 2 Sekunden - ungefähr 2 Chunks - würde die erste echte Transkription für größere Batches noch zusätzliche Metal-Pipeline-States nachkompilieren.
- Beim ersten Lauf keine Null-Temperatur. Bei greedy decoding mit
temperature=0emittiert das Modell auf Rausch-Eingaben sofort EOS - es werden also null Tokens generiert und die Kernel des autoregressiven Decode-Loops bleiben unkompiliert. Mittemperature=1.0erzwingen wir Token-Generierung und damit die komplette Decode-Pipeline. - Zwei Warmup-Durchlaeufe. Zuerst mit Sampling und Temperatur, um alle Kernel zu kompilieren, dann mit greedy decoding, um den schnellen Pfad zu validieren. Danach leeren wir den Memory-Cache, behalten aber den Shader-Cache.
Benchmark-Zahlen
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.
Guardrails
Ein paar Sicherheitsmechanismen, die wir auf die harte Tour gelernt haben:
- Token-Cap basierend auf der Dauer. Die maximale Generierungslänge ist auf
ceil(audioDuration × 20) + 64Tokens 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. - Repetition Detection. Wenn dasselbe Token zehnmal hintereinander auftaucht, stoppen wir. Das fängt degenerierte Ausgaben ab, bei denen das Modell in einer Schleife hängen bleibt.
- Minimale Audiolänge. Audio, das kürzer als ein FFT-Fenster ist - 400 Samples gleich 25 ms - gibt sofort leer zurück. Ohne diese Prüfung crasht das Reflection Padding in der STFT mit einem ungültigen Bereich.
- MLX-Fehlerkonvertierung. MLX verwendet für GPU-Fehler standardmäßig
fatalError. Wir kapseln kritische Pfade inwithError, um daraus Swift-throwszu machen, damit die App im HUD einen Fehler anzeigen kann, statt abzustuerzen.
Open Source
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.