Files
vllm-omni/vllm_omni/entrypoints/openai/protocol/audio.py
T
2026-08-20 07:09:12 +08:00

576 lines
23 KiB
Python

import math
from typing import Any, Literal
import numpy as np
from pydantic import AliasChoices, BaseModel, Field, field_validator, model_validator
_MAX_EMBEDDING_DIM = 8192
SUPPORTED_AUDIO_FORMATS: frozenset[str] = frozenset({"wav", "pcm", "flac", "mp3", "opus"})
SUPPORTED_CHAT_AUDIO_FORMATS: frozenset[str] = SUPPORTED_AUDIO_FORMATS | {"pcm16"}
DEFAULT_AUDIO_FORMAT: str = "wav"
def _normalize_ref_audio_value(value):
if value is None:
return None
if isinstance(value, str):
return value
if isinstance(value, (list, tuple)):
items = []
for item in value:
if not isinstance(item, str):
raise TypeError("'ref_audio' list entries must be strings")
items.append(item)
if not items:
raise ValueError("'ref_audio' list cannot be empty")
return items
raise TypeError("'ref_audio' must be a string or list of strings")
def _normalize_speaker_embedding_value(value):
if value is None:
return None
if not isinstance(value, (list, tuple)):
raise TypeError("'speaker_embedding' must be a list of numbers or list of embedding vectors")
if not value:
return []
first = value[0]
if isinstance(first, (list, tuple)):
embeddings = []
for item in value:
if not isinstance(item, (list, tuple)):
raise TypeError("'speaker_embedding' must not mix flat and nested values")
embeddings.append([float(x) for x in item])
return embeddings
return [float(x) for x in value]
class OpenAICreateSpeechRequest(BaseModel):
input: str
model: str | None = None
# Accept both "voice" (OpenAI convention) and "speaker" (model/internal
# convention) as input keys. Intentionally global — all TTS backends
# (Qwen3-TTS, Voxtral, Fish Speech) use this field for the speaker name.
voice: str | None = Field(
default=None,
validation_alias=AliasChoices("voice", "speaker"),
description="Speaker/voice to use. For Qwen3-TTS: vivian, ryan, aiden, etc.",
)
@field_validator("voice", mode="before")
@classmethod
def _normalize_voice(cls, voice: str | dict[str, str]) -> str:
if isinstance(voice, dict):
if "id" not in voice:
raise ValueError("voice dict must contain 'id'")
return voice["id"]
return voice
instructions: str | None = Field(
default=None,
description="Instructions for voice style/emotion (maps to 'instruct' for Qwen3-TTS)",
)
response_format: Literal["wav", "pcm", "flac", "mp3", "opus"] = DEFAULT_AUDIO_FORMAT
speed: float | None = Field(
default=1.0,
ge=0.25,
le=4.0,
)
stream_format: Literal["sse", "audio"] | None = Field(
default=None,
description=(
"Streaming output format. 'audio' streams raw pcm/wav bytes; "
"'sse' streams OpenAI speech.audio.* SSE events. If omitted, stream=true "
"selects SSE and stream=false remains non-streaming."
),
)
stream: bool = Field(
default=False,
description=(
"Streaming switch; defaults to OpenAI speech.audio.* SSE events. "
"Set stream_format='audio' to opt into raw pcm/wav byte streaming. "
"HTTP streaming requires response_format='pcm' or 'wav'. "
"Models with native speed control may accept speed adjustment over HTTP."
),
)
# Qwen3-TTS specific parameters
task_type: Literal["CustomVoice", "VoiceDesign", "Base"] | None = Field(
default=None,
description="TTS task type: CustomVoice, VoiceDesign, or Base (voice clone)",
)
language: str | None = Field(
default=None,
description="Language code (e.g., 'Chinese', 'English', 'Auto')",
)
ref_audio: str | list[str] | None = Field(
default=None,
description="Reference audio for voice cloning (Base task). URL, base64, or file URI.",
)
ref_text: str | None = Field(
default=None,
description="Transcript of reference audio for voice cloning (Base task)",
)
ref_audio_2: str | None = Field(
default=None,
description="Second reference audio for two-speaker dialogue (MOSS-TTSD). "
"URL, base64, or file URI. Ignored by single-speaker models.",
)
ambient_sound: str | None = Field(
default=None,
description="Sound description for ambient/effect synthesis (MOSS-SoundEffect). "
"Natural language, e.g. 'ocean waves crashing on a rocky beach'.",
)
duration_seconds: float | None = Field(
default=None,
ge=0.0,
description="Target audio duration in seconds (MOSS-SoundEffect). Converted to ~12.5 frames/s internally.",
)
x_vector_only_mode: bool | None = Field(
default=None,
description="Use speaker embedding only without in-context learning (Base task)",
)
speaker_embedding: list[float] | list[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",
)
seed: int | None = Field(
default=None,
ge=0,
le=2**63 - 1,
description="Random seed for reproducible generation. When set, ensures "
"deterministic output for the same input text and seed value.",
)
initial_codec_chunk_frames: int | None = Field(
default=None,
ge=0,
description="Per-request initial chunk size override. If null, computed dynamically based on server load.",
)
non_streaming_mode: bool | None = Field(
default=None,
description=(
"Qwen3-TTS prompt construction mode override. This does not "
"control HTTP response streaming or async-chunk pipelining. "
"When null, use model defaults: Base=False, CustomVoice/VoiceDesign=True."
),
)
extra_params: dict[str, Any] | None = Field(
default=None,
description=("Optional model-specific parameters passed directly to the model's extra_args."),
)
word_timestamps: bool = Field(
default=False,
description=(
"When true, non-streaming responses carry per-word timestamps in the "
"X-Word-Timestamps header (JSON list of {word, start_ms, end_ms}, "
"ASCII-escaped; replaced by X-Word-Timestamps-Omitted past 4 KB). "
"Requires the server to be launched with --forced-aligner (400 otherwise). "
"Not supported with stream=true; for streaming use the WebSocket "
"/v1/audio/speech/stream path."
),
)
@field_validator("stream_format")
@classmethod
def validate_stream_format(cls, v: str) -> str:
return v
@field_validator("ref_audio", mode="before")
@classmethod
def normalize_ref_audio(cls, v):
return _normalize_ref_audio_value(v)
@field_validator("speaker_embedding")
@classmethod
def validate_speaker_embedding(
cls, v: list[float] | list[list[float]] | None
) -> list[float] | list[list[float]] | None:
v = _normalize_speaker_embedding_value(v)
if v is None:
return None
if not v:
return []
if isinstance(v[0], list):
for item in v:
if not item:
raise ValueError("'speaker_embedding' nested vectors must be non-empty")
if not all(math.isfinite(x) for x in item):
raise ValueError("'speaker_embedding' values must be finite (no NaN or Inf)")
return v
if 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="before")
@classmethod
def normalize_references_alias(cls, data):
"""Map the BosonAI Higgs Audio v3 cookbook ``references`` array onto ``ref_audio`` / ``ref_text``.
Upstream Higgs Audio v3 examples and the boson.ai cookbook send voice
clones as::
{"references": [{"audio_path": str, "text": str}]}
vllm-omni's existing convention is ``ref_audio`` + ``ref_text``.
Without this alias pydantic silently drops the unknown ``references``
field and the request falls through to zero-shot synthesis with no
error, which is hard to debug from the client side. The normalizer
translates a single reference and rejects multi-reference payloads
(vllm-omni does not yet support multi-shot voice clone) or conflicts
with the explicit ``ref_audio`` / ``ref_text`` fields.
"""
if not isinstance(data, dict):
return data
refs = data.get("references")
if refs is None:
return data
if not isinstance(refs, list) or len(refs) == 0:
raise ValueError("'references' must be a non-empty array of {audio_path, text} objects")
if len(refs) > 1:
raise ValueError("'references' only supports a single reference; multi-shot voice clone is not supported")
first = refs[0]
if not isinstance(first, dict):
raise ValueError("'references[0]' must be an object with 'audio_path' and optional 'text'")
audio_path = first.get("audio_path")
if not isinstance(audio_path, str) or not audio_path:
raise ValueError(
"'references[0].audio_path' is required and must be a string (URL, data: URI, or file path)"
)
existing_ref_audio = data.get("ref_audio")
if existing_ref_audio and existing_ref_audio != audio_path:
raise ValueError("'references' and 'ref_audio' are mutually exclusive")
data["ref_audio"] = audio_path
text = first.get("text")
if isinstance(text, str) and text.strip():
existing_ref_text = data.get("ref_text")
if existing_ref_text and existing_ref_text != text:
raise ValueError("'references[0].text' and 'ref_text' conflict; supply only one")
data["ref_text"] = text
data.pop("references", None)
return data
@model_validator(mode="before")
@classmethod
def reject_higgs_audio_v2_unsupported_aliases(cls, data):
"""Reject unsupported rich-input aliases for higgs_audio_v2 BEFORE pydantic strips them.
OpenAICreateSpeechRequest is a permissive schema (it accepts any extra
field that the OpenAI Speech API didn't promise to ban) but several
of those aliases pull a request out of the v1 higgs_audio_v2 scope.
Catching them at parse time lets the API return a deterministic 4xx
with a model-specific error message instead of silently dropping the
field and proceeding with a degraded request.
"""
# Keys that are silently dropped by pydantic when posted to /v1/audio/speech
# (the schema doesn't declare them) but which a model-specific
# validator MUST be able to reject. Kept inline to avoid clashing
# with pydantic's ModelPrivateAttr discovery for class-level
# underscore-prefixed attributes.
higgs_audio_v2_reserved_keys = (
"messages", # ChatML rich content (out of scope for v1)
"reference_audio", # voice-cloning alias 1
"voice_prompt", # voice-cloning alias 2
"speaker_audio", # voice-cloning alias 3
"speakers", # multi-speaker dialogue
)
if not isinstance(data, dict):
return data
model_id = data.get("model")
if not isinstance(model_id, str):
return data
# Match the "higgs_audio_v2" model_type label, the HF architecture id
# in pipeline_registry.hf_architectures, and the hyphenated HF repo id
# (e.g. "bosonai/higgs-audio-v2-generation-3B-base") by normalizing
# both underscores and hyphens out of the id before substring matching.
normalized = model_id.lower().replace("-", "").replace("_", "")
if "higgsaudiov2" not in normalized:
return data
offending = sorted(k for k in higgs_audio_v2_reserved_keys if k in data)
if offending:
raise ValueError(
"higgs_audio_v2 v1 does not support these rich-input fields: "
f"{offending}. Supply plain text via the 'input' field instead."
)
return data
@model_validator(mode="after")
def validate_embedding_constraints(self) -> "OpenAICreateSpeechRequest":
if self.speaker_embedding is not None:
if self.ref_audio is not None and not isinstance(self.ref_audio, list):
raise ValueError("'speaker_embedding' and 'ref_audio' are mutually exclusive")
return self
def is_raw_audio_stream(self) -> bool:
return self.stream_format == "audio"
def is_sse_stream(self) -> bool:
return (self.stream or self.stream_format == "sse") and not self.is_raw_audio_stream()
def is_streaming(self) -> bool:
return self.is_raw_audio_stream() or self.is_sse_stream()
@model_validator(mode="after")
def validate_streaming_constraints(self) -> "OpenAICreateSpeechRequest":
if self.is_streaming():
if self.response_format not in ("pcm", "wav"):
raise ValueError(
"Streaming (stream=true, stream_format='audio', or stream_format='sse') "
"requires response_format='pcm' or 'wav'. "
f"Got response_format='{self.response_format}'."
)
if self.speed is None:
self.speed = 1.0
return self
class OpenAICreateAudioGenerateRequest(BaseModel):
"""Request model for audio generation via diffusion models (e.g. Stable Audio)."""
input: str = Field(
description="Text prompt describing the audio to generate",
)
model: str | None = None
response_format: Literal["wav", "pcm", "flac", "mp3", "opus"] = DEFAULT_AUDIO_FORMAT
speed: float | None = Field(
default=1.0,
ge=0.25,
le=4.0,
)
stream_format: Literal["sse", "audio"] | None = "audio"
audio_length: float | None = Field(
default=None,
description="Audio length in seconds",
)
audio_start: float | None = Field(
default=0.0,
description="Audio start time in seconds",
)
negative_prompt: str | None = Field(
default=None,
description="Negative prompt for classifier-free guidance",
)
guidance_scale: float | None = Field(
default=None,
description="Guidance scale for diffusion models",
)
num_inference_steps: int | None = Field(
default=None,
description="Number of inference steps",
)
seed: int | None = Field(
default=None,
description="Random seed for reproducibility",
)
@field_validator("stream_format")
@classmethod
def validate_stream_format(cls, v: str) -> str:
if v == "sse":
raise ValueError("'sse' is not a supported stream_format yet. Please use 'audio'.")
return v
class CreateAudio(BaseModel):
audio_tensor: np.ndarray
sample_rate: int = 24000
response_format: str = "wav"
speed: float = 1.0
base64_encode: bool = True
class Config:
arbitrary_types_allowed = True
class AudioResponse(BaseModel):
audio_data: bytes | str
media_type: str
# --- Batch Speech Models ---
class SpeechBatchItem(BaseModel):
"""Per-item input for batch speech. Only `input` is required;
all other fields override the batch-level defaults when set."""
input: str
voice: str | None = Field(default=None, validation_alias=AliasChoices("voice", "speaker"))
instructions: str | None = None
response_format: Literal["wav", "pcm", "flac", "mp3", "opus"] | None = None
speed: float | None = Field(default=None, ge=0.25, le=4.0)
task_type: Literal["CustomVoice", "VoiceDesign", "Base"] | None = None
language: str | None = None
ref_audio: str | None = None
ref_text: str | None = None
x_vector_only_mode: bool | None = None
max_new_tokens: int | None = None
initial_codec_chunk_frames: int | None = Field(default=None, ge=0)
non_streaming_mode: bool | None = None
class BatchSpeechRequest(BaseModel):
"""Top-level request for batch speech generation.
Fields here act as shared defaults; per-item overrides win."""
model: str | None = None
items: list[SpeechBatchItem] = Field(..., min_length=1)
voice: str | None = Field(default=None, validation_alias=AliasChoices("voice", "speaker"))
instructions: str | None = None
response_format: Literal["wav", "pcm", "flac", "mp3", "opus"] = DEFAULT_AUDIO_FORMAT
speed: float | None = Field(default=1.0, ge=0.25, le=4.0)
task_type: Literal["CustomVoice", "VoiceDesign", "Base"] | None = None
language: str | None = None
ref_audio: str | None = None
ref_text: str | None = None
x_vector_only_mode: bool | None = None
max_new_tokens: int | None = None
initial_codec_chunk_frames: int | None = Field(default=None, ge=0)
non_streaming_mode: bool | None = None
class SpeechInputTokenDetails(BaseModel):
"""Per-modality breakdown of the speech request's *input* tokens.
The aggregate ``input_tokens`` on :class:`SpeechTokenUsage` is the sum of
these. We surface the split (rather than one opaque number) for the same
reason OpenAI's realtime/chat usage does: text and audio inputs are billed
and reasoned about differently, and folding them together is misleading.
Fields:
text_tokens: Tokens of the text to synthesize. This is ``input`` plus
``instructions`` (style/emotion prompt), because both are tokenized
into the model prefill. This is the number that should scale with
how much text the caller asked to speak.
audio_tokens: Reference-audio codec frames used as voice-cloning
conditioning. NON-ZERO only when in-context voice cloning is
actually active (Qwen3-TTS ``task_type='Base'`` ICL). It is 0 for
CustomVoice/VoiceDesign and for x-vector-only cloning, because those
paths put no reference codec frames into the prefill. See issue
#4646: this is the value that previously leaked into ``prompt_tokens``
and made Base usage look independent of the input text.
"""
text_tokens: int = 0
audio_tokens: int = 0
class SpeechTokenUsage(BaseModel):
"""Token usage for a speech (TTS) request.
Field naming follows OpenAI's documented ``speech.audio.done`` event
(``input_tokens``/``output_tokens``/``total_tokens``), NOT chat's
``prompt_tokens``/``completion_tokens``.
input_tokens = text_tokens + audio_tokens (see SpeechInputTokenDetails)
output_tokens = generated codec/audio tokens (stage-0 decode steps)
total_tokens = input_tokens + output_tokens
IMPORTANT: ``input_tokens`` is computed from the *semantic* request inputs
(tokenized text + reference-audio frames), NOT from ``len(prompt_token_ids)``.
For staged TTS models that engine prompt is a ``[1] * prefill_len``
placeholder whose length mirrors the model prefill, so it is not a faithful
input-token count (issue #4646).
"""
input_tokens: int = 0
output_tokens: int = 0
total_tokens: int = 0
input_token_details: SpeechInputTokenDetails = Field(default_factory=SpeechInputTokenDetails)
class SpeechBatchItemResult(BaseModel):
index: int
status: Literal["success", "error"]
audio_data: str | None = None
media_type: str | None = None
error: str | None = None
# Per-item token usage (input text + reference-audio conditioning, and
# generated audio tokens). None when the item errored before generation.
usage: SpeechTokenUsage | None = None
class BatchSpeechResponse(BaseModel):
id: str
results: list[SpeechBatchItemResult]
total: int
succeeded: int
failed: int
class StreamingSpeechSessionConfig(BaseModel):
"""Configuration sent as the first WebSocket message for streaming TTS."""
model: str | None = None
voice: str | None = Field(default=None, validation_alias=AliasChoices("voice", "speaker"))
task_type: Literal["CustomVoice", "VoiceDesign", "Base"] | None = None
language: str | None = None
instructions: str | None = None
response_format: Literal["wav", "pcm", "flac", "mp3", "opus"] = DEFAULT_AUDIO_FORMAT
speed: float | None = Field(default=1.0, ge=0.25, le=4.0)
max_new_tokens: int | None = Field(default=None, ge=1)
initial_codec_chunk_frames: int | None = Field(
default=None,
ge=0,
description="Initial chunk size for reduced TTFA. Overrides the deploy config for this session.",
)
non_streaming_mode: bool | None = Field(
default=None,
description=(
"Qwen3-TTS prompt construction mode override. This does not "
"control WebSocket audio streaming or async-chunk pipelining. "
"When null, use model defaults: Base=False, CustomVoice/VoiceDesign=True."
),
)
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=(
"If true, send raw PCM audio chunks progressively over WebSocket. "
"Requires response_format='pcm'. WebSocket streaming currently requires "
"speed=1.0, including for models with native speed control."
),
)
word_timestamps: bool = Field(
default=False,
description=(
"When true, audio chunks are wrapped in JSON 'audio.chunk' frames carrying "
"base64-encoded PCM plus aligned word timestamps. Requires the server to be "
"launched with --forced-aligner. When false, audio is sent as raw binary "
"frames (existing behavior)."
),
)
@model_validator(mode="after")
def validate_streaming_constraints(self) -> "StreamingSpeechSessionConfig":
if self.stream_audio:
if self.response_format != "pcm":
raise ValueError(
"WebSocket streaming audio (stream_audio=true) requires response_format='pcm'. "
f"Got response_format='{self.response_format}'."
)
if self.speed is None:
self.speed = 1.0
elif self.speed != 1.0:
raise ValueError(
"WebSocket stream_audio=true currently requires speed=1.0; "
"native speed control is only available through HTTP streaming."
)
return self