[Frontend] Speaker embedding support for speech and voices APIs (#1227)

Signed-off-by: marksverdhei <marksverdhei@hotmail.com>
Signed-off-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
Signed-off-by: linyueqian <linyueqian@outlook.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
Co-authored-by: linyueqian <linyueqian@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-03-25 19:02:48 +00:00
1 parent d2b9f9ff0f
commit 29936a3edd
10 files changed
+1093 -23

No files matched your search

@@ -21,10 +21,16 @@ Examples:
--task-type Base \
--ref-audio "https://example.com/reference.wav" \
--ref-text "This is the reference transcript"
# Base task with pre-computed speaker embedding
python openai_speech_client.py --text "Hello world" \
--task-type Base \
--speaker-embedding embedding.json
"""
import argparse
import base64
import json
import os
import httpx
@@ -91,6 +97,9 @@ def run_tts_generation(args) -> None:
payload["ref_text"] = args.ref_text
if args.x_vector_only:
payload["x_vector_only_mode"] = True
if args.speaker_embedding:
with open(args.speaker_embedding) as f:
payload["speaker_embedding"] = json.load(f)
print(f"Model: {args.model}")
print(f"Task type: {args.task_type or 'CustomVoice'}")
@@ -214,6 +223,12 @@ def parse_args():
action="store_true",
help="Use x-vector only mode for voice cloning (no ICL)",
)
parser.add_argument(
"--speaker-embedding",
type=str,
default=None,
help="Path to JSON file containing a pre-computed speaker embedding vector (1024-dim for 0.6B, 2048-dim for 1.7B)",
)
# Generation parameters
parser.add_argument(
@@ -0,0 +1,378 @@
"""Speaker embedding extraction and interpolation for Qwen3-TTS.
Extracts speaker embeddings from reference audio files using the ECAPA-TDNN
speaker encoder from a Qwen3-TTS checkpoint, then interpolates between them
using SLERP and sends the result to the /v1/audio/speech API.
Requirements:
pip install torch librosa soundfile numpy httpx
Examples:
# Extract and save an embedding
python speaker_embedding_interpolation.py extract \
--model Qwen/Qwen3-TTS-12Hz-1.7B-Base \
--audio voice_a.wav \
--output voice_a_embedding.json
# Interpolate between two embeddings and generate speech
python speaker_embedding_interpolation.py interpolate \
--embedding-a voice_a_embedding.json \
--embedding-b voice_b_embedding.json \
--ratio 0.5 \
--text "Hello, this is a blended voice." \
--output blended.wav
# Full pipeline: extract from two audio files + interpolate + generate
python speaker_embedding_interpolation.py pipeline \
--model Qwen/Qwen3-TTS-12Hz-1.7B-Base \
--audio-a voice_a.wav \
--audio-b voice_b.wav \
--ratios 0.0 0.25 0.5 0.75 1.0 \
--text "Hello, this is a blended voice." \
--output-dir ./interpolated/
"""
import argparse
import json
import os
import sys
import httpx
import numpy as np
import torch
DEFAULT_API_BASE = "http://localhost:8000"
DEFAULT_API_KEY = "EMPTY"
# ──────────────────────────────────────────────
# Speaker embedding extraction (offline)
# ──────────────────────────────────────────────
def load_speaker_encoder(model_path: str, device: str = "cpu") -> torch.nn.Module:
"""Load just the ECAPA-TDNN speaker encoder from a Qwen3-TTS checkpoint.
This avoids loading the full TTS model by only instantiating the speaker
encoder sub-module from the checkpoint weights.
"""
from transformers import AutoConfig
# Register the config class so AutoConfig can resolve it
sys.path.insert(0, os.path.dirname(__file__))
try:
from vllm_omni.model_executor.models.qwen3_tts.configuration_qwen3_tts import (
Qwen3TTSConfig,
)
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import (
Qwen3TTSSpeakerEncoder,
)
AutoConfig.register("qwen3_tts", Qwen3TTSConfig)
except ImportError:
# If running outside the vllm-omni tree, try importing from the HF hub
config = AutoConfig.from_pretrained(model_path, trust_remote_code=True)
# Dynamically import from the downloaded model files
import importlib
from huggingface_hub import snapshot_download
model_dir = snapshot_download(model_path)
spec = importlib.util.spec_from_file_location(
"modeling_qwen3_tts",
os.path.join(model_dir, "modeling_qwen3_tts.py"),
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
Qwen3TTSSpeakerEncoder = mod.Qwen3TTSSpeakerEncoder
spec2 = importlib.util.spec_from_file_location(
"configuration_qwen3_tts",
os.path.join(model_dir, "configuration_qwen3_tts.py"),
)
mod2 = importlib.util.module_from_spec(spec2)
spec2.loader.exec_module(mod2)
Qwen3TTSConfig = mod2.Qwen3TTSConfig
config_obj = Qwen3TTSConfig.from_pretrained(model_path)
encoder = Qwen3TTSSpeakerEncoder(config_obj.speaker_encoder_config)
# Load weights
_load_speaker_encoder_weights(encoder, model_path)
encoder = encoder.to(device).eval()
return encoder
config = AutoConfig.from_pretrained(model_path, trust_remote_code=True)
encoder = Qwen3TTSSpeakerEncoder(config.speaker_encoder_config)
_load_speaker_encoder_weights(encoder, model_path)
encoder = encoder.to(device).eval()
return encoder
def _load_speaker_encoder_weights(encoder: torch.nn.Module, model_path: str) -> None:
"""Load only the speaker_encoder.* weights from the checkpoint."""
from huggingface_hub import snapshot_download
from safetensors.torch import load_file
model_dir = snapshot_download(model_path)
prefix = "speaker_encoder."
state_dict = {}
# Try safetensors first, then pytorch bin
safetensor_files = sorted(f for f in os.listdir(model_dir) if f.endswith(".safetensors"))
if safetensor_files:
for fname in safetensor_files:
shard = load_file(os.path.join(model_dir, fname))
for k, v in shard.items():
if k.startswith(prefix):
state_dict[k[len(prefix) :]] = v
else:
bin_files = sorted(f for f in os.listdir(model_dir) if f.endswith(".bin"))
for fname in bin_files:
shard = torch.load(os.path.join(model_dir, fname), map_location="cpu", weights_only=True)
for k, v in shard.items():
if k.startswith(prefix):
state_dict[k[len(prefix) :]] = v
if not state_dict:
raise RuntimeError(
f"No speaker_encoder weights found in {model_path}. Make sure this is a Qwen3-TTS-*-Base checkpoint."
)
encoder.load_state_dict(state_dict)
def compute_mel_spectrogram(audio: np.ndarray, sr: int = 24000) -> torch.Tensor:
"""Compute 128-bin mel spectrogram matching Qwen3-TTS's extraction pipeline."""
import librosa
# Resample to 24kHz if needed
if sr != 24000:
audio = librosa.resample(audio.astype(np.float32), orig_sr=sr, target_sr=24000)
y = torch.from_numpy(audio).unsqueeze(0).float()
from librosa.filters import mel as librosa_mel_fn
mel_basis = torch.from_numpy(librosa_mel_fn(sr=24000, n_fft=1024, n_mels=128, fmin=0, fmax=12000)).float()
n_fft = 1024
hop_size = 256
win_size = 1024
padding = (n_fft - hop_size) // 2
y = torch.nn.functional.pad(y.unsqueeze(1), (padding, padding), mode="reflect").squeeze(1)
hann_window = torch.hann_window(win_size)
spec = torch.stft(
y,
n_fft,
hop_length=hop_size,
win_length=win_size,
window=hann_window,
center=False,
return_complex=True,
)
spec = torch.abs(spec)
mel = torch.matmul(mel_basis, spec)
mel = torch.log(torch.clamp(mel, min=1e-5))
return mel.transpose(1, 2) # (1, T, 128)
@torch.inference_mode()
def extract_embedding(encoder: torch.nn.Module, audio_path: str, device: str = "cpu") -> np.ndarray:
"""Extract a 1024-dim speaker embedding from an audio file."""
import librosa
audio, sr = librosa.load(audio_path, sr=None, mono=True)
mel = compute_mel_spectrogram(audio, sr).to(device)
embedding = encoder(mel.to(next(encoder.parameters()).dtype))[0]
return embedding.float().cpu().numpy()
# ──────────────────────────────────────────────
# Interpolation
# ──────────────────────────────────────────────
def slerp(v0: np.ndarray, v1: np.ndarray, t: float) -> np.ndarray:
"""Spherical linear interpolation between two vectors."""
v0_norm = v0 / (np.linalg.norm(v0) + 1e-8)
v1_norm = v1 / (np.linalg.norm(v1) + 1e-8)
dot = np.clip(np.dot(v0_norm, v1_norm), -1.0, 1.0)
omega = np.arccos(dot)
if np.abs(omega) < 1e-6:
# Vectors are nearly parallel, fall back to lerp
return (1.0 - t) * v0 + t * v1
sin_omega = np.sin(omega)
return (np.sin((1.0 - t) * omega) / sin_omega) * v0 + (np.sin(t * omega) / sin_omega) * v1
# ──────────────────────────────────────────────
# API client
# ──────────────────────────────────────────────
def generate_speech(
text: str,
speaker_embedding: list[float],
output_path: str,
api_base: str = DEFAULT_API_BASE,
api_key: str = DEFAULT_API_KEY,
model: str = "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
) -> None:
"""Send a speaker_embedding to the TTS API and save the output."""
payload = {
"model": model,
"input": text,
"task_type": "Base",
"speaker_embedding": speaker_embedding,
"response_format": "wav",
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
}
with httpx.Client(timeout=300.0) as client:
resp = client.post(f"{api_base}/v1/audio/speech", json=payload, headers=headers)
if resp.status_code != 200:
print(f"Error {resp.status_code}: {resp.text}")
sys.exit(1)
with open(output_path, "wb") as f:
f.write(resp.content)
print(f"Saved: {output_path}")
# ──────────────────────────────────────────────
# CLI commands
# ──────────────────────────────────────────────
def cmd_extract(args):
"""Extract a speaker embedding from audio and save as JSON."""
print(f"Loading speaker encoder from {args.model}...")
encoder = load_speaker_encoder(args.model, device=args.device)
print(f"Extracting embedding from {args.audio}...")
emb = extract_embedding(encoder, args.audio, device=args.device)
print(f"Embedding shape: {emb.shape}")
output_path = args.output or os.path.splitext(args.audio)[0] + "_embedding.json"
with open(output_path, "w") as f:
json.dump(emb.tolist(), f)
print(f"Saved embedding to {output_path}")
def cmd_interpolate(args):
"""Interpolate between two embeddings and generate speech."""
with open(args.embedding_a) as f:
emb_a = np.array(json.load(f), dtype=np.float32)
with open(args.embedding_b) as f:
emb_b = np.array(json.load(f), dtype=np.float32)
print(f"Embedding A: {args.embedding_a} (dim={emb_a.shape[0]})")
print(f"Embedding B: {args.embedding_b} (dim={emb_b.shape[0]})")
print(f"SLERP ratio: {args.ratio}")
blended = slerp(emb_a, emb_b, args.ratio)
output_path = args.output or f"interpolated_t{args.ratio:.2f}.wav"
print(f"Generating speech: {args.text!r}")
generate_speech(
text=args.text,
speaker_embedding=blended.tolist(),
output_path=output_path,
api_base=args.api_base,
api_key=args.api_key,
model=args.model,
)
def cmd_pipeline(args):
"""Full pipeline: extract two embeddings, SLERP at multiple ratios, generate."""
print(f"Loading speaker encoder from {args.model}...")
encoder = load_speaker_encoder(args.model, device=args.device)
print(f"Extracting embedding A from {args.audio_a}...")
emb_a = extract_embedding(encoder, args.audio_a, device=args.device)
print(f"Extracting embedding B from {args.audio_b}...")
emb_b = extract_embedding(encoder, args.audio_b, device=args.device)
# Save extracted embeddings
os.makedirs(args.output_dir, exist_ok=True)
for label, emb, audio_path in [("a", emb_a, args.audio_a), ("b", emb_b, args.audio_b)]:
emb_path = os.path.join(args.output_dir, f"embedding_{label}.json")
with open(emb_path, "w") as f:
json.dump(emb.tolist(), f)
print(f"Saved embedding {label} to {emb_path}")
# Generate at each ratio
for t in args.ratios:
blended = slerp(emb_a, emb_b, t)
out_path = os.path.join(args.output_dir, f"t{t:.2f}.wav")
print(f"\n--- SLERP t={t:.2f} ---")
print(f"Generating: {args.text!r}")
generate_speech(
text=args.text,
speaker_embedding=blended.tolist(),
output_path=out_path,
api_base=args.api_base,
api_key=args.api_key,
model=args.model,
)
print(f"\nAll outputs saved to {args.output_dir}/")
def main():
parser = argparse.ArgumentParser(
description="Speaker embedding extraction and interpolation for Qwen3-TTS",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--api-base", default=DEFAULT_API_BASE, help="TTS API base URL")
parser.add_argument("--api-key", default=DEFAULT_API_KEY, help="API key")
parser.add_argument(
"--model",
default="Qwen/Qwen3-TTS-12Hz-1.7B-Base",
help="Model name (used for both weight loading and API requests)",
)
parser.add_argument("--device", default="cpu", help="Device for embedding extraction (cpu/cuda)")
sub = parser.add_subparsers(dest="command", required=True)
# extract
p_ext = sub.add_parser("extract", help="Extract speaker embedding from audio")
p_ext.add_argument("--audio", required=True, help="Input audio file")
p_ext.add_argument("--output", "-o", help="Output JSON path (default: <audio>_embedding.json)")
# interpolate
p_interp = sub.add_parser("interpolate", help="Interpolate between two embeddings and generate speech")
p_interp.add_argument("--embedding-a", required=True, help="JSON file with embedding A")
p_interp.add_argument("--embedding-b", required=True, help="JSON file with embedding B")
p_interp.add_argument("--ratio", "-r", type=float, default=0.5, help="SLERP ratio (0=A, 1=B)")
p_interp.add_argument("--text", required=True, help="Text to synthesize")
p_interp.add_argument("--output", "-o", help="Output wav path")
# pipeline
p_pipe = sub.add_parser("pipeline", help="Full pipeline: extract + interpolate + generate")
p_pipe.add_argument("--audio-a", required=True, help="Audio file for voice A")
p_pipe.add_argument("--audio-b", required=True, help="Audio file for voice B")
p_pipe.add_argument(
"--ratios",
nargs="+",
type=float,
default=[0.0, 0.25, 0.5, 0.75, 1.0],
help="SLERP ratios to generate (default: 0.0 0.25 0.5 0.75 1.0)",
)
p_pipe.add_argument("--text", required=True, help="Text to synthesize")
p_pipe.add_argument("--output-dir", default="./interpolated", help="Output directory")
args = parser.parse_args()
{"extract": cmd_extract, "interpolate": cmd_interpolate, "pipeline": cmd_pipeline}[args.command](args)
if __name__ == "__main__":
main()
@@ -0,0 +1,249 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
E2E tests for speaker_embedding API parameter.
Tests Base models (0.6B-Base and 1.7B-Base) which support voice cloning
via pre-computed ECAPA-TDNN embeddings, bypassing ref_audio extraction.
"""
import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
import struct
from pathlib import Path
import httpx
import pytest
from tests.conftest import OmniServer
from tests.utils import hardware_test
MODEL_BASE = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
MODEL_BASE_1_7B = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
# A synthetic 1024-dim speaker embedding (all 0.1 — not a real voice, but
# exercises the full code path through the talker's _build_prompt_embeds).
DUMMY_EMBEDDING_1024 = [0.1] * 1024
DUMMY_EMBEDDING_2048 = [0.1] * 2048
SYN_TEXT = "Hello."
MIN_AUDIO_BYTES = 2000
# Limit generation to keep tests fast (dummy embeddings produce nonsensical
# output that may never hit a natural stop token).
MAX_NEW_TOKENS = 256
def get_stage_config():
return str(
Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / "qwen3_tts.yaml"
)
def _server_args():
return [
"--stage-configs-path",
get_stage_config(),
"--stage-init-timeout",
"120",
"--trust-remote-code",
"--enforce-eager",
"--disable-log-stats",
]
def verify_wav_audio(content: bytes) -> bool:
if len(content) < 44:
return False
return content[:4] == b"RIFF" and content[8:12] == b"WAVE"
def assert_not_silence(pcm_bytes: bytes):
"""Assert PCM16 samples are not all identical (e.g. all-silence)."""
samples = struct.unpack(f"<{len(pcm_bytes) // 2}h", pcm_bytes)
unique = set(samples)
assert len(unique) > 1, f"All-silence detected: {len(samples)} samples, unique values: {unique}"
# ── 0.6B-Base model tests ──
@pytest.fixture(scope="module")
def base_server():
"""Start vLLM-Omni server with 0.6B-Base model."""
with OmniServer(MODEL_BASE, _server_args()) as server:
yield server
class TestSpeakerEmbeddingBase:
"""Speaker embedding tests against the 0.6B-Base model (supports Base task)."""
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_speaker_embedding_produces_audio(self, base_server) -> None:
"""speaker_embedding with Base task produces valid WAV audio."""
url = f"http://{base_server.host}:{base_server.port}/v1/audio/speech"
payload = {
"model": MODEL_BASE,
"input": SYN_TEXT,
"task_type": "Base",
"speaker_embedding": DUMMY_EMBEDDING_1024,
"x_vector_only_mode": True,
"response_format": "wav",
"max_new_tokens": MAX_NEW_TOKENS,
}
with httpx.Client(timeout=120.0) as client:
response = client.post(url, json=payload)
assert response.status_code == 200, f"Request failed: {response.text}"
assert response.headers.get("content-type") == "audio/wav"
assert verify_wav_audio(response.content), "Response is not valid WAV"
assert len(response.content) > MIN_AUDIO_BYTES, f"Audio too small: {len(response.content)} bytes"
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_speaker_embedding_pcm_not_silence(self, base_server) -> None:
"""speaker_embedding PCM output contains real audio, not all-silence."""
url = f"http://{base_server.host}:{base_server.port}/v1/audio/speech"
payload = {
"model": MODEL_BASE,
"input": SYN_TEXT,
"task_type": "Base",
"speaker_embedding": DUMMY_EMBEDDING_1024,
"x_vector_only_mode": True,
"response_format": "pcm",
"max_new_tokens": MAX_NEW_TOKENS,
}
with httpx.Client(timeout=120.0) as client:
response = client.post(url, json=payload)
assert response.status_code == 200, f"Request failed: {response.text}"
assert len(response.content) > MIN_AUDIO_BYTES
assert_not_silence(response.content)
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_speaker_embedding_streaming(self, base_server) -> None:
"""speaker_embedding works with streaming PCM output."""
url = f"http://{base_server.host}:{base_server.port}/v1/audio/speech"
payload = {
"model": MODEL_BASE,
"input": SYN_TEXT,
"task_type": "Base",
"speaker_embedding": DUMMY_EMBEDDING_1024,
"x_vector_only_mode": True,
"response_format": "pcm",
"stream": True,
"max_new_tokens": MAX_NEW_TOKENS,
}
with httpx.Client(timeout=120.0) as client:
response = client.post(url, json=payload)
assert response.status_code == 200, f"Request failed: {response.text}"
assert "audio/pcm" in response.headers.get("content-type", "")
assert len(response.content) > MIN_AUDIO_BYTES
assert_not_silence(response.content)
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_speaker_embedding_mutually_exclusive_with_ref_audio(self, base_server) -> None:
"""Sending both speaker_embedding and ref_audio returns 400."""
url = f"http://{base_server.host}:{base_server.port}/v1/audio/speech"
payload = {
"model": MODEL_BASE,
"input": SYN_TEXT,
"task_type": "Base",
"speaker_embedding": DUMMY_EMBEDDING_1024,
"ref_audio": "https://example.com/audio.wav",
"response_format": "wav",
}
with httpx.Client(timeout=30.0) as client:
response = client.post(url, json=payload)
assert response.status_code == 400, f"Expected 400, got {response.status_code}: {response.text}"
assert "mutually exclusive" in response.text
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_speaker_embedding_empty_rejected(self, base_server) -> None:
"""Empty speaker_embedding list returns 400."""
url = f"http://{base_server.host}:{base_server.port}/v1/audio/speech"
payload = {
"model": MODEL_BASE,
"input": SYN_TEXT,
"task_type": "Base",
"speaker_embedding": [],
"response_format": "wav",
}
with httpx.Client(timeout=30.0) as client:
response = client.post(url, json=payload)
assert response.status_code == 400, f"Expected 400, got {response.status_code}: {response.text}"
assert "non-empty" in response.text
# ── 1.7B-Base model tests (2048-dim embeddings) ──
@pytest.fixture(scope="module")
def base_1_7b_server():
"""Start vLLM-Omni server with 1.7B-Base model."""
with OmniServer(MODEL_BASE_1_7B, _server_args()) as server:
yield server
class TestSpeakerEmbedding1_7B:
"""Speaker embedding tests against the 1.7B-Base model (2048-dim embeddings)."""
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_2048_dim_embedding_produces_audio(self, base_1_7b_server) -> None:
"""2048-dim speaker_embedding with 1.7B-Base model produces valid WAV audio."""
url = f"http://{base_1_7b_server.host}:{base_1_7b_server.port}/v1/audio/speech"
payload = {
"model": MODEL_BASE_1_7B,
"input": SYN_TEXT,
"task_type": "Base",
"speaker_embedding": DUMMY_EMBEDDING_2048,
"x_vector_only_mode": True,
"response_format": "wav",
"max_new_tokens": MAX_NEW_TOKENS,
}
with httpx.Client(timeout=120.0) as client:
response = client.post(url, json=payload)
assert response.status_code == 200, f"Request failed: {response.text}"
assert response.headers.get("content-type") == "audio/wav"
assert verify_wav_audio(response.content), "Response is not valid WAV"
assert len(response.content) > MIN_AUDIO_BYTES, f"Audio too small: {len(response.content)} bytes"
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_1024_dim_on_1_7b_model_rejected_or_errors(self, base_1_7b_server) -> None:
"""1024-dim embedding on a 1.7B model (expects 2048) should fail gracefully."""
url = f"http://{base_1_7b_server.host}:{base_1_7b_server.port}/v1/audio/speech"
payload = {
"model": MODEL_BASE_1_7B,
"input": SYN_TEXT,
"task_type": "Base",
"speaker_embedding": DUMMY_EMBEDDING_1024,
"x_vector_only_mode": True,
"response_format": "wav",
"max_new_tokens": MAX_NEW_TOKENS,
}
with httpx.Client(timeout=120.0) as client:
response = client.post(url, json=payload)
# Wrong dimensions should produce an error, not silent garbage.
assert response.status_code != 200 or len(response.content) < MIN_AUDIO_BYTES, (
"Expected failure or degenerate output with wrong embedding dimensions"
)
@@ -235,6 +235,8 @@ def test_app(mocker: MockerFixture):
"created_at": info.get("created_at", 0),
"file_size": info.get("file_size", 0),
"mime_type": info.get("mime_type", ""),
"embedding_source": info.get("embedding_source", "audio"),
"embedding_dim": info.get("embedding_dim"),
}
)
return {"voices": speakers, "uploaded_voices": uploaded_voices}
@@ -243,9 +245,21 @@ def test_app(mocker: MockerFixture):
app.add_api_route("/v1/audio/speech/batch", speech_server.create_speech_batch, methods=["POST"])
# Add upload_voice endpoint
async def upload_voice(audio_sample: UploadFile = File(...), consent: str = Form(...), name: str = Form(...)):
async def upload_voice(
audio_sample: UploadFile | None = File(None),
speaker_embedding: str | None = Form(None),
consent: str = Form(...),
name: str = Form(...),
):
try:
result = await speech_server.upload_voice(audio_sample, consent, name)
if speaker_embedding is not None and audio_sample is not None:
raise ValueError("'audio_sample' and 'speaker_embedding' are mutually exclusive")
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)
else:
raise ValueError("Either 'audio_sample' or 'speaker_embedding' must be provided")
return {"success": True, "voice": result}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -446,13 +460,13 @@ class TestSpeechAPI:
response = client.post("/v1/audio/voices", files=files, data=data)
assert response.status_code == 422 # Validation error
# Missing file
# Missing both audio_sample and speaker_embedding
data = {
"consent": "user_consent_123",
"name": "test_voice6",
}
response = client.post("/v1/audio/voices", data=data)
assert response.status_code == 422 # Validation error
assert response.status_code == 400
def test_delete_voice_success(self, client):
"""Test successful voice deletion."""
@@ -489,6 +503,89 @@ class TestSpeechAPI:
result = response.json()
assert "not found" in result["detail"]
# ── speaker_embedding upload via voices endpoint ──
def test_upload_voice_embedding_success(self, client):
"""Upload a voice via speaker_embedding JSON."""
import json
emb = [0.1] * 1024
data = {
"speaker_embedding": json.dumps(emb),
"consent": "consent_emb_1",
"name": "emb_voice",
}
response = client.post("/v1/audio/voices", data=data)
assert response.status_code == 200, f"Upload failed: {response.text}"
result = response.json()
assert result["success"] is True
voice = result["voice"]
assert voice["name"] == "emb_voice"
assert voice["embedding_source"] == "direct"
assert voice["embedding_dim"] == 1024
# Clean up
client.delete("/v1/audio/voices/emb_voice")
def test_upload_voice_embedding_appears_in_listing(self, client):
"""Embedding-uploaded voice appears in list with correct source."""
import json
emb = [0.2] * 2048
data = {
"speaker_embedding": json.dumps(emb),
"consent": "consent_list",
"name": "listed_emb_voice",
}
response = client.post("/v1/audio/voices", data=data)
assert response.status_code == 200
listing = client.get("/v1/audio/voices").json()
uploaded = {v["name"]: v for v in listing["uploaded_voices"]}
assert "listed_emb_voice" in uploaded
assert uploaded["listed_emb_voice"]["embedding_source"] == "direct"
assert uploaded["listed_emb_voice"]["embedding_dim"] == 2048
# Clean up
client.delete("/v1/audio/voices/listed_emb_voice")
def test_upload_voice_embedding_and_audio_mutually_exclusive(self, client):
"""Providing both audio_sample and speaker_embedding returns 400."""
import json
emb = [0.1] * 1024
files = {"audio_sample": ("test.wav", b"fake", "audio/wav")}
data = {
"speaker_embedding": json.dumps(emb),
"consent": "consent_mx",
"name": "mx_voice",
}
response = client.post("/v1/audio/voices", files=files, data=data)
assert response.status_code == 400
assert "mutually exclusive" in response.json()["detail"]
def test_upload_voice_embedding_invalid_json(self, client):
"""Invalid JSON in speaker_embedding returns 400."""
data = {
"speaker_embedding": "not valid json [[[",
"consent": "consent_bad",
"name": "bad_json_voice",
}
response = client.post("/v1/audio/voices", data=data)
assert response.status_code == 400
assert "JSON" in response.json()["detail"]
def test_upload_voice_embedding_nan_rejected(self, client):
"""NaN values in speaker_embedding return 400."""
import json
data = {
"speaker_embedding": json.dumps([0.1] * 1023 + [float("nan")]),
"consent": "consent_nan",
"name": "nan_voice",
}
response = client.post("/v1/audio/voices", data=data)
assert response.status_code == 400
assert "finite" in response.json()["detail"]
class TestTTSMethods:
"""Unit tests for TTS validation and parameter building."""
@@ -610,6 +707,96 @@ class TestTTSMethods:
result = speech_server._validate_tts_request(req)
assert "does not support CustomVoice" in result
# ── speaker_embedding validation ──
def test_speaker_embedding_valid_base_task(self, speech_server):
"""speaker_embedding with Base task, x_vector_only_mode, and no ref_audio is accepted."""
emb = [0.1] * 1024
req = OpenAICreateSpeechRequest(input="Hello", task_type="Base", speaker_embedding=emb, x_vector_only_mode=True)
assert speech_server._validate_tts_request(req) is None
def test_speaker_embedding_auto_sets_x_vector_only_mode(self, speech_server):
"""speaker_embedding auto-implies x_vector_only_mode, so validation passes."""
emb = [0.1] * 1024
req = OpenAICreateSpeechRequest(input="Hello", task_type="Base", speaker_embedding=emb)
result = speech_server._validate_tts_request(req)
assert result is None
assert req.x_vector_only_mode is True
def test_speaker_embedding_wrong_task_type(self, speech_server):
"""speaker_embedding is only valid for Base task."""
emb = [0.1] * 1024
req = OpenAICreateSpeechRequest(
input="Hello", task_type="VoiceDesign", speaker_embedding=emb, instructions="warm"
)
result = speech_server._validate_tts_request(req)
assert "only valid for Base task" in result
def test_speaker_embedding_mutually_exclusive_with_ref_audio(self, speech_server):
"""speaker_embedding and ref_audio cannot both be provided (pydantic validation)."""
emb = [0.1] * 1024
with pytest.raises(ValidationError, match="mutually exclusive"):
OpenAICreateSpeechRequest(
input="Hello", task_type="Base", speaker_embedding=emb, ref_audio="data:audio/wav;base64,abc"
)
def test_speaker_embedding_nan_rejected(self, speech_server):
"""NaN values in speaker_embedding are rejected at parse level."""
with pytest.raises(ValidationError, match="finite"):
OpenAICreateSpeechRequest(input="Hello", task_type="Base", speaker_embedding=[0.1] * 1023 + [float("nan")])
def test_speaker_embedding_inf_rejected(self, speech_server):
"""Inf values in speaker_embedding are rejected at parse level."""
with pytest.raises(ValidationError, match="finite"):
OpenAICreateSpeechRequest(input="Hello", task_type="Base", speaker_embedding=[float("inf")] + [0.1] * 1023)
def test_speaker_embedding_empty_list_rejected(self, speech_server):
"""Empty speaker_embedding list is rejected."""
req = OpenAICreateSpeechRequest(input="Hello", task_type="Base", speaker_embedding=[])
result = speech_server._validate_tts_request(req)
assert "non-empty" in result
def test_speaker_embedding_wrong_dims_accepted(self, speech_server):
"""Non-standard dimensions pass validation (warning only, not an error)."""
emb = [0.1] * 512 # not 1024 or 2048
req = OpenAICreateSpeechRequest(input="Hello", task_type="Base", speaker_embedding=emb, x_vector_only_mode=True)
result = speech_server._validate_tts_request(req)
assert result is None
def test_speaker_embedding_2048_dims_accepted(self, speech_server):
"""2048-dim embedding (1.7B model) is accepted without warning."""
emb = [0.1] * 2048
req = OpenAICreateSpeechRequest(input="Hello", task_type="Base", speaker_embedding=emb, x_vector_only_mode=True)
assert speech_server._validate_tts_request(req) is None
def test_base_task_requires_ref_audio_or_speaker_embedding(self, speech_server):
"""Base task without ref_audio or speaker_embedding is rejected."""
req = OpenAICreateSpeechRequest(input="Hello", task_type="Base")
result = speech_server._validate_tts_request(req)
assert "ref_audio" in result and "speaker_embedding" in result
# ── speaker_embedding in _build_tts_params ──
def test_build_tts_params_with_speaker_embedding(self, speech_server):
"""speaker_embedding produces voice_clone_prompt and x_vector_only_mode."""
emb = [0.1] * 1024
req = OpenAICreateSpeechRequest(input="Hello", task_type="Base", speaker_embedding=emb)
params = speech_server._build_tts_params(req)
assert "voice_clone_prompt" in params
vcp = params["voice_clone_prompt"][0]
assert "ref_spk_embedding" in vcp
# Stored as plain list (not tensor) so it survives msgspec IPC serialization
assert isinstance(vcp["ref_spk_embedding"], list)
assert len(vcp["ref_spk_embedding"]) == 1024
assert params["x_vector_only_mode"] == [True]
def test_build_tts_params_without_speaker_embedding(self, speech_server):
"""Without speaker_embedding, voice_clone_prompt is not set."""
req = OpenAICreateSpeechRequest(input="Hello", voice="Ryan", language="English")
params = speech_server._build_tts_params(req)
assert "voice_clone_prompt" not in params
def test_build_tts_params(self, speech_server):
"""Test TTS parameter building."""
req = OpenAICreateSpeechRequest(input="Hello", voice="Ryan", language="English")
@@ -0,0 +1,55 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for the SLERP interpolation math in speaker_embedding_interpolation.py."""
import sys
from pathlib import Path
import numpy as np
import pytest
# Import the slerp function from the example script.
_examples_dir = str(Path(__file__).parent.parent.parent / "examples" / "online_serving" / "qwen3_tts")
sys.path.insert(0, _examples_dir)
from speaker_embedding_interpolation import slerp # noqa: E402
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class TestSlerp:
def test_endpoints(self):
"""t=0 returns v0, t=1 returns v1."""
v0 = np.random.randn(1024).astype(np.float64)
v1 = np.random.randn(1024).astype(np.float64)
np.testing.assert_allclose(slerp(v0, v1, 0.0), v0, atol=1e-6)
np.testing.assert_allclose(slerp(v0, v1, 1.0), v1, atol=1e-6)
def test_midpoint_unit_norm(self):
"""Midpoint of two unit vectors should also be approximately unit norm."""
v0 = np.random.randn(1024)
v0 /= np.linalg.norm(v0)
v1 = np.random.randn(1024)
v1 /= np.linalg.norm(v1)
mid = slerp(v0, v1, 0.5)
assert abs(np.linalg.norm(mid) - 1.0) < 0.05
def test_parallel_vectors_fallback(self):
"""Parallel vectors (omega~0) fall back to lerp without error."""
v0 = np.ones(1024)
v1 = np.ones(1024) * 1.001 # nearly parallel
result = slerp(v0, v1, 0.5)
expected = 0.5 * v0 + 0.5 * v1
np.testing.assert_allclose(result, expected, atol=1e-4)
def test_antiparallel_vectors(self):
"""Antiparallel vectors (omega~pi) should not produce NaN."""
v0 = np.ones(1024)
v1 = -np.ones(1024)
result = slerp(v0, v1, 0.5)
assert not np.any(np.isnan(result))
def test_output_shape_matches_input(self):
"""Output shape must match input shape."""
v0 = np.random.randn(2048)
v1 = np.random.randn(2048)
assert slerp(v0, v1, 0.3).shape == (2048,)
+28 -8
View File
@@ -1024,6 +1024,8 @@ async def list_voices(raw_request: Request):
"created_at": info.get("created_at", 0),
"file_size": info.get("file_size", 0),
"mime_type": info.get("mime_type", ""),
"embedding_source": info.get("embedding_source", "audio"),
"embedding_dim": info.get("embedding_dim"),
}
)
@@ -1040,18 +1042,26 @@ async def list_voices(raw_request: Request):
)
async def upload_voice(
raw_request: Request,
audio_sample: UploadFile = File(...),
audio_sample: UploadFile | None = File(None),
speaker_embedding: str | None = Form(None),
consent: str = Form(...),
name: str = Form(...),
):
"""Upload a new voice sample for voice cloning.
"""Upload a new voice for voice cloning.
Uploads an audio file that can be used as a reference for voice cloning
in Base task TTS requests. The voice can then be referenced by name
in subsequent TTS requests.
Accepts either an audio file or a pre-computed speaker embedding vector.
These are mutually exclusive: provide one or the other.
When using ``audio_sample``, the server stores the audio and extracts the
speaker embedding on first use (Base task models only).
When using ``speaker_embedding``, pass a JSON-encoded list of floats
(1024-dim for 0.6B, 2048-dim for 1.7B). The voice is stored as a
safetensors file and is immediately ready for use.
Args:
audio_sample: Audio file (max 10MB)
audio_sample: Audio file (max 10MB). Mutually exclusive with speaker_embedding.
speaker_embedding: JSON-encoded float list. Mutually exclusive with audio_sample.
consent: Consent recording ID
name: Name for the new voice
raw_request: Raw FastAPI request
@@ -1064,8 +1074,18 @@ async def upload_voice(
return base(raw_request).create_error_response(message="The model does not support Speech API")
try:
# Upload the voice
result = await handler.upload_voice(audio_sample, consent, name)
if speaker_embedding is not None and audio_sample is not None:
return base(raw_request).create_error_response(
message="'audio_sample' and 'speaker_embedding' are mutually exclusive"
)
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)
else:
return base(raw_request).create_error_response(
message="Either 'audio_sample' or 'speaker_embedding' must be provided"
)
return JSONResponse(content={"success": True, "voice": result})
+30 -1
View File
@@ -1,8 +1,11 @@
import math
from typing import Literal
import numpy as np
from pydantic import BaseModel, Field, field_validator, model_validator
_MAX_EMBEDDING_DIM = 8192
class OpenAICreateSpeechRequest(BaseModel):
input: str
@@ -41,7 +44,7 @@ class OpenAICreateSpeechRequest(BaseModel):
)
ref_audio: str | None = Field(
default=None,
description="Reference audio for voice cloning (Base task). URL, base64, or file path.",
description="Reference audio for voice cloning (Base task). URL, base64, or file URI.",
)
ref_text: str | None = Field(
default=None,
@@ -51,6 +54,13 @@ class OpenAICreateSpeechRequest(BaseModel):
default=None,
description="Use speaker embedding only without in-context learning (Base task)",
)
speaker_embedding: list[float] | None = Field(
default=None,
max_length=_MAX_EMBEDDING_DIM,
description="Pre-computed speaker embedding vector (1024-dim for 0.6B, "
"2048-dim for 1.7B). Skips speaker encoder extraction from ref_audio. "
"Implies x_vector_only_mode=True. Mutually exclusive with ref_audio.",
)
max_new_tokens: int | None = Field(
default=None,
description="Maximum tokens to generate",
@@ -68,6 +78,20 @@ class OpenAICreateSpeechRequest(BaseModel):
raise ValueError("'sse' is not a supported stream_format yet. Please use 'audio'.")
return v
@field_validator("speaker_embedding")
@classmethod
def validate_speaker_embedding(cls, v: list[float] | None) -> list[float] | None:
if v is not None and not all(math.isfinite(x) for x in v):
raise ValueError("'speaker_embedding' values must be finite (no NaN or Inf)")
return v
@model_validator(mode="after")
def validate_embedding_constraints(self) -> "OpenAICreateSpeechRequest":
if self.speaker_embedding is not None:
if self.ref_audio is not None:
raise ValueError("'speaker_embedding' and 'ref_audio' are mutually exclusive")
return self
@model_validator(mode="after")
def validate_streaming_constraints(self) -> "OpenAICreateSpeechRequest":
if self.stream:
@@ -177,6 +201,11 @@ class StreamingSpeechSessionConfig(BaseModel):
ref_audio: str | None = None
ref_text: str | None = None
x_vector_only_mode: bool | None = None
speaker_embedding: list[float] | None = Field(
default=None,
max_length=_MAX_EMBEDDING_DIM,
description="Pre-computed speaker embedding vector. Mutually exclusive with ref_audio.",
)
stream_audio: bool = Field(
default=False,
description=(
+139 -9
View File
@@ -532,6 +532,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
"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.
"embedding_source": "audio",
}
# Save metadata using metadata manager (concurrency safe)
@@ -559,6 +560,101 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
"file_size": file_size,
}
async def upload_voice_embedding(self, embedding_json: str, consent: str, name: str) -> dict:
"""Upload a voice from a pre-computed speaker embedding.
Stores the embedding as a safetensors file and marks it immediately
ready (no audio processing needed).
Args:
embedding_json: JSON-encoded list of floats (1024 or 2048 dim).
consent: Consent recording ID.
name: Name for the new voice.
Returns:
dict with voice information.
"""
try:
embedding = json.loads(embedding_json)
except (json.JSONDecodeError, TypeError) as exc:
raise ValueError(f"'speaker_embedding' must be valid JSON: {exc}") from exc
if not isinstance(embedding, list) or not embedding:
raise ValueError("'speaker_embedding' must be a non-empty list of numbers")
if not all(isinstance(x, (int, float)) for x in embedding):
raise ValueError("'speaker_embedding' must contain only numeric values")
if not all(math.isfinite(x) for x in embedding):
raise ValueError("'speaker_embedding' values must be finite (no NaN or Inf)")
emb_dim = len(embedding)
if emb_dim not in {1024, 2048}:
logger.warning(
"speaker_embedding has %d dimensions; expected 1024 (0.6B) or 2048 (1.7B)",
emb_dim,
)
voice_name_lower = name.lower()
if voice_name_lower in self.uploaded_speakers:
raise ValueError(f"Voice '{name}' already exists")
sanitized_name = _sanitize_filename(name)
sanitized_consent = _sanitize_filename(consent)
timestamp = int(time.time())
# Store as safetensors for efficient loading
try:
import torch
from safetensors.torch import save_file
tensor = torch.tensor(embedding, dtype=torch.float32)
filename = f"{sanitized_name}_{sanitized_consent}_{timestamp}.safetensors"
file_path = self.uploaded_speakers_dir / filename
if not _validate_path_within_directory(file_path, self.uploaded_speakers_dir):
raise ValueError("Invalid file path: potential path traversal attack detected")
save_file({"speaker_embedding": tensor}, str(file_path))
except ImportError:
raise ValueError("safetensors and torch are required for embedding upload")
speaker_data = {
"name": name,
"consent": consent,
"file_path": str(file_path),
"created_at": timestamp,
"mime_type": "application/x-safetensors",
"original_filename": filename,
"file_size": file_path.stat().st_size,
"cache_status": "ready",
"cache_file": str(file_path),
"cache_generated_at": timestamp,
"embedding_source": "direct",
"embedding_dim": emb_dim,
}
success = self.metadata_manager.create_speaker(voice_name_lower, speaker_data)
if not success:
try:
file_path.unlink()
except Exception:
pass
raise ValueError(f"Failed to create metadata for voice '{name}' (possibly already exists)")
self.uploaded_speakers[voice_name_lower] = speaker_data
self.supported_speakers.add(voice_name_lower)
logger.info(f"Uploaded voice '{name}' from speaker embedding ({emb_dim}-dim)")
return {
"name": name,
"consent": consent,
"created_at": timestamp,
"embedding_source": "direct",
"embedding_dim": emb_dim,
}
async def delete_voice(self, name: str) -> bool:
"""
Delete an uploaded voice.
@@ -669,23 +765,46 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
if request.voice is not None and request.voice not in self.supported_speakers:
return f"Invalid voice '{request.voice}'. Supported: {', '.join(sorted(self.supported_speakers))}"
# Validate speaker_embedding constraints
if request.speaker_embedding is not None:
if task_type != "Base":
return "'speaker_embedding' is only valid for Base task"
if not request.speaker_embedding:
return "'speaker_embedding' must be a non-empty list of floats"
# speaker_embedding implies x_vector_only_mode — set it before
# Base task validation so callers don't need to pass it explicitly.
request.x_vector_only_mode = True
emb_len = len(request.speaker_embedding)
# ECAPA-TDNN produces 1024-dim (0.6B) or 2048-dim (1.7B)
expected_dims = {1024, 2048}
if emb_len not in expected_dims:
logger.warning(
"speaker_embedding has %d dimensions; expected 1024 "
"(0.6B model) or 2048 (1.7B model). Wrong dimensions "
"will likely result in errors or degraded quality.",
emb_len,
)
# Validate Base task requirements
if task_type == "Base":
if request.voice is None:
if request.ref_audio is None:
return "Base task requires 'ref_audio' for voice cloning"
fmt_err = self._validate_ref_audio_format(request.ref_audio)
if fmt_err:
return fmt_err
# In-context voice cloning (default) requires non-empty ref_text.
# x_vector_only_mode skips in-context and only uses speaker embedding.
if not request.x_vector_only_mode:
# 1. Ensure a voice source is provided
if request.ref_audio is None and getattr(request, "speaker_embedding", None) is None:
return "Base task requires 'ref_audio' or 'speaker_embedding' for voice cloning"
# 2. Validate ref_audio format if it exists (using the helper from main)
if request.ref_audio is not None:
fmt_err = self._validate_ref_audio_format(request.ref_audio)
if fmt_err:
return fmt_err
# 3. Validate text requirements based on the mode
if not getattr(request, "x_vector_only_mode", False):
if not request.ref_text or not request.ref_text.strip():
return (
"Base task requires non-empty 'ref_text' (transcript of "
"the reference audio) unless 'x_vector_only_mode' is enabled"
)
else:
# Handle the case where request.voice is NOT None
pass
# voice is not None
voice_lower = request.voice.lower()
if voice_lower in self.uploaded_speakers:
@@ -890,7 +1009,18 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
# Voice clone: ref_audio resolved in create_speech(), not here.
if request.ref_text is not None:
params["ref_text"] = [request.ref_text]
if request.x_vector_only_mode is not None:
if request.speaker_embedding is not None:
# Store as plain float list (not tensor) so it survives msgspec
# serialization through the EngineCore IPC boundary. The talker's
# _build_prompt_embeds converts it back to a tensor on the GPU.
params["voice_clone_prompt"] = [
{
"ref_spk_embedding": list(request.speaker_embedding),
}
]
# speaker_embedding implies x_vector_only_mode
params["x_vector_only_mode"] = [True]
elif request.x_vector_only_mode is not None:
params["x_vector_only_mode"] = [request.x_vector_only_mode]
# Generation parameters
@@ -224,6 +224,7 @@ class OmniStreamingSpeechHandler:
ref_audio=config.ref_audio,
ref_text=config.ref_text,
x_vector_only_mode=config.x_vector_only_mode,
speaker_embedding=config.speaker_embedding,
stream=config.stream_audio,
)
@@ -1357,11 +1357,17 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
ref_code_prompt = ref_code_t
# Speaker embedding: use prompt embed if provided; otherwise extract from audio.
# NOTE: Do NOT use _as_singleton here — the embedding may be a plain
# float list (from API via msgspec IPC) that _as_singleton would
# destructively unwrap to a single scalar.
spk = None
if voice_clone_prompt is not None:
spk = _as_singleton(voice_clone_prompt.get("ref_spk_embedding"))
spk = voice_clone_prompt.get("ref_spk_embedding")
if isinstance(spk, torch.Tensor):
speaker_embed = spk.to(device=input_ids.device, dtype=torch.bfloat16).view(1, 1, -1)
elif isinstance(spk, (list, np.ndarray)):
# Plain list/array from API (survived msgspec IPC serialization).
speaker_embed = torch.tensor(spk, dtype=torch.bfloat16, device=input_ids.device).view(1, 1, -1)
else:
ref_audio_list = info_dict.get("ref_audio")
if not isinstance(ref_audio_list, list) or not ref_audio_list: