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
|
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).
|
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
|
## VoiceDesign voices
|
||||||
@ -341,6 +341,13 @@ The first request after container startup can be slower because CUDA graph captu
|
|||||||
|
|
||||||
## Changelog
|
## 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
|
### v6.4 — 2026-06-20
|
||||||
**Feature: Fully Automated Speaker Embeddings (.pt files)**
|
**Feature: Fully Automated Speaker Embeddings (.pt files)**
|
||||||
|
|
||||||
|
|||||||
@ -120,7 +120,10 @@ for scan_dir in SCAN_DIRS:
|
|||||||
|
|
||||||
voices[voice_id] = entry
|
voices[voice_id] = entry
|
||||||
|
|
||||||
|
if voices != _existing:
|
||||||
with open(output_file, "w", encoding="utf-8") as f:
|
with open(output_file, "w", encoding="utf-8") as f:
|
||||||
json.dump(voices, f, indent=2, ensure_ascii=False)
|
json.dump(voices, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
print(f"Success! Generated voices.json with {len(voices)} mapped voices.")
|
print(f"Success! Generated voices.json with {len(voices)} mapped voices.")
|
||||||
|
else:
|
||||||
|
pass # No changes, do not update mtime
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,7 @@ services:
|
|||||||
- ../config/speakers:/voices:ro
|
- ../config/speakers:/voices:ro
|
||||||
command: >
|
command: >
|
||||||
/bin/bash -c "
|
/bin/bash -c "
|
||||||
python3 /config/generate_voices.py &&
|
(while true; do python3 /config/generate_voices.py; sleep 10; done) &
|
||||||
python3 /config/run_server.py
|
python3 /config/run_server.py
|
||||||
--model /models/Qwen3-TTS
|
--model /models/Qwen3-TTS
|
||||||
--voices /config/voices.json
|
--voices /config/voices.json
|
||||||
|
|||||||
@ -36,7 +36,7 @@ services:
|
|||||||
- /path/to/active_voices:/voices:ro
|
- /path/to/active_voices:/voices:ro
|
||||||
command: >
|
command: >
|
||||||
/bin/bash -c "
|
/bin/bash -c "
|
||||||
python3 /config/generate_voices.py &&
|
(while true; do python3 /config/generate_voices.py; sleep 10; done) &
|
||||||
python3 /config/run_server.py
|
python3 /config/run_server.py
|
||||||
--model /models/Qwen3-TTS
|
--model /models/Qwen3-TTS
|
||||||
--voices /config/voices.json
|
--voices /config/voices.json
|
||||||
@ -134,7 +134,7 @@ services:
|
|||||||
- /path/to/active_voices:/voices:ro
|
- /path/to/active_voices:/voices:ro
|
||||||
command: >
|
command: >
|
||||||
/bin/bash -c "
|
/bin/bash -c "
|
||||||
python3 /config/generate_voices.py &&
|
(while true; do python3 /config/generate_voices.py; sleep 10; done) &
|
||||||
python3 /config/run_server.py
|
python3 /config/run_server.py
|
||||||
--model /models/Qwen3-TTS
|
--model /models/Qwen3-TTS
|
||||||
--voices /config/voices.json
|
--voices /config/voices.json
|
||||||
|
|||||||
@ -1,14 +1,37 @@
|
|||||||
--- /tmp/upstream_openai_server.py 2026-06-20 20:43:53.380341702 +0200
|
--- /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
|
+++ build/examples/openai_server.py 2026-06-20 21:18:30.515352046 +0200
|
||||||
@@ -145,6 +145,7 @@
|
@@ -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:
|
def resolve_voice(voice_name: str) -> dict:
|
||||||
"""Return voice config dict or fall back to default, else raise 400."""
|
"""Return voice config dict or fall back to default, else raise 400."""
|
||||||
|
+ global voices, last_voices_mtime
|
||||||
+ voice_name = voice_name.strip()
|
+ 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:
|
if voice_name in voices:
|
||||||
return voices[voice_name]
|
return voices[voice_name]
|
||||||
if default_voice and default_voice in voices:
|
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
|
Run generate_voice_clone_streaming in a background thread and yield
|
||||||
raw PCM bytes for each chunk as they arrive.
|
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(
|
for chunk, _sr, _timing in tts_model.generate_voice_clone_streaming(
|
||||||
text=text,
|
text=text,
|
||||||
language=voice_cfg.get("language", "Auto"),
|
language=voice_cfg.get("language", "Auto"),
|
||||||
@ -75,7 +98,7 @@
|
|||||||
):
|
):
|
||||||
q.put(chunk)
|
q.put(chunk)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -247,8 +293,14 @@
|
@@ -247,8 +309,14 @@
|
||||||
return tts_model.generate_voice_clone(
|
return tts_model.generate_voice_clone(
|
||||||
text=req.input,
|
text=req.input,
|
||||||
language=voice_cfg.get("language", "Auto"),
|
language=voice_cfg.get("language", "Auto"),
|
||||||
@ -91,7 +114,7 @@
|
|||||||
)
|
)
|
||||||
|
|
||||||
audio_arrays, sr = await loop.run_in_executor(None, _generate)
|
audio_arrays, sr = await loop.run_in_executor(None, _generate)
|
||||||
@@ -259,7 +311,7 @@
|
@@ -259,7 +327,7 @@
|
||||||
async def audio_stream():
|
async def audio_stream():
|
||||||
if fmt == "wav":
|
if fmt == "wav":
|
||||||
yield _wav_header(SAMPLE_RATE) # stream with unknown data length
|
yield _wav_header(SAMPLE_RATE) # stream with unknown data length
|
||||||
@ -100,7 +123,7 @@
|
|||||||
yield raw_chunk
|
yield raw_chunk
|
||||||
|
|
||||||
return StreamingResponse(audio_stream(), media_type=content_type)
|
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("--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("--port", type=int, default=8000, help="Bind port (default: 8000)")
|
||||||
p.add_argument("--device", default="cuda", help="Torch device (default: cuda)")
|
p.add_argument("--device", default="cuda", help="Torch device (default: cuda)")
|
||||||
@ -108,7 +131,21 @@
|
|||||||
return p.parse_args()
|
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,
|
args.model,
|
||||||
device=args.device,
|
device=args.device,
|
||||||
dtype=torch.bfloat16,
|
dtype=torch.bfloat16,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user