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
<LANG>_<M/F>_* 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 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-21 20:02:50 +02:00
parent 62a2ad9e7c
commit ceba2bf27a
2 changed files with 34 additions and 11 deletions

View File

@ -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 `<LANG>_<M/F>_...` 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.

View File

@ -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,