feat: true zero-downtime voice hot-reloading (v6.5)
This commit is contained in:
parent
4d7b2c5e0c
commit
7a8d8d17ad
@ -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)**
|
||||
|
||||
|
||||
@ -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.")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user