An enterprise-ready web application and REST API wrapping Qwen3-TTS for Apple Silicon Macs via mlx-audio. Includes a beautiful dark-mode Studio UI and an OpenAI-compatible /v1/audio/speech endpoint for drop-in migration.
- Three TTS modes: Custom Voice (11 built-in speakers), Voice Design (describe a voice in natural language), Voice Clone (enroll any voice from a 5–30 s audio sample)
- Six model variants: Pro 1.7B and Lite 0.6B, each available for all three modes — auto-selected based on your chosen mode
- OpenAI-compatible API — point your existing OpenAI client at
http://localhost:8765with one line change - Async job queue with real-time SSE progress streaming
- Persistent history — generated audio survives server restarts (SQLite)
- Voice enrollment — upload any audio format (MP3, M4A, WAV…), automatically converted to PCM WAV
- Language support — auto-detect or explicitly set one of 10 languages (EN, ZH, JA, KO, DE, FR, RU, PT, ES, IT)
- Advanced parameters — temperature, top-p, max tokens
- Runs 100% locally — no cloud, no data leaves your Mac
| Requirement | Version |
|---|---|
| macOS | Sequoia 15+ (Apple Silicon) |
| Python | 3.10.x (via pyenv recommended) |
| ffmpeg | Any recent version (brew install ffmpeg) |
| RAM | 8 GB unified (16 GB+ recommended for Pro 1.7B) |
| HuggingFace account | Optional — avoids download rate limits |
# 1. Clone the repo
git clone https://github.com/bnfone/Qwen3-TTS-Mac-FastAPI.git
cd Qwen3-TTS-Mac-FastAPI
# 2. Run setup (creates venv, installs all dependencies)
./setup.sh
# 3. (Optional) Configure environment
cp .env.example .env
# Edit .env to set HF_TOKEN, API_KEY, etc.The setup script:
- Detects pyenv and selects Python 3.10
- Creates
.venvvirtual environment - Installs all requirements including
mlx-audiofrom source - Creates
data/directories
./start.shOpen http://localhost:8765 in your browser.
The server runs on port 8765 by default. Change PORT= in .env to use a different port.
On first use, models are downloaded automatically from HuggingFace (mlx-community). The default 1.7B CustomVoice model (~2.1 GB) is pre-loaded at startup.
The web UI has five tabs:
| Tab | Description |
|---|---|
| Generate | Create speech in any of the three modes. Choose quality (Pro/Lite), language, and advanced parameters. Real-time progress bar. |
| Voices | Browse 11 built-in speakers and manage your enrolled voice clones. |
| History | Browse, search, play, and download all previously generated audio. Persists across restarts. |
| Models | Load and unload model variants. View download status and memory usage. |
| API Docs | Live curl examples and Python SDK snippet. Links to Swagger UI at /docs. |
Custom Voice — Pick from 11 built-in speakers and optionally add an emotion/style instruction:
- English: Ryan, Aiden, Ethan, Chelsie, Serena, Vivian
- Chinese: Uncle_Fu, Dylan, Eric
- Japanese: Ono_Anna · Korean: Sohee
Voice Design — Describe a voice in natural language. The model creates a unique voice matching your description. Example: "A deep, warm male narrator with a slight British accent"
Voice Clone — First enroll a voice (5–30 seconds of clean audio + exact transcript). Then select it from the dropdown to generate speech in that voice.
All endpoints accept and return JSON. Full interactive documentation available at http://localhost:8765/docs.
Submit an async generation job. Returns a job_id immediately.
curl -X POST http://localhost:8765/api/tts/generate \
-H "Content-Type: application/json" \
-d '{
"text": "The quick brown fox jumps over the lazy dog.",
"mode": "custom_voice",
"speaker": "Ryan",
"instruct": "Excited and enthusiastic",
"speed": 1.1,
"language": "en",
"response_format": "wav"
}'Request fields:
| Field | Type | Default | Description |
|---|---|---|---|
text |
string | required | Text to synthesize (max 10 000 chars) |
mode |
string | custom_voice |
custom_voice, voice_design, or voice_clone |
speaker |
string | Ryan |
Built-in speaker name (custom_voice mode) |
instruct |
string | "" |
Emotion/style instruction (custom_voice mode) |
speed |
float | 1.0 |
Speech rate (0.5–2.0) |
voice_description |
string | — | Voice description (voice_design mode) |
cloned_voice_id |
string | — | Enrolled voice ID (voice_clone mode) |
language |
string | auto |
Language code: auto, en, zh, ja, ko, de, fr, ru, pt, es, it |
temperature |
float | 0.6 |
Sampling temperature (0.0–2.0) |
top_p |
float | 0.8 |
Nucleus sampling (0.0–1.0) |
max_tokens |
int | 1200 |
Maximum audio tokens (100–4096) |
response_format |
string | wav |
Output format: wav or mp3 |
Poll job status.
curl http://localhost:8765/api/tts/jobs/<job_id>Real-time SSE progress stream.
curl -N http://localhost:8765/api/tts/jobs/<job_id>/events
# → event: status, data: Loading model…
# → event: progress, data: 0.12
# → event: progress, data: 0.45
# → event: completed, data: 20260314_123456_hello.wavDrop-in replacement for the OpenAI TTS API.
curl http://localhost:8765/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"model": "tts-1-hd",
"input": "Hello from local Qwen3-TTS!",
"voice": "alloy",
"speed": 1.0,
"response_format": "mp3",
"language": "en"
}' --output speech.mp3Voice mapping:
| OpenAI voice | Qwen3-TTS speaker |
|---|---|
alloy |
Ryan |
echo |
Aiden |
fable |
Ethan |
onyx |
Uncle_Fu |
nova |
Serena |
shimmer |
Vivian |
Model mapping:
| OpenAI model | Qwen3-TTS variant |
|---|---|
tts-1 |
Lite 0.6B CustomVoice |
tts-1-hd |
Pro 1.7B CustomVoice |
# List all voices
curl http://localhost:8765/api/voices
# Enroll a new cloned voice
curl -X POST http://localhost:8765/api/voices/clone \
-F "audio=@reference.wav" \
-F "name=MyNarrator" \
-F "ref_text=Exact words spoken in the audio file" \
-F "description=My custom narrator voice"
# Delete a cloned voice
curl -X DELETE http://localhost:8765/api/voices/cloned/<voice_id># List history (paginated)
curl "http://localhost:8765/api/history?page=1&limit=20&search=hello"
# Delete a history entry + audio file
curl -X DELETE http://localhost:8765/api/history/<job_id>
# Cleanup entries older than 7 days
curl -X POST http://localhost:8765/api/history/cleanup \
-H "Content-Type: application/json" \
-d '{"older_than_days": 7}'Change exactly one line in your existing code:
from openai import OpenAI
# Before:
# client = OpenAI(api_key="sk-...")
# After — only base_url changes:
client = OpenAI(
api_key="not-needed",
base_url="http://localhost:8765",
)
response = client.audio.speech.create(
model="tts-1-hd",
voice="nova",
input="Hello from local Qwen3-TTS!",
)
response.stream_to_file("speech.mp3")Copy .env.example to .env and edit as needed:
| Variable | Default | Description |
|---|---|---|
DEFAULT_MODEL |
1.7b-custom |
Model variant preloaded at startup |
PRELOAD_ON_STARTUP |
true |
Whether to load the default model on start |
HF_TOKEN |
— | HuggingFace token (avoids download rate limits) |
API_KEY |
— | Optional bearer token to protect the API |
PORT |
8765 |
Server port |
OUTPUTS_DIR |
data/outputs |
Directory for generated audio files |
VOICES_DIR |
data/voices |
Directory for cloned voice enrollments |
HISTORY_DB_PATH |
data/history.db |
SQLite database for generation history |
OUTPUT_TTL_HOURS |
72 |
Auto-cleanup TTL for generated files |
MAX_OUTPUT_DIR_MB |
2000 |
Maximum storage for generated audio |
Install test dependencies (already in requirements.txt):
source .venv/bin/activate
pip install pytest pytest-asyncio pytest-cov httpxRun all tests:
pytest tests/ -vRun with coverage report:
pytest tests/ -v --cov=app --cov-report=term-missingWhat the tests cover:
- All API endpoints (TTS generation, OpenAI compat, voices, history, models)
- Service unit tests (model variant resolution, job lifecycle, SQLite persistence)
- Request validation (missing fields, out-of-range values → 422)
- No real model downloads — mlx-audio is mocked
The speed parameter is accepted by mlx_audio's model.generate() but is not forwarded to the underlying generate_custom_voice() / generate_voice_design() methods. The library's own docstring marks it as "not directly supported yet" (see mlx_audio/tts/models/qwen3_tts/qwen3_tts.py, generate() line ~719).
Workaround implemented in app/services/tts_service.py:
After generation, the raw audio array is time-stretched via numpy linear resampling before it is written to disk:
# _apply_speed() in tts_service.py
new_length = max(1, int(len(audio) / speed))
np.interp(np.linspace(0, len(audio) - 1, new_length), np.arange(len(audio)), audio)speed > 1.0→ shorter array → faster playback at the same sample ratespeed < 1.0→ longer array → slower playbackspeed = 1.0→ no-op (skipped entirely)
When mlx-audio adds native speed support, remove _apply_speed() and the post-processing block in TTSService.generate(), and re-add params["speed"] = request.speed in _build_params().
Request → FastAPI Router
↓
JobManager (creates job, returns job_id)
↓ (background task)
generation_semaphore (asyncio.Semaphore(1) — MLX not thread-safe)
↓
TTSService.generate()
├── ModelManager.get_model() → load from HuggingFace if needed
├── _generate_sync() → runs model.generate() in ThreadPoolExecutor
│ └── pushes progress to queue.Queue (thread-safe)
├── _forward_progress() → bridges thread queue → asyncio SSE queue
└── job_manager.complete_job() → persists to SQLite
↓
Client streams progress via SSE (EventSource)
Tech stack:
- Backend: FastAPI + uvicorn + sse-starlette + pydantic-settings
- TTS engine: mlx-audio (Qwen3-TTS on Apple Silicon MLX)
- Persistence: SQLite (stdlib
sqlite3) - Audio conversion: ffmpeg (subprocess)
- Frontend: Single HTML file — Tailwind CSS CDN + Alpine.js CDN (no build step)
- Fonts: Syne (display) + DM Sans (body) + IBM Plex Mono (code/labels)
- Qwen3-TTS — The underlying TTS model by QwenLM / Alibaba
- qwen3-tts-apple-silicon — Apple Silicon MLX port by kapi2800
- mlx-community — Quantized MLX model weights on HuggingFace
- mlx-audio — MLX audio generation library by Blaizzy
MIT