Skip to content

[tts_v2] SpeechSynthesizer.call() silently returns partial audio when a task-failed event arrives mid-synthesis (exception swallowed in WebSocket receiver thread) #167

Description

@MicrowaveOven

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:

  1. complete_event.set() is called before the raise, unblocking the main thread waiting in streaming_complete().
  2. 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.
  3. call() then returns unconditionally:
self.streaming_complete(timeout_millis)
return self._audio_data   # partial audio, no error check

Steps to reproduce

  1. Use SpeechSynthesizer(model=..., voice=...) with a cosyvoice v2/v3 voice in synchronous mode.
  2. 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).
  3. 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:

  1. complete_event.set() is called before the raise, unblocking the main thread waiting in streaming_complete().
    1. 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.
    1. call() then returns unconditionally:
self.streaming_complete(timeout_millis)
return self._audio_data   # partial audio, no error check

Steps to reproduce

  1. Use SpeechSynthesizer(model=..., voice=...) with a cosyvoice v2/v3 voice in synchronous mode.
    1. 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).
    1. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions