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:
parent
62a2ad9e7c
commit
ceba2bf27a
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user