feat: true zero-downtime voice hot-reloading (v6.5)

This commit is contained in:
mARTin-B78 2026-06-20 21:19:19 +02:00
parent 4d7b2c5e0c
commit 7a8d8d17ad
5 changed files with 62 additions and 15 deletions

View File

@ -136,7 +136,7 @@ Or use the auto-transcription script with a running Whisper-compatible ASR servi
python config/auto_transcribe.py --api-url http://localhost:8010/v1/audio/transcriptions
```
`config/generate_voices.py` runs on container startup and creates `config/voices.json` from your speaker files.
`config/generate_voices.py` runs automatically in the background, continuously watching your `speakers` directory. Whenever you add a new `.wav` and `.txt` file, it instantly updates `config/voices.json`. The API server hot-reloads the changes, meaning **you never need to restart the container when adding new voices!**
When you start using a new voice for the first time, the server will automatically do the heavy lifting to extract the voice's acoustic fingerprint (a "speaker embedding") and save it as a `.pt` file in the `config/speakers/` directory. Future requests will instantly load this `.pt` file instead of re-analyzing the audio, which dramatically speeds up Time To First Audio (TTFA).
## VoiceDesign voices
@ -341,6 +341,13 @@ The first request after container startup can be slower because CUDA graph captu
## Changelog
### v6.5 — 2026-06-20
**Feature: True Zero-Downtime Voice Hot-Reloading**
- The server now watches `voices.json` and hot-reloads it automatically when changes are detected.
- Added a background loop in `docker-compose.yml` that continuously runs `generate_voices.py` every 10 seconds.
- You can now drop new `.wav` and `.txt` files into your `speakers/` directory and they will be instantly available via the API without ever restarting the Docker container!
### v6.4 — 2026-06-20
**Feature: Fully Automated Speaker Embeddings (.pt files)**

View File

@ -120,7 +120,10 @@ for scan_dir in SCAN_DIRS:
voices[voice_id] = entry
with open(output_file, "w", encoding="utf-8") as f:
json.dump(voices, f, indent=2, ensure_ascii=False)
if voices != _existing:
with open(output_file, "w", encoding="utf-8") as f:
json.dump(voices, f, indent=2, ensure_ascii=False)
print(f"Success! Generated voices.json with {len(voices)} mapped voices.")
else:
pass # No changes, do not update mtime
print(f"Success! Generated voices.json with {len(voices)} mapped voices.")

View File

@ -19,7 +19,7 @@ services:
- ../config/speakers:/voices:ro
command: >
/bin/bash -c "
python3 /config/generate_voices.py &&
(while true; do python3 /config/generate_voices.py; sleep 10; done) &
python3 /config/run_server.py
--model /models/Qwen3-TTS
--voices /config/voices.json

View File

@ -36,7 +36,7 @@ services:
- /path/to/active_voices:/voices:ro
command: >
/bin/bash -c "
python3 /config/generate_voices.py &&
(while true; do python3 /config/generate_voices.py; sleep 10; done) &
python3 /config/run_server.py
--model /models/Qwen3-TTS
--voices /config/voices.json
@ -134,7 +134,7 @@ services:
- /path/to/active_voices:/voices:ro
command: >
/bin/bash -c "
python3 /config/generate_voices.py &&
(while true; do python3 /config/generate_voices.py; sleep 10; done) &
python3 /config/run_server.py
--model /models/Qwen3-TTS
--voices /config/voices.json

View File

@ -1,14 +1,37 @@
--- /tmp/upstream_openai_server.py 2026-06-20 20:43:53.380341702 +0200
+++ build/examples/openai_server.py 2026-06-20 20:52:17.725652965 +0200
@@ -145,6 +145,7 @@
+++ build/examples/openai_server.py 2026-06-20 21:18:30.515352046 +0200
@@ -66,6 +66,8 @@
tts_model = None
voices: dict = {}
+voices_file_path: Optional[str] = None
+last_voices_mtime: float = 0.0
default_voice: Optional[str] = None
SAMPLE_RATE = 24000 # updated once the model loads
_model_lock = threading.Lock() # prevent concurrent GPU inference
@@ -145,6 +147,21 @@
def resolve_voice(voice_name: str) -> dict:
"""Return voice config dict or fall back to default, else raise 400."""
+ global voices, last_voices_mtime
+ voice_name = voice_name.strip()
+
+ # Hot-reload voices.json if it was modified
+ if voices_file_path and os.path.exists(voices_file_path):
+ try:
+ current_mtime = os.path.getmtime(voices_file_path)
+ if current_mtime > last_voices_mtime:
+ with open(voices_file_path, "r", encoding="utf-8") as f:
+ voices = json.load(f)
+ last_voices_mtime = current_mtime
+ logger.info("Hot-reloaded %d voices from %s", len(voices), voices_file_path)
+ except Exception as e:
+ logger.warning("Failed to hot-reload voices.json: %s", e)
+
if voice_name in voices:
return voices[voice_name]
if default_voice and default_voice in voices:
@@ -168,7 +169,47 @@
@@ -168,7 +185,47 @@
# ---------------------------------------------------------------------------
@ -57,7 +80,7 @@
"""
Run generate_voice_clone_streaming in a background thread and yield
raw PCM bytes for each chunk as they arrive.
@@ -182,10 +223,15 @@
@@ -182,10 +239,15 @@
for chunk, _sr, _timing in tts_model.generate_voice_clone_streaming(
text=text,
language=voice_cfg.get("language", "Auto"),
@ -75,7 +98,7 @@
):
q.put(chunk)
except Exception as exc:
@@ -247,8 +293,14 @@
@@ -247,8 +309,14 @@
return tts_model.generate_voice_clone(
text=req.input,
language=voice_cfg.get("language", "Auto"),
@ -91,7 +114,7 @@
)
audio_arrays, sr = await loop.run_in_executor(None, _generate)
@@ -259,7 +311,7 @@
@@ -259,7 +327,7 @@
async def audio_stream():
if fmt == "wav":
yield _wav_header(SAMPLE_RATE) # stream with unknown data length
@ -100,7 +123,7 @@
yield raw_chunk
return StreamingResponse(audio_stream(), media_type=content_type)
@@ -306,6 +358,7 @@
@@ -306,16 +374,20 @@
p.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
p.add_argument("--port", type=int, default=8000, help="Bind port (default: 8000)")
p.add_argument("--device", default="cuda", help="Torch device (default: cuda)")
@ -108,7 +131,21 @@
return p.parse_args()
@@ -344,6 +397,7 @@
def main():
- global tts_model, voices, default_voice, SAMPLE_RATE
+ global tts_model, voices, voices_file_path, last_voices_mtime, default_voice, SAMPLE_RATE
args = _parse_args()
# Build voice registry
if args.voices:
+ voices_file_path = args.voices
+ if os.path.exists(args.voices):
+ last_voices_mtime = os.path.getmtime(args.voices)
with open(args.voices) as f:
voices = json.load(f)
default_voice = next(iter(voices))
@@ -344,6 +416,7 @@
args.model,
device=args.device,
dtype=torch.bfloat16,