Skip to content

WavesStreamingTTS: a stream cut short by a clean socket close looks like a finished one #115

Description

@CaptainAni187

When the TTS WebSocket closes part way through a stream without sending complete and without an error, synthesize returns the audio it got so far as though that were the whole answer. Nothing raises and nothing on the object says the stream was cut short, so a caller writing the chunks to a file gets truncated audio and no way to know.

_on_close is where it happens:

def _on_close(self, ws, *args):
    self.is_connected = False
    if not self.is_complete:
        self.audio_queue.put(None)

None is the same sentinel the complete branch uses, so the consume loop reads a dropped connection and a finished stream identically and breaks out of the loop either way.

Reproducing

A stand-in socket that delivers two chunks and then closes cleanly, which is what a load balancer timing out an idle upstream or a worker restart looks like from the client side:

import json
from unittest.mock import patch
from smallestai.waves.stream_tts import TTSConfig, WavesStreamingTTS

class FakeWS:
    def __init__(self, url, header=None, on_open=None, on_message=None, on_error=None, on_close=None):
        self.on_open, self.on_message, self.on_close = on_open, on_message, on_close
    def run_forever(self):
        self.on_open(self)
        import time; time.sleep(0.4)
        self.on_message(self, json.dumps({"status": "", "data": {"audio": "YQ=="}}))
        self.on_message(self, json.dumps({"status": "", "data": {"audio": "Yg=="}}))
        self.on_close(self)          # no "complete", no error
    def send(self, payload): pass
    def close(self): pass

with patch("smallestai.waves.stream_tts.WebSocketApp", FakeWS):
    tts = WavesStreamingTTS(TTSConfig(voice_id="magnus", api_key="k"))
    chunks = list(tts.synthesize("a long sentence the server never finished"))

print("chunks received :", chunks)
print("is_complete     :", tts.is_complete)
chunks received : [b'a', b'b']
is_complete     : False
raised          : nothing

is_complete is False, so the object knows the stream never finished. The caller just never gets told.

What I think it should do

Raising on a close that arrives before complete seems right to me, since a partial answer presented as a whole one is worse than an error a caller can retry. But that turns a silent truncation into an exception for anyone relying on the current behaviour, so it is your call rather than mine. Two milder options if you would rather not break that: leave is_complete as the documented way to check after the generator ends, or put the truncation on the error queue only when at least one chunk was received.

I left this out of #112 for that reason. That PR only closes sockets and changes no behaviour. Happy to send whichever of these you prefer.

Found while reading the module for #112, on main at fb2502c.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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