Async-first TTS (Text-to-Speech) wrapper library for Python
Project description
SpeechFlow
English | 日本語
SpeechFlow is an async-first Python library for text-to-speech (TTS). It gives
cloud and local TTS engines the same get() and stream() interface, and it
ships small audio utilities for playback, WAV writing, and WebSocket output.
The core package depends only on NumPy. Engines and I/O integrations are installed as optional extras, so applications can install only the backends they use.
What You Get
- Unified async engine API:
await engine.get(...)andasync for chunk in engine.stream(...) - Optional sync wrappers for non-async applications
- Normalized
AudioDataobjects: float32 PCM samples, usually mono, with an explicit sample rate - Cloud engines: OpenAI, Google Gemini, FishAudio, ElevenLabs
- Local engines: Kokoro, Qwen3-TTS, Style-Bert-VITS2
- Audio utilities: WAV writing, sounddevice playback, and
/playbackWebSocket output - Domain-specific exceptions:
TTSError,ConfigurationError,StreamingError, andAudioProcessingError
Installation
SpeechFlow requires Python 3.11 or newer.
Core only:
uv add speechflow
Install one engine:
uv add "speechflow[openai]"
Install an engine with playback support:
uv add "speechflow[openai,player]"
Install everything:
uv add "speechflow[all]"
Using pip:
pip install speechflow
pip install "speechflow[openai]"
pip install "speechflow[openai,player]"
pip install "speechflow[all]"
Optional Extras
| Extra | Adds | Main dependency |
|---|---|---|
openai |
OpenAITTSEngine |
openai |
gemini |
GeminiTTSEngine |
google-genai |
fishaudio |
FishAudioTTSEngine |
fish-audio-sdk |
elevenlabs |
ElevenLabsTTSEngine |
elevenlabs |
kokoro |
KokoroTTSEngine |
kokoro, torch, Japanese language helpers |
qwen3tts |
Qwen3TTSEngine |
qwen-tts |
stylebert |
StyleBertTTSEngine |
style-bert-vits2, torch |
player |
AudioPlayer |
sounddevice |
ws |
WebSocketAudioSink |
websockets |
all |
All engines, playback, and WebSocket output | All optional dependencies above |
Optional engine classes are exported from the top-level package lazily. After
installing the openai extra, from speechflow import OpenAITTSEngine
becomes available, but importing speechflow itself does not load optional
engine dependencies. Accessing a class whose extra is not installed raises an
install-hint error such as pip install "speechflow[openai]".
GPU Setup For Local Engines
Local engines can be CPU-only, but GPU acceleration is often important for Kokoro, Qwen3-TTS, and Style-Bert-VITS2. If you need a specific PyTorch build, install it before installing the SpeechFlow extra so your environment keeps the wheel you selected.
Example for CUDA 12.6:
# uv
uv add torch torchvision torchaudio --index https://download.pytorch.org/whl/cu126
uv add "speechflow[kokoro]"
# pip
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu126
pip install "speechflow[kokoro]"
Replace cu126 with the CUDA wheel that matches your system.
Quick Start
Generate And Save Audio
import asyncio
from speechflow import AudioWriter, OpenAITTSEngine
async def main() -> None:
engine = OpenAITTSEngine(api_key="your-openai-api-key")
writer = AudioWriter()
audio = await engine.get("Hello from SpeechFlow.")
await writer.save(audio, "hello.wav")
print(audio.sample_rate, audio.channels, audio.duration)
asyncio.run(main())
Stream Audio
Every engine exposes stream() as an async iterator of AudioData chunks. The
chunking behavior depends on the engine, but consuming code can treat every
stream the same way.
import asyncio
from speechflow import AudioWriter, OpenAITTSEngine
async def main() -> None:
engine = OpenAITTSEngine(api_key="your-openai-api-key")
writer = AudioWriter()
await writer.save_stream(
engine.stream("This can be a longer piece of text."),
"streamed.wav",
)
asyncio.run(main())
Play Audio
Install the player extra first:
uv add "speechflow[openai,player]"
import asyncio
from speechflow import AudioPlayer, OpenAITTSEngine
async def main() -> None:
engine = OpenAITTSEngine(api_key="your-openai-api-key")
player = AudioPlayer()
audio = await engine.get("This will play through the default audio device.")
await player.play(audio)
asyncio.run(main())
For streaming playback:
combined = await player.play_stream(engine.stream("Play chunks as they arrive."))
play_stream() returns one combined AudioData containing every played chunk.
Warm Up Local Engines
Local engines can have a noticeable cold penalty on their first synthesis while lazy model and GPU resources initialize. Call warmup() after constructing an engine and before accepting the first request:
engine = StyleBertTTSEngine(model_name="jvnv-F1-jp", device="cuda")
await engine.warmup()
audio = await engine.get("The first user request is now served warm.")
warmup() is awaitable and completes only when preparation succeeds. Failures propagate as TTSError; repeated calls after success are inexpensive no-ops. Engines without specialized warmup behavior inherit an immediate no-op. Synchronous applications can call the blocking warmup_sync() wrapper.
Use Sync Wrappers
Sync wrappers provide a blocking API for callers that cannot use the async API directly. They submit work to one shared daemon background event loop and block the calling thread until a result is available. They also work when the caller already has a running event loop, but async code should normally prefer the async API to avoid blocking its event-loop thread.
from speechflow import AudioWriter, OpenAITTSEngine
engine = OpenAITTSEngine(api_key="your-openai-api-key")
writer = AudioWriter()
audio = engine.get_sync("Hello from sync code.")
writer.save_sync(audio, "sync.wav")
Important sync behavior:
- Engine, writer, and player sync wrappers all use the same thread-safe background event loop.
stream_sync()yields each async chunk synchronously as it arrives. Closing a partially consumed iterator also closes the underlying async stream.- Engine-specific per-call arguments such as
speed,instructions,stability, orspeakerare available on asyncget()andstream(). The inherited engine sync wrappers only accepttext,model, andvoice. - Every sync wrapper blocks its calling thread. Prefer
awaitandasync forin async code.
Public API
Top-level imports from speechflow:
| Name | Availability | Purpose |
|---|---|---|
AudioData |
Core | Audio container with data, sample_rate, channels, format, and duration |
TTSEngineBase |
Core | Abstract async engine contract |
AudioWriter |
Core | WAV writing for complete buffers and streams |
AudioPlayer |
player extra |
Playback for complete buffers and streams |
OpenAITTSEngine |
openai extra |
OpenAI TTS engine |
GeminiTTSEngine |
gemini extra |
Google Gemini TTS engine |
FishAudioTTSEngine |
fishaudio extra |
FishAudio TTS engine |
ElevenLabsTTSEngine |
elevenlabs extra |
ElevenLabs TTS engine |
KokoroTTSEngine |
kokoro extra |
Local Kokoro engine |
Qwen3TTSEngine |
qwen3tts extra |
Local Qwen3-TTS engine |
StyleBertTTSEngine |
stylebert extra |
Local Style-Bert-VITS2 engine |
TTSError |
Core | Base SpeechFlow exception |
ConfigurationError |
Core | Invalid configuration, missing credentials, unsupported parameters |
AudioProcessingError |
Core | Audio conversion, playback, writing, or output failures |
StreamingError |
Core | Mid-stream synthesis failures |
EngineNotFoundError |
Core | Requested engine is unavailable or not installed |
WebSocketAudioSink is exported from speechflow.audio when the ws extra is
installed:
from speechflow.audio import WebSocketAudioSink
AudioData
Engines and audio utilities exchange AudioData:
from speechflow import AudioData
audio = AudioData(
data=samples, # numpy.ndarray, normally float32 samples in [-1, 1]
sample_rate=24000, # int
channels=1, # int, default 1
format="pcm", # str, default "pcm"
)
seconds = audio.duration
AudioData is a lightweight container. It does not validate or normalize data
when you construct it yourself. SpeechFlow engines return normalized float32 PCM
data by convention.
Engine Behavior
All engines implement:
audio = await engine.get(text, model=None, voice=None)
async for chunk in engine.stream(text, model=None, voice=None):
...
Engine-specific options are added as keyword arguments without changing the base
text, model, and voice shape.
| Engine | Extra | Output rate | Streaming behavior |
|---|---|---|---|
| OpenAI | openai |
24 kHz | True chunked streaming |
| Gemini | gemini |
Parsed from response, usually 24 kHz | One complete-audio chunk in this implementation |
| FishAudio | fishaudio |
24 kHz | True chunked streaming |
| ElevenLabs | elevenlabs |
From output_format, default 24 kHz |
True chunked streaming |
| Kokoro | kokoro |
24 kHz | Segment-by-segment local generation |
| Qwen3-TTS | qwen3tts |
24 kHz | Sentence-by-sentence local generation |
| Style-Bert-VITS2 | stylebert |
Model-dependent, JVNV is typically 44.1 kHz | Sentence-by-sentence local generation |
Engines
Examples in this section show code that belongs inside an async function unless
they include an asyncio.run() wrapper.
OpenAI
from speechflow import OpenAITTSEngine
engine = OpenAITTSEngine(
api_key="your-openai-api-key",
model="gpt-4o-mini-tts",
voice="alloy",
instructions="Speak clearly and warmly.",
speed=1.0,
)
audio = await engine.get(
"Hello.",
voice="nova",
speed=1.05,
instructions="Use a calm tone.",
)
async for chunk in engine.stream("Stream this text."):
...
Models:
gpt-4o-mini-tts(default)tts-1tts-1-hdgpt-4o-mini-tts-2025-12-15
Voices:
alloy(default)ashballadcoralechofablenovaonyxsageshimmerversemarincedar
instructions is intended for gpt-4o-mini-tts. speed is passed through to
the API; it is reliably supported by tts-1 and tts-1-hd.
Google Gemini
from speechflow import GeminiTTSEngine
engine = GeminiTTSEngine(api_key="your-gemini-api-key")
audio = await engine.get(
"Hello.",
model="gemini-2.5-flash-preview-tts",
voice="Zephyr",
)
Models:
gemini-2.5-flash-preview-tts(default)gemini-2.5-pro-preview-ttsgemini-2.5-flash-lite-preview-tts
Voices:
Zephyr(default)PuckCharonKoreFenrirLedaOrusAoedeCallirrhoeAutonoeEnceladusIapetusUmbrielAlgiebaDespinaErinomeAlgenibRasalgethiLaomedeiaAchernarAlnilamSchedarGacruxPulcherrimaAchirdZubenelgenubiVindemiatrixSadachbiaSadaltagerSulafat
stream() uses the Gemini streaming API surface, but the current engine yields
complete TTS audio as a single chunk when Gemini returns it that way.
FishAudio
from speechflow import FishAudioTTSEngine
engine = FishAudioTTSEngine(
api_key="your-fishaudio-api-key",
model="s1",
voice="optional-reference-id",
speed=1.0,
volume=0.0,
)
audio = await engine.get(
"Hello.",
speed=1.1,
volume=0.0,
)
Models:
s1(default)speech-1.6speech-1.5
voice is a FishAudio reference ID. speed is a speed multiplier (1.0 =
normal) and volume is a volume adjustment in dB (0.0 = no change); both are
passed to the SDK without engine-level clamping. Output is 24 kHz mono PCM.
ElevenLabs
from speechflow import ElevenLabsTTSEngine
engine = ElevenLabsTTSEngine(
api_key="your-elevenlabs-api-key",
model="eleven_multilingual_v2",
voice="21m00Tcm4TlvDq8ikWAM",
output_format="pcm_24000",
)
audio = await engine.get(
"Hello.",
stability=0.5,
similarity_boost=0.75,
speed=1.0,
)
Models:
eleven_multilingual_v2(default)eleven_v3eleven_flash_v2_5eleven_turbo_v2_5
The default voice is 21m00Tcm4TlvDq8ikWAM. output_format must be a PCM
format such as pcm_24000; the sample rate is parsed from that suffix.
Non-PCM formats are rejected with ConfigurationError.
Kokoro
from speechflow import KokoroTTSEngine
engine = KokoroTTSEngine(lang_code="a")
audio = await engine.get("Hello from Kokoro.", voice="af_heart", speed=1.0)
async for chunk in engine.stream("First sentence.\nSecond sentence."):
...
Constructor options:
lang_code: one of the supported language codes below, defaultadevice: Torch device string such ascpuorcuda;Nonelets Kokoro select
Languages and default voices:
| Code | Language | Default voice |
|---|---|---|
a |
American English | af_heart |
b |
British English | bf_emma |
e |
Spanish | ef_dora |
f |
French | ff_siwis |
h |
Hindi | hf_alpha |
i |
Italian | if_sara |
j |
Japanese | jf_alpha |
p |
Brazilian Portuguese | pf_dora |
z |
Mandarin Chinese | zf_xiaobei |
The selected voice must start with the configured language code. Japanese support checks for a UniDic dictionary and attempts to download it if missing. If that fails, run:
python -m unidic download
Qwen3-TTS
from speechflow import Qwen3TTSEngine
engine = Qwen3TTSEngine()
audio = await engine.get(
"Hello from Qwen3-TTS.",
language="English",
speaker="Vivian",
)
Models:
Qwen/Qwen3-TTS-12Hz-0.6B-BaseQwen/Qwen3-TTS-12Hz-0.6B-CustomVoiceQwen/Qwen3-TTS-12Hz-1.7B-BaseQwen/Qwen3-TTS-12Hz-1.7B-CustomVoice(default)Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign
Languages:
Auto(default)ChineseEnglishJapaneseKoreanGermanFrenchRussianPortugueseSpanishItalian
CustomVoice speakers:
Vivian(default)SerenaUncle_FuDylanEricRyanAidenOno_AnnaSohee
Model variants control which arguments are valid:
- CustomVoice models use
speaker;voiceis accepted as a synonym whenspeakeris not provided. - Base models require
ref_audioandref_text, or a stored profile created withset_voice_profile(). - VoiceDesign models require
instruct, a natural-language voice description.
Voice cloning with a Base model:
engine = Qwen3TTSEngine(model_id="Qwen/Qwen3-TTS-12Hz-1.7B-Base")
await engine.set_voice_profile(
ref_audio="/path/to/reference.wav",
ref_text="Transcript of the reference audio.",
)
audio = await engine.get("Use the stored voice profile.", language="English")
await engine.clear_voice_profile()
ref_audio can be a file path, URL, base64 string, or a (numpy.ndarray, sample_rate) tuple. Output is 24 kHz mono PCM.
Style-Bert-VITS2
from speechflow import StyleBertTTSEngine
engine = StyleBertTTSEngine(model_name="jvnv-F1-jp")
audio = await engine.get(
"こんにちは。",
speaker_id=0,
style="Happy",
style_weight=5.0,
speed=1.0,
pitch=0.0,
)
English synthesis uses an English-compatible model and language="EN":
engine = StyleBertTTSEngine(model_name="rikka-botan-en", language="EN")
audio = await engine.get(
"Hello from the exhibition.",
speaker_id=0,
style="Neutral",
)
You must provide either model_name or model_path.
Pretrained model names:
jvnv-F1-jpjvnv-F2-jpjvnv-M1-jpjvnv-M2-jprikka-botan-en
JVNV JP-Extra models are Japanese-only. Use rikka-botan-en or another
English-compatible Style-Bert-VITS2 model with language="EN".
To use a local Style-Bert-VITS2 model, pass model_path pointing to a directory
containing the model files expected by the engine:
- one
.safetensorsfile config.jsonstyle_vectors.npy
For example:
engine = StyleBertTTSEngine(model_path="/path/to/stylebert-model", language="EN")
Styles:
Neutral(default)HappySadAngryFearSurpriseDisgust
style_weight is clamped to 0.0 through 10.0. speed must be greater than
0.0. pitch is specified in semitones. Advanced keyword arguments are
forwarded to the underlying Style-Bert-VITS2 TTSModel.infer() call.
Pretrained models are downloaded from Hugging Face on first use and cached under your user cache directory. The sample rate comes from the loaded model configuration; JVNV models are typically 44.1 kHz.
Audio Utilities
AudioWriter
AudioWriter is included in the core package and writes WAV files using the
standard-library wave module.
from speechflow import AudioWriter
async def save_examples(audio, engine) -> None:
writer = AudioWriter()
await writer.save(audio, "output.wav")
combined = await writer.save_stream(engine.stream("Save a stream."), "stream.wav")
print(combined.duration)
Supported extensions:
.wav.wave
Behavior:
- Converts float32 samples to int16 PCM.
- Clips samples to
[-1.0, 1.0]before conversion. - Creates parent directories when needed.
- Collects stream chunks before writing because WAV headers need the final data length.
- Returns the input
AudioDataforsave()and combinedAudioDataforsave_stream(). - Raises
AudioProcessingErrorfor unsupported extensions, inconsistent stream parameters, empty streams, or write failures.
AudioPlayer
Install the player extra to use AudioPlayer.
from speechflow import AudioPlayer
async def play_examples(audio, engine) -> None:
player = AudioPlayer()
await player.play(audio)
combined = await player.play_stream(engine.stream("Play this stream."))
player.stop()
print(combined.duration)
Behavior:
- Uses
sounddevice.OutputStream. - Starts stream playback after the first chunk arrives.
- Requires all chunks in a stream to share the same sample rate and channel count.
- Returns combined
AudioDatafromplay_stream(). - Can be used as a sync or async context manager; leaving the context calls
stop(). - Raises
AudioProcessingErrorfor playback failures, empty streams, or inconsistent stream parameters.
WebSocketAudioSink
Install the ws extra to send audio to a WebSocket /playback endpoint.
uv add "speechflow[openai,ws]"
import asyncio
from speechflow import OpenAITTSEngine
from speechflow.audio import WebSocketAudioSink
async def main() -> None:
engine = OpenAITTSEngine(api_key="your-openai-api-key")
async with WebSocketAudioSink("ws://127.0.0.1:8765/playback") as sink:
await sink.send_stream(engine.stream("Send this as streaming PCM."))
asyncio.run(main())
API:
await sink.connect()await sink.close()await sink.send_stream(stream)await sink.send_batch(audio)await sink.flush()async with WebSocketAudioSink(url) as sink: ...
Wire behavior:
send_stream()sends{"type": "begin", "format": "f32le", "sample_rate": ...}, then little-endian float32 mono PCM binary chunks, then{"type": "end"}.send_batch()sends one 16-bit PCM mono WAV binary message.flush()sends{"type": "flush"}.- If
flush()is called from another task whilesend_stream()is active, the active stream is cancelled, noendframe is sent, andsend_stream()returns the audio sent so far. - The sink supports mono audio only and does not resample.
- Mixed stream sample rates, mixed channel counts, concurrent streams, connection failures, and send failures raise
AudioProcessingError.
Errors
Catch TTSError to handle any SpeechFlow-domain error, or catch specific
subclasses when you want different recovery behavior.
from speechflow import ConfigurationError, StreamingError, TTSError
try:
audio = await engine.get("Hello.")
except ConfigurationError as exc:
print(f"Configuration problem: {exc}")
except StreamingError as exc:
print(f"Streaming failed: {exc}")
except TTSError as exc:
print(f"TTS failed: {exc}")
Common mapping:
- Missing API keys and unsupported parameters raise
ConfigurationError. - Synthesis failures raise
TTSError. - Streaming failures raise
StreamingError. - Playback, WAV writing, and WebSocket output failures raise
AudioProcessingError.
Notes For Application Code
- Pass API keys directly to engine constructors. SpeechFlow does not load
.envfiles or manage credentials. - Keep async code async. In web servers, notebooks, and GUI applications with an existing event loop, use
await engine.get(...)andasync for chunk in engine.stream(...). - Use
AudioData.sample_rateinstead of assuming every engine returns the same rate. - Use
stream()when you want low-latency consumption, but check the engine table above: some engines yield sentence-sized chunks, and Gemini currently yields complete audio as one chunk. - Use
AudioWriter.save_stream()when you need a WAV file from an audio stream. - Use
AudioPlayer.play_stream()orWebSocketAudioSink.send_stream()when you want to consume chunks as they arrive.
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file speechflow-0.8.0.tar.gz.
File metadata
- Download URL: speechflow-0.8.0.tar.gz
- Upload date:
- Size: 72.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.8.22
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e9a52a424bc4f2c23fcf2ef6c56810073c1468b3fff7ea595de6dbaf487fcdc9
|
|
| MD5 |
aeb69e8e0986f0b12a2d5af1ac1a8950
|
|
| BLAKE2b-256 |
d34e9576370ed1eb7be6b7d5ce1affd46aa13a7fa799beb4949fa8e5bde5d898
|
File details
Details for the file speechflow-0.8.0-py3-none-any.whl.
File metadata
- Download URL: speechflow-0.8.0-py3-none-any.whl
- Upload date:
- Size: 50.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.8.22
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2f80fa6a37950afe5460bd48d8e02a33aa45378c1c3c00519fc9108fe5a9303a
|
|
| MD5 |
ccb8e22d2927979b35b9f5b319ce0c4c
|
|
| BLAKE2b-256 |
09edffd53057fdf6dabbdd139a2d16ae203a87ef3143f8e55fccdc9bd61e0d64
|