Skip to content

Qwen3-TTS PR 3: sentence chunking and error status for /v1/audio/speech - #300

Merged
NSagan271 merged 8 commits into
mainfrom
engine/speech-sentence-chunking
Oct 1, 2026
Merged

NSagan271 merged 8 commits into
mainfrom
engine/speech-sentence-chunking

Conversation

@merceod

@merceod merceod commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

Long inputs to /v1/audio/speech are synthesized as ordered sentence chunks.

speech_chunking.split_sentences groups sentences (Latin and CJK terminators, paragraph breaks, over-long sentences wrap at clauses and then spaces) into pieces of about speech_chunk_max_chars. serving_speech submits one engine request per piece, keeps speech_chunk_lookahead pieces in flight while the current one streams, and concatenates the PCM in order for both streaming and non-streaming responses. Each autoregressive request stays within the length its model was trained for, the engine batches the pieces like independent requests, and a client seed advances per piece.

Opt-in per adapter (speech_chunk_min_chars, off by default) and overridable per request with sentence_chunking: true|false. Adapters that keep the default behave as before. Qwen3-TTS turns it on for inputs of 600+ characters.

Also in here: a streaming request now looks at its first engine result before answering 200, so a rejected request (bad voice, dead worker) comes back with its error status instead of an empty WAV, and a mid-stream error closes the stream.

Six commits, one per file. Tests in test/modular/test_speech_chunking.py cover the splitter and the handler on the stubbed router (ordered ids and playback, per-chunk seeds, streaming concatenation, opt-out, forced chunking, error status). Full test/modular passes.

@Gaurav-Shah05 Gaurav-Shah05 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

One thing to fix: pieces submitted ahead aren't aborted when the stream ends early (below). On a live Qwen3-TTS server, closing a chunked stream cancelled the piece being read, and the two lookahead pieces ran to the end.

if requested is None:
requested = getattr(req, "sentence_chunking", None)
min_chars = getattr(adapter, "speech_chunk_min_chars", None)
if requested is False or (requested is None and (min_chars is None or len(text) < min_chars)):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Any client can send sentence_chunking: true and nothing bounds input, so a 1.2 MB input becomes over 3,000 engine requests where the unchunked request stops at the model's max length. I think the override needs a cap.

@NSagan271 NSagan271 Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Added a speech_chunk_max_pieces to the adapter, which bounds the number of chunks and raises a 400 if it's exceeded.

async for c in iterator:
# Mid-stream the status is already sent; closing the stream is the
# only honest signal left, so raise rather than end quietly.
_raise_if_error(c)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The request is already parked in recently_completed when its error chunk arrives, so raising here (and at the up-front _raise_if_error(first)) leaves iter_result_chunks early and its finally aborts a request that has already ended. #330 fixes the same abort in _stream_wav, so these two will conflict. I will rebase #330 once this merges.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Now, the error is tracked and the stream is read to the end, with the error being raised after


lookahead = max(1, int(getattr(adapter, "speech_chunk_lookahead", 2)))
if req.stream:
pending = [submit(i) for i in range(min(lookahead, len(chunks)))]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

On a disconnect or a failed piece only the piece being read is aborted, so the lookahead pieces keep generating, and the non-streaming path goes on to submit and abort every remaining piece. I think the loop should stop on disconnect and abort every id still in pending in a finally.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Now, both the streaming and non-streaming paths keep track of the number of pending chunks that have been processed and aborts the lookahead requests in finally

@NSagan271

Copy link
Copy Markdown
Collaborator

@Gaurav-Shah05 addressed the comments, can you take another look?

@Gaurav-Shah05 Gaurav-Shah05 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

@NSagan271
NSagan271 merged commit 3ea8bce into main Oct 1, 2026
4 checks passed
@NSagan271
NSagan271 deleted the engine/speech-sentence-chunking branch October 1, 2026 18:47
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.

3 participants