Repository navigation
Rust frontend: serve Qwen3-TTS on /v1/audio/speech - #1166
Merged
Merged
Conversation
Port Qwen3TTSAdapter to the Rust frontend, so --rust-frontend serves /v1/audio/speech for the Qwen3-TTS models instead of answering 404. - Adapter::Qwen3TTS: instructions -> instruct, Talker sampling, a sent speed forwarded as ignored. ref_audio (voice clone) is a 400 here: the Python route decodes and checks the clip before submit, and this frontend has no decoder. - The speech route now does what create_speech does: format check before the adapter, sentence chunking of long inputs (split_sentences ported, per-piece ids and seed offsets, two pieces submitted ahead), and the stream refusal text from check_audio_format. - Orpheus and Qwen3-Omni forward a sent speed too, as their Python adapters do. test/rust/test_rust_speech.py runs both frontends on the same requests and compares submits, refusals, bytes and HTTP chunks.
arun-elr
added a commit
to arun-elr/mstar
that referenced
this pull request
Nov 3, 2026
mstar-project#845's branch with upstream/main merged in. No conflict here: main's changes to this PR's files (mstar-project#970 in the data worker, mstar-project#1126 and mstar-project#1159 in the API server, mstar-project#1166 in the Rust frontend, mstar-project#1131 in the conductor and the runtime wrapper) are all away from the version plumbing. This PR's own change over mstar-project#845 is unchanged by the merge.
2 tasks done
This was referenced Nov 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
--rust-frontendhad no port ofQwen3TTSAdapter, so mstar-server answered 404 on/v1/audio/speechfor every Qwen3-TTS model (it loggedQwen3TTSAdapter has no port in this frontend) and all TTS serving and benchmarking ran on Uvicorn. This ports it, along with the parts of the Python speech route a port needs to answer the same way (format check order, sentence chunking of long inputs), and measures what the Rust frontend buys on the speech lane.What changed
rust/server/src/adapters.rs:Adapter::Qwen3TTS(from_python("Qwen3TTSAdapter"),supports_speech).speech_to_requestbuilds whatQwen3TTSAdapter.speech_to_requestbuilds: extra fields pass through,voiceset when sent,instructionsbecomesinstruct(an explicitinstructwins, an empty one is dropped),temperature/top_p/seedonto the Talker keys, a sentspeedforwarded so the server logs it as ignored.speech_chunking()holds each adapter'sspeech_chunk_*settings (Qwen3-TTS splits from 600 characters, the others only onsentence_chunking: true).speedtoo. Their Python adapters already do (_forward_sent_speed); the Rust ports dropped it.rust/server/src/speech_chunking.rs(new):split_sentences,_plan_chunksand_chunk_kwargsported. Lengths count characters and whitespace is Python'sstr.isspace, so the pieces are Python's.rust/server/src/main.rsaudio_speech:create_speech's order: format check, adapter, piece plan, then one submit per piece (<id>-<i>ids, seed + i per piece, two pieces submitted ahead of the one playing). A stream still waits for the first piece's first item before committing the 200, then writes the WAV header and one HTTP chunk per audio chunk, as_stream_pcmdoes; a later error aborts the body, and dropping an unread piece aborts it.rust/server/src/media.rscheck_audio_format: an emptyresponse_formatmeans wav, and a stream that asks for a container gets the Python text (response_format 'mp3' cannot be streamed by this server; use one of: wav, pcm). A non-streamed mp3/flac/opus keeps the existing Rust refusal, since there is no encoder here.rust/server/src/protocol.rs:SpeechRequest.speedtells unsent fromnull, as pydantic'smodel_fields_setdoes.rust/server/Cargo.toml:regex, for\p{M}(the splitter never starts a piece on a combining mark) and Python's printable classes in a quoted refusal. Already in the lock through tokenizers (1.13.0); no new crate.docs/environment_variables.rst:ref_audiounder "Rust frontend limitations".ref_audio(Qwen3-TTS Base voice cloning) is a 400 on the Rust frontend that points at the Python frontend: the Python route decodes the clip and checks its length before submit, and mstar-server has no audio decoder.Tests
test/rust/test_rust_speech.py(new, 70 cases; runs in the rust-transport job, which installs fastapi): the real mstar-server and bridge against Python'screate_speech, on stubs that answer every piece with the same audio. For Orpheus, Qwen3-Omni and Qwen3-TTS, 9 requests x stream on/off: the submits (text, modalities, model kwargs, piece ids) and the status, content type, cache-control and body bytes are equal. 8 refusals (stream + container, too many pieces, an engine refusal before and after the first piece, a model without speech) give the same status and JSON on both. A streamed WAV and PCM, one piece and three, are the same list of HTTP chunks as the Python body yields (the 44-byte header, then one chunk per audio chunk). 120 random long inputs over the splitter's edge cases (closers, CJK ends, paragraph breaks, clause marks, runs without spaces, combining marks, U+001C, NBSP) give the same 1898 piece texts and the same 8 piece-count refusals. Against main's binary, 59 of the 70 fail.cargo test --releasein rust/server: 76 passed (17 new: the splitter, the format refusal and Python repr, the Qwen3-TTS mapping, speed presence).pytest test/rust: 591 passed, 14 skipped (main: 521 passed, 14 skipped; the same skips).test_rust_openai_adapters.pyno longer lists Qwen3-TTS speech as not ported.ruff check .: clean. api_server modules in test/modular (openai adapters/router/realtime/transcriptions, speech chunking, media, serving metrics, body limit, request ids): 1124 passed, 2 failed on both this branch and main, the same two ids.test/modular test/distributed test/chatterbox test/kokoro test/omnivoice test/sampling_test mstar/model/*/tests benchmark/worker_phases,-n 6): 11220 passed, 1025 skipped, 20 failed, the same 20 ids on main (eval_compare_multigpu/transport, send_registration_failure, wan_vae / z_image bit-exact, lingbot, serving_metrics, client_error_text); the TP lockstep sim (11) and piecewise signature (16) modules pass on both.ruff check .were rerun.Gate
2 x B300 (sm_103), driver 595.91.07 (CUDA 13.2), torch 2.12.1+cu132, FlashInfer 0.7.2.post1; Xeon 6740E host, server pinned to 8 cores and the client to 4, with idle-priority spinners on them so the cores don't park. Qwen3-TTS 1.7B, stock
configs/qwen3tts_1p7b.yaml,-O2, GPU 0. Seed-TTS EN, first 200 sentences, voice ryan, closed loop, streamed WAV; per c two passes, the first after 5 x c warmup requests. Four sessions alternated, each a fresh server and Inductor cache: main (482dfc7) on the Python frontend, then this branch (6583ca1) on the Rust frontend, twice. Mean of a session's two passes:Past the gate, one more session per arm at c=64 and c=128 (main first, not alternated; each pass is about 3 s, 200 requests being 1.5 to 3 rounds at these concurrencies, so audio-s/s there is mostly ramp and tail):
The Rust c=64 pass 2 took 3.7 s against 3.1 to 3.3 s for the other three with no request over 1.9 s; not traced. Even at c=128 the API path stays near a third of one core on either frontend (the Uvicorn loop 17%), while first byte grows to about 0.5 s on both; so the API path is not what limits the speech lane through c=128, and the Rust frontend buys no throughput on it. Hypothesis, not checked: that first byte is requests waiting for the engine.
Not in this PR
ref_audio(voice cloning) on the Rust frontend: it needs the clip decoded and length-checked before submit, as_resolve_ref_audio/check_ref_audiodo.speed, Kokoro, OmniVoice and VoxCPM2 map notemperature/top_p, OmniVoice, Chatterbox, Fish, CosyVoice3 and VoxCPM2 take aref_audioclip, CosyVoice3 splits with its own rules. Fish (44.1 kHz) and VoxCPM2 (48 kHz) also need the launcher to pass the model's output rate; mstar-server stampsMSTAR_SAMPLE_RATE, default 24000.GET /v1/audio/voiceson the Rust frontend.temperature/top_psent as strings still reach the API server as sent (the API server'snormalize_generation_kwargsreads them), where the Python frontend coerces them to floats first; unchanged here.