From 7a8d8d17ad28b9f7f612cd5754f36bccf948f4c5 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sat, 20 Jun 2026 21:19:19 +0200 Subject: [PATCH] feat: true zero-downtime voice hot-reloading (v6.5) --- README.md | 9 +++++- config/generate_voices.py | 9 ++++-- docker/docker-compose.simple.yml | 2 +- docker/docker-compose.yml | 4 +-- patches/openai_server.patch | 53 +++++++++++++++++++++++++++----- 5 files changed, 62 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index d825046..077df5f 100644 --- a/README.md +++ b/README.md @@ -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)** diff --git a/config/generate_voices.py b/config/generate_voices.py index 54e1eca..c8a3cfa 100644 --- a/config/generate_voices.py +++ b/config/generate_voices.py @@ -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.") diff --git a/docker/docker-compose.simple.yml b/docker/docker-compose.simple.yml index eae93e0..28bf08b 100644 --- a/docker/docker-compose.simple.yml +++ b/docker/docker-compose.simple.yml @@ -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 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index adbe3e0..be00e52 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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 diff --git a/patches/openai_server.patch b/patches/openai_server.patch index 2ad1031..a450f9c 100644 --- a/patches/openai_server.patch +++ b/patches/openai_server.patch @@ -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,