846 lines
37 KiB
Python
846 lines
37 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
import os
|
|
from collections.abc import Iterable, Mapping, Sequence
|
|
from dataclasses import replace
|
|
from functools import partial
|
|
from threading import Lock
|
|
|
|
import torch
|
|
import torch.nn as nn
|
|
from transformers.feature_extraction_utils import BatchFeature
|
|
from vllm.config import VllmConfig
|
|
from vllm.config.multimodal import BaseDummyOptions
|
|
from vllm.forward_context import get_forward_context, is_forward_context_available
|
|
from vllm.inputs import MultiModalDataDict
|
|
from vllm.logger import init_logger
|
|
from vllm.model_executor.models.interfaces import SupportsMultiModal
|
|
from vllm.multimodal import MULTIMODAL_REGISTRY
|
|
from vllm.multimodal.inputs import MultiModalFieldConfig, MultiModalKwargsItems
|
|
from vllm.multimodal.parse import MultiModalDataItems, MultiModalDataParser
|
|
from vllm.multimodal.processing import (
|
|
BaseDummyInputsBuilder,
|
|
BaseMultiModalProcessor,
|
|
BaseProcessingInfo,
|
|
ProcessorInputs,
|
|
PromptIndexTargets,
|
|
PromptInsertion,
|
|
PromptUpdate,
|
|
)
|
|
from vllm.sequence import IntermediateTensors
|
|
from vllm.v1.outputs import SamplerOutput
|
|
from vllm.v1.sample.metadata import SamplingMetadata
|
|
from vllm.v1.sample.sampler import Sampler
|
|
|
|
from vllm_omni.data_entry_keys import EmbeddingsStruct, OmniPayloadStruct, to_dict, to_struct
|
|
from vllm_omni.model_executor.models.cosyvoice3.utils import (
|
|
concat_text_with_prompt_ids,
|
|
extract_speech_feat,
|
|
extract_speech_token,
|
|
extract_spk_embedding,
|
|
extract_text_token,
|
|
)
|
|
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
|
from vllm_omni.transformers_utils.configs.cosyvoice3 import CosyVoice3Config
|
|
from vllm_omni.utils.speaker_cache import get_speaker_cache
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
|
|
class CosyVoice3MultiModalProcessingInfo(BaseProcessingInfo):
|
|
def get_hf_config(self):
|
|
"""If the config is not already present pass it
|
|
as a class and it will try to find it in your
|
|
model directory just copy the config class there also.
|
|
"""
|
|
return self.ctx.get_hf_config(CosyVoice3Config)
|
|
|
|
def get_supported_mm_limits(self) -> Mapping[str, int | None]:
|
|
"""How many audio can you pass. I think I should keep it as 1
|
|
For now I have kept it None.
|
|
"""
|
|
return {"audio": None}
|
|
|
|
def get_data_parser(self):
|
|
return MultiModalDataParser(
|
|
target_sr=self.ctx.get_hf_config().target_sr,
|
|
expected_hidden_size=self._get_expected_hidden_size(),
|
|
)
|
|
|
|
|
|
class CosyVoice3MultiModalProcessor(BaseMultiModalProcessor[CosyVoice3MultiModalProcessingInfo]):
|
|
def _ensure_cached_runtime_components(self, model_dir: str, config: CosyVoice3Config) -> None:
|
|
cached_model_dir = getattr(self, "_cached_model_dir", None)
|
|
if cached_model_dir == model_dir:
|
|
return
|
|
|
|
# If model_dir is an HF repo ID (not a local path), resolve to cache
|
|
if not os.path.isdir(model_dir):
|
|
from huggingface_hub import snapshot_download
|
|
|
|
model_dir = snapshot_download(model_dir)
|
|
|
|
import onnxruntime
|
|
|
|
from vllm_omni.model_executor.models.cosyvoice3.tokenizer import get_qwen_tokenizer
|
|
from vllm_omni.model_executor.models.cosyvoice3.utils import mel_spectrogram
|
|
|
|
option = onnxruntime.SessionOptions()
|
|
option.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL
|
|
option.intra_op_num_threads = 1
|
|
|
|
self.tokenizer = get_qwen_tokenizer(
|
|
token_path=os.path.join(model_dir, config.qwen_pretrain_path),
|
|
skip_special_tokens=config.skip_special_tokens,
|
|
version=config.version,
|
|
)
|
|
self.speech_tokenizer = onnxruntime.InferenceSession(
|
|
os.path.join(model_dir, config.speech_tokenizer_path),
|
|
sess_options=option,
|
|
providers=["CUDAExecutionProvider" if torch.cuda.is_available() else "CPUExecutionProvider"],
|
|
)
|
|
self.feat_extractor = partial(mel_spectrogram, **getattr(config, "feat_extractor", {}))
|
|
self.campplus_session = onnxruntime.InferenceSession(
|
|
os.path.join(model_dir, config.campplus_onxx_path),
|
|
sess_options=option,
|
|
providers=["CPUExecutionProvider"],
|
|
)
|
|
self._cached_model_dir = model_dir
|
|
self._speaker_cache = get_speaker_cache()
|
|
|
|
def _call_hf_processor(
|
|
self,
|
|
prompt: str,
|
|
mm_data: Mapping[str, object],
|
|
mm_kwargs: Mapping[str, object],
|
|
tok_kwargs: Mapping[str, object],
|
|
) -> BatchFeature:
|
|
"""
|
|
apply-> cached_apply_hf_processor -> apply_hf_processor_mm ->
|
|
_call_hf_processor.
|
|
_call_hf_processor takes input prompt and mm_data and returns
|
|
token ids and tensors
|
|
"""
|
|
config = self.info.ctx.get_hf_config()
|
|
model_dir = self.info.ctx.model_config.model
|
|
self._ensure_cached_runtime_components(model_dir, config)
|
|
|
|
audio = mm_data.get("audio", None)
|
|
|
|
if audio is None:
|
|
audio = mm_data.get("audios")
|
|
if audio is not None:
|
|
audio = audio[0], config.target_sr
|
|
|
|
text_token, text_token_len = extract_text_token(prompt, self.tokenizer, config.allowed_special)
|
|
if audio is None:
|
|
# Text-only path for profiling/cache
|
|
return BatchFeature({"input_ids": text_token, "input_len": [text_token_len]})
|
|
|
|
prompt_text = mm_kwargs.get("prompt_text")
|
|
|
|
if not isinstance(prompt_text, str):
|
|
raise ValueError(f"prompt text is None : {prompt_text}")
|
|
|
|
prompt_text_token, prompt_text_token_len = extract_text_token(
|
|
prompt_text, self.tokenizer, config.allowed_special
|
|
)
|
|
|
|
input_ids, input_len = concat_text_with_prompt_ids(
|
|
text_token,
|
|
text_token_len,
|
|
prompt_text_token,
|
|
prompt_text_token_len,
|
|
)
|
|
logger.debug(
|
|
"cosyvoice _call_hf_processor: prompt_text_token=%s text_token=%s input_ids=%s "
|
|
"prompt_text_len=%s text_len=%s input_len=%s",
|
|
prompt_text_token.tolist(),
|
|
text_token.tolist(),
|
|
input_ids.tolist(),
|
|
int(prompt_text_token_len),
|
|
int(text_token_len),
|
|
int(input_len),
|
|
)
|
|
device = "cpu"
|
|
|
|
# Speaker cache: skip 3 ONNX sessions on cache hit
|
|
voice_name = mm_kwargs.get("voice_name")
|
|
cache_key = None
|
|
if voice_name and isinstance(voice_name, str):
|
|
cache_key = self._speaker_cache.make_cache_key(
|
|
voice_name,
|
|
model_type="cosyvoice3",
|
|
created_at=int(mm_kwargs.get("voice_created_at") or 0),
|
|
)
|
|
cached = self._speaker_cache.get(cache_key)
|
|
if cached is not None:
|
|
ft = BatchFeature(
|
|
{
|
|
"input_ids": input_ids,
|
|
"speech_feat": cached["speech_feat"].clone(),
|
|
"speech_token": cached["speech_token"].clone(),
|
|
"speech_token_len": [cached["speech_token_len"].clone()],
|
|
"embedding": cached["embedding"].clone(),
|
|
}
|
|
)
|
|
return ft
|
|
|
|
speech_token, speech_token_len = extract_speech_token(audio, self.speech_tokenizer, device)
|
|
speech_feat, speech_feat_len = extract_speech_feat(audio, self.feat_extractor, device)
|
|
|
|
if config.sample_rate == 24000:
|
|
token_len = min(int(speech_feat.shape[1] / 2), speech_token.shape[1])
|
|
speech_feat, speech_feat_len[:] = speech_feat[:, : 2 * token_len], 2 * token_len
|
|
speech_token, speech_token_len[:] = speech_token[:, :token_len], token_len
|
|
|
|
embedding = extract_spk_embedding(audio, self.campplus_session, device)
|
|
|
|
# Cache the extracted artifacts for named speakers
|
|
if cache_key is not None:
|
|
self._speaker_cache.put(
|
|
cache_key,
|
|
{
|
|
"speech_feat": speech_feat.detach().cpu(),
|
|
"speech_token": speech_token.detach().cpu(),
|
|
"speech_token_len": speech_token_len.detach().cpu(),
|
|
"embedding": embedding.detach().cpu(),
|
|
},
|
|
)
|
|
|
|
ft = BatchFeature(
|
|
{
|
|
"input_ids": input_ids,
|
|
"speech_feat": speech_feat,
|
|
"speech_token": speech_token,
|
|
"speech_token_len": [speech_token_len],
|
|
"embedding": embedding,
|
|
}
|
|
)
|
|
|
|
return ft
|
|
|
|
def _get_mm_fields_config(
|
|
self,
|
|
hf_inputs: "BatchFeature",
|
|
hf_processor_mm_kwargs: Mapping[str, object],
|
|
) -> Mapping[str, MultiModalFieldConfig]:
|
|
return {
|
|
"speech_feat": MultiModalFieldConfig.batched("audio"),
|
|
"speech_token": MultiModalFieldConfig.batched("audio"),
|
|
"speech_token_len": MultiModalFieldConfig.batched("audio"),
|
|
"embedding": MultiModalFieldConfig.batched("audio"),
|
|
}
|
|
|
|
def _hf_processor_applies_updates(
|
|
self,
|
|
prompt_text: str,
|
|
mm_items: MultiModalDataItems,
|
|
hf_processor_mm_kwargs: Mapping[str, object],
|
|
tokenization_kwargs: Mapping[str, object],
|
|
) -> bool:
|
|
return False
|
|
|
|
def _get_prompt_updates(
|
|
self,
|
|
mm_items: MultiModalDataItems,
|
|
hf_processor_mm_kwargs: Mapping[str, object],
|
|
out_mm_kwargs: MultiModalKwargsItems,
|
|
) -> Sequence[PromptUpdate]:
|
|
def insertion_end(item_idx):
|
|
# TODO: Think if this can be done better
|
|
# sos + task + audio token ... ideally this needs to be split into
|
|
# two start and end but somehow I couldn't pass two of these
|
|
# wutg target .start() and .end()
|
|
token_len = out_mm_kwargs["audio"][0]["speech_token_len"].data[0].item()
|
|
return [1] * (1 + 1 + token_len)
|
|
|
|
return [
|
|
PromptInsertion(
|
|
modality="audio",
|
|
target=PromptIndexTargets.start(),
|
|
insertion=insertion_end,
|
|
),
|
|
]
|
|
|
|
|
|
class CosyVoice3DummyInputsBuilder(BaseDummyInputsBuilder[CosyVoice3MultiModalProcessingInfo]):
|
|
def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
|
|
return "Hello, this is a test of the CosyVoice3 system capability."
|
|
|
|
def get_dummy_mm_data(
|
|
self, seq_len: int, mm_counts: Mapping[str, int], mm_options: Mapping[str, BaseDummyOptions] | None = None
|
|
) -> MultiModalDataDict:
|
|
num_audios = mm_counts.get("audio")
|
|
max_prompt_seconds = 30
|
|
prompt_sample_rate = 24000
|
|
target_audio_length = max_prompt_seconds * prompt_sample_rate
|
|
|
|
audio_overrides = mm_options.get("audio") if mm_options else None
|
|
mm_data = {
|
|
"audio": (
|
|
self._get_dummy_audios(
|
|
length=target_audio_length,
|
|
num_audios=num_audios,
|
|
overrides=audio_overrides,
|
|
)[0],
|
|
24000,
|
|
),
|
|
}
|
|
return mm_data
|
|
|
|
def get_dummy_processor_inputs(
|
|
self, seq_len: int, mm_counts: Mapping[str, int], mm_options: Mapping[str, BaseDummyOptions] | None = None
|
|
) -> ProcessorInputs:
|
|
inputs = super().get_dummy_processor_inputs(seq_len, mm_counts, mm_options)
|
|
inputs.hf_processor_mm_kwargs = {"prompt_text": "Testing my voices. Why should I not?"}
|
|
return inputs
|
|
|
|
|
|
@MULTIMODAL_REGISTRY.register_processor(
|
|
CosyVoice3MultiModalProcessor,
|
|
info=CosyVoice3MultiModalProcessingInfo,
|
|
dummy_inputs=CosyVoice3DummyInputsBuilder,
|
|
)
|
|
class CosyVoice3Model(
|
|
nn.Module,
|
|
SupportsMultiModal,
|
|
):
|
|
supports_multimodal_raw_input_only = True
|
|
supports_multimodal = True
|
|
requires_raw_input_tokens = True
|
|
prefer_model_sampler = True
|
|
_sampling_eps = 1e-5
|
|
|
|
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
|
|
super().__init__()
|
|
self.config = vllm_config.model_config.hf_config
|
|
self.have_multimodal_outputs = True
|
|
self.model_stage = vllm_config.model_config.model_stage
|
|
model_dir = vllm_config.model_config.model
|
|
if not os.path.isdir(model_dir):
|
|
from huggingface_hub import snapshot_download
|
|
|
|
model_dir = snapshot_download(model_dir)
|
|
self.model_dir = model_dir
|
|
self.model = None
|
|
if self.model_stage == "cosyvoice3_talker":
|
|
# Initialize talker stage (text to speech tokens)
|
|
from vllm_omni.model_executor.models.cosyvoice3.cosyvoice3_talker import CosyVoice3LM, VLLMQwen2Encoder
|
|
|
|
llm_vllm_config = self._create_llm_vllm_config(vllm_config)
|
|
llm = VLLMQwen2Encoder(vllm_config=llm_vllm_config, prefix="model")
|
|
self.talker = CosyVoice3LM(
|
|
llm_input_size=self.config.llm["llm_input_size"],
|
|
llm_output_size=self.config.llm["llm_output_size"],
|
|
speech_token_size=self.config.llm["speech_token_size"],
|
|
llm=llm,
|
|
length_normalized_loss=self.config.llm["length_normalized_loss"],
|
|
lsm_weight=self.config.llm["lsm_weight"],
|
|
mix_ratio=self.config.llm["mix_ratio"],
|
|
)
|
|
# KV cache is now managed externally by vLLM's PagedAttention
|
|
# No need for self.llm_cache
|
|
self.model = self.talker
|
|
elif self.model_stage == "cosyvoice3_code2wav":
|
|
# Initialize code2wav stage (flow matching + vocoder)
|
|
from vllm_omni.model_executor.models.cosyvoice3.cosyvoice3_code2wav import CosyVoice3Code2Wav
|
|
|
|
self.code2wav = CosyVoice3Code2Wav(self.config)
|
|
self.model = self.code2wav.flow_model
|
|
self.hift = self.code2wav.hift
|
|
# Keep additional information synchronized for async_chunk updates.
|
|
self.enable_update_additional_information = True
|
|
|
|
# Expose streaming parameters
|
|
self.token_overlap_len = self.code2wav.token_overlap_len
|
|
self.mel_overlap_len = self.code2wav.mel_overlap_len
|
|
self.mel_window = self.code2wav.mel_window
|
|
self.mel_cache_len = self.code2wav.mel_cache_len
|
|
self.source_cache_len = self.code2wav.source_cache_len
|
|
self.speech_window = self.code2wav.speech_window
|
|
self._stream_audio_cache_by_req: dict[str, torch.Tensor] = {}
|
|
self._stream_audio_cache_lock = Lock()
|
|
self._stream_vocoder_cache_by_req: dict[str, dict[str, torch.Tensor]] = {}
|
|
else:
|
|
raise ValueError(f"Model stage not supported {self.model_stage}")
|
|
|
|
def _create_llm_vllm_config(self, parent_config: VllmConfig) -> VllmConfig:
|
|
"""Create VllmConfig for the inner Qwen2 LLM.
|
|
|
|
This creates a modified VllmConfig with the Qwen2 HF config loaded from
|
|
the pretrained model directory. The cache config is inherited from the parent
|
|
to enable PagedAttention with the same memory configuration.
|
|
"""
|
|
from transformers import Qwen2Config
|
|
|
|
qwen_config_path = os.path.join(self.model_dir, self.config.llm["llm"]["pretrain_path"])
|
|
qwen_hf_config = Qwen2Config.from_pretrained(qwen_config_path)
|
|
|
|
# Use parent's cache config - critical for PagedAttention to work correctly
|
|
return parent_config.with_hf_config(qwen_hf_config, architectures=["Qwen2Model"])
|
|
|
|
@staticmethod
|
|
def _cross_fade_audio(audio: torch.Tensor, prev_tail: torch.Tensor) -> torch.Tensor:
|
|
"""Blend previous chunk tail into current chunk head using a Hamming window.
|
|
|
|
This mirrors upstream CosyVoice's `fade_in_out(...)` semantics:
|
|
update the current head in-place using a 2*overlap window, then
|
|
concatenate the unchanged remainder.
|
|
"""
|
|
if audio.numel() == 0 or prev_tail.numel() == 0:
|
|
return audio
|
|
overlap = min(int(audio.numel()), int(prev_tail.numel()))
|
|
if overlap <= 0:
|
|
return audio
|
|
window = torch.hamming_window(2 * overlap, periodic=False, dtype=audio.dtype, device=audio.device)
|
|
fade_in = window[:overlap]
|
|
fade_out = window[overlap:]
|
|
blended = audio[:overlap] * fade_in + prev_tail[-overlap:].to(device=audio.device, dtype=audio.dtype) * fade_out
|
|
if overlap == int(audio.numel()):
|
|
return blended
|
|
return torch.cat([blended, audio[overlap:]], dim=0)
|
|
|
|
def _stitch_stream_audio(self, req_id: str | None, audio: torch.Tensor, stream_finished: bool) -> torch.Tensor:
|
|
"""Pass-through stitching for async_chunk.
|
|
|
|
Chunk overlap is already removed in mel domain via token_offset_tokens.
|
|
Applying an additional waveform-domain fade/cache step introduces either
|
|
duplicated overlap (if no tail trim) or duration shrink (if tail trim).
|
|
"""
|
|
if req_id is not None and stream_finished and hasattr(self, "_stream_audio_cache_by_req"):
|
|
with self._stream_audio_cache_lock:
|
|
self._stream_audio_cache_by_req.pop(req_id, None)
|
|
if hasattr(self, "_stream_vocoder_cache_by_req"):
|
|
self._stream_vocoder_cache_by_req.pop(req_id, None)
|
|
return audio
|
|
|
|
@staticmethod
|
|
def _split_request_ids(ids: torch.Tensor, seq_token_counts: list[int] | None = None) -> list[torch.Tensor]:
|
|
"""Split concatenated input_ids into per-request segments."""
|
|
if seq_token_counts is not None:
|
|
boundaries = [0]
|
|
for count in seq_token_counts:
|
|
boundaries.append(boundaries[-1] + int(count))
|
|
total = ids.numel()
|
|
return [ids[boundaries[i] : min(boundaries[i + 1], total)] for i in range(len(seq_token_counts))]
|
|
|
|
if is_forward_context_available():
|
|
slices = get_forward_context().ubatch_slices
|
|
if slices is not None and len(slices) > 1 and not any(hasattr(s, "token_slice") for s in slices):
|
|
boundaries = [0]
|
|
for s in slices:
|
|
boundaries.append(boundaries[-1] + int(s))
|
|
return [ids[boundaries[i] : boundaries[i + 1]] for i in range(len(boundaries) - 1)]
|
|
|
|
return [ids]
|
|
|
|
def _sanitize_codec_tokens(self, req_ids: torch.Tensor) -> torch.Tensor:
|
|
"""Filter non-code tokens before feeding flow token embedding."""
|
|
vocab_size = int(self.code2wav.input_embedding.num_embeddings)
|
|
valid_mask = (req_ids >= 0) & (req_ids < vocab_size)
|
|
return req_ids[valid_mask]
|
|
|
|
@staticmethod
|
|
def _req_scalar(param: torch.Tensor | None, req_idx: int, default: float | int) -> float | int:
|
|
if param is None or param.numel() == 0:
|
|
return default
|
|
index = min(req_idx, int(param.numel()) - 1)
|
|
value = param.reshape(-1)[index].item()
|
|
if isinstance(default, int):
|
|
return int(value)
|
|
return float(value)
|
|
|
|
@staticmethod
|
|
def _multinomial_sample(probs: torch.Tensor, generator: torch.Generator | None = None) -> torch.Tensor:
|
|
return torch.multinomial(probs, 1, replacement=True, generator=generator).reshape(())
|
|
|
|
@classmethod
|
|
def _nucleus_sample_one(
|
|
cls,
|
|
weighted_scores: torch.Tensor,
|
|
*,
|
|
top_p: float,
|
|
top_k: int,
|
|
generator: torch.Generator | None,
|
|
) -> int:
|
|
probs = weighted_scores.softmax(dim=0)
|
|
sorted_prob, sorted_idx = probs.sort(descending=True, stable=True)
|
|
kept_probs: list[torch.Tensor] = []
|
|
kept_indices: list[torch.Tensor] = []
|
|
cum_prob = 0.0
|
|
max_keep = len(sorted_idx) if top_k <= 0 else min(int(top_k), len(sorted_idx))
|
|
for i in range(len(sorted_idx)):
|
|
if cum_prob < top_p and len(kept_probs) < max_keep:
|
|
cum_prob += float(sorted_prob[i].item())
|
|
kept_probs.append(sorted_prob[i])
|
|
kept_indices.append(sorted_idx[i])
|
|
else:
|
|
break
|
|
|
|
if not kept_probs:
|
|
return int(sorted_idx[0].item())
|
|
|
|
sample_probs = torch.stack(kept_probs)
|
|
sample_idx = cls._multinomial_sample(sample_probs, generator=generator)
|
|
return int(torch.stack(kept_indices)[int(sample_idx.item())].item())
|
|
|
|
@classmethod
|
|
def _ras_sample_one(
|
|
cls,
|
|
weighted_scores: torch.Tensor,
|
|
decoded_tokens: Sequence[int],
|
|
*,
|
|
top_p: float,
|
|
top_k: int,
|
|
win_size: int,
|
|
tau_r: float,
|
|
generator: torch.Generator | None,
|
|
) -> int:
|
|
top_id = cls._nucleus_sample_one(
|
|
weighted_scores,
|
|
top_p=top_p,
|
|
top_k=top_k,
|
|
generator=generator,
|
|
)
|
|
if win_size > 0 and decoded_tokens:
|
|
recent = torch.as_tensor(
|
|
list(decoded_tokens[-win_size:]),
|
|
device=weighted_scores.device,
|
|
dtype=torch.long,
|
|
)
|
|
rep_num = int((recent == top_id).sum().item())
|
|
if rep_num >= win_size * tau_r:
|
|
weighted_scores = weighted_scores.clone()
|
|
weighted_scores[top_id] = float("-inf")
|
|
fallback_probs = weighted_scores.softmax(dim=0)
|
|
top_id = int(cls._multinomial_sample(fallback_probs, generator=generator).item())
|
|
return top_id
|
|
|
|
def _cosyvoice3_ras_enabled(self, sampling_metadata: SamplingMetadata) -> bool:
|
|
if self.model_stage != "cosyvoice3_talker":
|
|
return False
|
|
if sampling_metadata.max_num_logprobs is not None:
|
|
return False
|
|
if sampling_metadata.temperature is None:
|
|
return False
|
|
if bool(sampling_metadata.bad_words_token_ids):
|
|
return False
|
|
if torch.any(sampling_metadata.frequency_penalties != 0):
|
|
return False
|
|
if torch.any(sampling_metadata.presence_penalties != 0):
|
|
return False
|
|
return True
|
|
|
|
def sample(
|
|
self,
|
|
logits: torch.Tensor,
|
|
sampling_metadata: SamplingMetadata,
|
|
) -> SamplerOutput | None:
|
|
if logits is None or logits.numel() == 0:
|
|
return None
|
|
if self.model_stage != "cosyvoice3_talker":
|
|
return None
|
|
|
|
sampler = getattr(self, "_talker_sampler", None)
|
|
if sampler is None:
|
|
sampler = Sampler()
|
|
self._talker_sampler = sampler
|
|
|
|
if not self._cosyvoice3_ras_enabled(sampling_metadata):
|
|
return sampler(logits=logits, sampling_metadata=sampling_metadata)
|
|
|
|
logits = logits.to(torch.float32)
|
|
sampling_for_processors = replace(sampling_metadata, no_penalties=True)
|
|
logits = sampler.apply_logits_processors(logits, sampling_for_processors, predict_bonus_token=False)
|
|
|
|
sampling_cfg = dict(self.config.llm.get("sampling", {}))
|
|
default_top_p = float(sampling_cfg.get("top_p", 0.8))
|
|
default_top_k = int(sampling_cfg.get("top_k", 25))
|
|
win_size = int(sampling_cfg.get("win_size", 10))
|
|
tau_r = float(sampling_cfg.get("tau_r", 0.1))
|
|
|
|
sampled_ids: list[int] = []
|
|
for req_idx in range(int(logits.shape[0])):
|
|
row_logits = logits[req_idx]
|
|
|
|
temperature = float(self._req_scalar(sampling_metadata.temperature, req_idx, 1.0))
|
|
if temperature < self._sampling_eps:
|
|
sampled_ids.append(int(torch.argmax(row_logits).item()))
|
|
continue
|
|
|
|
top_p = float(self._req_scalar(sampling_metadata.top_p, req_idx, default_top_p))
|
|
top_k = int(self._req_scalar(sampling_metadata.top_k, req_idx, default_top_k))
|
|
generator = sampling_metadata.generators.get(req_idx)
|
|
weighted_scores = torch.log_softmax(row_logits / max(temperature, self._sampling_eps), dim=0)
|
|
decoded_tokens = (
|
|
sampling_metadata.output_token_ids[req_idx] if req_idx < len(sampling_metadata.output_token_ids) else []
|
|
)
|
|
sampled_ids.append(
|
|
self._ras_sample_one(
|
|
weighted_scores,
|
|
decoded_tokens,
|
|
top_p=top_p,
|
|
top_k=top_k,
|
|
win_size=win_size,
|
|
tau_r=tau_r,
|
|
generator=generator,
|
|
)
|
|
)
|
|
|
|
sampled = torch.tensor(sampled_ids, device=logits.device, dtype=torch.int32)
|
|
return SamplerOutput(sampled_token_ids=sampled.unsqueeze(-1), logprobs_tensors=None)
|
|
|
|
def compute_logits(self, hidden_states: torch.Tensor | OmniOutput) -> torch.Tensor | None:
|
|
if isinstance(hidden_states, OmniOutput):
|
|
hidden_states = hidden_states.text_hidden_states
|
|
if self.model_stage == "cosyvoice3_talker":
|
|
logits = self.model.llm_decoder(hidden_states)
|
|
# The decoder outputs speech_token_size + 200 logits. The official
|
|
# CosyVoice3 treats ALL tokens >= speech_token_size (the last 200)
|
|
# as stop signals. Merge their probabilities into a single EOS
|
|
# token (6562) via logsumexp so that vLLM's stop_token_ids=[6562]
|
|
# fires with the correct aggregate stop probability.
|
|
speech_token_size = self.config.llm["speech_token_size"]
|
|
eos_idx = self.config.llm["eos_token_id"]
|
|
stop_logits = logits[..., speech_token_size:] # last 200
|
|
merged_stop = torch.logsumexp(stop_logits, dim=-1, keepdim=True)
|
|
logits[..., speech_token_size:] = float("-inf") # mask all
|
|
logits[..., eos_idx] = merged_stop.squeeze(-1) # restore merged
|
|
# Pad to full vocab_size for vLLM token handling.
|
|
vocab_size = self.config.vocab_size
|
|
pad_size = vocab_size - logits.size(-1)
|
|
if pad_size > 0:
|
|
pad_shape = logits.shape[:-1] + (pad_size,)
|
|
pad = logits.new_full(pad_shape, float("-inf"))
|
|
logits = torch.cat([logits, pad], dim=-1)
|
|
return logits
|
|
else:
|
|
raise RuntimeError(f"compute_logits is only valid for {self.model_stage}.")
|
|
|
|
def embed_multimodal(self, **kwargs: object) -> torch.Tensor:
|
|
if self.model_stage == "cosyvoice3_talker":
|
|
speech_token = kwargs["speech_token"]
|
|
speech_token_emb = self.model.speech_embedding(speech_token)
|
|
return speech_token_emb
|
|
else:
|
|
raise RuntimeError(f"embed_multimodal is only valid for {self.model_stage}.")
|
|
|
|
def embed_input_ids(
|
|
self,
|
|
input_ids: torch.Tensor,
|
|
multimodal_embeddings=None,
|
|
is_multimodal=None,
|
|
) -> torch.Tensor:
|
|
if self.model_stage == "cosyvoice3_talker":
|
|
if is_multimodal is not None and any(is_multimodal):
|
|
embed_tokens = self.model.llm.model.embed_tokens(input_ids)
|
|
sos = self.model.speech_embedding.weight[self.model.sos].reshape(1, -1)
|
|
task_id = self.model.speech_embedding.weight[self.model.task_id].reshape(1, -1)
|
|
prompt_speech_token_emb = multimodal_embeddings[0]
|
|
pstoken_len = prompt_speech_token_emb.shape[0] # Get length from tensor shape
|
|
embed_tokens = torch.cat(
|
|
[sos, embed_tokens[2 + pstoken_len :], task_id, prompt_speech_token_emb], dim=0
|
|
)
|
|
else:
|
|
embed_tokens = self.model.speech_embedding.weight[input_ids]
|
|
return embed_tokens
|
|
elif self.model_stage == "cosyvoice3_code2wav":
|
|
assert input_ids.dim() == 1
|
|
hidden = int(self.config.hidden_size)
|
|
return torch.zeros(
|
|
(input_ids.shape[0], hidden),
|
|
device=input_ids.device,
|
|
)
|
|
else:
|
|
raise RuntimeError(f"embed_input_ids is not valid for {self.model_stage}.")
|
|
|
|
def forward(
|
|
self,
|
|
input_ids: torch.Tensor,
|
|
positions: torch.Tensor,
|
|
intermediate_tensors: IntermediateTensors | None = None,
|
|
inputs_embeds: torch.Tensor | None = None,
|
|
additional_information: dict[str, object] | None = None,
|
|
**kwargs: object,
|
|
) -> OmniOutput:
|
|
if self.model_stage == "cosyvoice3_talker":
|
|
if inputs_embeds is None:
|
|
inputs_embeds = self.embed_input_ids(input_ids)
|
|
|
|
# [total_tokens, hidden]
|
|
hidden_states = self.model.llm(inputs_embeds, positions)
|
|
|
|
multimodal_outputs = {}
|
|
|
|
if "speech_token" in kwargs:
|
|
# Prompt conditioning tensors for code2wav: live under
|
|
# ``embed.*`` per OmniPayloadStruct schema.
|
|
multimodal_outputs = to_dict(
|
|
OmniPayloadStruct(
|
|
embed=EmbeddingsStruct(
|
|
speech_token=kwargs.get("speech_token"),
|
|
speech_feat=kwargs.get("speech_feat"),
|
|
embedding=kwargs.get("embedding"),
|
|
),
|
|
)
|
|
)
|
|
|
|
return OmniOutput(text_hidden_states=hidden_states, multimodal_outputs=multimodal_outputs)
|
|
elif self.model_stage == "cosyvoice3_code2wav":
|
|
runtime_info = kwargs.get("model_intermediate_buffer")
|
|
if runtime_info is None:
|
|
runtime_info = kwargs.get("runtime_additional_information", [])
|
|
if "runtime_additional_information" in kwargs and "model_intermediate_buffer" not in kwargs:
|
|
logger.warning_once("runtime_additional_information is deprecated, use model_intermediate_buffer")
|
|
|
|
seq_token_counts = kwargs.get("seq_token_counts")
|
|
flat_ids = input_ids.reshape(-1).to(dtype=torch.long)
|
|
request_ids_list = self._split_request_ids(flat_ids, seq_token_counts)
|
|
|
|
num_reqs = max(1, len(request_ids_list))
|
|
sample_rate = torch.tensor(int(self.config.sample_rate), dtype=torch.int32)
|
|
empty_audio = torch.zeros((0,), dtype=torch.float32, device=input_ids.device)
|
|
audios: list[torch.Tensor] = [empty_audio] * num_reqs
|
|
srs: list[torch.Tensor] = [sample_rate] * num_reqs
|
|
if not isinstance(runtime_info, list):
|
|
runtime_info = []
|
|
|
|
for idx, req_ids in enumerate(request_ids_list):
|
|
raw = runtime_info[idx] if idx < len(runtime_info) and isinstance(runtime_info[idx], dict) else {}
|
|
payload = to_struct(raw)
|
|
meta = payload.meta
|
|
embed = payload.embed
|
|
|
|
req_id = meta.req_id[0] if (meta and meta.req_id) else None
|
|
stream_finished = (
|
|
bool(meta.stream_finished.item()) if (meta and meta.stream_finished is not None) else False
|
|
)
|
|
speech_token = embed.speech_token if embed else None
|
|
speech_feat = embed.speech_feat if embed else None
|
|
embedding = embed.embedding if embed else None
|
|
if speech_token is None or speech_feat is None or embedding is None:
|
|
if stream_finished and req_id is not None and hasattr(self, "_stream_vocoder_cache_by_req"):
|
|
with self._stream_audio_cache_lock:
|
|
self._stream_vocoder_cache_by_req.pop(req_id, None)
|
|
audios[idx] = self._stitch_stream_audio(req_id, empty_audio, stream_finished)
|
|
if req_ids.numel() > 0 and (
|
|
(meta and meta.left_context_size is not None) or payload.generated_len is not None
|
|
):
|
|
info_keys = ",".join(
|
|
sorted(f for f in payload.__struct_fields__ if getattr(payload, f) is not None)
|
|
)
|
|
logger.warning_once(
|
|
"CosyVoice3 code2wav missing prompt conditioning for non-empty codec tokens: "
|
|
"raw_len=%d info_keys=%s",
|
|
int(req_ids.numel()),
|
|
info_keys,
|
|
)
|
|
continue
|
|
|
|
token = self._sanitize_codec_tokens(req_ids)
|
|
if token.numel() == 0:
|
|
audios[idx] = self._stitch_stream_audio(req_id, empty_audio, stream_finished)
|
|
if req_ids.numel() > 0:
|
|
logger.warning_once(
|
|
"CosyVoice3 code2wav received no valid codec tokens after filtering: "
|
|
"raw_len=%d raw_range=[%d,%d] vocab_size=%d",
|
|
req_ids.numel(),
|
|
int(req_ids.min().item()),
|
|
int(req_ids.max().item()),
|
|
int(self.code2wav.input_embedding.num_embeddings),
|
|
)
|
|
continue
|
|
|
|
# `generated_len` is injected for many models by the generic
|
|
# runner, so only explicit chunk-routing fields should switch
|
|
# code2wav into the streaming path.
|
|
uses_streaming_decode = meta and (
|
|
meta.stream_finished is not None or meta.left_context_size is not None
|
|
)
|
|
if uses_streaming_decode:
|
|
token_offset = max(0, meta.left_context_size or 0)
|
|
|
|
cache_state = None
|
|
if req_id is not None and hasattr(self, "_stream_vocoder_cache_by_req"):
|
|
with self._stream_audio_cache_lock:
|
|
cache_state = self._stream_vocoder_cache_by_req.get(req_id)
|
|
|
|
tts_speech, new_cache_state = self.code2wav.forward_streaming(
|
|
token=token.unsqueeze(0),
|
|
prompt_token=speech_token[:1],
|
|
prompt_feat=speech_feat[:1],
|
|
embedding=embedding[:1],
|
|
cache_state=cache_state,
|
|
n_timesteps=10,
|
|
token_offset_tokens=token_offset,
|
|
finalize=stream_finished,
|
|
)
|
|
|
|
if req_id is not None and hasattr(self, "_stream_vocoder_cache_by_req"):
|
|
with self._stream_audio_cache_lock:
|
|
if new_cache_state is None or stream_finished:
|
|
self._stream_vocoder_cache_by_req.pop(req_id, None)
|
|
else:
|
|
self._stream_vocoder_cache_by_req[req_id] = new_cache_state
|
|
else:
|
|
token_offset = max(0, meta.talker_prefill_offset or 0) if meta else 0
|
|
tts_speech = self.code2wav.forward(
|
|
token=token.unsqueeze(0),
|
|
prompt_token=speech_token[:1],
|
|
prompt_feat=speech_feat[:1],
|
|
embedding=embedding[:1],
|
|
n_timesteps=10,
|
|
token_offset_tokens=token_offset,
|
|
)
|
|
|
|
audio = tts_speech.reshape(-1).to(dtype=torch.float32)
|
|
|
|
audios[idx] = self._stitch_stream_audio(req_id, audio, stream_finished)
|
|
|
|
return OmniOutput(text_hidden_states=None, multimodal_outputs={"audio": audios, "sr": srs})
|
|
else:
|
|
raise ValueError(f"Unsupported model_stage: {self.model_stage}")
|
|
|
|
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
|
if self.model_stage == "cosyvoice3_talker":
|
|
# Load weights for text to speech LM stage using vLLM's weight loading
|
|
llm_weight_path = os.path.join(self.model_dir, "llm.pt")
|
|
device = next(self.parameters()).device
|
|
checkpoint = torch.load(llm_weight_path, map_location=device)
|
|
|
|
# 1. Load Qwen2 model weights into vLLM's Qwen2Model
|
|
# The checkpoint has prefix "llm.model.model." for the transformer weights
|
|
# vLLM's Qwen2Model expects just the model structure without extra prefixes
|
|
qwen_weights = []
|
|
for name, weight in checkpoint.items():
|
|
if name.startswith("llm.model.model."):
|
|
# Strip prefix: llm.model.model.X -> X (for vLLM's Qwen2Model)
|
|
vllm_name = name.replace("llm.model.model.", "")
|
|
qwen_weights.append((vllm_name, weight))
|
|
|
|
# Use vLLM's built-in load_weights which handles stacked params
|
|
# (q_proj+k_proj+v_proj -> qkv_proj, gate_proj+up_proj -> gate_up_proj)
|
|
self.model.llm.model.load_weights(iter(qwen_weights))
|
|
|
|
# 2. Load CosyVoice3LM-specific weights (speech_embedding, llm_decoder)
|
|
speech_emb_state = {
|
|
k.replace("speech_embedding.", ""): v
|
|
for k, v in checkpoint.items()
|
|
if k.startswith("speech_embedding.")
|
|
}
|
|
self.model.speech_embedding.load_state_dict(speech_emb_state)
|
|
|
|
llm_decoder_state = {
|
|
k.replace("llm_decoder.", ""): v for k, v in checkpoint.items() if k.startswith("llm_decoder.")
|
|
}
|
|
self.model.llm_decoder.load_state_dict(llm_decoder_state)
|
|
|
|
self.model.to(device).eval()
|
|
elif self.model_stage == "cosyvoice3_code2wav":
|
|
# Load weights for code2wav stage (flow + hift)
|
|
device = next(self.parameters()).device
|
|
self.code2wav.load_weights(self.model_dir, device)
|
|
else:
|
|
raise ValueError(f"{self.model_stage} not supported yet!")
|