diff --git a/.gitignore b/.gitignore index 3aedd7b..f82a141 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,12 @@ config/faster-qwen3-tts.code-workspace config/voices.json config/voices.json* +# Generated at container startup from the voice library's designed voices. +# Contains the user's own character design prompts, so it stays local; the +# bundled vd_* presets live in voicedesign_voices.presets.json, which IS +# tracked and seeds this file on a fresh checkout. +config/voicedesign_voices.json + # Converted private audio (M4A → WAV, generated at runtime) config/converted/ diff --git a/README.md b/README.md index abe4ac8..39e1568 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,14 @@ The first request after container startup can be slower because CUDA graph captu ## Changelog +### v6.8 — 2026-07-29 +**Fix: Stale Speaker Embeddings and Unregistered Voice Design Voices** +- **Stale `.pt` Embeddings:** A speaker embedding is a cache baked from one specific pairing of reference audio and transcript. Re-recording a voice replaced those sources but left the old `.pt` in place, so the server kept cloning from an embedding whose audio tokens no longer matched its transcript — generation ignored the requested text and emitted unrelated filler. `generate_voices.py` now treats an embedding older than its reference audio or transcript as absent and regenerates it. +- **Designed Voices Were Never Registered:** Voices created with VoiceDesign were not written into `voicedesign_voices.json` at all, so the server knew only its 8 bundled presets and silently substituted one of them for every custom voice. The registry is now generated from the voice library's `.meta.json` files, and an unknown voice returns 404 instead of a different character. +- **Instruct Merging:** A per-request `instruct` is now appended to the voice's own description rather than replacing it, so directing a line's emotion no longer discards the character's identity and re-rolls a new voice. +- **VoiceDesign Hot-Reload:** The VoiceDesign server picks up registry changes while running, matching the voice-clone server. Per-voice `temperature`/`top_p`/`top_k` set in the registry are honoured and preserved across regeneration. +- **Registry No Longer Tracked:** `config/voicedesign_voices.json` is generated from your own voice library and carries your design prompts, so it is now gitignored. The bundled presets live in the tracked `config/voicedesign_voices.presets.json`, which seeds the registry on a fresh checkout. + ### v6.7 — 2026-06-26 **Feature: Native Speed Control and Word-Level Timestamps** - **Speed Parameter:** The `speed` parameter in the OpenAI `SpeechRequest` schema is now fully supported. Audio tempo is natively adjusted using `ffmpeg` without affecting pitch, and works for both streaming and non-streaming responses. diff --git a/config/generate_voices.py b/config/generate_voices.py index b440e32..f90e908 100644 --- a/config/generate_voices.py +++ b/config/generate_voices.py @@ -166,6 +166,10 @@ else: # which hot-reloads this file. VOICEDESIGN_OUTPUT = "/config/voicedesign_voices.json" +# The generated registry carries the user's own design prompts, so it is not +# tracked in git. The bundled vd_* presets are, in this seed file — it is the +# only copy of them a fresh clone gets. +VOICEDESIGN_PRESETS = "/config/voicedesign_voices.presets.json" VOICEDESIGN_NOTE_PREFIX = "Voice Design:" _LANG_BY_FLAG = { @@ -175,15 +179,27 @@ _LANG_BY_FLAG = { } -def _build_voicedesign_registry(): +def _load_json_dict(path): try: - with open(VOICEDESIGN_OUTPUT, encoding="utf-8") as f: - existing = json.load(f) + with open(path, encoding="utf-8") as f: + data = json.load(f) except (OSError, json.JSONDecodeError): - existing = {} + return {} + return data if isinstance(data, dict) else {} - # Keep the bundled vd_* presets; rebuild every app-managed entry. + +def _build_voicedesign_registry(): + existing = _load_json_dict(VOICEDESIGN_OUTPUT) + + # Keep the bundled vd_* presets; rebuild every app-managed entry. On a + # fresh checkout the registry does not exist yet, so seed the presets from + # the tracked file — without them the server has no built-in voices at all. registry = {k: v for k, v in existing.items() if k.startswith("vd_")} + if not registry: + registry = { + k: v for k, v in _load_json_dict(VOICEDESIGN_PRESETS).items() + if k.startswith("vd_") + } added = 0 for scan_dir in SCAN_DIRS: diff --git a/config/run_voicedesign_server.py b/config/run_voicedesign_server.py index 136c198..257c8b1 100644 --- a/config/run_voicedesign_server.py +++ b/config/run_voicedesign_server.py @@ -422,7 +422,20 @@ def main(): _load_model_kwargs = args voices_file_path = args.voices - with open(args.voices) as f: + # The registry is generated by the voice-clone container and is not tracked + # in git, so on a fresh checkout it may not exist yet. Start from the + # bundled presets instead of crashing — hot-reload picks up the real + # registry as soon as it appears. + startup_file = args.voices + if not os.path.exists(startup_file): + startup_file = os.path.join( + os.path.dirname(args.voices) or ".", "voicedesign_voices.presets.json" + ) + logger.warning( + "%s not found, falling back to bundled presets %s", + args.voices, startup_file, + ) + with open(startup_file) as f: voices = json.load(f) try: last_voices_mtime = os.path.getmtime(args.voices) @@ -430,7 +443,7 @@ def main(): last_voices_mtime = 0.0 default_voice = next(iter(voices), None) _build_voice_list() - logger.info("Loaded %d voices from %s", len(voices), args.voices) + logger.info("Loaded %d voices from %s", len(voices), startup_file) uvicorn.run(app, host=args.host, port=args.port, log_level="info") diff --git a/config/voicedesign_voices.json b/config/voicedesign_voices.presets.json similarity index 100% rename from config/voicedesign_voices.json rename to config/voicedesign_voices.presets.json