Environment
- dashscope: 1.26.4 (also verified on 1.24.0, 1.25.0, 1.26.0 - 1.26.3, all affected)
- Python: 3.14
- OS: macOS
- Model: cosyvoice-v3.5-plus (tts_v2, non-callback synchronous mode)
Describe the bug
When calling SpeechSynthesizer.call(text) in synchronous (non-callback) mode, if the server sends a task-failed event after some audio chunks have already been delivered, call() returns the partially accumulated audio bytes as if the synthesis succeeded. No exception reaches the caller, so the application has no way to know the audio is truncated.
Real-world impact: a 73-character Chinese text produced an MP3 that only speaks the first ~48 characters. The bytes were non-empty (165,602 bytes), call() returned normally, and the truncated audio was served to end users.
Root cause
In dashscope/audio/tts_v2/speech_synthesizer.py, the on_message handler runs in the websocket-client receiver thread:
elif EventType.FAILED == event:
self.start_event.set()
self.complete_event.set()
if self.async_call:
self.callback.on_error(message)
self.callback.on_close()
else:
logger.error(f'TaskFailed: {message}')
raise Exception(f'TaskFailed: {message}') # raised in the receiver thread
Problems:
complete_event.set() is called before the raise, unblocking the main thread waiting in streaming_complete().
- The
raise Exception(...) happens inside the websocket-client callback. The websocket-client library wraps callbacks in _callback(), which catches the exception, logs it, and calls on_error (which in this SDK only logs). The exception is therefore swallowed and never propagates to the main thread.
call() then returns unconditionally:
self.streaming_complete(timeout_millis)
return self._audio_data # partial audio, no error check
Steps to reproduce
- Use
SpeechSynthesizer(model=..., voice=...) with a cosyvoice v2/v3 voice in synchronous mode.
- Call
synthesizer.call(long_text) and have the server fail mid-task (transient server error; can be simulated by injecting a task-failed frame after several audio frames).
- Observe that
call() returns partial audio bytes without raising, while the log shows TaskFailed: ....
The only way for applications to detect this today is to manually inspect synthesizer.get_response()["header"]["event"] == "task-failed" after call() returns, which is undocumented and easy to miss.
Expected behavior
call() (and streaming_complete()) should raise an exception in the caller's thread when the task failed, instead of silently returning partial audio.
Suggested fix
Store the error in a field inside the receiver thread and re-raise it in the main thread after the wait:
# __init__
self._receiver_error = None
# on_message, FAILED branch (non-async path)
self._receiver_error = Exception(f'TaskFailed: {message}')
self.complete_event.set()
# streaming_complete, after complete_event.wait()
if self._receiver_error is not None:
raise self._receiver_error
Thanks for looking into this.on_message handler runs in the websocket-client receiver thread:
elif EventType.FAILED == event:
self.start_event.set()
self.complete_event.set()
if self.async_call:
self.callback.on_error(message)
self.callback.on_close()
else:
logger.error(f'TaskFailed: {message}')
raise Exception(f'TaskFailed: {message}') # raised in the receiver thread
Problems:
complete_event.set() is called before the raise, unblocking the main thread waiting in streaming_complete().
-
- The
raise Exception(...) happens inside the websocket-client callback. The websocket-client library wraps callbacks in _callback(), which catches the exception, logs it, and calls on_error (which in this SDK only logs). The exception is therefore swallowed and never propagates to the main thread.
-
call() then returns unconditionally:
self.streaming_complete(timeout_millis)
return self._audio_data # partial audio, no error check
Steps to reproduce
- Use
SpeechSynthesizer(model=..., voice=...) with a cosyvoice v2/v3 voice in synchronous mode.
-
- Call
synthesizer.call(long_text) and have the server fail mid-task (transient server error; can be simulated by injecting a task-failed frame after several audio frames).
-
- Observe that
call() returns partial audio bytes without raising, while the log shows TaskFailed: ....
The only way for applications to detect this today is to manually inspect synthesizer.get_response()["header"]["event"] == "task-failed" after call() returns, which is undocumented and easy to miss.
Expected behavior
call() (and streaming_complete()) should raise an exception in the caller's thread when the task failed, instead of silently returning partial audio.
Suggested fix
Store the error in a field inside the receiver thread and re-raise it in the main thread after the wait:
# __init__
self._receiver_error = None
# on_message, FAILED branch (non-async path)
self._receiver_error = Exception(f'TaskFailed: {message}')
self.complete_event.set()
# streaming_complete, after complete_event.wait()
if self._receiver_error is not None:
raise self._receiver_error
Thanks for looking into this.
Environment
Describe the bug
When calling
SpeechSynthesizer.call(text)in synchronous (non-callback) mode, if the server sends atask-failedevent after some audio chunks have already been delivered,call()returns the partially accumulated audio bytes as if the synthesis succeeded. No exception reaches the caller, so the application has no way to know the audio is truncated.Real-world impact: a 73-character Chinese text produced an MP3 that only speaks the first ~48 characters. The bytes were non-empty (165,602 bytes),
call()returned normally, and the truncated audio was served to end users.Root cause
In
dashscope/audio/tts_v2/speech_synthesizer.py, theon_messagehandler runs in the websocket-client receiver thread:Problems:
complete_event.set()is called before the raise, unblocking the main thread waiting instreaming_complete().raise Exception(...)happens inside the websocket-client callback. Thewebsocket-clientlibrary wraps callbacks in_callback(), which catches the exception, logs it, and callson_error(which in this SDK only logs). The exception is therefore swallowed and never propagates to the main thread.call()then returns unconditionally:Steps to reproduce
SpeechSynthesizer(model=..., voice=...)with a cosyvoice v2/v3 voice in synchronous mode.synthesizer.call(long_text)and have the server fail mid-task (transient server error; can be simulated by injecting atask-failedframe after severalaudioframes).call()returns partial audio bytes without raising, while the log showsTaskFailed: ....The only way for applications to detect this today is to manually inspect
synthesizer.get_response()["header"]["event"] == "task-failed"aftercall()returns, which is undocumented and easy to miss.Expected behavior
call()(andstreaming_complete()) should raise an exception in the caller's thread when the task failed, instead of silently returning partial audio.Suggested fix
Store the error in a field inside the receiver thread and re-raise it in the main thread after the wait:
Thanks for looking into this.
on_messagehandler runs in the websocket-client receiver thread:Problems:
complete_event.set()is called before the raise, unblocking the main thread waiting instreaming_complete().raise Exception(...)happens inside the websocket-client callback. Thewebsocket-clientlibrary wraps callbacks in_callback(), which catches the exception, logs it, and callson_error(which in this SDK only logs). The exception is therefore swallowed and never propagates to the main thread.call()then returns unconditionally:Steps to reproduce
SpeechSynthesizer(model=..., voice=...)with a cosyvoice v2/v3 voice in synchronous mode.synthesizer.call(long_text)and have the server fail mid-task (transient server error; can be simulated by injecting atask-failedframe after severalaudioframes).call()returns partial audio bytes without raising, while the log showsTaskFailed: ....The only way for applications to detect this today is to manually inspect
synthesizer.get_response()["header"]["event"] == "task-failed"aftercall()returns, which is undocumented and easy to miss.Expected behavior
call()(andstreaming_complete()) should raise an exception in the caller's thread when the task failed, instead of silently returning partial audio.Suggested fix
Store the error in a field inside the receiver thread and re-raise it in the main thread after the wait:
Thanks for looking into this.