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
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ pip install fish-audio-sdk[utils]

## Authentication

Get your API key from [fish.audio/app/api-keys](https://fish.audio/app/api-keys):
Get your API key from [fish.audio/app/api-keys](https://fish.audio/app/api-keys/?utm_source=github&utm_medium=sdk-readme&utm_content=python):

```bash
export FISH_API_KEY=your_api_key_here
Expand Down Expand Up @@ -93,6 +93,18 @@ production_audio = client.tts.convert(
text="Production speech",
model="s2.1-pro",
)

# Free to use, including on a new account with no API credit
free_audio = client.tts.convert(
text="Free speech",
model="s2.1-pro-free",
)

# Drama 3: our most expressive model (uses API credit)
drama_audio = client.tts.convert(
text="Expressive speech",
model="drama-3",
)
```

**With custom voice:**
Expand Down
2 changes: 1 addition & 1 deletion src/fishaudio/types/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class PaginatedResponse(BaseModel, Generic[T]):


# Model types
Model = Literal["s1", "s2-pro", "s2.1-pro"]
Model = Literal["s1", "s2-pro", "s2.1-pro", "s2.1-pro-free", "drama-3"]

# Deprecated models
DEPRECATED_MODELS = {"speech-1.5", "speech-1.6"}
Expand Down
7 changes: 6 additions & 1 deletion src/fishaudio/types/tts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from typing import Annotated, Literal, Optional

from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_serializer

from .shared import AudioFormat, LatencyMode

Expand Down Expand Up @@ -157,6 +157,11 @@ class TTSRequest(BaseModel):
condition_on_previous_chunks: bool = True
early_stop_threshold: float = 1.0

@field_serializer("opus_bitrate")
def _opus_bitrate_bps(self, value: int) -> int:
# The API takes opus_bitrate in bps (24000, 32000, ...); -1000 means auto.
return value * 1000 if value > 0 else value


# WebSocket event types for streaming TTS
class StartEvent(BaseModel):
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/test_tts.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,30 @@ def test_convert_basic(self, tts_client, mock_client_wrapper):
call_args = mock_client_wrapper.request.call_args
assert call_args[1]["headers"]["model"] == "s2.1-pro"

def test_convert_with_free_model(self, tts_client, mock_client_wrapper):
"""Test the free s2.1-pro-free model is sent as the model header."""
mock_response = Mock()
mock_response.iter_bytes.return_value = iter([b"audio"])
mock_client_wrapper.request.return_value = mock_response

audio = tts_client.convert(text="Hello world", model="s2.1-pro-free")

assert audio == b"audio"
call_args = mock_client_wrapper.request.call_args
assert call_args[1]["headers"]["model"] == "s2.1-pro-free"

def test_convert_with_drama_3(self, tts_client, mock_client_wrapper):
"""Test the drama-3 model is sent as the model header."""
mock_response = Mock()
mock_response.iter_bytes.return_value = iter([b"audio"])
mock_client_wrapper.request.return_value = mock_response

audio = tts_client.convert(text="Hello world", model="drama-3")

assert audio == b"audio"
call_args = mock_client_wrapper.request.call_args
assert call_args[1]["headers"]["model"] == "drama-3"

def test_convert_with_reference_id(self, tts_client, mock_client_wrapper):
"""Test TTS with reference voice ID."""
mock_response = Mock()
Expand Down
8 changes: 8 additions & 0 deletions tests/unit/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,11 @@ def test_voice_with_unlist_visibility(self, sample_voice_response):
sample_voice_response["visibility"] = "unlist"
voice = Voice.model_validate(sample_voice_response)
assert voice.visibility == "unlist"


def test_tts_request_sends_opus_bitrate_in_bps():
assert TTSRequest(text="hi", format="opus").model_dump()["opus_bitrate"] == 32000
assert TTSRequest(text="hi", opus_bitrate=64).model_dump()["opus_bitrate"] == 64000
assert (
TTSRequest(text="hi", opus_bitrate=-1000).model_dump()["opus_bitrate"] == -1000
)
Loading