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
4 changes: 2 additions & 2 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ jobs:
python-version: "3"
- name: Install dependencies
run: uv sync
- name: Run mypy
run: uv run mypy src/fishaudio --ignore-missing-imports
- name: Run ty
run: uv run ty check

test:
name: Test Python ${{ matrix.python-version }}
Expand Down
183 changes: 183 additions & 0 deletions examples/getting_started.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Fish Audio SDK - Getting Started\n",
"\n",
"This notebook demonstrates the basic usage of the Fish Audio Python SDK:\n",
"- Initialize the client\n",
"- Convert text to speech\n",
"- Save and play audio\n",
"- Use different voices"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Setup\n",
"\n",
"First, install the SDK and set your API key:\n",
"\n",
"```bash\n",
"pip install fishaudio\n",
"export FISH_API_KEY=\"your_api_key\"\n",
"```\n",
"\n",
"Or create a `.env` file with `FISH_API_KEY=your_api_key`"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"ExecuteTime": {
"end_time": "2025-12-17T02:19:01.713412Z",
"start_time": "2025-12-17T02:19:01.692232Z"
}
},
"outputs": [],
"source": "from dotenv import load_dotenv\nfrom fishaudio import FishAudio\nfrom fishaudio.utils import play\n# from fishaudio.utils import save # Uncomment if saving audio to file\n\nload_dotenv()\n\nclient = FishAudio()"
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Simple Text-to-Speech\n",
"\n",
"Convert text to speech and play it directly in the notebook."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"ExecuteTime": {
"end_time": "2025-12-17T02:19:02.811072Z",
"start_time": "2025-12-17T02:19:01.715025Z"
}
},
"outputs": [],
"source": [
"audio = client.tts.convert(text=\"Hello! Welcome to Fish Audio.\")\n",
"\n",
"play(audio, notebook=True)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Save Audio to File\n",
"\n",
"Save the generated audio to an MP3 file."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"ExecuteTime": {
"end_time": "2025-12-17T02:19:02.822441Z",
"start_time": "2025-12-17T02:19:02.818578Z"
}
},
"outputs": [],
"source": [
"# audio = client.tts.convert(text=\"This audio will be saved to a file.\")\n",
"# save(audio, \"output.mp3\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Using a Specific Voice\n",
"\n",
"Use `reference_id` to specify a voice model from your Fish Audio account."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"ExecuteTime": {
"end_time": "2025-12-17T02:19:02.826568Z",
"start_time": "2025-12-17T02:19:02.822894Z"
}
},
"outputs": [],
"source": [
"# Replace with your voice model ID\n",
"# audio = client.tts.convert(\n",
"# text=\"Hello from a custom voice!\",\n",
"# reference_id=\"your-voice-model-id\"\n",
"# )\n",
"# play(audio, notebook=True)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Streaming Audio\n",
"\n",
"For longer text, use `stream()` to process audio chunks as they arrive."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"ExecuteTime": {
"end_time": "2025-12-17T02:19:03.824681Z",
"start_time": "2025-12-17T02:19:02.826991Z"
}
},
"outputs": [],
"source": [
"stream = client.tts.stream(text=\"This is a longer piece of text that will be streamed.\")\n",
"audio = stream.collect()\n",
"\n",
"play(audio, notebook=True)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Check Account Credits"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"ExecuteTime": {
"end_time": "2025-12-17T02:19:03.897563Z",
"start_time": "2025-12-17T02:19:03.833734Z"
}
},
"outputs": [],
"source": [
"credits = client.account.get_credits()\n",
"print(f\"Remaining credits: {credits.credit}\")"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11.0"
}
},
"nbformat": 4,
"nbformat_minor": 4
}
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dependencies = [
"ormsgpack>=1.5.0",
"pydantic>=2.9.1",
"httpx-ws>=0.6.2",
"typing-extensions>=4.15.0",
]
requires-python = ">=3.9"
readme = "README.md"
Expand Down Expand Up @@ -55,14 +56,15 @@ asyncio_mode = "auto"

[dependency-groups]
dev = [
"mypy>=1.14.1",
"fish-audio-sdk[utils]",
"pydoc-markdown>=4.8.2",
"pytest>=8.3.5",
"pytest-asyncio>=0.24.0",
"pytest-cov>=5.0.0",
"pytest-rerunfailures>=16.0.1",
"python-dotenv>=1.0.1",
"ruff>=0.14.3",
"ty>=0.0.2",
]

[[tool.pydoc-markdown.loaders]]
Expand All @@ -80,3 +82,6 @@ pages = [
{title = "Utils", name="fishaudio/utils", contents = ["fishaudio.utils.*"] },
{title = "Exceptions", name="fishaudio/exceptions", contents = ["fishaudio.exceptions.*"] },
]

[tool.uv.sources]
fish-audio-sdk = { workspace = true }
4 changes: 2 additions & 2 deletions src/fish_audio_sdk/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
AsyncGenerator,
Awaitable,
Callable,
Concatenate,
Generator,
Generic,
ParamSpec,
TypeVar,
)

from typing_extensions import Concatenate, ParamSpec

import httpx
import httpx._client
import httpx._types
Expand Down
10 changes: 5 additions & 5 deletions src/fishaudio/utils/play.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import io
import subprocess
from typing import Iterator, Union
from typing import Iterable, Union

from ..exceptions import DependencyError

Expand All @@ -17,7 +17,7 @@ def _is_installed(command: str) -> bool:


def play(
audio: Union[bytes, Iterator[bytes]],
audio: Union[bytes, Iterable[bytes]],
*,
notebook: bool = False,
use_ffmpeg: bool = True,
Expand All @@ -26,7 +26,7 @@ def play(
Play audio using various playback methods.

Args:
audio: Audio bytes or iterator of bytes
audio: Audio bytes or iterable of bytes
notebook: Use Jupyter notebook playback (IPython.display.Audio)
use_ffmpeg: Use ffplay for playback (default, falls back to sounddevice)

Expand All @@ -51,13 +51,13 @@ def play(
```
"""
# Consolidate iterator to bytes
if isinstance(audio, Iterator):
if not isinstance(audio, bytes):
audio = b"".join(audio)

# Notebook mode
if notebook:
try:
from IPython.display import Audio, display
from IPython.display import Audio, display # ty: ignore[unresolved-import]

display(Audio(audio, rate=44100, autoplay=True))
return
Expand Down
8 changes: 4 additions & 4 deletions src/fishaudio/utils/save.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
"""Audio saving utility."""

from typing import Iterator, Union
from typing import Iterable, Union


def save(audio: Union[bytes, Iterator[bytes]], filename: str) -> None:
def save(audio: Union[bytes, Iterable[bytes]], filename: str) -> None:
"""
Save audio to a file.

Args:
audio: Audio bytes or iterator of bytes
audio: Audio bytes or iterable of bytes
filename: Path to save the audio file

Examples:
Expand All @@ -27,7 +27,7 @@ def save(audio: Union[bytes, Iterator[bytes]], filename: str) -> None:
```
"""
# Consolidate iterator to bytes if needed
if isinstance(audio, Iterator):
if not isinstance(audio, bytes):
audio = b"".join(audio)

# Write to file
Expand Down
22 changes: 15 additions & 7 deletions tests/unit/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,24 @@ def test_play_ffmpeg_not_installed(self):
assert "ffplay" in str(exc_info.value)

def test_play_sounddevice_mode(self):
"""Test using sounddevice directly."""
# Since we can't easily test sounddevice without installing it,
# just test that the error is raised when it's not available
with patch(
"subprocess.run", side_effect=[subprocess.CalledProcessError(1, "which")]
):
"""Test using sounddevice for playback."""
mock_sd = Mock()
mock_sf = Mock()
mock_sf.read.return_value = ([0.1, 0.2], 44100)

with patch.dict("sys.modules", {"sounddevice": mock_sd, "soundfile": mock_sf}):
play(b"audio", use_ffmpeg=False)

mock_sf.read.assert_called_once()
mock_sd.play.assert_called_once()
mock_sd.wait.assert_called_once()

def test_play_sounddevice_not_installed(self):
"""Test error when sounddevice not installed."""
with patch.dict("sys.modules", {"sounddevice": None, "soundfile": None}):
with pytest.raises(DependencyError) as exc_info:
play(b"audio", use_ffmpeg=False)

# Should mention sounddevice in the error
assert "sounddevice" in str(exc_info.value) or "fishaudio[utils]" in str(
exc_info.value
)
Expand Down
Loading