From ceba2bf27acb355b546afb341e92ebe0ee894ad2 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Mon, 21 Sep 2026 20:02:50 +0200 Subject: [PATCH] feat: resolve language aliases in the VoiceClone voice field OpenAI-compatible clients may send a route alias such as FR, FR_F or FR_M as the voice. Map it (case-insensitively) to a configured __* speaker instead of falling back to the default English voice; a bare language code prefers a female voice. Regenerated patches/openai_server.patch from source so it applies cleanly to both the pinned upstream commit and upstream main. Co-Authored-By: Claude Opus 5 --- README.md | 6 +++++- patches/openai_server.patch | 39 +++++++++++++++++++++++++++---------- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a5820a5..8cb994e 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ The streaming service on port `8023` uses the same generated `config/voices.json |---|---|---|---| | `model` | string | `tts-1` | Kept for OpenAI compatibility | | `input` | string | required | Text to synthesize | -| `voice` | string | first configured voice | Voice ID from the selected service | +| `voice` | string | first configured voice | Voice ID from the selected service. VoiceClone also accepts a language alias (`FR`, `FR_F`, `fr_m`) that resolves to the first matching `__...` voice; a bare language code prefers a female voice | | `response_format` | string | `wav` | `wav`, `pcm`, `mp3`, or `zip` (for timestamps) | | `speed` | float | 1.0 | Scales audio tempo via ffmpeg | | `language` | string | voice config | Per-request override for VoiceDesign/CustomVoice | @@ -378,6 +378,10 @@ The first request after container startup can be slower because CUDA graph captu ## Changelog +### v6.11 — 2026-09-21 +**Feature: Language Aliases for VoiceClone Voices** +- OpenAI-compatible clients that send a route alias such as `FR`, `FR_F` or `FR_M` in the `voice` field now get a configured speaker in that language (case-insensitive) instead of silently falling back to the default English voice. Unknown aliases still return the default voice or a 400 as before. + ### v6.10.1 — 2026-09-21 **Fix: Fresh `docker build` Crashes on Model Load** - **Root Cause:** The Dockerfile cloned upstream `faster-qwen3-tts` from `main`. Upstream v0.4.0 (2026-08-25) moved to Transformers 5 (`qwen-tts-hf`), but `qwen-asr` pins `transformers==4.57.6` and downgraded it in the next build step, so `Qwen3TTSModel.from_pretrained` failed on startup. Images built before that date were unaffected. diff --git a/patches/openai_server.patch b/patches/openai_server.patch index 9e8905b..0399632 100644 --- a/patches/openai_server.patch +++ b/patches/openai_server.patch @@ -1,5 +1,5 @@ diff --git a/examples/openai_server.py b/examples/openai_server.py -index 2199e14..05151ce 100644 +index 2199e14..ca5eaf9 100644 --- a/examples/openai_server.py +++ b/examples/openai_server.py @@ -36,11 +36,13 @@ API usage: @@ -147,10 +147,12 @@ index 2199e14..05151ce 100644 # --------------------------------------------------------------------------- -@@ -145,6 +257,21 @@ def _to_mp3_bytes(pcm: np.ndarray, sample_rate: int) -> bytes: +@@ -144,9 +256,39 @@ def _to_mp3_bytes(pcm: np.ndarray, sample_rate: int) -> bytes: + 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.""" ++ """Return voice config dict, accepting a bare language code as an alias.""" + global voices, last_voices_mtime + voice_name = voice_name.strip() + @@ -168,8 +170,25 @@ index 2199e14..05151ce 100644 + if voice_name in voices: return voices[voice_name] ++ # OpenAI-compatible clients sometimes send a route alias (e.g. ``FR``, ++ # ``FR_F`` or ``FR_M``) in the voice field. Resolve it to a configured ++ # speaker instead of silently switching to the default English voice. ++ alias = re.fullmatch(r"([A-Z]{2})(?:_([MF]))?", voice_name, re.IGNORECASE) ++ if alias: ++ prefix = alias.group(1).upper() + "_" ++ if alias.group(2): ++ prefix += alias.group(2).upper() + "_" ++ candidates = [name for name in voices if name.upper().startswith(prefix)] ++ if candidates: ++ # For a language-only alias prefer a female voice, then registry order. ++ selected = next((name for name in candidates ++ if not alias.group(2) and name.upper().startswith(prefix + "F_")), candidates[0]) ++ logger.warning("Voice %r is a language alias; using configured voice %r", voice_name, selected) ++ return voices[selected] if default_voice and default_voice in voices: -@@ -168,7 +295,47 @@ def resolve_voice(voice_name: str) -> dict: + logger.warning( + "Voice %r not configured; falling back to default voice %r", +@@ -168,7 +310,47 @@ def resolve_voice(voice_name: str) -> dict: # --------------------------------------------------------------------------- @@ -218,7 +237,7 @@ index 2199e14..05151ce 100644 """ Run generate_voice_clone_streaming in a background thread and yield raw PCM bytes for each chunk as they arrive. -@@ -177,21 +344,65 @@ async def _stream_chunks(voice_cfg: dict, text: str) -> AsyncGenerator[bytes, No +@@ -177,21 +359,65 @@ async def _stream_chunks(voice_cfg: dict, text: str) -> AsyncGenerator[bytes, No _DONE = object() def producer(): @@ -294,7 +313,7 @@ index 2199e14..05151ce 100644 thread = threading.Thread(target=producer, daemon=True) thread.start() -@@ -203,7 +414,7 @@ async def _stream_chunks(voice_cfg: dict, text: str) -> AsyncGenerator[bytes, No +@@ -203,7 +429,7 @@ async def _stream_chunks(voice_cfg: dict, text: str) -> AsyncGenerator[bytes, No break if isinstance(item, Exception): raise item @@ -303,7 +322,7 @@ index 2199e14..05151ce 100644 # --------------------------------------------------------------------------- -@@ -220,7 +431,8 @@ async def health(): +@@ -220,7 +446,8 @@ async def health(): async def create_speech(req: SpeechRequest): if tts_model is None: raise HTTPException(status_code=503, detail="Model not loaded") @@ -313,7 +332,7 @@ index 2199e14..05151ce 100644 raise HTTPException(status_code=400, detail="'input' text is empty") voice_cfg = resolve_voice(req.voice) -@@ -230,36 +442,84 @@ async def create_speech(req: SpeechRequest): +@@ -230,36 +457,84 @@ async def create_speech(req: SpeechRequest): "wav": "audio/wav", "pcm": "audio/pcm", "mp3": "audio/mpeg", @@ -411,7 +430,7 @@ index 2199e14..05151ce 100644 yield raw_chunk return StreamingResponse(audio_stream(), media_type=content_type) -@@ -306,16 +566,21 @@ def _parse_args(): +@@ -306,16 +581,21 @@ def _parse_args(): 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)") @@ -434,7 +453,7 @@ index 2199e14..05151ce 100644 with open(args.voices) as f: voices = json.load(f) default_voice = next(iter(voices)) -@@ -344,6 +609,7 @@ def main(): +@@ -344,6 +624,7 @@ def main(): args.model, device=args.device, dtype=torch.bfloat16,