197 lines
7.4 KiB
Python
197 lines
7.4 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Base contract for per-model TTS serving adapters.
|
|
|
|
This package factors the per-model ``if self._tts_model_type == ...`` dispatch
|
|
in ``serving_speech.py`` into one adapter class per model. Each adapter owns its
|
|
model's request normalization, validation, prompt/param building, sampling
|
|
overrides, and output policy, so adding a model means writing one adapter file
|
|
instead of editing the shared serving module in ~10 scattered places.
|
|
|
|
See the RFC for the full design (issue #4327). This is the foundation landed in
|
|
the first migration PR; Qwen3-TTS is the first model routed through it while the
|
|
remaining models stay on the legacy path until individually migrated.
|
|
"""
|
|
|
|
from abc import ABC, abstractmethod
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Any, ClassVar
|
|
|
|
if TYPE_CHECKING:
|
|
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
|
|
|
|
|
_conditioning_cache_salt_fn: "Callable[..., str] | None" = None
|
|
|
|
|
|
def conditioning_cache_salt(request: "OpenAICreateSpeechRequest", tts_params: dict) -> str:
|
|
"""Return the conditioning cache salt for ``request`` + ``tts_params``.
|
|
|
|
Lazily imports and caches ``serving_speech._conditioning_cache_salt`` on first
|
|
use: the import is deferred to break the adapters<->serving_speech import
|
|
cycle, and cached so it resolves once instead of on every ``build()`` call.
|
|
"""
|
|
global _conditioning_cache_salt_fn
|
|
if _conditioning_cache_salt_fn is None:
|
|
from vllm_omni.entrypoints.openai.serving_speech import _conditioning_cache_salt
|
|
|
|
_conditioning_cache_salt_fn = _conditioning_cache_salt
|
|
return _conditioning_cache_salt_fn(request, tts_params)
|
|
|
|
|
|
@dataclass
|
|
class OutputPolicy:
|
|
"""How the orchestrator aggregates engine output for a model.
|
|
|
|
``accumulate_nonstreaming`` enables MOSS-style cross-step accumulation in the
|
|
non-streaming path. Streaming cumulative/delta semantics stay engine-side,
|
|
keyed by request id, and are unaffected by this flag.
|
|
"""
|
|
|
|
accumulate_nonstreaming: bool = False
|
|
|
|
|
|
@dataclass
|
|
class PreparedRequest:
|
|
"""Everything the generic orchestrator needs to call ``<engine>.generate()``.
|
|
|
|
The fields mirror what ``_prepare_speech_generation`` assembled inline:
|
|
``prompt`` is the engine prompt dict, ``tts_params`` the per-model parameter
|
|
dict, ``model_type`` the discriminator used for logging, and
|
|
``output_policy`` controls non-streaming aggregation.
|
|
"""
|
|
|
|
prompt: dict[str, Any]
|
|
tts_params: dict[str, Any] = field(default_factory=dict)
|
|
model_type: str = "generic"
|
|
output_policy: OutputPolicy = field(default_factory=OutputPolicy)
|
|
#: Cross-cutting per-request state the orchestrator still owns (e.g. the
|
|
#: Qwen3-TTS ref-audio warmup artifact key tracked after ``generate()``).
|
|
warmup_artifact_key: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class SpeechServingContext:
|
|
"""Shared state and helpers an adapter may use.
|
|
|
|
During the incremental migration this holds a back-reference to the owning
|
|
``OmniOpenAIServingSpeech`` instance (``server``) so adapters can reuse the
|
|
existing, battle-tested helper implementations (ref-audio resolution,
|
|
uploaded-speaker handling, prompt-length estimation, speaker storage)
|
|
without relocating them. Follow-up PRs may narrow this to explicit fields as
|
|
more models migrate. Exactly one of ``engine_client`` / ``diffusion_engine``
|
|
is set, matching the deployment's serving backend.
|
|
"""
|
|
|
|
server: Any
|
|
engine_client: Any | None = None
|
|
diffusion_engine: Any | None = None
|
|
|
|
|
|
class TTSModelAdapter(ABC):
|
|
"""Mandatory base class for a TTS model served via ``/v1/audio/speech``.
|
|
|
|
One concrete subclass per model, registered by stage key. The serving layer
|
|
resolves exactly one adapter at startup and dispatches all per-model
|
|
decisions to it. Adapters reuse shared helpers through ``ctx`` rather than
|
|
re-implementing them.
|
|
"""
|
|
|
|
#: Stable discriminator string (the model-type from detection); registry key.
|
|
name: ClassVar[str]
|
|
#: Engine ``model_stage`` key(s) this model uses, for documentation only.
|
|
stage_keys: ClassVar[frozenset[str]] = frozenset()
|
|
#: Serving backend: ``"ar"`` (engine_client) or ``"diffusion"``.
|
|
backend: ClassVar[str] = "ar"
|
|
|
|
def __init__(self, ctx: SpeechServingContext) -> None:
|
|
self.ctx = ctx
|
|
|
|
def normalize(self, request: "OpenAICreateSpeechRequest") -> None:
|
|
"""In-place request normalization/mutation (e.g. infer task type,
|
|
lowercase voice). Default: no-op."""
|
|
|
|
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
|
|
"""Return an error string, or ``None`` if the request is valid.
|
|
|
|
Should be free of new side effects beyond what ``normalize`` did.
|
|
"""
|
|
return None
|
|
|
|
@abstractmethod
|
|
async def build(
|
|
self,
|
|
request: "OpenAICreateSpeechRequest",
|
|
sampling_params_list: list,
|
|
has_inline_ref_audio: bool,
|
|
) -> PreparedRequest:
|
|
"""Build the engine prompt + tts_params for this request.
|
|
|
|
``sampling_params_list`` is passed read-only for models (e.g. MOSS) that
|
|
fold the resolved seed into ``additional_information`` at build time.
|
|
|
|
``has_inline_ref_audio`` is captured by the orchestrator *before*
|
|
``validate()`` runs, because ``_apply_uploaded_speaker`` (invoked inside
|
|
several adapters' ``validate``) sets ``request.ref_audio`` in place.
|
|
Recomputing it here would misclassify uploaded voices as inline and drop
|
|
the ``voice_name`` / ``voice_created_at`` metadata.
|
|
"""
|
|
|
|
def apply_sampling_overrides(
|
|
self,
|
|
sampling_params_list: list,
|
|
request: "OpenAICreateSpeechRequest",
|
|
) -> list:
|
|
"""Apply model-specific sampling mutations.
|
|
|
|
The orchestrator guarantees the call order:
|
|
stream-coercion -> extra_params -> THIS -> seed. Default: identity.
|
|
"""
|
|
return sampling_params_list
|
|
|
|
|
|
class ARTTSAdapter(TTSModelAdapter):
|
|
"""Adapter for models served through the AR ``engine_client`` path.
|
|
|
|
Covers pure-AR codec models as well as AR-base-LM + diffusion-side-computation
|
|
hybrids (e.g. VoxCPM2, Ming) whose internal diffusion is invisible to the
|
|
serving layer.
|
|
"""
|
|
|
|
backend: ClassVar[str] = "ar"
|
|
|
|
|
|
class DiffusionTTSAdapter(TTSModelAdapter):
|
|
"""Adapter for pure-diffusion pipelines served through the diffusion engine.
|
|
|
|
In scope today: OmniVoice. Bridges to the diffusion pipeline parameter
|
|
contract (#3572) when present; see ``extra_body_params``.
|
|
"""
|
|
|
|
backend: ClassVar[str] = "diffusion"
|
|
|
|
#: Backing diffusion pipeline class (for EXTRA_BODY_PARAMS lookup).
|
|
pipeline_cls: ClassVar[type | None] = None
|
|
|
|
@classmethod
|
|
def extra_body_params(cls) -> frozenset[str]:
|
|
"""Fallback-safe access to the pipeline's declared body params.
|
|
|
|
Returns the pipeline's ``EXTRA_BODY_PARAMS`` if the #3572 contract is
|
|
present, else an empty frozenset (the adapter then uses its own inline
|
|
parameter logic).
|
|
"""
|
|
params = getattr(cls.pipeline_cls, "EXTRA_BODY_PARAMS", None)
|
|
return frozenset(params) if params is not None else frozenset()
|
|
|
|
|
|
# Re-exported here to avoid import cycles at call sites.
|
|
__all__ = [
|
|
"ARTTSAdapter",
|
|
"DiffusionTTSAdapter",
|
|
"OutputPolicy",
|
|
"PreparedRequest",
|
|
"SpeechServingContext",
|
|
"TTSModelAdapter",
|
|
]
|