Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
- name: Install package
run: |
python -m pip install --upgrade pip
python -m pip install -e .
python -m pip install -e . python-multipart
- name: Lint (ruff, bug-catching subset)
# Full `ruff check` + `ruff format --check` needs a cleanup pass first
# (21 UP-rule violations, 5 files unformatted) — tracked in #19.
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ re-prefill, so correctness never depends on the cache).
| `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 |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

| `GET /v1/models`, `GET /health` | model list, liveness |

Bring up a speech server and talk to it:
Expand All @@ -147,6 +148,15 @@ curl -H 'Authorization: Bearer demo' -H 'Content-Type: application/json' \
http://127.0.0.1:8000/v1/audio/speech -o speech.wav
```

Speech recognition rides the same server with `--asr-model` (decoder-style Qwen3-ASR; the
OpenAI `prompt` field becomes the decoder's context prompt, `hotwords` is an extension):

```sh
pip install 'vllm-omni-mlx[asr]'
vllm-omni-mlx serve --asr-model mlx-community/Qwen3-ASR-1.7B-4bit
curl -F file=@speech.wav -F language=en http://127.0.0.1:8000/v1/audio/transcriptions
```

Chat works the same way on the same server (`/v1/chat/completions`,
`/v1/messages`, both with `"stream": true`). Voices, instructions, voice
cloning, streaming knobs, and per-checkpoint differences: the
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ Changelog = "https://github.com/ThinkFlowLab/vllm-omni-mlx/releases"
[project.optional-dependencies]
omni = ["mlx-vlm>=0.7"]
tts = ["mlx-audio>=0.5.7,<0.6"]
asr = ["mlx-audio>=0.5.7,<0.6", "python-multipart>=0.0.9"]

[project.scripts]
# serve/tts subcommands, upstream vllm-omni's `vllm serve <model> --omni` shape
vllm-omni-mlx = "vllm_omni_mlx.__main__:main"

[tool.setuptools]
packages = ["vllm_omni_mlx", "vllm_omni_mlx.tts"]
packages = ["vllm_omni_mlx", "vllm_omni_mlx.tts", "vllm_omni_mlx.asr"]
11 changes: 11 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,14 @@ Operational and development scripts.
```sh
python scripts/profile_compiled_loop.py --max-tokens 400
```

- `asr_roundtrip.py` (#68): TTS → ASR round-trip gate. Synthesizes fixed
sentences with the serving TTS checkpoint, transcribes them with the serving
ASR checkpoint, and fails if mean word-error-rate exceeds `--max-wer`
(default 0.15). No external fixtures; catches catastrophic breakage on
either side, not fine WER differences. Both models stay resident (~5.4 GiB
peak on the 4-bit defaults), so run it on its own:

```sh
python scripts/asr_roundtrip.py --voices vivian ryan
```
100 changes: 100 additions & 0 deletions scripts/asr_roundtrip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""TTS → ASR round-trip gate (#68 task 3): our speech in, our transcript out.

No external fixtures: each sentence is synthesized by the serving TTS
checkpoint, transcribed by the serving ASR checkpoint, and scored by
word-error-rate against the input text (lowercased, punctuation stripped,
digits spelled out in the text so normalization stays trivial). Catches
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

on its own, not next to another heavy battery on a 16 GB machine.

python scripts/asr_roundtrip.py --voices vivian ryan --max-wer 0.15
"""

from __future__ import annotations

import argparse
import os
import re
import statistics
import sys
import time

os.environ.setdefault("HF_HUB_OFFLINE", "1")

SENTENCES = [
"The quick brown fox jumps over the lazy dog.",
"Please turn off the lights before you leave the office tonight.",
"Apple silicon runs large language models with unified memory.",
"Peter Piper picked a peck of pickled peppers.",
"Tomorrow morning we will review the results of the experiment.",
"The server answers every request in under half a second.",
]


def normalize(text: str) -> list[str]:
return re.sub(r"[^a-z' ]+", " ", text.lower()).split()


def wer(reference: str, hypothesis: str) -> float:
ref, hyp = normalize(reference), normalize(hypothesis)
prev = list(range(len(hyp) + 1))
for i, r in enumerate(ref, 1):
cur = [i]
for j, h in enumerate(hyp, 1):
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (r != h)))
prev = cur
return prev[-1] / max(len(ref), 1)


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--tts-model", default=None)
parser.add_argument("--asr-model", default=None)
parser.add_argument("--voices", nargs="+", default=["vivian"])
parser.add_argument("--max-wer", type=float, default=0.15, help="fail if the mean WER exceeds this")
args = parser.parse_args()

import mlx.core as mx

from vllm_omni_mlx.asr.config import ASRConfig, load_asr_model
from vllm_omni_mlx.asr.service import ASRService
from vllm_omni_mlx.tts.config import TTSConfig, load_tts_model
from vllm_omni_mlx.tts.service import TTSService

tts_cfg = TTSConfig(model_ref=args.tts_model) if args.tts_model else TTSConfig()
asr_cfg = ASRConfig(model_ref=args.asr_model) if args.asr_model else ASRConfig()
tts = TTSService(load_tts_model(tts_cfg), tts_cfg)
asr = ASRService(load_asr_model(asr_cfg), asr_cfg)
print(f"tts={tts.name}\nasr={asr.name}", file=sys.stderr)

rows = []
for voice in args.voices:
for text in SENTENCES:
t0 = time.perf_counter()
wav, _ = tts.speech_bytes(text, voice=voice)
t1 = time.perf_counter()
out = asr.transcribe(wav)
t2 = time.perf_counter()
score = wer(text, out.text)
rows.append((voice, text, out.text, score, t1 - t0, t2 - t1, out.duration))
print(f"[{voice}] WER {score:.2f} tts {t1 - t0:.1f}s asr {t2 - t1:.2f}s ({out.duration:.1f}s audio)\n"
f" ref: {text}\n hyp: {out.text}")
mx.clear_cache()

scores = [r[3] for r in rows]
mean = statistics.mean(scores)
print(f"\nutterances {len(rows)} mean WER {mean:.3f} max {max(scores):.2f} "
f"exact {sum(s == 0 for s in scores)}/{len(scores)} peak {mx.get_peak_memory() / 2**30:.2f} GiB")
if mean > args.max_wer:
print(f"FAIL: mean WER {mean:.3f} > {args.max_wer}", file=sys.stderr)
return 1
print("PASS")
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading