Skip to content

Commit c316875

Browse files
committed
test: add unit tests for WebSocketOptions and its integration in streaming
1 parent 2bf07e8 commit c316875

3 files changed

Lines changed: 150 additions & 2 deletions

File tree

tests/integration/test_tts_websocket_integration.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import pytest
44

5+
from fishaudio import WebSocketOptions
56
from fishaudio.types import Prosody, TTSConfig, TextEvent, FlushEvent
67
from .conftest import TEST_REFERENCE_ID
78

@@ -118,6 +119,59 @@ def text_stream():
118119
with pytest.raises(WebSocketError, match="WebSocket stream ended with error"):
119120
list(client.tts.stream_websocket(text_stream()))
120121

122+
def test_websocket_very_long_generation_with_timeout(self, client, save_audio):
123+
"""
124+
Test that very long text generation succeeds with increased timeout.
125+
126+
This test generates a very long response that could potentially take >20 seconds
127+
to fully generate, which would cause a WebSocketNetworkError with the default
128+
keepalive_ping_timeout_seconds=20. By using an increased timeout of 60 seconds,
129+
we can handle longer generation times without disconnection.
130+
131+
This is the SOLUTION to issue #47. To reproduce the timeout issue, run:
132+
python reproduce_issue_47.py --mode=both
133+
"""
134+
# Use significantly increased timeout to handle very long generations
135+
ws_options = WebSocketOptions(
136+
keepalive_ping_timeout_seconds=60.0,
137+
keepalive_ping_interval_seconds=30.0,
138+
)
139+
140+
def text_stream():
141+
# Generate a very long piece of text that will take significant time to process
142+
long_text = [
143+
"This is a test of very long form text-to-speech generation. ",
144+
"We are testing the ability to handle extended generation times without timing out. ",
145+
"The default WebSocket keepalive timeout of 20 seconds can be insufficient for long responses. ",
146+
"By increasing the keepalive_ping_timeout_seconds to 60 seconds, we allow for longer gaps between chunks. ",
147+
"This is particularly important for conversational AI applications where responses can be quite lengthy. ",
148+
"The WebSocket connection should remain stable throughout the entire generation process. ",
149+
"We include enough text here to ensure the generation takes a substantial amount of time. ",
150+
"This helps verify that the increased timeout setting is working correctly. ",
151+
"The audio streaming should continue smoothly without any network errors. ",
152+
"Each sentence adds more content to be synthesized into speech. ",
153+
"The system should handle this gracefully with the custom WebSocket options. ",
154+
"This demonstrates the practical value of the WebSocketOptions feature. ",
155+
"Users can now configure timeouts based on their specific use case requirements. ",
156+
"Long-form content generation is now much more reliable. ",
157+
"The implementation passes through all necessary parameters to the underlying httpx_ws library. ",
158+
]
159+
for sentence in long_text:
160+
yield sentence
161+
162+
# This should succeed with increased timeout
163+
audio_chunks = list(
164+
client.tts.stream_websocket(text_stream(), ws_options=ws_options)
165+
)
166+
167+
assert len(audio_chunks) > 0, "Should receive audio chunks for very long text"
168+
complete_audio = b"".join(audio_chunks)
169+
# Very long text should produce substantial audio
170+
assert len(complete_audio) > 10000, (
171+
"Very long text should produce substantial audio data"
172+
)
173+
save_audio(audio_chunks, "test_websocket_very_long_with_timeout.mp3")
174+
121175

122176
class TestAsyncTTSWebSocketIntegration:
123177
"""Test async TTS WebSocket streaming with real API."""

tests/unit/test_core.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@
44
from unittest.mock import patch
55
import httpx
66

7-
from fishaudio.core import OMIT, ClientWrapper, AsyncClientWrapper, RequestOptions
7+
from fishaudio.core import (
8+
OMIT,
9+
ClientWrapper,
10+
AsyncClientWrapper,
11+
RequestOptions,
12+
WebSocketOptions,
13+
)
814

915

1016
class TestOMIT:
@@ -51,6 +57,38 @@ def test_get_timeout(self):
5157
assert timeout.connect == 30.0
5258

5359

60+
class TestWebSocketOptions:
61+
"""Test WebSocketOptions class."""
62+
63+
def test_to_httpx_ws_kwargs_all_options(self):
64+
"""Test to_httpx_ws_kwargs with all options set."""
65+
options = WebSocketOptions(
66+
keepalive_ping_timeout_seconds=60.0,
67+
keepalive_ping_interval_seconds=30.0,
68+
max_message_size_bytes=131072,
69+
queue_size=1024,
70+
)
71+
kwargs = options.to_httpx_ws_kwargs()
72+
assert kwargs == {
73+
"keepalive_ping_timeout_seconds": 60.0,
74+
"keepalive_ping_interval_seconds": 30.0,
75+
"max_message_size_bytes": 131072,
76+
"queue_size": 1024,
77+
}
78+
79+
def test_to_httpx_ws_kwargs_partial_options(self):
80+
"""Test to_httpx_ws_kwargs with only some options set."""
81+
options = WebSocketOptions(keepalive_ping_timeout_seconds=60.0)
82+
kwargs = options.to_httpx_ws_kwargs()
83+
assert kwargs == {"keepalive_ping_timeout_seconds": 60.0}
84+
assert "keepalive_ping_interval_seconds" not in kwargs
85+
86+
def test_to_httpx_ws_kwargs_no_options(self):
87+
"""Test to_httpx_ws_kwargs with no options set."""
88+
options = WebSocketOptions()
89+
assert options.to_httpx_ws_kwargs() == {}
90+
91+
5492
class TestClientWrapper:
5593
"""Test sync ClientWrapper."""
5694

tests/unit/test_tts_realtime.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pytest
44
from unittest.mock import Mock, AsyncMock, MagicMock, patch
55

6-
from fishaudio.core import ClientWrapper, AsyncClientWrapper
6+
from fishaudio.core import ClientWrapper, AsyncClientWrapper, WebSocketOptions
77
from fishaudio.resources.tts import TTSClient, AsyncTTSClient
88
from fishaudio.types import Prosody, TTSConfig, TextEvent, FlushEvent, ReferenceAudio
99
import ormsgpack
@@ -345,6 +345,30 @@ def submit_side_effect(fn):
345345
assert len(start_event_payload["request"]["references"]) == 1
346346
assert start_event_payload["request"]["references"][0]["text"] == "Param"
347347

348+
@patch("fishaudio.resources.tts.connect_ws")
349+
@patch("fishaudio.resources.tts.ThreadPoolExecutor")
350+
def test_stream_websocket_with_ws_options(
351+
self, mock_executor, mock_connect_ws, tts_client, mock_client_wrapper
352+
):
353+
"""Test WebSocket streaming passes through WebSocketOptions."""
354+
mock_ws = MagicMock()
355+
mock_ws.__enter__ = Mock(return_value=mock_ws)
356+
mock_ws.__exit__ = Mock(return_value=None)
357+
mock_connect_ws.return_value = mock_ws
358+
mock_future = Mock()
359+
mock_future.result.return_value = None
360+
mock_executor_instance = Mock()
361+
mock_executor_instance.submit.return_value = mock_future
362+
mock_executor.return_value = mock_executor_instance
363+
364+
with patch("fishaudio.resources.tts.iter_websocket_audio") as mock_receiver:
365+
mock_receiver.return_value = iter([b"audio"])
366+
ws_options = WebSocketOptions(keepalive_ping_timeout_seconds=60.0)
367+
list(tts_client.stream_websocket(iter(["Test"]), ws_options=ws_options))
368+
assert (
369+
mock_connect_ws.call_args[1]["keepalive_ping_timeout_seconds"] == 60.0
370+
)
371+
348372

349373
class TestAsyncTTSRealtimeClient:
350374
"""Test asynchronous AsyncTTSClient realtime streaming."""
@@ -649,3 +673,35 @@ async def text_stream():
649673
start_event_payload = ormsgpack.unpackb(first_call[0][0])
650674
assert len(start_event_payload["request"]["references"]) == 1
651675
assert start_event_payload["request"]["references"][0]["text"] == "Param"
676+
677+
@pytest.mark.asyncio
678+
@patch("fishaudio.resources.tts.aconnect_ws")
679+
async def test_stream_websocket_with_ws_options(
680+
self, mock_aconnect_ws, async_tts_client, async_mock_client_wrapper
681+
):
682+
"""Test async WebSocket streaming passes through WebSocketOptions."""
683+
mock_ws = MagicMock()
684+
mock_ws.__aenter__ = AsyncMock(return_value=mock_ws)
685+
mock_ws.__aexit__ = AsyncMock(return_value=None)
686+
mock_ws.send_bytes = AsyncMock()
687+
mock_aconnect_ws.return_value = mock_ws
688+
689+
async def mock_audio_receiver(ws):
690+
yield b"audio"
691+
692+
with patch(
693+
"fishaudio.resources.tts.aiter_websocket_audio",
694+
return_value=mock_audio_receiver(mock_ws),
695+
):
696+
ws_options = WebSocketOptions(keepalive_ping_timeout_seconds=60.0)
697+
698+
async def text_stream():
699+
yield "Test"
700+
701+
async for _ in async_tts_client.stream_websocket(
702+
text_stream(), ws_options=ws_options
703+
):
704+
pass
705+
assert (
706+
mock_aconnect_ws.call_args[1]["keepalive_ping_timeout_seconds"] == 60.0
707+
)

0 commit comments

Comments
 (0)