Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ evals = [
]
server = [
"fastapi>=0.115.8",
"ormsgpack>=1.5.0",
"uvicorn>=0.34.0",
"websockets>=15.0.1",
]
Expand Down
230 changes: 230 additions & 0 deletions python/tests/voice/test_fish_audio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
from collections import deque
from unittest.mock import AsyncMock, patch

import ormsgpack
import pytest
from pydantic import SecretStr
from timbal.voice.config import DEFAULT_VOICE_ID
from timbal.voice.fish_audio import (
DEFAULT_TTS_MODEL,
FishAudioStreamTTS,
_resolve_api_key,
build_start_request,
effective_reference_id,
effective_tts_model,
)
from timbal.voice.providers import AudioOutputConfig


def _cfg(**kwargs) -> AudioOutputConfig:
return AudioOutputConfig(**kwargs)


def test_resolve_api_key_explicit_and_secret():
assert _resolve_api_key("plain") == "plain"
assert _resolve_api_key(SecretStr("secret")) == "secret"


def test_resolve_api_key_env(monkeypatch):
monkeypatch.setenv("FISH_API_KEY", "env-key")
assert _resolve_api_key(None) == "env-key"


def test_resolve_api_key_missing(monkeypatch):
monkeypatch.delenv("FISH_API_KEY", raising=False)
with pytest.raises(ValueError, match="FISH_API_KEY"):
_resolve_api_key(None)


def test_effective_tts_model_swaps_foreign_ids():
assert effective_tts_model(_cfg(model=None)) == DEFAULT_TTS_MODEL
assert effective_tts_model(_cfg(model="eleven_flash_v2_5")) == DEFAULT_TTS_MODEL
assert effective_tts_model(_cfg(model="faseeh-v1-preview")) == DEFAULT_TTS_MODEL
assert effective_tts_model(_cfg(model="s1")) == "s1"
assert effective_tts_model(_cfg(model="s2.1-pro-free")) == "s2.1-pro-free"


def test_effective_reference_id(monkeypatch):
monkeypatch.delenv("FISH_VOICE_ID", raising=False)
# Foreign/empty voice → None → Fish Audio's platform default voice.
assert effective_reference_id(_cfg(voice=None)) is None
assert effective_reference_id(_cfg(voice=DEFAULT_VOICE_ID)) is None
assert effective_reference_id(_cfg(voice="9a9cf47702da476aa4629e2506d4a857")) == "9a9cf47702da476aa4629e2506d4a857"
monkeypatch.setenv("FISH_VOICE_ID", "env-voice")
assert effective_reference_id(_cfg(voice=None)) == "env-voice"


def test_build_start_request(monkeypatch):
monkeypatch.delenv("FISH_VOICE_ID", raising=False)
req = build_start_request(
_cfg(voice="voice-1", sample_rate=16000, extra={"temperature": 0.6, "speed": 1.1}),
)
assert req == {
"text": "",
"format": "pcm",
"sample_rate": 16000,
"latency": "balanced",
"prosody": {"speed": 1.1, "volume": 0.0},
"condition_on_previous_chunks": True,
"reference_id": "voice-1",
"temperature": 0.6,
}
# Overridable via tts_extra.
off = build_start_request(_cfg(voice=None, extra={"condition_on_previous_chunks": False}))
assert off["condition_on_previous_chunks"] is False
# No reference_id key at all when the platform default voice is used.
assert "reference_id" not in build_start_request(_cfg(voice=None, sample_rate=16000))


class FakeWS:
"""Scripted Fish Audio live socket: iteration serves msgpack frames."""

def __init__(self, frames: list[dict]) -> None:
self._frames = deque(ormsgpack.packb(f) for f in frames)
self.sent: list[dict] = []
self.closed = False

async def send(self, raw: bytes) -> None:
self.sent.append(ormsgpack.unpackb(raw))

def __aiter__(self):
return self

async def __anext__(self) -> bytes:
if not self._frames:
raise StopAsyncIteration
return self._frames.popleft()

async def close(self) -> None:
self.closed = True


@pytest.mark.asyncio
async def test_stream_protocol_roundtrip(monkeypatch):
monkeypatch.setenv("FISH_API_KEY", "test-key")
monkeypatch.delenv("FISH_VOICE_ID", raising=False)
pcm = b"\x01\x02" * 8
fake = FakeWS(
[
{"event": "audio", "audio": pcm},
{"event": "audio", "audio": pcm},
{"event": "finish", "reason": "stop"},
]
)

tts = FishAudioStreamTTS()
await tts.connect(_cfg(model="eleven_flash_v2_5", voice=DEFAULT_VOICE_ID, sample_rate=16000))

with patch("timbal.voice.fish_audio.ws_connect", AsyncMock(return_value=fake)) as connect_mock:
stream = tts.open_stream()
await stream.feed("Hello, ")
await stream.feed("world.")
await stream.end()
chunks = [chunk async for chunk in stream.audio()]

assert chunks == [pcm, pcm]
assert fake.closed

uri = connect_mock.await_args.args[0]
assert uri == "wss://api.fish.audio/v1/tts/live"
headers = connect_mock.await_args.kwargs["additional_headers"]
assert headers["Authorization"] == "Bearer test-key"
assert headers["model"] == DEFAULT_TTS_MODEL

start, text1, text2, flush, stop = fake.sent
assert start["event"] == "start"
assert start["request"]["format"] == "pcm"
assert start["request"]["sample_rate"] == 16000
assert "reference_id" not in start["request"]
assert text1 == {"event": "text", "text": "Hello, "}
assert text2 == {"event": "text", "text": "world."}
assert flush == {"event": "flush"}
assert stop == {"event": "stop"}


@pytest.mark.asyncio
async def test_finish_error_raises(monkeypatch):
monkeypatch.setenv("FISH_API_KEY", "test-key")
fake = FakeWS([{"event": "finish", "reason": "error"}])

tts = FishAudioStreamTTS()
await tts.connect(_cfg(voice="voice-1"))

with patch("timbal.voice.fish_audio.ws_connect", AsyncMock(return_value=fake)):
stream = tts.open_stream()
await stream.feed("Hello")
await stream.end()
with pytest.raises(RuntimeError, match="reason=error"):
async for _ in stream.audio():
pass


@pytest.mark.asyncio
async def test_synthesize_wraps_stream(monkeypatch):
monkeypatch.setenv("FISH_API_KEY", "test-key")
pcm = b"\x00\x01" * 4
fake = FakeWS(
[
{"event": "audio", "audio": pcm},
{"event": "finish", "reason": "stop"},
]
)

tts = FishAudioStreamTTS()
await tts.connect(_cfg(voice="voice-1"))

with patch("timbal.voice.fish_audio.ws_connect", AsyncMock(return_value=fake)):
chunks = [chunk async for chunk in tts.synthesize("Hello")]

assert chunks == [pcm]


@pytest.mark.asyncio
async def test_http_402_rejection_gives_actionable_error(monkeypatch):
from websockets.datastructures import Headers
from websockets.exceptions import InvalidStatus
from websockets.http11 import Response

monkeypatch.setenv("FISH_API_KEY", "test-key")
rejection = InvalidStatus(Response(402, "Payment Required", Headers()))

tts = FishAudioStreamTTS()
await tts.connect(_cfg(voice="voice-1"))

with patch("timbal.voice.fish_audio.ws_connect", AsyncMock(side_effect=rejection)):
stream = tts.open_stream()
with pytest.raises(RuntimeError, match="HTTP 402.*s2.1-pro-free"):
await stream.feed("Hello")


@pytest.mark.asyncio
async def test_abort_unblocks_audio(monkeypatch):
monkeypatch.setenv("FISH_API_KEY", "test-key")
tts = FishAudioStreamTTS()
await tts.connect(_cfg(voice="voice-1"))

stream = tts.open_stream()
await stream.abort()
chunks = [chunk async for chunk in stream.audio()]
assert chunks == []


@pytest.mark.asyncio
async def test_unknown_events_ignored(monkeypatch):
monkeypatch.setenv("FISH_API_KEY", "test-key")
pcm = b"\x07\x08"
fake = FakeWS(
[
{"event": "log", "message": "future extension"},
{"event": "audio", "audio": pcm},
{"event": "finish", "reason": "stop"},
]
)

tts = FishAudioStreamTTS()
await tts.connect(_cfg(voice="voice-1"))

with patch("timbal.voice.fish_audio.ws_connect", AsyncMock(return_value=fake)):
chunks = [chunk async for chunk in tts.synthesize("Hi")]

assert chunks == [pcm]
Loading