Summary
Add user-provided hotword biasing to Qwen3-ASR so callers can pass a list of phrases (proper nouns, product names, domain jargon) that the decoder should prefer during token generation. No retraining, no new model — a decode-time intervention that adds a score bonus to tokens that advance a match against a precomputed trie.
Motivation
Qwen3-ASR picks the most likely transcription, which is often not the most correct one for niche vocabulary:
| Baseline |
Desired |
| "off clarer" |
"Aufklärer" |
| "cocoro" |
"Kokoro" |
| "fast confirmer" |
"FastConformer" |
| "fire red V A D" |
"FireRedVAD" |
Callers currently post-process with fuzzy matching, which fixes the word but not the word-boundary / capitalization artifacts the misrecognition induces. Biasing moves the fix into the decoder where it belongs.
Proposed API
```swift
let asr = try await Qwen3ASRModel.fromPretrained()
let hotwords = HotwordSet(phrases: [
.init("Aufklärer", score: 2.0),
.init("speech-swift", score: 2.5),
.init("Kokoro", score: 2.0),
.init("FastConformer", score: 2.0),
])
let text = asr.transcribe(
audio: audio,
sampleRate: 16000,
hotwords: hotwords
)
```
- `HotwordSet` owns a token-id trie built from the tokenizer once; reused across calls.
- `score` is an optional per-phrase bonus added to the logits when the decoder is "inside" that phrase in the trie.
- Existing `transcribe(audio:sampleRate:language:context:)` signature stays; `hotwords` is a new optional parameter.
Implementation
Code reuse
`Sources/SpeechWakeWord/ContextGraph.swift` already implements an Aho-Corasick BPE-token trie for the keyword-spotting decoder. The same data structure applies here — the only difference is the score-application point. Promote `ContextGraph` to `AudioCommon` and use it from both modules.
Integration point
In the Qwen3-ASR autoregressive token step (MLX path):
- Maintain a per-stream trie cursor (starts at root, advances on each emitted token).
- Before argmax/sampling, add `score` to the logit of every token that would advance the cursor from its current node.
- On match completion, reset cursor to root (phrase committed).
- On mismatch, fall back through Aho-Corasick failure links (preserves partial matches across phrase overlap).
Purely decode-time — no change to model weights or forward pass.
Modules touched
- `Sources/AudioCommon/` — promote `ContextGraph` here (rename namespace).
- `Sources/Qwen3ASR/` — add `HotwordSet`, wire into text decoder token loop.
- `Sources/SpeechWakeWord/` — import `ContextGraph` from `AudioCommon`.
CLI
```bash
audio transcribe recording.wav --hotwords "Kokoro,Aufklärer,FastConformer"
audio transcribe recording.wav --hotwords-file vocab.txt # one phrase per line, optional ":score" suffix
```
Tests
- Unit: `HotwordSet` trie build from tokenizer; score lookup on a token path; failure-link traversal on mismatch.
- E2E: transcribe a clip with a non-standard term (e.g., a TTS-generated phrase containing "Aufklärer"). Assert:
- without hotwords → model produces the misrecognized spelling
- with hotwords → model produces the expected spelling
Benchmarking
- Relative WER on a domain set (a small hand-curated set with proper nouns).
- Inference RTF with/without hotwords — should be negligible since it's a single extra vector add per step.
- Scaling: how does WER change as hotword count grows (1, 10, 100, 1000)?
Out of scope
- Language-model (external n-gram / neural LM) rescoring.
- Automatic hotword discovery from transcript context.
- Extending to Parakeet / Omnilingual / Nemotron (separate follow-ups once the abstraction is proven on Qwen3-ASR).
References
- `Sources/SpeechWakeWord/ContextGraph.swift` — existing Aho-Corasick BPE trie.
- `Sources/Qwen3ASR/QuantizedTextDecoder.swift` — token generation loop (integration point).
Summary
Add user-provided hotword biasing to Qwen3-ASR so callers can pass a list of phrases (proper nouns, product names, domain jargon) that the decoder should prefer during token generation. No retraining, no new model — a decode-time intervention that adds a score bonus to tokens that advance a match against a precomputed trie.
Motivation
Qwen3-ASR picks the most likely transcription, which is often not the most correct one for niche vocabulary:
Callers currently post-process with fuzzy matching, which fixes the word but not the word-boundary / capitalization artifacts the misrecognition induces. Biasing moves the fix into the decoder where it belongs.
Proposed API
```swift
let asr = try await Qwen3ASRModel.fromPretrained()
let hotwords = HotwordSet(phrases: [
.init("Aufklärer", score: 2.0),
.init("speech-swift", score: 2.5),
.init("Kokoro", score: 2.0),
.init("FastConformer", score: 2.0),
])
let text = asr.transcribe(
audio: audio,
sampleRate: 16000,
hotwords: hotwords
)
```
Implementation
Code reuse
`Sources/SpeechWakeWord/ContextGraph.swift` already implements an Aho-Corasick BPE-token trie for the keyword-spotting decoder. The same data structure applies here — the only difference is the score-application point. Promote `ContextGraph` to `AudioCommon` and use it from both modules.
Integration point
In the Qwen3-ASR autoregressive token step (MLX path):
Purely decode-time — no change to model weights or forward pass.
Modules touched
CLI
```bash
audio transcribe recording.wav --hotwords "Kokoro,Aufklärer,FastConformer"
audio transcribe recording.wav --hotwords-file vocab.txt # one phrase per line, optional ":score" suffix
```
Tests
Benchmarking
Out of scope
References