Repository navigation
feat(asr): /v1/audio/transcriptions adapter over qwen3_asr (#68 tasks 1–2) - #104
Conversation
…nferMatrixLab#68 tasks 1-2) asr/ package: config (loader, default Qwen3-ASR-1.7B-4bit), capabilities (decoder vs encoder-decoder matrix, boot-time rejection with guidance), service (validation + lock). Shared audio_io.decode_audio lifted out of tts/generate.py. POST /v1/audio/transcriptions (json/text/verbose_json), serve --asr-model, [asr] extra (python-multipart).
| | `POST /v1/messages` | Anthropic (SSE streaming, image blocks) | | ||
| | `POST /v1/audio/speech` | OpenAI audio (`wav` / chunked `pcm` with `stream: true`) | | ||
| | `GET /v1/audio/voices` | preset speakers of the loaded TTS model | | ||
| | `POST /v1/audio/transcriptions` | OpenAI audio (multipart `file`; `json` / `text` / `verbose_json`) — `--asr-model`, `[asr]` extra | |
There was a problem hiding this comment.
Both models-matrix rows (lines 57 and 64, unchanged in this diff) still read 🚧 planned — /v1/audio/transcriptions (#68) while this PR adds the endpoint to the API table and a working example. Worth flipping them in the same pass so the README is not internally inconsistent after merge: Qwen3-ASR decoder-style served (buffered; streaming = #68 task 4), whisper/parakeet/voxtral still planned.
Same catch-up applies to docs/speech.md: since #85 the per-endpoint parameter detail lives there (README is launch-only), but there is no ASR section yet — multipart fields, hotwords list semantics, ISO-code-or-name language, and the srt/vtt/stream 400s are all only discoverable from the code today. Fine as a fast-follow when task 4 lands if you prefer, but a 10-line section now would match how /v1/audio/speech is documented.
|
Reviewer verification (head Verified independently, not just read:
Notes
One inline comment on README/docs catch-up. Nothing blocking — this is a clean, well-scoped tasks-1–3 slice; the two-class capability matrix and boot-time family rejection are exactly the |
| hotword_list = [w.strip() for w in str(hotwords).replace("\n", ",").split(",") if w.strip()] if hotwords else None | ||
| if fmt not in asr_render_formats: | ||
| raise ApiError(400, f"response_format must be one of {', '.join(asr_render_formats)}, got '{fmt}'") | ||
| data = await upload.read() |
There was a problem hiding this comment.
[nit] The 25 MiB cap is enforced only after the whole upload is in RAM: await upload.read() here, then MAX_UPLOAD_BYTES is checked inside ASRService.transcribe (asr/service.py:64). A multi-GiB POST spikes memory before the 400 (Starlette spools the multipart body to disk, but this read materializes it). Cheap fix: check int(request.headers.get('content-length', 0)) before parsing, or read with a bound. Not blocking for a single-user local server.
| catastrophic breakage on either side — a broken decode loop, a resample | ||
| mismatch, a mangled prompt — not fine WER differences between checkpoints. | ||
|
|
||
| Both models stay resident in one process (≈3 GB on the 4-bit defaults); run it |
There was a problem hiding this comment.
[nit] Docstring says "≈3 GB on the 4-bit defaults" but this PR's scripts/README.md entry says "~5.4 GiB peak" (and the PR body reports 5.40 GiB measured). One of the two is wrong — please align so the round-trip gate's footprint guidance matches what was measured.
| #: upload cap, same as the OpenAI API's | ||
| MAX_UPLOAD_BYTES = 25 * 1024 * 1024 | ||
|
|
||
| #: ISO-639-1 → the language names Qwen3-ASR's prompt template uses |
There was a problem hiding this comment.
[nit] Comment says ISO-639-1, but the map includes yue (ISO 639-3) and fil (ISO 639-2/3). Only the comment is off — the codes themselves are right for Qwen3-ASR. Maybe "ISO-639-1/3 codes".
hsliuustc0106
left a comment
There was a problem hiding this comment.
Approve (with 3 nits, posted inline) — reviewed at 63a2683 per the delegated-review protocol.
Verified against the pinned mlx-audio 0.5.8 wheel (not installed in my env — claims checked from source, weights-gated claims not executed — evidence reviewed only):
stt.utils.load_modelexists with the expected signature; qwen3_asrconfig.model_type = "qwen3_asr"andsupport_languagespresent, so the boot-time capability gate and_resolve_languagerest on real API.Model.generate(qwen3_asr.py:1227) accepts exactly the kwargs the service passes:max_tokens,temperature,language,verbose,system_prompt,hotwords.- The
generate_transcriptionside-effect claim is real: defaultoutput_path="transcript"writestranscript.txtinto cwd (stt/generate.py:148) — callingmodel.generatedirectly is justified. hotwords→merge_hotwordsfolds intosystem_prompt(stt/utils.py:20) — matches the prompt-head/prefix-cache-key framing.audio_io.read(file, dtype, sample_rate, nchannels)signature matches;decode_ref_audiorefactor preserves both error messages verbatim and the 24 kHz rate.- starlette 1.7.0 raises
AssertionErrorwithout python-multipart (formparsers.py:69) — the route's 500-with-guidance catch is correct for the pinned version.
Evidence bars (serving PR): met. asyncio.to_thread worker dispatch is exercised by the TestClient route tests, and the live serve smoke on real audio covers the load-adjacent path. Weight-gated tests follow the taxonomy (HF_HUB_OFFLINE, safetensors-presence gate, ReleaseAfterClass, one heavy model). Round-trip WER gate (task 3) is a nice addition — harness logic reviewed, WER numbers not re-executed here.
Process flag, not a code issue: CI has produced zero checks on this PR — it's a fork PR that modifies .github/workflows/ci.yml, so the workflow needs a maintainer "Approve and run" before it can go green. Please get a green run before merging, since the python-multipart CI change itself is what's unverified.
Nits (non-blocking, posted inline): cap-after-read in server.py:401; ≈3 GB vs ~5.4 GiB docstring/README mismatch in asr_roundtrip.py:11; ISO-639-1 comment covers 639-2/3 codes in asr/service.py:31.
Resolve scripts/README.md (both sides appended entries — keep asr_roundtrip.py from #104 plus acc_all_checkpoints.py from this PR). No code conflicts: tests/asr_oracle.py stays at tests/ top level beside audio_metrics.py (only test_* files for the tts package moved into tests/tts/ per #106), and the battery invokes itself via --_worker subprocesses, so no test-module paths needed updating.
…ll + news (#114) Collapses the duplicated Supported-Models table (VoxCPM2 PR appended a second copy instead of editing the first), marks ASR served per #104 with the #68 streaming/prefix-cache remainder, adds MOSS-TTS-Nano-100M (#110), the [asr] install line, --asr-model in Options, and news bullets for #104/#98/#110.
Tasks 1–2 of #68: a decoder-style ASR adapter and a buffered
POST /v1/audio/transcriptions. No prefix cache or compile yet (that is task 5), and no streaming (task 4).What's in it
asr/packageconfig.py: loader, defaultmlx-community/Qwen3-ASR-1.7B-4bitcapabilities.py: two-class matrix (decoder vs encoder-decoder) keyed onconfig.jsonmodel_type. An unserved family is rejected at boot with guidance, before any weights load (thetts/variants.pypattern)service.py: validation + ASR lock. Request errors (decode, caps, language) raise before the lock is takenaudio_io.decode_audio: lifted out oftts/generate.pyso TTS reference clips and ASR uploads share one decode+resample path.decode_ref_audiokeeps its base64 handling and its error messagesPOST /v1/audio/transcriptions: multipartfile,language,prompt,response_format(json/text/verbose_json),temperature.srt/vttandstreamreturn a 400 for nowserve --asr-model <repo>, combinable with--tts-model[asr]extra: mlx-audio (same pin) +python-multipartThings worth a look
model.generateinstead ofgenerate_transcription: the latter writestranscript.txtinto the cwd as a side effect. Calling the model directly gives the sameSTTOutputwithout it.prompt→system_prompt: the static text head of the decoder prompt, i.e. the task-5 cache key.hotwordsis an extension field that mlx-audio folds into the same head.languageaccepts an ISO-639-1 code or a name and is validated against the checkpoint'ssupport_languages.python-multipartfor form parsing and CI only installs-e ., so the workflow now installs it too. Without it the route tests skip. Happy to move it into base deps instead if you'd rather.Verification
python -m unittest tests.test_asr: 20 tests, with the real-weights test running (checkpoint cached locally).ruff check --select E9,F .clean.test_audio_speech,test_clone_scaffold,test_tts_generate,test_variantsstill pass.serve --asr-model mlx-community/Qwen3-ASR-1.7B-4biton an M-series Mac, with a clip made by macOSsay(4.5 s): correct transcript, 0.40–0.47 s per request warm (1.5 s first request). Passinghotwords=Qwenand apromptchanged "Quen" to "Qwen". A bad language,srt, and a non-audio file each return a 400 with a message.Round-trip gate (task 3)
scripts/asr_roundtrip.pysynthesizes 6 fixed sentences per voice with the serving TTS checkpoint (Qwen3-TTS-12Hz-1.7B-CustomVoice-4bit), transcribes them withQwen3-ASR-1.7B-4bit, and scores word-error-rate against the input text. It fails above--max-wer(default 0.15). Run on an M-series Mac with voices vivian and ryan, 12 utterances:Not done
Refs #68.