[Qwen3TTS][ServingSpeech] Bugfix/voice upload and add optional ref_text (#2046)

Signed-off-by: JuanPZuluaga <juanz9312@gmail.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
This commit is contained in:
Juan Pablo ZuluagaandYueqian Lin authored and GitHub committed 2026-03-26 14:26:52 +00:00
1 parent 3a5483d339
commit 574ec99ef4
7 files changed
+139 -43

No files matched your search

+14 -2
View File
@@ -160,6 +160,7 @@ Upload a new voice sample for voice cloning in Base task TTS requests.
| `audio_sample` | file | Yes | Audio file (max 10MB, supported formats: wav, mp3, flac, ogg, aac, webm, mp4) |
| `consent` | string | Yes | Consent recording ID |
| `name` | string | Yes | Name for the new voice |
| `ref_text` | string | No | Transcript of the audio. When provided, enables in-context voice cloning (higher quality). Without it, only the speaker embedding is extracted. |
**Response Example:**
@@ -182,7 +183,8 @@ Upload a new voice sample for voice cloning in Base task TTS requests.
curl -X POST http://localhost:8091/v1/audio/voices \
-F "audio_sample=@/path/to/voice_sample.wav" \
-F "consent=user_consent_id" \
-F "name=custom_voice_1"
-F "name=custom_voice_1" \
-F "ref_text=The exact transcript of the audio sample."
```
## Streaming Text Input (WebSocket)
@@ -318,6 +320,8 @@ curl -X POST http://localhost:8091/v1/audio/speech \
```
### Upload Voice
Upload voice (speaker embedding only):
```bash
curl -X POST http://localhost:8091/v1/audio/voices \
-F "audio_sample=@/path/to/voice_sample.wav" \
@@ -325,13 +329,21 @@ curl -X POST http://localhost:8091/v1/audio/voices \
-F "name=custom_voice_1"
```
Upload voice with transcript (in-context cloning, higher quality):
```bash
curl -X POST http://localhost:8091/v1/audio/voices \
-F "audio_sample=@/path/to/voice_sample.wav" \
-F "consent=user_consent_id" \
-F "name=custom_voice_2" \
-F "ref_text=The exact transcript of the audio sample."
```
### Use Uploaded Voice
```bash
curl -X POST http://localhost:8091/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"input": "Hello, this is a cloned voice",
"task_type": "Base",
"voice": "custom_voice_1"
}' --output cloned.wav
```
@@ -242,6 +242,7 @@ Upload a new voice sample for voice cloning in Base task TTS requests.
- `audio_sample` (required): Audio file (max 10MB, supported formats: wav, mp3, flac, ogg, aac, webm, mp4)
- `consent` (required): Consent recording ID
- `name` (required): Name for the new voice
- `ref_text` (optional): Transcript of the audio. Enables in-context voice cloning (higher quality).
**Response Example:**
```json
@@ -262,7 +263,8 @@ Upload a new voice sample for voice cloning in Base task TTS requests.
curl -X POST http://localhost:8000/v1/audio/voices \
-F "audio_sample=@/path/to/voice_sample.wav" \
-F "consent=user_consent_id" \
-F "name=custom_voice_1"
-F "name=custom_voice_1" \
-F "ref_text=The exact transcript of the audio sample."
```
### Endpoint
+3 -1
View File
@@ -232,6 +232,7 @@ Upload a new voice sample for voice cloning in Base task TTS requests.
- `audio_sample` (required): Audio file (max 10MB, supported formats: wav, mp3, flac, ogg, aac, webm, mp4)
- `consent` (required): Consent recording ID
- `name` (required): Name for the new voice
- `ref_text` (optional): Transcript of the audio. Enables in-context voice cloning (higher quality).
**Response Example:**
```json
@@ -252,7 +253,8 @@ Upload a new voice sample for voice cloning in Base task TTS requests.
curl -X POST http://localhost:8000/v1/audio/voices \
-F "audio_sample=@/path/to/voice_sample.wav" \
-F "consent=user_consent_id" \
-F "name=custom_voice_1"
-F "name=custom_voice_1" \
-F "ref_text=The exact transcript of the audio sample."
```
### Endpoint
@@ -250,6 +250,7 @@ def test_app(mocker: MockerFixture):
speaker_embedding: str | None = Form(None),
consent: str = Form(...),
name: str = Form(...),
ref_text: str = Form(None),
):
try:
if speaker_embedding is not None and audio_sample is not None:
@@ -257,7 +258,7 @@ def test_app(mocker: MockerFixture):
if speaker_embedding is not None:
result = await speech_server.upload_voice_embedding(speaker_embedding, consent, name)
elif audio_sample is not None:
result = await speech_server.upload_voice(audio_sample, consent, name)
result = await speech_server.upload_voice(audio_sample, consent, name, ref_text=ref_text)
else:
raise ValueError("Either 'audio_sample' or 'speaker_embedding' must be provided")
return {"success": True, "voice": result}
@@ -361,30 +362,36 @@ class TestSpeechAPI:
assert "voices" in response.json()
def test_upload_voice_success(self, client, tmp_path):
"""Test successful voice upload."""
# Create a mock audio file
audio_content = b"fake audio content" * 1000 # ~17KB
files = {
"audio_sample": ("test.wav", audio_content, "audio/wav"),
}
data = {
"consent": "user_consent_123",
"name": "test_voice",
}
"""Test successful voice upload without ref_text."""
audio_content = b"fake audio content" * 1000
files = {"audio_sample": ("test.wav", audio_content, "audio/wav")}
data = {"consent": "user_consent_123", "name": "test_voice"}
response = client.post("/v1/audio/voices", files=files, data=data)
assert response.status_code == 200
result = response.json()
assert result["success"] is True
assert "voice" in result
voice_info = result["voice"]
assert voice_info["name"] == "test_voice"
assert voice_info["consent"] == "user_consent_123"
assert "created_at" in voice_info
assert voice_info["mime_type"] == "audio/wav"
assert voice_info["file_size"] == len(audio_content)
response = client.delete("/v1/audio/voices/test_voice")
def test_upload_voice_with_ref_text(self, client, tmp_path):
"""Test voice upload with ref_text enables in-context cloning."""
audio_content = b"fake audio content" * 1000
files = {"audio_sample": ("test.wav", audio_content, "audio/wav")}
data = {"consent": "c1", "name": "test_voice_rt", "ref_text": "Hello world transcript"}
response = client.post("/v1/audio/voices", files=files, data=data)
assert response.status_code == 200
result = response.json()
assert result["success"] is True
assert result["voice"]["name"] == "test_voice_rt"
assert result["voice"].get("ref_text") == "Hello world transcript"
response = client.delete("/v1/audio/voices/test_voice_rt")
def test_upload_voice_file_too_large(self, client):
"""Test voice upload with file exceeding size limit."""
# Create a file larger than 10MB
@@ -831,31 +838,48 @@ class TestTTSMethods:
assert server.supported_speakers == {"ryan", "vivian", "aiden"}
def test_build_tts_params_with_uploaded_voice(self, speech_server):
"""Test _build_tts_params auto-sets ref_audio for uploaded voices."""
# Mock an uploaded speaker
"""Test _build_tts_params auto-sets ref_audio for uploaded voices (x_vector only)."""
speech_server.uploaded_speakers = {
"custom_voice": {
"name": "custom_voice",
"file_path": "/tmp/voice_samples/custom_voice_consent_123.wav",
"mime_type": "audio/wav",
"ref_text": None,
}
}
speech_server.supported_speakers = {"ryan", "vivian", "custom_voice"}
# Mock _get_uploaded_audio_data to return base64 data
with patch.object(speech_server, "_get_uploaded_audio_data") as mock_get_audio:
mock_get_audio.return_value = "data:audio/wav;base64,ZmFrZWF1ZGlv"
req = OpenAICreateSpeechRequest(input="Hello", voice="custom_voice", task_type="Base")
req = OpenAICreateSpeechRequest(input="Hello", voice="custom_voice")
params = speech_server._build_tts_params(req)
# Verify ref_audio was auto-set
assert "ref_audio" in params
assert params["ref_audio"] == ["data:audio/wav;base64,ZmFrZWF1ZGlv"]
assert "x_vector_only_mode" in params
assert params["x_vector_only_mode"] == [True]
mock_get_audio.assert_called_once_with("custom_voice")
assert params["task_type"] == ["Base"]
assert "ref_text" not in params
def test_build_tts_params_with_uploaded_voice_ref_text(self, speech_server):
"""Test _build_tts_params enables in-context cloning when ref_text is stored."""
speech_server.uploaded_speakers = {
"custom_voice": {
"name": "custom_voice",
"file_path": "/tmp/voice_samples/custom_voice_consent_123.wav",
"mime_type": "audio/wav",
"ref_text": "Hello world transcript",
}
}
speech_server.supported_speakers = {"ryan", "vivian", "custom_voice"}
with patch.object(speech_server, "_get_uploaded_audio_data") as mock_get_audio:
mock_get_audio.return_value = "data:audio/wav;base64,ZmFrZWF1ZGlv"
req = OpenAICreateSpeechRequest(input="Hello", voice="custom_voice")
params = speech_server._build_tts_params(req)
assert params["ref_audio"] == ["data:audio/wav;base64,ZmFrZWF1ZGlv"]
assert params["x_vector_only_mode"] == [False]
assert params["task_type"] == ["Base"]
assert params["ref_text"] == ["Hello world transcript"]
def test_build_tts_params_without_uploaded_voice(self, speech_server):
"""Test _build_tts_params does not auto-set ref_audio for non-uploaded voices."""
+2 -1
View File
@@ -1046,6 +1046,7 @@ async def upload_voice(
speaker_embedding: str | None = Form(None),
consent: str = Form(...),
name: str = Form(...),
ref_text: str = Form(None),
):
"""Upload a new voice for voice cloning.
@@ -1081,7 +1082,7 @@ async def upload_voice(
if speaker_embedding is not None:
result = await handler.upload_voice_embedding(speaker_embedding, consent, name)
elif audio_sample is not None:
result = await handler.upload_voice(audio_sample, consent, name)
result = await handler.upload_voice(audio_sample, consent, name, ref_text=ref_text)
else:
return base(raw_request).create_error_response(
message="Either 'audio_sample' or 'speaker_embedding' must be provided"
+69 -13
View File
@@ -1,5 +1,6 @@
import asyncio
import base64
import io
import json
import math
import os
@@ -11,6 +12,7 @@ from pathlib import Path
from typing import Any
import numpy as np
import soundfile as sf
import torch
from fastapi import Request, UploadFile
from fastapi.responses import Response, StreamingResponse
@@ -54,6 +56,8 @@ _TTS_LANGUAGES: set[str] = {
"Spanish",
"Italian",
}
_REF_AUDIO_MIN_DURATION = 1.0 # seconds
_REF_AUDIO_MAX_DURATION = 30.0 # seconds
_TTS_MAX_INSTRUCTIONS_LENGTH = 500
_TTS_MAX_NEW_TOKENS_MIN = 1
_TTS_MAX_NEW_TOKENS_MAX = 4096
@@ -437,8 +441,12 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
logger.error(f"Could not read audio file for voice {voice_name}: {e}")
return None
async def upload_voice(self, audio_file: UploadFile, consent: str, name: str) -> dict:
"""Upload a new voice sample."""
async def upload_voice(
self, audio_file: UploadFile, consent: str, name: str, *, ref_text: str | None = None
) -> dict:
# Normalize ref_text: treat whitespace-only as absent
if ref_text is not None:
ref_text = ref_text.strip() or None
# Validate file size (max 10MB)
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
audio_file.file.seek(0, 2) # Seek to end
@@ -512,10 +520,29 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
if not _validate_path_within_directory(file_path, self.uploaded_speakers_dir):
raise ValueError("Invalid file path: potential path traversal attack detected")
# Read content and validate duration before saving
content = await audio_file.read()
try:
wav_np, sr = sf.read(io.BytesIO(content))
duration = len(wav_np) / sr if sr > 0 else 0.0
if duration < _REF_AUDIO_MIN_DURATION:
raise ValueError(
f"Reference audio too short ({duration:.1f}s). "
f"At least {_REF_AUDIO_MIN_DURATION:.0f}s of clear speech is required."
)
if duration > _REF_AUDIO_MAX_DURATION:
raise ValueError(
f"Reference audio too long ({duration:.1f}s). "
f"Maximum {_REF_AUDIO_MAX_DURATION:.0f}s supported — use a shorter clip."
)
except ValueError:
raise
except Exception as e:
logger.warning("Could not validate audio duration: %s", e)
# Save audio file
try:
with open(file_path, "wb") as f:
content = await audio_file.read()
f.write(content)
except Exception as e:
raise ValueError(f"Failed to save audio file: {e}")
@@ -529,6 +556,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
"mime_type": mime_type,
"original_filename": audio_file.filename,
"file_size": file_size,
"ref_text": ref_text,
"cache_status": "pending", # The initial cache state is pending.
"cache_file": None, # The initial cache file is empty.
"cache_generated_at": None, # The initial cache generation time is empty.
@@ -552,13 +580,16 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
logger.info(f"Uploaded new voice '{name}' with consent ID '{consent}'")
# Return voice information without exposing the server file path
return {
result = {
"name": name,
"consent": consent,
"created_at": timestamp,
"mime_type": mime_type,
"file_size": file_size,
}
if ref_text is not None:
result["ref_text"] = ref_text
return result
async def upload_voice_embedding(self, embedding_json: str, consent: str, name: str) -> dict:
"""Upload a voice from a pre-computed speaker embedding.
@@ -863,7 +894,19 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
wav_np = np.asarray(wav_np, dtype=np.float32)
if wav_np.ndim > 1:
wav_np = np.mean(wav_np, axis=-1)
return wav_np.tolist(), int(sr)
sr = int(sr)
duration = len(wav_np) / sr if sr > 0 else 0.0
if duration < _REF_AUDIO_MIN_DURATION:
raise ValueError(
f"Reference audio too short ({duration:.1f}s). "
f"At least {_REF_AUDIO_MIN_DURATION:.0f}s of clear speech is required."
)
if duration > _REF_AUDIO_MAX_DURATION:
raise ValueError(
f"Reference audio too long ({duration:.1f}s). "
f"Maximum {_REF_AUDIO_MAX_DURATION:.0f}s supported — use a shorter clip."
)
return wav_np.tolist(), sr
async def _generate_audio_chunks(self, generator, request_id: str, response_format: str = "pcm"):
"""Generate audio chunks for streaming response.
@@ -987,15 +1030,22 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
if request.voice is not None:
params["speaker"] = [request.voice]
# If voice is an uploaded speaker and no ref_audio provided, auto-set it
# Uploaded voices use task_type="Base" (CustomVoice requires built-in spk_id).
# If ref_text was provided at upload time, use in-context cloning; otherwise x_vector only.
if request.voice.lower() in self.uploaded_speakers and request.ref_audio is None:
audio_data = self._get_uploaded_audio_data(request.voice)
if audio_data:
params["ref_audio"] = [audio_data]
params["x_vector_only_mode"] = [True]
logger.info(f"Auto-set ref_audio for uploaded voice: {request.voice}")
else:
if not audio_data:
raise ValueError(f"Audio file for uploaded voice '{request.voice}' is missing or corrupted")
speaker_info = self.uploaded_speakers[request.voice.lower()]
stored_ref_text = speaker_info.get("ref_text")
params["ref_audio"] = [audio_data]
params["task_type"] = ["Base"]
if stored_ref_text:
params["ref_text"] = [stored_ref_text]
params["x_vector_only_mode"] = [False]
else:
params["x_vector_only_mode"] = [True]
logger.info("Auto-set ref_audio for uploaded voice: %s (icl=%s)", request.voice, bool(stored_ref_text))
elif params["task_type"][0] == "CustomVoice":
params["speaker"] = ["Vivian"] # Default for CustomVoice
@@ -1162,8 +1212,14 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
tts_params = {}
else:
tts_params = self._build_tts_params(request)
if request.ref_audio is not None:
wav_list, sr = await self._resolve_ref_audio(request.ref_audio)
# Resolve ref_audio (explicit or auto-set for uploaded voices)
# to [[wav_list, sr]] so the model doesn't re-decode base64.
ref_audio_source = request.ref_audio
if ref_audio_source is None and isinstance(tts_params.get("ref_audio"), list):
# Uploaded voice: ref_audio was auto-set as [base64_data_url]
ref_audio_source = tts_params["ref_audio"][0]
if ref_audio_source is not None and isinstance(ref_audio_source, str):
wav_list, sr = await self._resolve_ref_audio(ref_audio_source)
tts_params["ref_audio"] = [[wav_list, sr]]
ph_len = self._estimate_prompt_len(tts_params)
@@ -236,8 +236,7 @@ class Qwen3TTSCode2Wav(nn.Module):
if n == 0 or n % q != 0:
if n > 0:
logger.warning(
"Code2Wav input_ids length %d not divisible by num_quantizers %d, "
"likely a warmup run; returning empty audio.",
"Code2Wav input_ids length %d not divisible by num_quantizers %d; skipping malformed request.",
n,
q,
)