Skip to content

feat(qwen): local Qwen3-ASR provider (POC, CPU-only) - #28

Draft
sumerc wants to merge 3 commits into
mainfrom
qwen-asr-int
Draft

feat(qwen): local Qwen3-ASR provider (POC, CPU-only)#28
sumerc wants to merge 3 commits into
mainfrom
qwen-asr-int

Conversation

@sumerc

@sumerc sumerc commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Draft — parked deliberately, not awaiting review. Working end-to-end POC of
Qwen3-ASR as a local provider. Opened so the work is recoverable if we revisit;
the accuracy result below is why it is not proposed for merge.

What is here

  • third_party/qwen-asr/antirez/qwen-asr @ b00b789, vendored as plain C sources
  • internal/qwen/ — cgo wrapper + non-darwin stub, matching internal/whisper
  • transcriber/qwen.go — provider seam, matching transcriber/whisper.go
  • localmodel.Model.IsDir — directory-model support (see below)
  • make qwen-lib / make download-qwen

Dependency-free C, so the build is cc + ar — no cmake, no submodule, and
none of the ggml-sharing hazard whisper-lib exists to prevent: it links its own
kernels plus Accelerate and shares nothing with parakeet/whisper.

Why it is parked

Accuracy on the actual use case. On Turkish dictation containing English
technical vocabulary it resolves loanwords through Turkish phonology:

spoken Qwen Whisper
"sample" "sempol" "sample"
"check edebilirsin" "çekebilirsin" "check edebilirsin"
"transcribe" "transkribe" "transcribe"

çekebilirsin is a real Turkish word, so the error is silent. This is intrinsic,
not configuration — forced language, auto-detect and vocabulary hints all
produced identical or worse output. Published benchmarks put Qwen3-ASR above
Whisper-large-v3, but those are monolingual read speech and do not measure
code-switching.

Cost. CPU only (no GPU backend exists upstream), bf16 and unquantized:

disk RSS 23 s clip
qwen3-asr-0.6b 1.8 GB ~2.8 GB 1944 ms
whisper-turbo-q5 574 MB ~1100 ms wall

Faster paths exist (qwen3-asr.cpp on ggml+Metal, MLX 4-bit), but they buy speed, not accuracy —
and quantization moves WER the wrong way. Not worth the work for a model that is
already the weaker one at full precision here.

Notes for a future revisit

  • The model is a DIRECTORY (safetensors + tokenizer JSON), which the single-file
    • one-sha256 contract behind install.sh and localmodel.Download cannot
      express. IsDir skips it in the manifest and refuses download.
  • Accelerate resolves to cblas_sgemm$NEWLAPACK (macOS 13.3+) while zee targets
    11.0 — fine locally, would fail to load on 11–13.2.
  • Threading is not optional: the library defaults to n_threads=1. Missing
    qwen_set_threads() cost ~2x before it was caught.
  • ZEE_QWEN_THREADS / ZEE_QWEN_VERBOSE=2 for experiments.

Full measurements in docs/design-notes.md.

Contains the local-provider fallback fix (cherry-picked) so the branch is
testable on checkout; that fix is proposed separately in the companion PR
against main.

🤖 Generated with Claude Code

sumerc and others added 2 commits July 30, 2026 14:22
A local provider read its model ID from config.json and hard-errored when
that ID belonged to a different engine, which is the normal state right
after any provider switch: the persisted ID is whatever the PREVIOUS
provider had selected, and it reaches the new provider via SetModel before
the tray ever offers this engine's model list.

The result was that every recording failed with `unknown <engine> model
"<other-engine-id>"` until the user happened to open the model menu and
pick something. Fall back to the provider's own default instead.

Falling back is safe because the default is by construction the right
engine's file. What must never happen is loading another engine's weights,
and that is still rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Vendors antirez/qwen-asr (b00b789) into third_party/qwen-asr as plain C
sources and wraps it in internal/qwen, following the layout the other two
local engines already use. Dependency-free C, so the build is cc + ar — no
cmake, no submodule, and none of the ggml-sharing hazard whisper-lib exists
to prevent: it links its own kernels plus Accelerate and shares nothing
with parakeet/whisper.

Works end to end, 30 languages including Turkish, and is wired into the
tray like any other local provider. Draft rather than merge-ready — see
docs/design-notes.md for the measurements. In short:

  - CPU only. No GPU backend exists upstream; matmuls go through
    cblas_sgemm. Parakeet and Whisper both run on Metal here.
  - Weights are bf16 and unquantized: 1.8 GB on disk, ~2.8 GB resident,
    against 574 MB for whisper-turbo-q5.
  - 23 s clip: 1944 ms vs Whisper's ~1100 ms wall.
  - Accuracy on Turkish dictation with English technical vocabulary is
    worse than Whisper's: it resolves loanwords through Turkish phonology
    ("sample" -> "sempol", "check edebilirsin" -> "çekebilirsin"). That is
    intrinsic, not configuration — forced language, auto-detect and hints
    all produced the same or worse output.

The model is a DIRECTORY (safetensors + tokenizer JSON), which the
single-file + one-sha256 contract behind install.sh and localmodel.Download
cannot express. localmodel.Model.IsDir marks it so it is skipped by the
manifest and refuses to download; `make download-qwen` fetches it by hand.

Includes the local-provider fallback fix (cherry-picked) so the branch is
testable on checkout; it is also proposed separately against main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The measurement entry was all latency; the reason for parking is accuracy.
Records the loanword failure on real dictation, that it is intrinsic rather
than configuration (forced language, auto-detect and hints all produce the
same or worse output), and the mechanism — an LLM decoder's monolingual
prior, which is the same thing that makes it strong on monolingual speech.

Marks the earlier "quality was a wash" line superseded rather than deleting
it: that claim came from a Turkish TTS clip, and a clean synthetic signal in
one language turned out to say nothing about code-switching.

Also scopes what was actually measured. Everything here is antirez/qwen-asr
— CPU, bf16, the slowest available implementation. qwen3-asr.cpp (ggml +
Metal + Q8_0), MLX 4-bit and ONNX were not tested, with the one published
qwen3-asr.cpp number and why none of them changes the conclusion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant