aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md301
-rwxr-xr-xaudiobook.py35
-rw-r--r--converter/__init__.py2
-rw-r--r--converter/config.py10
-rw-r--r--converter/converter.py14
-rw-r--r--converter/tts.py18
-rw-r--r--docs/backend-faster.md41
-rw-r--r--docs/backend-qwen.md63
-rw-r--r--requirements.txt2
-rw-r--r--tests/test_chunking.py2
-rw-r--r--tests/test_converter.py22
-rw-r--r--tests/test_tts.py18
12 files changed, 229 insertions, 299 deletions
diff --git a/README.md b/README.md
index 17b5f9b..8d2ae3c 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,14 @@
-# Qwen3 Audiobook Converter
+# tts-audiobook-generator
-Convert TXT, PDF, and EPUB files into audiobooks using the Qwen3-TTS voice model.
-
-This builds upon [WhiskeyCoder/Qwen3-Audiobook-Converter](https://github.com/WhiskeyCoder/Qwen3-Audiobook-Converter) adding more output files, support for faster backends, metadata, generated cover art, transcription/speed/language options, better text cleanup, and clearer instructions. The converter supports three TTS backends, selected with `--backend`: the original Qwen3-TTS Gradio demos (`gradio`, default), [faster-qwen3-tts](https://github.com/andimarafioti/faster-qwen3-tts) (`faster`), and [audio.cpp](https://github.com/0xShug0/audio.cpp) (`audiocpp`) — the last of which can serve either the same Qwen3-TTS 1.7B model (Option 3) or any of audio.cpp's larger non-Qwen TTS families like Higgs Audio v3 4B, VoxCPM2, and IndexTTS-2/2.5 (Option 4).
+Generate audiobooks from books (epub, txt, pdf) using text-to-speech backends such as `audio.cpp` and `qwen-tts`.
## Overview
-The converter sends text extracted from your books to a locally running Qwen3-TTS server and assembles the returned audio into a single audiobook file.
+The converter sends text extracted from your books to a locally running TTS server and assembles the returned audio into a single audiobook file.
- Input: `.txt`, `.pdf`, or `.epub`
- Output: `.m4b`, `.mp3`, `.ogg`, or `.flac`
-- Supports [qwen-tts](https://pypi.org/project/qwen-tts/), [faster-qwen3-tts](https://github.com/andimarafioti/faster-qwen3-tts), and [audio.cpp](https://github.com/0xShug0/audio.cpp) backend servers (audio.cpp can host any of its TTS model families, not just Qwen3-TTS)
+- Supports [audio.cpp](https://github.com/0xShug0/audio.cpp), [qwen-tts](https://pypi.org/project/qwen-tts/), and [faster-qwen3-tts](https://github.com/andimarafioti/faster-qwen3-tts) backend servers (audio.cpp can host any of its TTS model families, not just Qwen3-TTS)
- Output a single file or one per chapter
- Automatic metadata (title/artist/album tags, chapter track numbers) and a generated cover
- Clone voices from .wav reference files or use the built-in speaker in the CustomVoice model.
@@ -25,10 +23,10 @@ The converter sends text extracted from your books to a locally running Qwen3-TT
Create a python 3.12 environment, clone the repo, and install the requirements.
```bash
-conda create -n qwen3-tts python=3.12 -y
-conda activate qwen3-tts
-git clone https://git.historia.vg/git/qwen3-audiobook-converter
-cd qwen3-audiobook-converter
+conda create -n audiobook python=3.12 -y
+conda activate audiobook
+git clone https://git.historia.vg/git/tts-audiobook-generator
+cd tts-audiobook-generator
pip install -r requirements.txt
```
@@ -38,119 +36,36 @@ You need to install one of the following backends (see below for installation/us
| Backend | Description |
| -------------------------------------------------------------------- | ------------------------------------------------------ |
-| [Qwen-TTS](https://pypi.org/project/qwen-tts/) | Gradio server released by Qwen |
-| [Faster-Qwen-TTS](https://github.com/andimarafioti/faster-qwen3-tts) | Qwen server with 2-8x faster inference for NVidia GPUs |
| [audio.cpp](https://github.com/0xShug0/audio.cpp) | Newer C++ TTS backend that supports many recent models |
+| [Qwen-TTS](https://pypi.org/project/qwen-tts/) | Qwen demo server (qwen-tts-demo) |
+| [Faster-Qwen-TTS](https://github.com/andimarafioti/faster-qwen3-tts) | Qwen server with 2-8x faster inference for NVidia GPUs |
## Options
-| Flag | Description |
-| ----------------------------- | ------------------------------------------------------------------------------------------------ |
-| `--format {mp3,m4b,ogg,flac}` | Output format (default `m4b`). `m4b` uses AAC audio and has built-in chapters. |
-| `--clone <path>` | Reference audio (`wav`) for voice cloning. |
-| `--transcription "..."` | Override whisper auto-transcription with manual audio transcript. |
-| `--no-transcription` | Skip auto-transcription of the reference audio. |
-| `--speed <n>` | Playback speed, pitch-preserving (`1.0` = normal). A normal-speed copy is also output. |
-| `--single-file` | Merge all chapters into a single file (default: one file per chapter). `m4b` is always one file. |
-| `--language <lang>` | Output language for the synthesized speech. Can add an accent even if the text is English. |
-| `--backend {gradio,faster,audiocpp}` | TTS server to use (default `gradio`). `faster` and `audiocpp` require their server running first — see the backend sections above. With `audiocpp` the server may host any audio.cpp TTS model family (see Option 4). |
-| `--voice <name>` | Voice to request from a server-side voice configuration (`--backend faster` or `audiocpp` only). Required for audio.cpp families without built-in speakers (everything except Qwen3-TTS). |
-| `--chunk` | Force client-side chunking into `CHUNK_SIZE`-word requests. Only matters for `--backend audiocpp`, which otherwise sends each chapter as one request and lets the server chunk long text itself (may double-chunk); the `gradio` and `faster` backends always chunk. |
-| `--model <id>` | `--backend audiocpp` only: audio.cpp server model entry id to use for this run. Overrides `AUDIOCPP_MODEL_ID` in `converter/config.py`, so a server hosting several lazily-loaded models (one `server.json`, see Option 4) can be used without editing config — pick the model per run. Leave unset to use the config id, or to auto-select when the server hosts exactly one entry. |
-| `--debug` | Troubleshooting: dump each chunk's raw audio and sent text to `debug/` and log every request. |
+| Flag | Description |
+| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `--backend {audiocpp,qwen,faster}` | TTS server to use (default `audiocpp`). |
+| `--format {mp3,m4b,ogg,flac}` | Output format (default `m4b`). |
+| `--speed <n>` | Playback speed, pitch-preserving (`1.0` = normal). A normal-speed copy is also output. |
+| `--single-file` | Merge all chapters into a single file. `m4b` is always one file. |
+| `--language <lang>` | Output language for the synthesized speech. Can add an accent even if the text is English. |
+| `--chunk` | Force client-side chunking into `CHUNK_SIZE`-word requests. Only matters for `--backend audiocpp`, which otherwise sends each chapter as one request and lets the server chunk long text itself (may double-chunk); the `qwen` and `faster` backends always chunk. |
+| `--model <id>` | `--backend audiocpp` only: Choose the model from `server.json` |
+| `--debug` | Dump each chunk's raw audio and sent text to `debug/` and log every request. |
+| `--voice <name>` | `audiocpp`, `faster`: Server-side voice to request |
+| `--clone <path>` | `qwen`: Reference audio (`wav`) for voice cloning. |
+| `--transcription "..."` | `qwen`: Override whisper auto-transcription with manual audio transcript. |
+| `--no-transcription` | `qwen`: Skip auto-transcription of the reference audio. |
Other options including backend server URLs/ports are configured in `converter/config.py`
-## Backend Option 1: Qwen3-TTS
-
-Install qwen-tts with pip:
-
-```bash
-conda activate qwen3-tts
-pip install -U qwen-tts
-```
-
-Run the backend with `qwen-tts-demo`. Add `--no-flash-attn` if FlashAttention isn't installed (see below). Note that the Base model and CustomVoice model run on different ports.
-
-### Voice clone
-
-```bash
-qwen-tts-demo Qwen/Qwen3-TTS-12Hz-1.7B-Base --ip 127.0.0.1 --port 7861 [--no-flash-attn]
-```
-
-Then in another terminal:
-
-```bash
-python audiobook.py --clone reference.wav
-```
-
-The reference `.wav` should be ~10-15 seconds (3 second minimum, 60 second maximum; ~15 seconds is ideal). Longer is **not** better.
-
-Whisper (`faster_whisper` or `whisper`) is used automatically to transcribe the reference audio. Without a Whisper backend it falls back to x-vector-only cloning. Override with `--transcription "What the .wav says"` or skip transcription with `--no-transcription`.
-
-
-### Custom voice (i.e. built-in voice)
-
-```bash
-conda activate qwen3-tts
-qwen-tts-demo Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice --ip 127.0.0.1 --port 7860 [--no-flash-attn]
-```
-
-```bash
-python audiobook.py
-```
-
-Change the voice settings in `converter/config.py`.
-
-
-## Backend Option 2: faster-qwen-tts
-
-`--backend faster` talks to the OpenAI-compatible server from [faster-qwen3-tts](https://github.com/andimarafioti/faster-qwen3-tts), which uses CUDA graph capture for roughly 5-10x faster inference with the same models. **It requires an NVIDIA GPU**.
-
+## Backend Option 1: audio.cpp
-Install into the **same `qwen3-tts` conda environment** used for qwen-tts.
+`audiocpp` is an easy to use server that hosts numerous TTS model families.
-```bash
-conda activate qwen3-tts
-pip install -U qwen-tts
-pip install "faster-qwen3-tts[demo]"
-```
-
-### Voice Clone
-
-**This backend always uses voice cloning**. The reference voice and language are configured on the **server**, not through the converter. The server does not transcribe reference audio itself, so do it manually or use the `tools/make_faster_voices_json.py` helper (see below).
-
-The pip package does not include the server script, so clone the repository:
-
-```bash
-git clone https://github.com/andimarafioti/faster-qwen3-tts
-cd faster-qwen3-tts
-```
+### Download and build audiocpp_server
-Create a `voices.json` mapping names to reference configurations (.wav to clone, transcript, language). Optionally run `python ./tools/make_faster_voices_json.py path/to/clone/wavs` to automatically create a `voices.json` using whisper to automatically transcribe the test audio.
-
-```json
-{
- "default": {"ref_audio": "voice1.wav", "ref_text": "Transcript of voice 1.", "language": "English"},
- "obama": {"ref_audio": "voice2.wav", "ref_text": "Transcript of voice 2.", "language": "English"}
-}
-```
-
-Run the server
-
-```bash
-python examples/openai_server.py --voices voices.json --port 8000
-```
-
-Then from another terminal, run audiobook.py with `--backend faster`
-
-```bash
-python audiobook.py --backend faster [--voice NAME]
-```
-
-## Backend Option 3: audio.cpp with Qwen3-TTS
-
-Build `audiocpp_server` for your platform and backend `(cuda, vulkan, hip, cpu)`. Check [audio.cpp's readme](https://github.com/0xShug0/audio.cpp) for details. I'm using one of the helper scripts:
+Download and build `audiocpp_server` for your platform and backend `(cuda, vulkan, hip, cpu)`. Check [audio.cpp's readme](https://github.com/0xShug0/audio.cpp) for details. I'm using one of the helper scripts:
```bash
git clone https://github.com/0xShug0/audio.cpp
@@ -158,26 +73,40 @@ cd audio.cpp
scripts/build_linux.sh --backend cuda --target audiocpp_server
```
-Download the Qwen3-TTS GGUF packages (Base for cloning, CustomVoice for built-in speakers) with the python model manager script. This will download these to `./models`
+### Install models
+
+Download model packages with the python model manager script from the audio.cpp checkout. Each installs to `./models`. Here are two examples, Higgs Audio and Qwen3-TTS:
```bash
-python3 tools/model_manager_v2.py install qwen3_tts_1_7b_base_q8_0
-python3 tools/model_manager_v2.py install qwen3_tts_1_7b_customvoice_q8_0
+python tools/model_manager_v2.py install higgs_audio_tts_4b_q8_0
+python tools/model_manager_v2.py install qwen3_tts_1_7b_base_q8_0
+python tools/model_manager_v2.py install qwen3_tts_1_7b_customvoice_q8_0
```
-Create a `server.json` file. One server can host multiple models. Note that the `id:` field(s) must match `AUDIOCPP_MODEL_ID` and `AUDIOCPP_CLONE_MODEL_ID` in qwen3_ebook_converter's .`converter/config.py`. Optionally run `tools/make_audiocpp_server_json.py path/to/clone/wavs --audiocpp-dir /path/to/audio.cpp` to make `server.json` for you with automatic whisper transcription (see [Generating server.json](#generating-serverjson-with-make_audiocpp_server_json)).
+You can run `python tools/model_manager_v2.py list` to see all available models.
-Cloning voices can be configured per model entry (`voice_presets`) or once at the server level (`voice_dir` + a `prompt_text` file), which every hosted model can clone from. The generator uses the server-level form:
+### Create server.json
+
+Create a `server.json` config file. One server can host multiple models and multiple cloned voices. The `id:` fields are the model names you will set for `tts-audiobook-generator` with `--model`.
+
+A helper tool is available in this repo `tools/make_audiocpp_server_json.py path/to/clone/wavs` that will interactively make this file for you, including automatically transcribing `.wav` voices to clone with `whisper`. Just pass it a path of `.wav` files. Make sure you're in a Python environment that has `whisper` (i.e. `conda activate audiobook` before running)
```json
{
"host": "127.0.0.1",
"port": 8080,
"backend": "cuda",
- "lazy_load": false,
+ "lazy_load": true,
"voice_dir": "/path/to/clone/wavs",
"models": [
{
+ "id": "higgs",
+ "family": "higgs_audio_tts",
+ "path": "models/Higgs-Audio-v3-TTS-4B-GGUF",
+ "task": "tts",
+ "mode": "offline"
+ },
+ {
"id": "qwen",
"family": "qwen3_tts",
"path": "models/Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF",
@@ -185,7 +114,7 @@ Cloning voices can be configured per model entry (`voice_presets`) or once at th
"mode": "offline"
},
{
- "id": "qwen3-clone",
+ "id": "qwen-clone",
"family": "qwen3_tts",
"path": "models/Qwen3-TTS-12Hz-1.7B-Base-GGUF",
"task": "tts",
@@ -195,141 +124,31 @@ Cloning voices can be configured per model entry (`voice_presets`) or once at th
}
```
-`voice_dir` points at a directory of `.wav` reference files plus a `prompt_text` file with one `<basename>|<transcript>` line per voice:
-
-```
-narrator|Transcript of the reference audio.
-obama|Transcript of reference audio 2.
-```
-
-A request with `"voice": "narrator"` then clones `voice_dir/narrator.wav` using that transcript. (Per-entry `voice_presets` work too — see audio.cpp's server readme.)
-
-Run the server. The `audiocpp_server` path will be slightly different depending on your platform and build options:
+Run the server with this config file. The `audiocpp_server` path will be slightly different depending on your platform and build options:
```bash
./build/linux-cuda-release/bin/audiocpp_server --config server.json
```
-Then in a different terminal, run `audiobook.py`
-
-```bash
-# Built-in speaker
-python audiobook.py --backend audiocpp
-
-# Voice cloning
-python audiobook.py --backend audiocpp --voice narrator
-```
-
-## Backend Option 4: audio.cpp with non-Qwen models
-
-The same `audiocpp_server` can host most of audio.cpp's other TTS model families, including models that are larger or higher quality than Qwen3-TTS 1.7B. The converter detects the model family from the server at startup and adapts its requests automatically (language codes, style instructions, etc.), so no other converter settings change: point `AUDIOCPP_MODEL_ID` at the entry you want, start the server, and convert with `--backend audiocpp --voice <name>`.
-
-One difference from Qwen3-TTS: **all of these families are clone-only** — they have no built-in speakers, so a reference voice must be configured on the server and selected with `--voice`. Running without `--voice` fails fast with a hint instead of synthesizing a random voice.
-
-Supported families (see [audio.cpp's model list](https://github.com/0xShug0/audio.cpp#supported-models) for the full catalog):
-
-| Family | Model | Languages | Notes |
-| ------------------------------------------- | --------------------- | ------------------------ | ------------------------------------------------ |
-| `higgs_audio_tts` | Higgs Audio v3 TTS 4B | 100+ | Largest TTS in audio.cpp; expressive, inline emotion/style control |
-| `voxcpm2` | VoxCPM2-2B | 29 listed | 48 kHz output (others are 24 kHz); cloning + "ultimate clone" (audio + transcript) |
-| `index_tts2` | IndexTTS-2 | zh, en | Top-tier cloning quality |
-| `index_tts2` (package `index_tts2_5_*`) | IndexTTS-2.5 | zh, en, ja, es, ar | Multilingual IndexTTS variant |
-
-The converter also works with families not in this table (Fish Audio, Chatterbox, DotTTS, OmniVoice, ...) through its generic profile: clone-only, voice from `--voice`, language detected by the model itself. Anything you can host in `audiocpp_server` with `"task": "tts"` should work, and `make_audiocpp_server_json.py` reads the full catalog from your audio.cpp checkout, so every TTS family audio.cpp supports is offered — not just the ones listed above.
-
-### Install and run
-
-Build `audiocpp_server` exactly as in Option 3 (same binary), then download a model package with audio.cpp's model manager from the audio.cpp checkout:
-
-```bash
-python3 tools/model_manager_v2.py install higgs_audio_tts_4b_q8_0
-# or: python3 tools/model_manager_v2.py install voxcpm2_q8_0
-# or: python3 tools/model_manager_v2.py install index_tts2_q8_0
-# or: python3 tools/model_manager_v2.py install index_tts2_5_q8_0
-```
-
-Create a `server.json` hosting the model. Cloning voices go in a server-level `voice_dir` (a directory of `.wav` files plus a `prompt_text` file — see Option 3) so every hosted model can use them. Note that `id` must match `AUDIOCPP_MODEL_ID` in `converter/config.py` (set `AUDIOCPP_CLONE_MODEL_ID` to the same id — single-model servers use one entry for both):
-
-```json
-{
- "host": "127.0.0.1",
- "port": 8080,
- "backend": "cuda",
- "lazy_load": false,
- "voice_dir": "/path/to/clone/wavs",
- "models": [
- {
- "id": "higgs",
- "family": "higgs_audio_tts",
- "path": "models/Higgs-Audio-v3-TTS-4B-GGUF",
- "task": "tts",
- "mode": "offline"
- }
- ]
-}
-```
-
-One `server.json` can host several families at once (add more entries to `models` and set `"lazy_load": true` so each loads only on first use). Then pick the entry per run with `--model <id>` (see below).
-
-Start the server and convert:
+In a different terminal, run `audiobook.py`. Pick the TTS `--model` and `--voice` from server.json:
```bash
-./build/linux-cuda-release/bin/audiocpp_server --config server.json
-
-# In another terminal
-python audiobook.py --backend audiocpp --voice narrator
-# or, on a multi-model server:
+# Higgs Audio (clone-only)
python audiobook.py --backend audiocpp --model higgs --voice narrator
-```
-
-VRAM note: the 4B Higgs Audio Q8_0 package needs roughly 2.5x the memory of the 1.7B Qwen3-TTS packages; VoxCPM2-2B and IndexTTS-2 sit in between. BF16/F16 packages roughly double the footprint again.
-### Generating server.json with make_audiocpp_server_json
+# Qwen3-TTS built-in speaker
+python audiobook.py --backend audiocpp --model qwen
-`tools/make_audiocpp_server_json.py` reads the model catalog (`model_specs/*.json`) from a local audio.cpp checkout and offers every TTS family it supports as a multi-select checklist, so one `server.json` can host several lazily-loaded models. It transcribes your reference wavs with whisper and writes a server-level `voice_dir` + `prompt_text` file automatically.
-
-```bash
-# Interactive: point at your audio.cpp checkout and pick families from a checklist
-python tools/make_audiocpp_server_json.py path/to/clone/wavs --audiocpp-dir /path/to/audio.cpp
-
-# Non-interactive: host Higgs Audio + VoxCPM2 in one lazily-loaded server
-python tools/make_audiocpp_server_json.py path/to/clone/wavs \
- --audiocpp-dir /path/to/audio.cpp \
- --families higgs_audio_tts,voxcpm2 \
- --backend cuda --output server.json --force
+# Qwen3-TTS voice cloning
+python audiobook.py --backend audiocpp --model qwen-clone --voice narrator
```
-The checkout can also be auto-detected (an `audio.cpp` directory next to/above your working directory, or the `AUDIOCPP_DIR` environment variable), so `--audiocpp-dir` is optional when you run from there. Pressing Enter at the checklist selects the default Qwen3-TTS flow (built-in speakers + cloning); otherwise enter comma-separated numbers for any combination of families. With more than one family the tool defaults to `"lazy_load": true` (models load on first use and stay in memory until the server exits — restart the server, or `POST /v1/tasks/unload_models`, before switching to a large model to free VRAM).
-
-For a single hosted entry the tool offers to rewrite `AUDIOCPP_MODEL_ID`/`AUDIOCPP_CLONE_MODEL_ID` in `converter/config.py` to the new id so `audiobook.py` talks to it without manual editing. With several entries it instead prints the available ids — pick one per run with `--model` (or set `AUDIOCPP_MODEL_ID`). Transcripts matter a lot for cloning quality — fill in any empty lines in `prompt_text` by hand before starting the server.
-
-### Language handling
-
-`--language` works with these families too, adapted per family: IndexTTS sends a language code (`English` → `en`), while Higgs Audio and VoxCPM2 detect the language from the text themselves and omit the field. `--language Auto` never sends a language.
-
-## Optional: FlashAttention for qwen-tts-demo server
-
-FlashAttention provides a small speed boost on the `qwen-tts-demo` backend. It is **not** relevant with the `faster` or `audiocpp` backends, and switching to either of those will provide a bigger speed boost.
-
-`qwen-tts-demo` server tries to use FlashAttention 2 by default and requires `--no-flash-attn` without it. You have two options to install FlashAttention in your python environment:
+## Other TTS Backends
-1. Build from source (takes absolutely forever). If you run out of memory, lower MAX_JOBS until you don't.
-
-```bash
-conda activate qwen3-tts
-pip install ninja packaging psutil
-MAX_JOBS=4 pip install --no-build-isolation flash-attn
-```
-
-2. pip install a prebuilt wheel matching your torch / CUDA / Python / CXX11-ABI combination:
-
-```bash
-conda activate qwen3-tts
-python -c "import torch; print(torch.__version__, torch.version.cuda, torch._C._GLIBCXX_USE_CXX11_ABI)"
-```
+Installation and usage documentation for other supported TTS backends is in the `docs/` directory:
-- [Official wheels](https://github.com/Dao-AILab/flash-attention/releases) - Pick `cp312` + matching `cuX` + `torchX.Y` + `cxx11abiTRUE/FALSE`
-- [Third-party wheels](https://mjunya.com/flash-attention-prebuild-wheels/)
+- [qwen-tts instructions](docs/backend-qwen.md)
+- [faster-qwen-tts instructions](docs/backend-faster.md)
## Tips
diff --git a/audiobook.py b/audiobook.py
index f177268..d3a5426 100755
--- a/audiobook.py
+++ b/audiobook.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""
-Qwen-Based Audiobook Converter
-Converts TXT, PDF and EPUB files into audiobooks using a local Qwen3-TTS server.
+TTS Audiobook Generator
+Converts TXT, PDF and EPUB files into audiobooks using a local TTS server.
Edit converter/config.py to change voice and processing settings.
"""
@@ -28,7 +28,7 @@ from converter.converter import (
from converter.tts import (
BACKEND_AUDIOCPP,
BACKEND_FASTER,
- BACKEND_GRADIO,
+ BACKEND_QWEN,
VOICE_MODE_CLONE,
VOICE_MODE_CUSTOM,
normalize_language,
@@ -38,21 +38,24 @@ from converter.tts import (
def main() -> None:
"""Entry point with argparse."""
parser = argparse.ArgumentParser(
- description="Convert books to audiobooks using the Qwen3-TTS voice model",
+ description="Convert books to audiobooks using a local TTS server",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
- # Use the Qwen Gradio demo with a custom voice (default - Vivian speaker)
+ # Use the default audio.cpp audiocpp_server (speaker mode - Vivian speaker, or a server-side voice)
python audiobook.py
- # Use the Qwen Gradio demo with voice cloning from reference audio
- python audiobook.py --clone path/to/reference.wav
+ # Use the audio.cpp audiocpp_server with a server-side voice preset
+ python audiobook.py --backend audiocpp --voice narrator
+
+ # Use the Qwen demo server with a custom voice
+ python audiobook.py --backend qwen
+
+ # Use the Qwen demo server with voice cloning from reference audio
+ python audiobook.py --backend qwen --clone path/to/reference.wav
# Use the faster-qwen3-tts server (voice cloning, configured server-side)
python audiobook.py --backend faster [--voice NAME]
-
- # Use an audio.cpp audiocpp_server (speaker mode, or a server-side voice)
- python audiobook.py --backend audiocpp [--voice NAME]
"""
)
@@ -117,14 +120,14 @@ Examples:
parser.add_argument(
"--backend",
- choices=[BACKEND_GRADIO, BACKEND_FASTER, BACKEND_AUDIOCPP],
+ choices=[BACKEND_AUDIOCPP, BACKEND_QWEN, BACKEND_FASTER],
default=config.BACKEND,
- help=("TTS server to talk to: the Qwen3-TTS Gradio demos (gradio), the "
+ help=("TTS server to talk to: the Qwen3-TTS demo server (qwen), the "
"faster-qwen3-tts OpenAI-compatible server (faster), or an "
"audio.cpp audiocpp_server (audiocpp) hosting any of its TTS "
"model families — Qwen3-TTS, Higgs Audio, VoxCPM2, IndexTTS2, "
"and more. Defaults to the BACKEND setting in "
- "converter/config.py (gradio).")
+ "converter/config.py (audiocpp).")
)
parser.add_argument(
@@ -137,7 +140,7 @@ Examples:
"with --ref-audio). audiocpp: a voice_preset or voice_dir entry "
"(cloning); required for audio.cpp families without built-in "
"speakers (everything except Qwen3-TTS CustomVoice). Not used by "
- "the gradio backend (use converter/config.py SPEAKER or --clone "
+ "the qwen backend (use converter/config.py SPEAKER or --clone "
"there).")
)
@@ -155,7 +158,7 @@ Examples:
help=("Force client-side chunking into CHUNK_SIZE-word requests (see "
"converter/config.py). Only matters for --backend audiocpp, which "
"otherwise sends each chapter as one request and lets the server "
- "chunk long text itself; the gradio and faster backends always "
+ "chunk long text itself; the qwen and faster backends always "
"chunk.")
)
@@ -223,7 +226,7 @@ Examples:
else:
if args.voice is not None:
parser.error("--voice requires --backend faster or audiocpp; the "
- "gradio backend uses built-in speakers "
+ "qwen backend uses built-in speakers "
"(converter/config.py SPEAKER) or --clone")
if args.language is not None:
try:
diff --git a/converter/__init__.py b/converter/__init__.py
index 80735d1..86a827f 100644
--- a/converter/__init__.py
+++ b/converter/__init__.py
@@ -1 +1 @@
-"""Qwen-based audiobook converter package."""
+"""TTS audiobook generator package."""
diff --git a/converter/config.py b/converter/config.py
index 8e60250..d15efa5 100644
--- a/converter/config.py
+++ b/converter/config.py
@@ -8,20 +8,20 @@ MAX_RETRIES = 3 # Attempts per chunk request
HEARTBEAT_INTERVAL_SECONDS = 30 # Print "still working" in console logs every N seconds
# Words per TTS generation request (client-side chunking).
-# The gradio and faster backends always chunk with this size
+# The qwen and faster backends always chunk with this size
# The audio.cpp backend chunks long text itself, so this is ignored
# by default with that backend. Force chunking with --chunk
CHUNK_SIZE = 250
# Default TTS backend.
-# gradio: qwen-tts-demo
-# faster: faster-qwen-tts
# audiocpp: audiocpp_server
+# qwen: qwen-tts-demo
+# faster: faster-qwen-tts
# The --backend CLI flag overrides this
-BACKEND = "gradio"
+BACKEND = "audiocpp"
###############################################################################
-# BACKEND 1: qwen-tts-demo (gradio) options #
+# BACKEND 1: qwen-tts-demo (qwen) options #
###############################################################################
# There are different API URLs for CustomVoice and Base models so you can run both at once
diff --git a/converter/converter.py b/converter/converter.py
index 4da3626..3915fe4 100644
--- a/converter/converter.py
+++ b/converter/converter.py
@@ -18,7 +18,7 @@ from .tts import (
BACKENDS,
BACKEND_AUDIOCPP,
BACKEND_FASTER,
- BACKEND_GRADIO,
+ BACKEND_QWEN,
MODEL_SIZE,
VOICE_MODE_CLONE,
VOICE_MODE_CUSTOM,
@@ -131,12 +131,12 @@ def prompt_overwrite(existing: List[Path], output_name: str) -> bool:
class AudiobookConverter:
- """Audiobook converter using the Qwen TTS API."""
+ """Audiobook converter using a local TTS API."""
def __init__(self, voice_mode: str = VOICE_MODE_CUSTOM, voice_clone_ref_audio: Optional[str] = None,
voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False,
speed: float = 1.0, single_file: bool = False, output_format: str = config.AUDIO_FORMAT,
- language: Optional[str] = None, backend: str = BACKEND_GRADIO,
+ language: Optional[str] = None, backend: str = config.BACKEND,
voice: Optional[str] = None, debug: bool = False,
chunk: bool = False, model_id: Optional[str] = None):
if speed <= 0:
@@ -158,7 +158,7 @@ class AudiobookConverter:
self.backend = backend
self.voice = voice
self.debug = bool(debug)
- # Client-side chunking: the gradio and faster backends always chunk
+ # Client-side chunking: the qwen and faster backends always chunk
# (their servers do one generation per request and silently truncate
# long text). The audio.cpp server chunks long text itself, so it
# defaults to one request per chapter; --chunk forces client-side
@@ -192,7 +192,7 @@ class AudiobookConverter:
f"Unknown voice mode: {self.voice_mode!r} "
f"(expected one of {VOICE_MODES})"
)
- if self.voice_mode == VOICE_MODE_CLONE and self.backend == BACKEND_GRADIO:
+ if self.voice_mode == VOICE_MODE_CLONE and self.backend == BACKEND_QWEN:
if not self.voice_clone_ref_audio:
raise ValueError(
"Voice Clone mode requires a reference audio file. "
@@ -464,7 +464,7 @@ class AudiobookConverter:
def _chapter_chunks(self, text: str) -> List[str]:
"""Split chapter text into TTS requests.
- Client-side chunking splits into CHUNK_SIZE-word chunks (gradio and
+ Client-side chunking splits into CHUNK_SIZE-word chunks (qwen and
faster always; audio.cpp only with --chunk). Otherwise (audio.cpp
default) the whole text is one request and the server does its own
long-form chunking.
@@ -575,7 +575,7 @@ class AudiobookConverter:
def _print_banner(self) -> None:
"""Print the startup summary for the selected backend."""
print("=" * 70)
- print("QWEN-BASED AUDIOBOOK CONVERTER")
+ print("TTS AUDIOBOOK GENERATOR")
print("=" * 70)
print(f"Books folder: {BOOKS_FOLDER}")
print(f"Output folder: {AUDIOBOOKS_FOLDER}")
diff --git a/converter/tts.py b/converter/tts.py
index 83284a7..9b54cf4 100644
--- a/converter/tts.py
+++ b/converter/tts.py
@@ -1,6 +1,6 @@
"""Client wrappers for the TTS backends.
-QwenTTSClient talks to the Qwen3-TTS Gradio demos (custom voice / voice clone).
+QwenTTSClient talks to the Qwen3-TTS demo server (custom voice / voice clone).
FasterTTSClient talks to the OpenAI-compatible server from the
faster-qwen3-tts repository (voice cloning only; the reference voice is
configured server-side — see the "Faster backend" section of the README).
@@ -40,10 +40,10 @@ VOICE_MODE_CLONE = "voice_clone"
VOICE_MODES = (VOICE_MODE_CUSTOM, VOICE_MODE_CLONE)
# TTS backends (re-exported for the CLI and the converter orchestrator).
-BACKEND_GRADIO = "gradio"
+BACKEND_QWEN = "qwen"
BACKEND_FASTER = "faster"
BACKEND_AUDIOCPP = "audiocpp"
-BACKENDS = (BACKEND_GRADIO, BACKEND_FASTER, BACKEND_AUDIOCPP)
+BACKENDS = (BACKEND_AUDIOCPP, BACKEND_QWEN, BACKEND_FASTER)
# Languages understood by the Qwen3-TTS API. Display names must match the
# demo dropdown exactly (the demo silently falls back to "Auto" for
@@ -188,7 +188,7 @@ def _resolve_request_seed() -> int:
def speaker_display_name() -> str:
- """Return the Gradio display name for the configured custom speaker."""
+ """Return the display name for the configured custom speaker."""
return SPEAKER_DISPLAY_NAMES.get(
config.SPEAKER.lower(), config.SPEAKER)
@@ -343,7 +343,7 @@ class _BaseTTSClient:
class QwenTTSClient(_BaseTTSClient):
- """Generates audio chunks through a Qwen3-TTS Gradio server."""
+ """Generates audio chunks through a Qwen3-TTS demo server."""
def __init__(self, voice_mode: str = "custom_voice", voice_clone_ref_audio: Optional[str] = None,
voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False,
@@ -392,7 +392,7 @@ class QwenTTSClient(_BaseTTSClient):
except Exception as exc:
raise RuntimeError(
f"Qwen API initialization failed at {api_url}: {exc}. "
- "Make sure the Qwen Gradio server is running and reachable, and that your "
+ "Make sure the Qwen demo server is running and reachable, and that your "
"installed Qwen3-TTS version matches this converter's API expectations "
"(voice clone requires the Base-model demo: Qwen/Qwen3-TTS-12Hz-1.7B-Base)."
) from exc
@@ -635,7 +635,7 @@ class FasterTTSClient(_BaseTTSClient):
repository (examples/openai_server.py). The reference voice (ref audio,
ref text) and language are configured on the server itself via
--ref-audio/--ref-text or a --voices JSON file; this client only sends
- text. Unlike the Gradio demo, the server performs one generation per
+ text. Unlike the Qwen demo, the server performs one generation per
request, so long chunks are sub-chunked client-side.
"""
@@ -779,7 +779,7 @@ class AudioCppTTSClient(_BaseTTSClient):
printed by the CLI.
Each response is a complete WAV file, so sub-request audio is
- concatenated with the same lossless path used for the Gradio client.
+ concatenated with the same lossless path used for the Qwen client.
"""
def __init__(self, voice: Optional[str] = None, language: Optional[str] = None,
@@ -796,7 +796,7 @@ class AudioCppTTSClient(_BaseTTSClient):
self.language = normalize_language(
language if language is not None else config.LANGUAGE)
# One seed value per run, reused for every request (see
- # _resolve_request_seed). Unlike the Gradio demo, audio.cpp has no
+ # _resolve_request_seed). Unlike the Qwen demo, audio.cpp has no
# negative "randomize" seed, so a negative value means "send no seed
# at all" (see _request_wav) and the server randomizes.
self._seed = _resolve_request_seed()
diff --git a/docs/backend-faster.md b/docs/backend-faster.md
new file mode 100644
index 0000000..83614c7
--- /dev/null
+++ b/docs/backend-faster.md
@@ -0,0 +1,41 @@
+# Backend Option 3: faster-qwen-tts
+
+`--backend faster` talks to the OpenAI-compatible server from [faster-qwen3-tts](https://github.com/andimarafioti/faster-qwen3-tts), which uses CUDA graph capture for roughly 5-10x faster inference with the same models. **It requires an NVIDIA GPU**.
+
+Install into the **same `audiobook` conda environment** used for qwen-tts.
+
+```bash
+conda activate audiobook
+pip install -U qwen-tts
+pip install "faster-qwen3-tts[demo]"
+```
+
+**This backend always uses voice cloning**. The reference voice and language are configured on the **server**, not through the converter. The server does not transcribe reference audio itself, so do it manually or use the `tools/make_faster_voices_json.py` helper (see below).
+
+The pip package does not include the server script, so clone the repository:
+
+```bash
+git clone https://github.com/andimarafioti/faster-qwen3-tts
+cd faster-qwen3-tts
+```
+
+Create a `voices.json` mapping names to reference configurations (.wav to clone, transcript, language). Optionally run `python ./tools/make_faster_voices_json.py path/to/clone/wavs` to automatically create a `voices.json` using whisper to automatically transcribe the test audio.
+
+```json
+{
+ "default": {"ref_audio": "voice1.wav", "ref_text": "Transcript of voice 1.", "language": "English"},
+ "obama": {"ref_audio": "voice2.wav", "ref_text": "Transcript of voice 2.", "language": "English"}
+}
+```
+
+Run the server
+
+```bash
+python examples/openai_server.py --voices voices.json --port 8000
+```
+
+Then from another terminal, run audiobook.py with `--backend faster`
+
+```bash
+python audiobook.py --backend faster [--voice NAME]
+```
diff --git a/docs/backend-qwen.md b/docs/backend-qwen.md
new file mode 100644
index 0000000..34078e3
--- /dev/null
+++ b/docs/backend-qwen.md
@@ -0,0 +1,63 @@
+# Backend Option 2: Qwen3-TTS
+
+Install qwen-tts with pip:
+
+```bash
+conda activate audiobook
+pip install -U qwen-tts
+```
+
+Run the backend with `qwen-tts-demo`. Add `--no-flash-attn` if FlashAttention isn't installed (see below). Note that the Base model and CustomVoice model run on different ports.
+
+## Voice clone
+
+```bash
+qwen-tts-demo Qwen/Qwen3-TTS-12Hz-1.7B-Base --ip 127.0.0.1 --port 7861 [--no-flash-attn]
+```
+
+Then in another terminal:
+
+```bash
+python audiobook.py --backend qwen --clone reference.wav
+```
+
+The reference `.wav` should be ~10-15 seconds (3 second minimum, 60 second maximum; ~15 seconds is ideal). Longer is **not** better.
+
+Whisper (`faster_whisper` or `whisper`) is used automatically to transcribe the reference audio. Without a Whisper backend it falls back to x-vector-only cloning. Override with `--transcription "What the .wav says"` or skip transcription with `--no-transcription`.
+
+## Custom voice (i.e. built-in voice)
+
+```bash
+conda activate audiobook
+qwen-tts-demo Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice --ip 127.0.0.1 --port 7860 [--no-flash-attn]
+```
+
+```bash
+python audiobook.py --backend qwen
+```
+
+Change the voice settings in `converter/config.py`.
+
+## Optional: FlashAttention for qwen-tts-demo server
+
+FlashAttention provides a *small* speed boost on the `qwen` backend. It is **not** relevant with other backends, and switching to either of those will provide a bigger speed boost.
+
+`qwen-tts-demo` server tries to use FlashAttention 2 by default and requires `--no-flash-attn` without it. You have two options to install FlashAttention in your python environment:
+
+1. Build from source (takes absolutely forever). If you run out of memory, lower MAX_JOBS until you don't.
+
+```bash
+conda activate audiobook
+pip install ninja packaging psutil
+MAX_JOBS=4 pip install --no-build-isolation flash-attn
+```
+
+2. pip install a prebuilt wheel matching your torch / CUDA / Python / CXX11-ABI combination:
+
+```bash
+conda activate audiobook
+python -c "import torch; print(torch.__version__, torch.version.cuda, torch._C._GLIBCXX_USE_CXX11_ABI)"
+```
+
+- [Official wheels](https://github.com/Dao-AILab/flash-attention/releases) - Pick `cp312` + matching `cuX` + `torchX.Y` + `cxx11abiTRUE/FALSE`
+- [Third-party wheels](https://mjunya.com/flash-attention-prebuild-wheels/)
diff --git a/requirements.txt b/requirements.txt
index 8a0ea5a..038c7fb 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,4 @@
-# Core dependencies for Qwen Audiobook Converter
+# Core dependencies for TTS Audiobook Generator
gradio_client>=0.7.0
pypdf>=4.0.0
ebooklib>=0.18
diff --git a/tests/test_chunking.py b/tests/test_chunking.py
index 3cd926b..2904e40 100644
--- a/tests/test_chunking.py
+++ b/tests/test_chunking.py
@@ -10,7 +10,7 @@ from converter.chunking import split_into_chunks
class ChunkSizeDefaultTests(unittest.TestCase):
"""Guard the request-size setting: each API call is one model
generation, and the servers silently truncate audio when a single
- generation runs too long (~2.5 min faster backend, ~11 min Gradio
+ generation runs too long (~2.5 min faster backend, ~11 min Qwen
demo), so the default chunk size must stay well inside that budget.
There is no hard ceiling beyond CHUNK_SIZE; users raising it accept
the truncation risk themselves."""
diff --git a/tests/test_converter.py b/tests/test_converter.py
index e7c0776..f09e151 100644
--- a/tests/test_converter.py
+++ b/tests/test_converter.py
@@ -56,17 +56,17 @@ class ConfigurationValidationTests(unittest.TestCase):
def test_language_defaults_to_config(self):
with patch("converter.converter.QwenTTSClient") as mock_tts:
- AudiobookConverter()
+ AudiobookConverter(backend=tts.BACKEND_QWEN)
self.assertEqual(mock_tts.call_args.kwargs["language"], config.LANGUAGE)
def test_output_format_defaults_to_config(self):
with patch("converter.converter.QwenTTSClient"):
- converter = AudiobookConverter()
+ converter = AudiobookConverter(backend=tts.BACKEND_QWEN)
self.assertEqual(converter.output_format, config.AUDIO_FORMAT)
def test_language_normalized_before_tts_client(self):
with patch("converter.converter.QwenTTSClient") as mock_tts:
- converter = AudiobookConverter(language="ja")
+ converter = AudiobookConverter(language="ja", backend=tts.BACKEND_QWEN)
self.assertEqual(converter.language, "Japanese")
self.assertEqual(mock_tts.call_args.kwargs["language"], "Japanese")
@@ -129,7 +129,7 @@ class NarratorTagTests(unittest.TestCase):
converter = AudiobookConverter.__new__(AudiobookConverter)
converter.voice_mode = voice_mode
converter.voice_clone_ref_audio = ref_audio
- converter.backend = tts.BACKEND_GRADIO
+ converter.backend = tts.BACKEND_QWEN
converter.voice = None
return converter
@@ -274,8 +274,8 @@ class DebugDumpTests(unittest.TestCase):
def test_debug_flag_wiring(self):
with patch("converter.converter.QwenTTSClient"):
- self.assertFalse(AudiobookConverter().debug)
- self.assertTrue(AudiobookConverter(debug=True).debug)
+ self.assertFalse(AudiobookConverter(backend=tts.BACKEND_QWEN).debug)
+ self.assertTrue(AudiobookConverter(debug=True, backend=tts.BACKEND_QWEN).debug)
class SetupLoggingTests(unittest.TestCase):
@@ -496,14 +496,14 @@ class PreflightOverwritesTests(unittest.TestCase):
(converter_mod.BOOKS_FOLDER / "book.txt").unlink()
with patch("builtins.input", side_effect=AssertionError("should not prompt")):
book_files, planned = AudiobookConverter.preflight_overwrites(
- tts.BACKEND_GRADIO, None, tts.VOICE_MODE_CUSTOM, None, "mp3")
+ tts.BACKEND_QWEN, None, tts.VOICE_MODE_CUSTOM, None, "mp3")
self.assertEqual(book_files, [])
self.assertEqual(planned, [])
def test_new_book_planned_without_prompt(self):
with patch("builtins.input", side_effect=AssertionError("should not prompt")):
book_files, planned = AudiobookConverter.preflight_overwrites(
- tts.BACKEND_GRADIO, None, tts.VOICE_MODE_CUSTOM, None, "mp3")
+ tts.BACKEND_QWEN, None, tts.VOICE_MODE_CUSTOM, None, "mp3")
self.assertEqual(len(book_files), 1)
self.assertEqual(planned, [(book_files[0], "book_Vivian")])
@@ -511,14 +511,14 @@ class PreflightOverwritesTests(unittest.TestCase):
(converter_mod.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").write_bytes(b"existing")
with patch("builtins.input", return_value=""):
book_files, planned = AudiobookConverter.preflight_overwrites(
- tts.BACKEND_GRADIO, None, tts.VOICE_MODE_CUSTOM, None, "mp3")
+ tts.BACKEND_QWEN, None, tts.VOICE_MODE_CUSTOM, None, "mp3")
self.assertEqual(planned, [(book_files[0], "book_Vivian")])
def test_existing_output_declined_is_skipped(self):
(converter_mod.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").write_bytes(b"existing")
with patch("builtins.input", return_value="n"):
book_files, planned = AudiobookConverter.preflight_overwrites(
- tts.BACKEND_GRADIO, None, tts.VOICE_MODE_CUSTOM, None, "mp3")
+ tts.BACKEND_QWEN, None, tts.VOICE_MODE_CUSTOM, None, "mp3")
self.assertEqual(len(book_files), 1)
self.assertEqual(planned, [])
@@ -536,7 +536,7 @@ class RunOverwritePromptTests(unittest.TestCase):
self.converter = AudiobookConverter.__new__(AudiobookConverter)
self.converter.voice_mode = tts.VOICE_MODE_CUSTOM
self.converter.voice_clone_ref_audio = None
- self.converter.backend = tts.BACKEND_GRADIO
+ self.converter.backend = tts.BACKEND_QWEN
self.converter.voice = None
self.converter.speed = 1.0
self.converter.single_file = False
diff --git a/tests/test_tts.py b/tests/test_tts.py
index bebbaf6..89248f2 100644
--- a/tests/test_tts.py
+++ b/tests/test_tts.py
@@ -1142,19 +1142,21 @@ class BackendWiringTests(unittest.TestCase):
voice="narrator", language=config.LANGUAGE,
chunk_text=False, model_id="higgs")
- def test_gradio_backend_uses_qwen_client(self):
+ def test_qwen_backend_uses_qwen_client(self):
with patch("converter.converter.FasterTTSClient") as mock_faster, \
patch("converter.converter.QwenTTSClient") as mock_qwen, \
patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
- AudiobookConverter(voice_mode=tts.VOICE_MODE_CUSTOM)
+ AudiobookConverter(voice_mode=tts.VOICE_MODE_CUSTOM,
+ backend=tts.BACKEND_QWEN)
mock_qwen.assert_called_once()
mock_faster.assert_not_called()
mock_audiocpp.assert_not_called()
- def test_gradio_clone_mode_still_requires_reference(self):
+ def test_qwen_clone_mode_still_requires_reference(self):
with patch("converter.converter.QwenTTSClient"):
with self.assertRaises(ValueError):
- AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE)
+ AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
+ backend=tts.BACKEND_QWEN)
def test_audiocpp_clone_mode_does_not_require_reference(self):
# Cloning is server-side for the audiocpp backend, so the
@@ -1182,9 +1184,10 @@ class BackendWiringTests(unittest.TestCase):
self.assertGreater(len(chunks), 1)
self.assertTrue(all(len(chunk.split()) <= 10 for chunk in chunks))
- def test_chapter_chunks_gradio_always_splits(self):
+ def test_chapter_chunks_qwen_always_splits(self):
with patch("converter.converter.QwenTTSClient"):
- converter = AudiobookConverter(voice_mode=tts.VOICE_MODE_CUSTOM)
+ converter = AudiobookConverter(voice_mode=tts.VOICE_MODE_CUSTOM,
+ backend=tts.BACKEND_QWEN)
text = " ".join(f"word{i}" for i in range(50))
with patch.object(config, "CHUNK_SIZE", 10):
chunks = converter._chapter_chunks(text)
@@ -1257,7 +1260,8 @@ class BackendWiringTests(unittest.TestCase):
ref.write_bytes(b"x")
with patch("converter.converter.QwenTTSClient"):
converter = AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
- voice_clone_ref_audio=str(ref))
+ voice_clone_ref_audio=str(ref),
+ backend=tts.BACKEND_QWEN)
self.assertEqual(converter._narrator_tag(), "ref")