A multi-provider text-to-speech (TTS) tool that implements the Apple macOS /usr/bin/say command interface while supporting multiple TTS backends including Chatterbox (local AI), OpenAI, ElevenLabs, Deepgram, Gemini, and Amazon Polly.
- macOS
sayCompatible: Drop-in replacement for the macOSsaycommand with identical CLI interface - Multiple TTS Providers: Extensible provider system with support for:
- macOS native
saycommand (default on macOS) - Chatterbox (local AI TTS, default on other platforms)
- VibeVoice-Realtime (local AI TTS via MLX, Apple Silicon; install with
gensay[vibevoice]— ~2.5× faster than realtime with the warm daemon) - ElevenLabs (cloud API)
- Deepgram (cloud API; Flux TTS, Aura-2, and Aura models)
- Gemini TTS (cloud API; prompt-steerable native speech generation, multi-speaker dialogue)
- OpenAI TTS (cloud API)
- Amazon Polly (cloud API)
- Mock provider for testing
- macOS native
- Smart Text Chunking: Intelligently splits long text for optimal TTS processing
- Audio Caching: Automatic caching with LRU eviction to speed up repeated synthesis
- Progress Tracking: Built-in progress bars with tqdm and customizable callbacks
- Multiple Audio Formats: Support for AIFF, WAV, M4A, MP3, CAF, FLAC, AAC, OGG
- Background Pre-caching: Queue and cache audio chunks in the background (Chatterbox only)
- Interactive REPL Mode: Start an interactive session with provider initialized once for repeated use
- Warm Inference Daemon: Keep local AI models loaded in a background process; ad-hoc
gensaycalls reuse the warm model via a Unix socket - Offline Resilience: Cloud providers (ElevenLabs, Deepgram, Gemini, OpenAI, Polly) automatically fall back to macOS
saywhen the network is unreachable
- Installation
- Quick Start
- Hero Examples — Every Provider (in USAGE.md)
- Command Line Usage
- Python API
- Provider Configurations
- Advanced Features
- Development
- License
It's 2026, use uv
gensay is intended to be used as a CLI tool that is a drop-in replacement to the macOS say CLI.
PortAudio is required if you plan to use the ElevenLabs provider. The pyaudio dependency needs the PortAudio C library to compile successfully.
Other providers (macOS, OpenAI, Amazon Polly, Chatterbox, Deepgram) do not require PortAudio.
Homebrew (macOS):
brew install portaudioNix:
nix-env -iA nixpkgs.portaudio# Install as a tool
uv tool install gensay
# With extras: ElevenLabs provider (requires PortAudio, see above)
uv tool install 'gensay[elevenlabs]'
# Deepgram provider (Flux TTS / Aura) ships in the core install; this extra
# only adds keyring support for `gensay config set deepgram.api_key`
uv tool install 'gensay[deepgram]'
# OS keychain storage for API keys (`gensay config set <provider>.api_key`)
# without any provider extra — e.g. for OpenAI, which ships in core
uv tool install 'gensay[keychain]'
# With extras: Chatterbox provider (local Text-to-Speech model, ~2GB PyTorch dependencies)
uv tool install 'gensay[chatterbox]' \
--with git+https://github.com/anthonywu/chatterbox.git@allow-dep-updates
# Or add to your project
uv add gensay
# From source (with automatic PortAudio path configuration)
git clone https://github.com/anthonywu/gensay
cd gensay
just setup# Audio format conversion (for non-native formats like MP3, OGG, FLAC)
# Requires ffmpeg installed on system
pip install 'gensay[audio-formats]'
# Install all optional dependencies (large: includes the Chatterbox/PyTorch stack)
pip install 'gensay[all]'Installation Help:
- PyAudio documentation - For PortAudio/PyAudio installation issues
- ElevenLabs Python library docs - Official ElevenLabs Python documentation
For developer/maintainer installation, just setup automatically configures PortAudio and FFmpeg paths for both Nix and Homebrew.
Homebrew:
export C_INCLUDE_PATH="$(brew --prefix portaudio)/include:$C_INCLUDE_PATH"
export LIBRARY_PATH="$(brew --prefix portaudio)/lib:$LIBRARY_PATH"Nix:
export C_INCLUDE_PATH="$(nix-build '<nixpkgs>' -A portaudio --no-out-link)/include:$C_INCLUDE_PATH"
export LIBRARY_PATH="$(nix-build '<nixpkgs>' -A portaudio --no-out-link)/lib:$LIBRARY_PATH"Then install into local venv:
uv sync --all-extras
# temporarily, we have to use a special release of chatterbox library to allow for dependency resolution
uv pip install git+https://github.com/anthonywu/chatterbox.git@allow-dep-updatesChatterbox uses TorchCodec which requires FFmpeg libraries at process start. Since 0.5.0, gensay self-heals: if FFmpeg is detectable (Nix store or Homebrew), it re-executes itself once with DYLD_LIBRARY_PATH set correctly — no manual export needed for gensay -p chatterbox, the daemon, or daemon start children.
If FFmpeg can't be auto-detected, set it manually:
Homebrew:
export DYLD_LIBRARY_PATH="$(brew --prefix ffmpeg)/lib:$DYLD_LIBRARY_PATH"
gensay --provider chatterbox "Hello"Nix:
# Find the ffmpeg-lib output in the Nix store
FFMPEG_LIB=$(nix-store -qR "$(which ffmpeg)" | grep 'ffmpeg.*-lib$')
export DYLD_LIBRARY_PATH="$FFMPEG_LIB/lib:$DYLD_LIBRARY_PATH"
gensay --provider chatterbox "Hello"Note: DYLD_LIBRARY_PATH must be set before the Python process starts; it cannot be set from within Python.
# Basic usage - speaks the text
gensay "Hello, world!"
# Use specific voice
gensay -v Samantha "Hello from Samantha"
# Save to audio file
gensay -o greeting.m4a "Welcome to gensay"
# List available voices (two ways)
gensay -v '?'
gensay --list-voicesWant a copy-pasteable example for a specific backend? See the hero examples for every provider in USAGE.md — setup + canonical commands for macOS, Chatterbox, ElevenLabs, Deepgram, Gemini, OpenAI, Amazon Polly, and Mock.
Third-party packages can add providers via the gensay.providers entry-point group — see Provider plugins in USAGE.md.
# Speak text
gensay "Hello, world!"
# Read from file
gensay -f document.txt
# Read from stdin
echo "Hello from pipe" | gensay -f -
# Specify voice
gensay -v Alex "Hello from Alex"
# Adjust speech rate (words per minute)
gensay -r 200 "Speaking faster"
# Save to file
gensay -o output.m4a "Save this speech"
# Specify audio format
gensay -o output.wav --format wav "Different format"# Use macOS native say command
gensay --provider macos "Using system TTS"
# List voices for specific provider
gensay --provider macos --list-voices
gensay --provider mock --list-voices
# Use mock provider for testing
gensay --provider mock "Testing without real TTS"
# Use Chatterbox explicitly
gensay --provider chatterbox "Local AI voice"
# Default provider depends on platform
gensay "Hello" # Uses 'macos' on macOS, 'chatterbox' on other platforms# Show progress bar
gensay --progress "Long text with progress tracking"
# Pre-cache audio chunks in background
gensay --provider chatterbox --cache-ahead "Pre-process this text"
# Adjust chunk size
gensay --chunk-size 1000 "Process in larger chunks"
# Cache management
gensay --cache-stats # Show cache statistics
gensay --clear-cache # Clear all cached audio
gensay --no-cache "Text" # Disable cache for this runLocal AI providers (Chatterbox, VibeVoice) pay multi-second model load on every process start. The daemon keeps the provider resident; subsequent gensay invocations are cheap RPCs over a user-local Unix socket.
VibeVoice in particular is a strong fit for the daemon: once warm, it synthesizes at roughly 0.4× real-time factor — about 2.5× faster than playback speed. Measured on an Apple M4 Max (macOS, mlx-community/VibeVoice-Realtime-0.5B-fp16, uncached text, wall clock including CLI startup + socket RPC):
| Input | Wall time | Audio produced | RTF |
|---|---|---|---|
| 1 short sentence | 1.4 s | 3.3 s | 0.42× |
| ~2 sentences | 4.4 s | 11.6 s | 0.38× |
| ~4 sentences | 10.1 s | 26.7 s | 0.38× |
The same requests on the cold path take ~10–13 s each, dominated by model load. Slower or busier machines will see higher RTFs; note that the mlx-audio VibeVoice port returns the full clip at once (no chunk streaming), so time-to-first-audio grows with clip length.
Cloud providers (ElevenLabs, OpenAI, Polly) are intentionally not hostable in the daemon — their client init is milliseconds, so there is nothing worth keeping warm.
When user config and the daemon meet, resolution is:
| Situation | Result |
|---|---|
daemon start without -p |
Provider from daemon.provider config key → top-level provider config (if daemon-hostable) → chatterbox. A cloud speak-default (e.g. provider = "elevenlabs") is ignored here. |
Bare gensay "…" with a cloud default provider |
Cloud provider speaks directly (cold path); warm routing only engages for warm-eligible providers. |
--via-daemon / warm routing without explicit -p |
The running daemon decides. If your configured provider default differs, gensay prints a warning and forwards the request with no provider assertion. |
--via-daemon with explicit -p |
The provider is asserted; a daemon hosting a different one answers provider_mismatch (the fail-loud path for "you really meant it"). |
Rule of thumb: config picks your defaults, -p makes a claim, the daemon's resident model wins unless you make a claim.
# Start once per session (preloads model, detaches)
gensay daemon start -p chatterbox
# Ad-hoc speak — auto-routes to the daemon when provider is warm-eligible
gensay -p chatterbox "Build finished"
gensay -p chatterbox "Need your input"
# Force / forbid daemon routing
gensay --via-daemon -p chatterbox "must use daemon"
gensay --no-daemon -p chatterbox "cold path this time"
gensay --auto-daemon -p chatterbox "start daemon if missing"
# Lifecycle
gensay daemon status
gensay daemon status --json
gensay daemon stop
# Foreground (launchd / debugging)
gensay daemon run -p chatterboxEnvironment knobs (twelve-factor):
| Env | Meaning |
|---|---|
GENSAY_RUNTIME_DIR |
Directory for socket + pidfile |
GENSAY_SOCKET |
Explicit socket path |
GENSAY_VIA_DAEMON |
1 = require daemon |
GENSAY_NO_DAEMON |
1 = force cold path |
GENSAY_AUTO_DAEMON |
1 = auto-start when missing |
GENSAY_DAEMON_IDLE_UNLOAD_S |
Unload model after idle seconds (0 = never) |
GENSAY_DAEMON_IDLE_EXIT_S |
Exit process after idle seconds (0 = never) |
Socket location defaults to platformdirs.user_runtime_dir("gensay") (not /tmp).
Bare gensay "hello" can pick up preferred flags from a TOML file in the platform config dir (XDG on Linux):
| Platform | Default path |
|---|---|
| Linux | ~/.config/gensay/config.toml ($XDG_CONFIG_HOME/gensay/…) |
| macOS | ~/Library/Application Support/gensay/config.toml |
| Override | GENSAY_CONFIG=/path/to/config.toml |
Precedence: CLI flags > GENSAY_* env > config file > built-ins.
# Scaffold an annotated example, or set keys directly
gensay config init
gensay config path
gensay config keys
gensay config set provider chatterbox
gensay config set auto_daemon true
gensay config set daemon.provider chatterbox
gensay config get provider
gensay config get auto_daemon
gensay config unset voice
gensay config show
gensay config show --jsonExample config.toml:
provider = "chatterbox"
voice = "default"
rate = 150
auto_daemon = true
[daemon]
provider = "chatterbox"
idle_unload_s = 0After that, gensay "Build finished" uses chatterbox (and auto-starts the warm daemon if configured) without repeating flags.
<provider>.api_key keys are secrets: config set stores them in the OS keychain (via keyring), never in the plaintext TOML file.
gensay config set elevenlabs.api_key # prompts with hidden input (safe paste) → Keychain/Secret Service
gensay config show # prints "elevenlabs.api_key = (stored in OS keychain)"
gensay config unset elevenlabs.api_key # removes from keychainOmit the value to get a hidden password prompt — the secret never lands in your shell history or process list. Passing the value inline (gensay config set elevenlabs.api_key '<your-key>') still works, e.g. for scripting via stdin: printf '%s' "$KEY" | gensay config set elevenlabs.api_key.
Runtime precedence: provider env var (ELEVENLABS_API_KEY, also via .env) > OS keychain.
Start an interactive session where the provider is initialized once and reused for each prompt (in-process; no daemon required).
# Start REPL mode (--repl, --interactive, and -i are all equivalent)
gensay --repl
gensay --interactive
gensay -i
# With a specific provider and voice
gensay --provider openai -v nova --repl
# Chatterbox with REPL (keeps model loaded in this terminal)
gensay -p chatterbox -iIn REPL mode:
- Type text and press Enter to speak it
- Type
exitorquitto exit - Press Ctrl+C or Ctrl+D to exit
from gensay import ChatterboxProvider, TTSConfig, AudioFormat
# Create provider
provider = ChatterboxProvider()
# Speak text
provider.speak("Hello from Python")
# Save to file
provider.save_to_file("Save this", "output.m4a")
# List voices
voices = provider.list_voices()
for voice in voices:
print(f"{voice['id']}: {voice['name']}")from gensay import ChatterboxProvider, TTSConfig, AudioFormat
# Configure TTS
config = TTSConfig(
voice="default",
rate=150,
format=AudioFormat.M4A,
cache_enabled=True,
extra={
'show_progress': True,
'chunk_size': 500
}
)
# Create provider with config
provider = ChatterboxProvider(config)
# Add progress callback
def on_progress(progress: float, message: str):
print(f"Progress: {progress:.0%} - {message}")
config.progress_callback = on_progress
# Use the configured provider
provider.speak("Text with all options configured")from gensay import chunk_text_for_tts, TextChunker
# Simple chunking
chunks = chunk_text_for_tts(long_text, max_chunk_size=500)
# Advanced chunking with custom strategy
chunker = TextChunker(
max_chunk_size=1000,
strategy="paragraph", # or "sentence", "word", "character"
overlap_size=50
)
chunks = chunker.chunk_text(document)- Install the optional dependency (requires PortAudio):
pip install 'gensay[elevenlabs]' - Get an API key from ElevenLabs
- Set the environment variable:
export ELEVENLABS_API_KEY="your-api-key"
# List ElevenLabs voices
gensay --provider elevenlabs --list-voices
# Use a specific ElevenLabs voice
gensay --provider elevenlabs -v Rachel "Hello from ElevenLabs"
# Save to file with high quality
gensay --provider elevenlabs -o speech.mp3 "High quality AI speech"- Get an API key from OpenAI Platform
- Set the environment variable (or store it once in the OS keychain):
export OPENAI_API_KEY="sk-..." # or (prompts with hidden input, keeps the key out of shell history): gensay config set openai.api_key # → Keychain/Secret Service
# List OpenAI voices
gensay --provider openai --list-voices
# Use a specific voice (alloy, ash, ballad, coral, echo, fable, onyx, nova, sage, shimmer)
gensay --provider openai -v nova "Hello from OpenAI"
# Save to file
gensay --provider openai -o speech.mp3 "OpenAI TTS output"OpenAI offers several models — pick one per run with -m or persist with gensay config set openai.model <id> (see gensay -p openai -v '?' for the full list):
tts-1(default): Faster, lower latencytts-1-hd: Higher quality audiogpt-4o-mini-tts: Newest; supports steerable delivery/instructions
Option A - Environment variables:
- Sign in to AWS Console
- Go to IAM → Users → Create user
- Attach the
AmazonPollyReadOnlyAccesspolicy - Create access keys under Security credentials → Access keys
- Configure credentials (choose one method):
export AWS_ACCESS_KEY_ID="AKIA..."
export AWS_SECRET_ACCESS_KEY="..."
export AWS_DEFAULT_REGION="us-west-2"Option B - AWS CLI v2:
This easy lets you sign in through the AWS Command Line Interface
export AWS_DEFAULT_REGION=us-west-2
# on your desktop with a browser
aws login --region us-west-2
# in an env without a browser
aws login --region us-west-2 --remote# List Polly voices (60+ voices in many languages)
gensay --provider polly --list-voices
# Use a specific voice
gensay --provider polly -v Joanna "Hello from Amazon Polly"
# Save to file
gensay --provider polly -o speech.mp3 "Polly TTS output"Polly supports multiple engines via config.extra['engine']:
neural(default): Higher quality, natural-soundingstandard: Lower cost, available for all voices
Deepgram's Flux TTS (/v2/speak) and Aura/Aura-2 (/v1/speak) batch REST APIs. The voice is embedded in the model string (e.g. flux-haley-en, aura-2-thalia-en) — pass either a short voice name or a full model string with -v.
Default model: Flux (flux-haley-en). Which model speaks is resolved in this order:
-v <model string>— full model passthrough, e.g.-v flux-kit-en,-v aura-2-thalia-en-v <short name>— resolved from the voice catalog, newest family wins: Flux > Aura-2 > Aura (e.g.-v asteria→aura-2-asteria-en; use the fullaura-asteria-enstring for the legacy Aura voice)gensay config set deepgram.model <model string>— your per-user provider default- Built-in fallback —
flux-haley-en
Rate mapping (-r WPM, ~150 WPM = 1.0x): Flux accepts only the discrete ladder {0.85, 0.9, ..., 1.15} and gensay snaps to it (out-of-range values clamp); Aura accepts a continuous multiplier, clamped to 0.5–2.0.
- (Optional) install the extra — only needed to store the API key in the OS keychain via
config set:pip install 'gensay[deepgram]' - Get an API key from Deepgram Console
- Set the environment variable (or use the OS keychain, see below):
export DEEPGRAM_API_KEY="your-api-key"
# List Deepgram voices (Flux + Aura-2 + Aura catalog)
gensay --provider deepgram --list-voices
# No voice flags → Flux default (flux-haley-en)
gensay --provider deepgram "Hello from Deepgram Flux"
# Use a short voice name or a full model string
gensay --provider deepgram -v kit "British Flux voice"
gensay --provider deepgram -v aura-2-thalia-en "Aura-2 voice"
# Save to file
gensay --provider deepgram -o speech.mp3 "Deepgram Flux TTS output"Provider-specific config keys:
gensay config set deepgram.api_key # hidden prompt → Keychain/Secret Service
gensay config set deepgram.model aura-2-thalia-en # override the Flux defaultThe caching system automatically stores generated audio to speed up repeated synthesis:
from gensay import TTSCache
# Create cache instance
cache = TTSCache(
enabled=True,
max_size_mb=10000,
max_items=1000
)
# Get cache statistics
stats = cache.get_stats()
print(f"Cache size: {stats['size_mb']:.2f} MB")
print(f"Cached items: {stats['items']}")
# Clear cache
cache.clear()Cache Location
Cache files are stored in platform-specific user cache directories:
- macOS:
~/Library/Caches/gensay - Linux:
~/.cache/gensay - Windows:
%LOCALAPPDATA%\gensay\gensay\Cache
Managing Cache
# Show cache statistics
gensay --cache-stats
# Clear all cached audio
gensay --clear-cache
# Disable caching for a specific command
gensay --no-cache "Text to synthesize without caching"Manual Deletion
To manually delete the cache, remove the cache directory:
# macOS/Linux
rm -rf ~/Library/Caches/gensay # macOS
rm -rf ~/.cache/gensay # Linux
# Windows (PowerShell)
Remove-Item -Recurse -Force $env:LOCALAPPDATA\gensay\gensay\Cachefrom gensay.providers import TTSProvider, TTSConfig, AudioFormat
from typing import Optional, Union, Any
from pathlib import Path
class MyCustomProvider(TTSProvider):
def speak(self, text: str, voice: Optional[str] = None,
rate: Optional[int] = None) -> None:
# Your implementation
self.update_progress(0.5, "Halfway done")
# ... generate and play audio ...
self.update_progress(1.0, "Complete")
def save_to_file(self, text: str, output_path: Union[str, Path],
voice: Optional[str] = None, rate: Optional[int] = None,
format: Optional[AudioFormat] = None) -> Path:
# Your implementation
return Path(output_path)
def list_voices(self) -> list[dict[str, Any]]:
return [
{'id': 'voice1', 'name': 'Voice One', 'language': 'en-US'}
]
def get_supported_formats(self) -> list[AudioFormat]:
return [AudioFormat.WAV, AudioFormat.MP3]All providers support async operations:
import asyncio
from gensay import ChatterboxProvider
async def main():
provider = ChatterboxProvider()
# Async speak
await provider.speak_async("Async speech")
# Async save
await provider.save_to_file_async("Async save", "output.m4a")
asyncio.run(main())This project uses just for common development tasks. First, install just:
# macOS (using Nix which you already have)
nix-env -iA nixpkgs.just
# Or using Homebrew
brew install just
# Or using cargo
cargo install just# Setup development environment
just setup
# Run tests
just test
# Run all quality checks
just check
# See all available commands
just# Run all tests
just test
# Run tests with coverage
just test-cov
# Run specific test
just test-specific tests/test_providers.py::test_mock_provider_speak
# Quick test (mock provider only)
just quick-testjust test runs the nox matrix across Python 3.11–3.15.
- 3.15 is best-effort: verified against a pre-release interpreter (uv-managed 3.15.0a3); expect sharper edges until the final 3.15 release, and avoid claiming stable-grade 3.15 support to end users.
- Pre-release Pythons need modern Rust: on interpreters without published wheels, deps build from source and Rust-backed sdists (e.g.
jitervia OpenAI) require rustup stable ≥ 1.88 (rustup update stable).
# Run linter
just lint
# Auto-fix linting issues
just lint-fix
# Format code
just format
# Type checking
just typecheck
# Run all checks (lint, format, typecheck)
just check
# Pre-commit checks (format, lint, test)
just pre-commit# Run with mock provider
just run-mock "Hello, world!"
just run-mock -v '?'
# Run with macOS provider
just run-macos "Hello from macOS"
# Cache management
just cache-stats
just cache-clear# Run example script
just demo
# Clean build artifacts
just clean
# Build package
just buildIf you prefer not to use just, here are the equivalent commands:
# Setup
uv venv
uv pip install -e ".[dev]"
# Testing
uv run pytest -v
uv run pytest --cov=gensay --cov-report=term-missing
# Linting and formatting
uv run ruff check src tests
uv run ruff format src tests
# Type checking
uvx ty check srcgensay/
├── src/gensay/
│ ├── __init__.py
│ ├── main.py # CLI entry point
│ ├── providers/ # TTS provider implementations
│ │ ├── base.py # Abstract base provider
│ │ ├── chatterbox.py # Chatterbox provider
│ │ ├── macos_say.py # macOS say wrapper
│ │ └── ... # Other providers
│ ├── cache.py # Caching system
│ └── text_chunker.py # Text chunking logic
├── tests/ # Test suite
├── examples/ # Example scripts
├── justfile # Development commands
└── README.md
- Python 3.11+ with type hints
- Follow PEP8 and Google Python Style Guide
- Use
rufffor linting and formatting - Keep docstrings concise but informative
- Prefer
pathlib.Pathoveros.path - Use
pytestfor testing
gensay is distributed under the terms of the MIT license.