████████╗ ██████╗ █████╗ ███╗ ██╗ ███████╗ ██████╗ ██████╗ ██╗ ██████╗ ███████╗
╚══██╔══╝ ██╔══██╗ ██╔══██╗ ████╗ ██║ ██╔════╝ ██╔════╝ ██╔══██╗ ██║ ██╔══██╗ ██╔════╝
██║ ██████╔╝ ███████║ ██╔██╗ ██║ ███████╗ ██║ ██████╔╝ ██║ ██████╔╝ █████╗
██║ ██╔══██╗ ██╔══██║ ██║╚██╗██║ ╚════██║ ██║ ██╔══██╗ ██║ ██╔══██╗ ██╔══╝
██║ ██║ ██╗ ██║ ██║ ██║ ╚████║ ███████║ ╚██████╗ ██║ ██╗ ██║ ██████╔╝ ███████╗
╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═══╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝
Local TTS — text file to MP3, two engines, one command (or a GUI)
transcribe.py reads a text file, sends it to a locally running TTS inference server, and saves the result as an MP3. Optionally plays it back immediately.
transcribe-ui.py is a tkinter GUI that wraps both engines — start/stop the server, type or load text, pick a voice, generate, play, and save, all from one window.
Two engines are supported — both run on port 8029, so only one can be active at a time:
| Engine | Model | Port | Strength |
|---|---|---|---|
qwen (UI default) |
Qwen3-TTS-12Hz-1.7B-CustomVoice | 8029 | Multiple voices, natural language style |
fish (CLI default) |
Fish Audio S2 Pro F16 GGUF (s2.cpp) | 8029 | High quality, tone/emotion tags |
Both servers run fully offline. No API keys, no cloud calls.
- GUI (
transcribe-ui.py) — start/stop server, load text, pick voice, generate, play, save - CLI (
transcribe.py) — single command: text file in, MP3 out, elapsed time printed - Fish Audio tone tags — inline
[pause],[whisper],[excited], etc. --toneflag to set a global tone for the whole file- Qwen3-TTS voice selection (
Aiden,Ryan,Vivian, and more) --instructfor natural language style control on Qwen- Auto-plays the result after saving
- CUDA-accelerated on NVIDIA GPUs (RTX 3000+, tested on RTX 5080)
- NVIDIA GPU with CUDA 12.6+ drivers
- Windows 10/11
- Python 3.14 (system Python)
- ffmpeg in PATH (e.g.
winget install Gyan.FFmpeg)
# Run as Administrator
.\setup_fish2tts.ps1Installs CMake/Git/FFmpeg, clones + builds s2.cpp, then downloads
s2-pro-f16.gguf (~9.9 GB) from HuggingFace automatically.
# Run as Administrator (requires setup_fish2tts.ps1 to have run first — needs FFmpeg)
.\setup_qwen3tts.ps1Creates a Python 3.14 venv (qwen3tts_venv/), installs qwen-tts + PyTorch CUDA, downloads the model (~4.5 GB).
pip install -r requirements.txtpython transcribe-ui.py- Select Qwen3-TTS or Fish Audio engine
- Click ▶ Start Server — a server window opens; wait for the port indicator to turn UP
- Type or load a
.txtfile - Pick a voice / tone and click ▶ Generate
- Click ▶ Play to listen, or 💾 Save As... to export
Start the server first (keep it running in its own terminal):
.\start_fish2tts.ps1 # Fish Audio
.\start_qwen3tts.ps1 # Qwen3-TTSThen run:
# Fish Audio — CLI default
python transcribe.py input/speech.txt
python transcribe.py input/speech.txt --tone "professional broadcast tone"
python transcribe.py input/speech.txt --output output/my_clip.mp3
# Qwen3-TTS
python transcribe.py input/speech.txt --engine qwen
python transcribe.py input/speech.txt --engine qwen --voice Ryan
python transcribe.py input/speech.txt --engine qwen --instruct "speak slowly and warmly"Embed inline in speech.txt for per-phrase control:
[professional broadcast tone] Welcome to tonight's broadcast.
[pause] And now, your host. [excited] Let's get started!
Pass --tone TAG to apply one tag to the entire text.
Note: The quantized
s2-pro-f16.ggufonly reliably recognises[Male]/[Female]prefixes. Full tone tags (e.g.[excited]) are silently treated as male by this model size.
Supported tags:
[pause] [emphasis] [laughing] [inhale] [chuckle] [tsk] [singing]
[excited] [laughing tone] [interrupting] [chuckling] [excited tone]
[volume up] [echo] [angry] [low volume] [sigh] [low voice] [whisper]
[screaming] [shouting] [loud] [surprised] [short pause] [exhale]
[delight] [panting] [audience laughter] [with strong accent] [volume down]
[clearing throat] [sad] [moaning] [shocked] [whisper in small voice]
[professional broadcast tone] [pitch up]
Aiden Dylan Eric Ono Anna Ryan Serena Sohee Uncle Fu Vivian
Default voice: Aiden. Use --voice NAME on the CLI or the Voice dropdown in the UI.
For style control use --instruct:
--instruct "speak slowly and warmly"
--instruct "excited, enthusiastic delivery"
--instruct "calm, professional broadcast tone"transcribe-ui.py tkinter GUI (Qwen3 default engine)
├── Engine radio + Start/Stop server (PowerShell scripts)
├── Port 8029 health poll (daemon thread, 2s interval)
├── Voice/Instruct panel (switches per engine)
├── Text area + Load File
└── Generate → Play / Save As
transcribe.py CLI (Fish default engine)
├── build_parser() argparse — all flags
├── normalize_tone() strips/re-wraps [brackets] for Fish tone tags
├── _find_ffmpeg() locates ffmpeg in PATH or known install locations
├── wav_to_mp3() shells out to ffmpeg for WAV → MP3
├── run_fish() POST /generate multipart form → WAV bytes → MP3
├── run_qwen() POST /v1/audio/speech JSON → MP3 bytes directly
└── main() resolves server URL from .env, dispatches, saves, plays
qwen3tts_server.py FastAPI wrapper around qwen-tts (Python 3.14 venv)
Endpoint: POST /v1/audio/speech (OpenAI TTS compatible)
Rope-deltas state reset between requests (fixes 2nd-gen 500)
ffmpeg WAV→MP3 runs off the event loop via asyncio.to_thread
s2.cpp server External binary (s2.cpp/build/Release/s2.exe)
Endpoint: POST /generate (multipart form data)
Started via start_fish2tts.ps1
Fish Audio — port 8029
POST /generate
Body: multipart/form-data
text = "<text with optional [tone tags]>"
params = '{"max_new_tokens":1024,"temperature":0.58,"top_p":0.88,"top_k":40,"codec_follow_backend":true}'
Response: WAV audio bytes
Qwen3-TTS — port 8029
POST /v1/audio/speech
Body: application/json
{ "input": "...", "voice": "aiden", "response_format": "mp3", "instruct": "..." }
Response: MP3 audio bytes
GET /v1/health → {"status":"ok","model":"...","gpu":"..."}
Both engines share port 8029. Only one server can run at a time.
| Variable | Default | Purpose |
|---|---|---|
FISH_SPEECH_SERVER |
http://localhost:8029 |
s2.cpp server URL |
FISH_SPEECH_API_KEY |
(empty) | Bearer token if s2.cpp started with --api-key |
QWEN_TTS_SERVER |
http://localhost:8029 |
Qwen3-TTS server URL |
QWEN_TTS_API_KEY |
(empty) | Bearer token (unused currently) |
FISH_TONES |
[Male],[Female] |
Tone options shown in the UI |
QWEN_VOICES |
Aiden,Dylan,Eric,... |
Voice options shown in the UI |
Transcribe/
├── transcribe.py # TTS CLI (both engines)
├── transcribe-ui.py # tkinter GUI (both engines)
├── qwen3tts_server.py # Qwen3-TTS FastAPI server
├── setup_fish2tts.ps1 # One-time Fish Audio setup (run as admin)
├── setup_qwen3tts.ps1 # One-time Qwen3-TTS setup (run as admin)
├── start_fish2tts.ps1 # Fish Audio server launcher (port 8029)
├── start_qwen3tts.ps1 # Qwen3-TTS server launcher (port 8029)
├── requirements.txt # System Python deps (transcribe.py + transcribe-ui.py)
├── .env # Server URLs, API keys, UI voice options
├── .gitignore
├── input/speech.txt # Input text
├── output/ # Generated MP3s (git-ignored)
├── models/
│ ├── s2-pro-f16.gguf # Fish Audio GGUF (~9.9 GB) — git-ignored
│ ├── tokenizer.json
│ └── qwen3tts/ # Qwen3-TTS model weights (~4.5 GB) — git-ignored
├── qwen3tts_venv/ # Python 3.14 venv for Qwen3-TTS — git-ignored
└── s2.cpp/ # Fish Audio C++ engine (cloned by setup) — git-ignored
└── build/Release/s2.exe
- Python versions: Both
transcribe.pyandqwen3tts_server.pyrun on Python 3.14. numba 0.65+ and librosa 0.11+ both support 3.14; Python 3.12 is no longer needed. - Default engines: The GUI (
transcribe-ui.py) defaults to Qwen3-TTS. The CLI (transcribe.py) defaults to Fish Audio. - Port sharing: Both engines use port 8029. Only one server can run at a time — stop the current one before starting the other.
- Codec GPU:
--codec-follow-backendis set by default. This forces the audio codec onto the same CUDA device as the transformer. Without it, codec runs on CPU and synthesis stalls. - Fish GGUF model: Hosted at tommyho510/s2-pro-gguf on HuggingFace. Downloaded automatically by
setup_fish2tts.ps1. Licensed CC BY-NC-SA 4.0 — based on Fish Audio S2 Pro. - Fish GGUF gender-only: The quantized
s2-pro-f16.ggufonly reliably recognises[Male]/[Female]tone prefixes. Full tone tags are silently treated as male. - Model size: The Fish GGUF contains both transformer and codec weights in one file — no separate codec download needed.
- 503 responses: s2.cpp handles one request at a time. If the server returns 503, a synthesis is in progress — wait and retry.
- Qwen second-generation fix: The server resets
rope_deltasstate before each request to prevent a shape-mismatch error that caused HTTP 500 on the second call. - playsound file locking: The UI writes each generation to a unique timestamped file (
output/ui_<ms>.mp3) to avoid Windows MCI handle conflicts when playsound holds the previous file open.
If you find this useful, consider buying me a coffee!