On /v1/audio/speech, a synthesis failure is reported to the client when response_format is wav, but is silently swallowed when it is pcm. The streaming path returns HTTP 200 with a well-formed, empty chunked body and a clean EOF, so a client cannot distinguish "synthesis failed" from "the model produced no audio".
Observed
With response_format: "pcm", a failure that happens after the request parses (an unknown speaker, a CustomVoice model without a speaker, and so on) does not reach the HTTP layer. The response is 200 / audio/pcm with a zero-byte chunked body that terminates normally.
The same failing request with response_format: "wav" returns 502 and the usual envelope:
{"error":{"message":"synthesis failed","type":"server_error"}}
In both cases the server logs the real cause on stderr, for example [Prompt] FATAL: unknown speaker 'nosuchvoice'.
Expected
A synthesis failure should be machine-detectable on the streaming path as well — through a non-2xx status when nothing has been written yet, through an error signal after the stream has started, or by rejecting the request before streaming begins.
Steps to reproduce
-
Start tts-server with a CustomVoice checkpoint, for example:
./build/tts-server \
--model qwen-talker-0.6b-customvoice-Q4_K_M.gguf \
--codec qwen-tokenizer-12hz-Q4_K_M.gguf \
--alias qwen3-tts-0.6b-customvoice --lang English --port 18860
-
Request synthesis with a speaker that does not exist, using the pcm format:
curl -i -X POST http://127.0.0.1:18860/v1/audio/speech \
-H 'Content-Type: application/json' \
-d '{"model":"qwen3-tts-0.6b-customvoice","input":"hello","voice":"nosuchvoice","response_format":"pcm"}'
Result: HTTP/1.1 200 OK, Content-Type: audio/pcm, empty body.
-
Repeat with "response_format":"wav".
Result: HTTP/1.1 502, error envelope as above.
Reproduced on a8a7716 (current master) with the 0.6B CustomVoice Q4_K_M checkpoint on a CPU build.
Where it comes from
tts_backend::synthesize returns an ABI status and fills an error string (src/tts-server.h:87).
The wav path keeps both and maps the status onto HTTP (src/tts-server.h:271-273, via tts_status_to_http at src/tts-server.h:241):
int rc = be.synthesize(req, sink, synth_err);
...
tts_json_error(res, tts_status_to_http(rc), "server_error", ...);
The pcm worker thread calls the same function but keeps neither the return value nor the error text (src/tts-server.h:314):
std::string synth_err;
be.synthesize(req, push, synth_err);
std::lock_guard<std::mutex> lk(st->mu);
st->done = true;
st->cv.notify_all();
stream_state (src/tts-server.h:288-297) has no field for a failure, so the chunked provider (src/tts-server.h:322-345) sees done with an empty buffer and calls sink.done(), which closes the response as a successful, empty stream.
Why it matters for clients
Headers are already sent by the time the backend fails in the general case, so an unconditional status change is not always possible. But the common failures above are detected before any audio has been produced, so at that point the status is still open. Today a client's only recourse is a heuristic — treating "HTTP 200 with zero or implausibly short PCM" as a failure — which cannot distinguish a genuine failure from a very short utterance.
Possible directions
Listing these only to be concrete; the design call is yours.
- Validate what can be validated before streaming starts (speaker existence, the CustomVoice-requires-a-speaker rule) and return
400 from tts_handle_speech.
- Carry the status and error text in
stream_state and, if nothing has been written yet, emit the JSON error envelope with the mapped status instead of opening the stream.
- If the failure happens mid-stream, aborting the connection rather than closing it cleanly would at least surface as a transport error on the client side.
Happy to provide more detail, or to test a candidate fix against the setup above.
On
/v1/audio/speech, a synthesis failure is reported to the client whenresponse_formatiswav, but is silently swallowed when it ispcm. The streaming path returns HTTP 200 with a well-formed, empty chunked body and a clean EOF, so a client cannot distinguish "synthesis failed" from "the model produced no audio".Observed
With
response_format: "pcm", a failure that happens after the request parses (an unknown speaker, a CustomVoice model without a speaker, and so on) does not reach the HTTP layer. The response is200/audio/pcmwith a zero-byte chunked body that terminates normally.The same failing request with
response_format: "wav"returns502and the usual envelope:{"error":{"message":"synthesis failed","type":"server_error"}}In both cases the server logs the real cause on stderr, for example
[Prompt] FATAL: unknown speaker 'nosuchvoice'.Expected
A synthesis failure should be machine-detectable on the streaming path as well — through a non-2xx status when nothing has been written yet, through an error signal after the stream has started, or by rejecting the request before streaming begins.
Steps to reproduce
Start
tts-serverwith a CustomVoice checkpoint, for example:Request synthesis with a speaker that does not exist, using the pcm format:
Result:
HTTP/1.1 200 OK,Content-Type: audio/pcm, empty body.Repeat with
"response_format":"wav".Result:
HTTP/1.1 502, error envelope as above.Reproduced on
a8a7716(current master) with the 0.6B CustomVoice Q4_K_M checkpoint on a CPU build.Where it comes from
tts_backend::synthesizereturns an ABI status and fills an error string (src/tts-server.h:87).The wav path keeps both and maps the status onto HTTP (
src/tts-server.h:271-273, viatts_status_to_httpatsrc/tts-server.h:241):The pcm worker thread calls the same function but keeps neither the return value nor the error text (
src/tts-server.h:314):stream_state(src/tts-server.h:288-297) has no field for a failure, so the chunked provider (src/tts-server.h:322-345) seesdonewith an empty buffer and callssink.done(), which closes the response as a successful, empty stream.Why it matters for clients
Headers are already sent by the time the backend fails in the general case, so an unconditional status change is not always possible. But the common failures above are detected before any audio has been produced, so at that point the status is still open. Today a client's only recourse is a heuristic — treating "HTTP 200 with zero or implausibly short PCM" as a failure — which cannot distinguish a genuine failure from a very short utterance.
Possible directions
Listing these only to be concrete; the design call is yours.
400fromtts_handle_speech.stream_stateand, if nothing has been written yet, emit the JSON error envelope with the mapped status instead of opening the stream.Happy to provide more detail, or to test a candidate fix against the setup above.