Signed-off-by: Yueqian Lin <linyueqian@outlook.com> Signed-off-by: Sy0307 <24841857+Sy0307@users.noreply.github.com> Co-authored-by: Sy0307 <24841857+Sy0307@users.noreply.github.com>
434 lines
19 KiB
Python
434 lines
19 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""Stage-1 Code2Wav model for PersonaPlex (Moshi finetune).
|
|
|
|
This is the ``LLM_GENERATION`` codec stage of the 2-stage PersonaPlex pipeline.
|
|
It consumes the per-frame audio codebooks produced by the talker (stage 0) and
|
|
the depformer (built by the lead), and turns them into 24 kHz PCM by calling the
|
|
external Mimi neural codec from the ``moshi`` package.
|
|
|
|
Layout contract (mirrors ``Qwen3TTSCode2Wav``):
|
|
|
|
* Per request, ``input_ids`` holds a flat codebook-major codec sequence
|
|
``[k * F]`` where ``k`` is the number of *active* codebooks Mimi decodes
|
|
(``num_codebooks``, i.e. the ``cb 0..7`` slice) and ``F`` is the number of
|
|
codec frames. The talker emits a 17-row token stack per frame; the input
|
|
processor (built by the lead) keeps only rows ``1:9`` (the 8 PCM-bearing audio
|
|
codebooks) and flattens them codebook-major before this stage.
|
|
* ``forward(...)`` returns an :class:`OmniOutput` whose ``multimodal_outputs``
|
|
carries ``{"model_outputs": [wav_per_request], "sr": [sr_per_request]}`` — the
|
|
exact shape ``Qwen3TTSCode2Wav`` returns, so the downstream audio packer is
|
|
unchanged.
|
|
|
|
The Mimi decoder is the transformers ``MimiModel`` (kyutai/mimi weights), not
|
|
in vLLM's safetensors loader, so they are loaded eagerly in ``load_weights``
|
|
part of the vLLM weights iterator (the codec owns its own checkpoint; see the
|
|
experimental fullduplex package for the same pattern).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterable, Mapping
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import torch
|
|
import torch.nn as nn
|
|
from vllm.config import VllmConfig
|
|
from vllm.forward_context import get_forward_context, is_forward_context_available
|
|
from vllm.logger import init_logger
|
|
|
|
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
_MIMI_DECODE_BATCH_FRAMES = 5
|
|
|
|
|
|
def _codec_ids_from_payload_or_input(
|
|
input_ids: torch.Tensor,
|
|
runtime_info: Mapping[str, Any] | None,
|
|
) -> torch.Tensor:
|
|
"""Prefer connector-delivered codec ids over scheduler placeholders."""
|
|
if isinstance(runtime_info, Mapping):
|
|
codes = runtime_info.get("codes")
|
|
if isinstance(codes, Mapping):
|
|
audio = codes.get("audio")
|
|
if isinstance(audio, torch.Tensor) and audio.numel() > 0:
|
|
return audio.reshape(-1).to(device=input_ids.device, dtype=torch.long)
|
|
if isinstance(audio, (list, tuple)) and audio:
|
|
return torch.as_tensor(audio, device=input_ids.device, dtype=torch.long).reshape(-1)
|
|
return input_ids.reshape(-1).to(dtype=torch.long)
|
|
|
|
|
|
class PersonaPlexCode2Wav(nn.Module):
|
|
"""Stage-1 Code2Wav model for PersonaPlex (GenerationModelRunner).
|
|
|
|
Wraps the external Mimi decoder. The vLLM generation runner only calls a
|
|
handful of methods on this module: ``embed_input_ids`` (dummy embeddings),
|
|
``compute_logits`` (none -- this stage never samples), ``forward`` (the
|
|
actual codec->PCM decode), ``make_omni_output`` (output normalization), and
|
|
``load_weights`` (eager Mimi construction).
|
|
"""
|
|
|
|
input_modalities = "audio"
|
|
requires_request_ids = True
|
|
|
|
def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
|
|
super().__init__()
|
|
self.vllm_config = vllm_config
|
|
self.model_path = vllm_config.model_config.model
|
|
self.config = vllm_config.model_config.hf_config
|
|
|
|
# Runner-facing capability flags, matching Qwen3TTSCode2Wav so the
|
|
# GenerationModelRunner drives this stage the same way.
|
|
self.have_multimodal_outputs = True
|
|
self.has_preprocess = False
|
|
self.has_postprocess = False
|
|
self.enable_update_additional_information = True
|
|
self.requires_raw_input_tokens = True
|
|
|
|
mimi_cfg = getattr(self.config, "mimi_config", None)
|
|
# Number of *active* codebooks Mimi decodes to PCM (cb 0..7).
|
|
self._num_codebooks = int(getattr(mimi_cfg, "num_codebooks", 8))
|
|
self._output_sample_rate = int(getattr(mimi_cfg, "sample_rate", 24000))
|
|
self._samples_per_frame = int(getattr(mimi_cfg, "samples_per_frame", 1920))
|
|
self._mimi_name = getattr(mimi_cfg, "mimi_name", None) or getattr(self.config, "mimi_name", None)
|
|
self._max_codec_sessions = int(getattr(vllm_config.model_config, "duplex_max_sessions", 1))
|
|
|
|
# The Mimi module is constructed in load_weights() (it owns its own
|
|
# weight format) and assigned here so vLLM's memory profiler can see it.
|
|
self.mimi: nn.Module | None = None
|
|
self._additional_mimi = nn.ModuleList()
|
|
self._mimi_device: torch.device | None = None
|
|
self._request_codes: dict[str, torch.Tensor] = {}
|
|
self._request_codec_slots: dict[str, int] = {}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Runner-facing no-op / placeholder hooks (mirror Qwen3TTSCode2Wav).
|
|
# ------------------------------------------------------------------
|
|
def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor:
|
|
# This stage ignores token embeddings; keep a stable dummy embedding so
|
|
# the vLLM runner has a valid hidden-state placeholder.
|
|
if input_ids.numel() == 0:
|
|
return torch.empty((0, 1), device=input_ids.device, dtype=torch.float32)
|
|
return torch.zeros((input_ids.shape[0], 1), device=input_ids.device, dtype=torch.float32)
|
|
|
|
def compute_logits(
|
|
self,
|
|
hidden_states: torch.Tensor | OmniOutput,
|
|
sampling_metadata: Any = None,
|
|
) -> None:
|
|
# Code2Wav never samples; the runner short-circuits when logits are None.
|
|
return None
|
|
|
|
def get_dummy_runtime_additional_information(self, num_reqs: int) -> list[dict[str, object]]:
|
|
return [{"meta": {"personaplex_dummy_profile": True}} for _ in range(num_reqs)]
|
|
|
|
def _split_request_ids(
|
|
self,
|
|
ids: torch.Tensor,
|
|
seq_token_counts: list[int] | None = None,
|
|
) -> list[torch.Tensor]:
|
|
"""Split concatenated ``input_ids`` into per-request segments.
|
|
|
|
Uses ``seq_token_counts`` (injected by the runner via model_kwargs) when
|
|
available, falling back to forward-context ``ubatch_slices`` when
|
|
micro-batching is active. Returns ``[ids]`` for single-request batches.
|
|
Mirrors ``Qwen3TTSCode2Wav._split_request_ids``.
|
|
"""
|
|
if seq_token_counts is not None and len(seq_token_counts) > 1:
|
|
boundaries = [0]
|
|
for count in seq_token_counts:
|
|
boundaries.append(boundaries[-1] + count)
|
|
n = ids.numel()
|
|
return [ids[boundaries[i] : min(boundaries[i + 1], n)] 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] + s)
|
|
return [ids[boundaries[i] : boundaries[i + 1]] for i in range(len(boundaries) - 1)]
|
|
return [ids]
|
|
|
|
# ------------------------------------------------------------------
|
|
# Decode.
|
|
# ------------------------------------------------------------------
|
|
@torch.no_grad()
|
|
def forward(
|
|
self,
|
|
input_ids: torch.Tensor | None = None,
|
|
positions: torch.Tensor | None = None,
|
|
intermediate_tensors: Any = None,
|
|
inputs_embeds: torch.Tensor | None = None,
|
|
runtime_additional_information: list[dict[str, Any]] | None = None,
|
|
**kwargs: Any,
|
|
) -> OmniOutput:
|
|
"""Decode flat codebook-major codec ids into PCM via Mimi.
|
|
|
|
``input_ids`` per request is ``[k * F]`` (codebook-major), where ``k``
|
|
is ``self._num_codebooks``. The connector may send either a new delta
|
|
chunk or the cumulative prefix of a resumable request. Request-local
|
|
code history identifies the new suffix, and the streaming Mimi decoder
|
|
consumes each new frame exactly once.
|
|
"""
|
|
sr_val = int(self._output_sample_rate)
|
|
sr_tensor = torch.tensor(sr_val, dtype=torch.int32)
|
|
empty = torch.zeros((0,), dtype=torch.float32)
|
|
|
|
if input_ids is None or input_ids.numel() == 0:
|
|
return OmniOutput(
|
|
text_hidden_states=None,
|
|
multimodal_outputs={"model_outputs": [empty], "sr": [sr_tensor]},
|
|
)
|
|
|
|
ids = input_ids.reshape(-1).to(dtype=torch.long)
|
|
request_ids_list = self._split_request_ids(ids, kwargs.get("seq_token_counts"))
|
|
num_req = len(request_ids_list)
|
|
state_ids = self._resolve_request_ids(
|
|
num_req,
|
|
kwargs.get("request_ids"),
|
|
runtime_additional_information,
|
|
)
|
|
|
|
if self.mimi is None:
|
|
raise RuntimeError("PersonaPlexCode2Wav.forward called before Mimi was loaded in load_weights().")
|
|
|
|
k = int(self._num_codebooks)
|
|
device = self._mimi_device or ids.device
|
|
audios: list[torch.Tensor] = [empty] * num_req
|
|
srs = [sr_tensor] * num_req
|
|
|
|
for i, req_ids in enumerate(request_ids_list):
|
|
runtime_info = (
|
|
runtime_additional_information[i]
|
|
if runtime_additional_information is not None and i < len(runtime_additional_information)
|
|
else None
|
|
)
|
|
meta = runtime_info.get("meta") if isinstance(runtime_info, Mapping) else None
|
|
if isinstance(meta, Mapping) and meta.get("personaplex_dummy_profile") is True:
|
|
continue
|
|
flat = _codec_ids_from_payload_or_input(req_ids, runtime_info)
|
|
n = int(flat.numel())
|
|
if n == 0 or n % k != 0:
|
|
if n > 0:
|
|
logger.warning(
|
|
"PersonaPlex Code2Wav input_ids length %d not divisible by "
|
|
"num_codebooks %d; skipping malformed request.",
|
|
n,
|
|
k,
|
|
)
|
|
continue
|
|
frames = n // k
|
|
codes_kf = flat.reshape(k, frames)
|
|
state_id = state_ids[i]
|
|
delta_kf = self._new_code_suffix(state_id, codes_kf)
|
|
if delta_kf.shape[1] == 0:
|
|
continue
|
|
wav = self._decode_streaming_frames(state_id, delta_kf.to(device=device))
|
|
if wav.numel() > 0:
|
|
audios[i] = wav.to(dtype=torch.float32).reshape(-1)
|
|
|
|
return OmniOutput(
|
|
text_hidden_states=None,
|
|
multimodal_outputs={"model_outputs": audios, "sr": srs},
|
|
)
|
|
|
|
@staticmethod
|
|
def _resolve_request_ids(
|
|
count: int,
|
|
request_ids: object,
|
|
runtime_additional_information: list[dict[str, Any]] | None,
|
|
) -> list[str | None]:
|
|
if isinstance(request_ids, list | tuple):
|
|
if len(request_ids) != count:
|
|
raise ValueError(
|
|
"PersonaPlex Code2Wav request id count does not match inputs: "
|
|
f"request_ids={len(request_ids)}, inputs={count}"
|
|
)
|
|
return [str(request_id) for request_id in request_ids]
|
|
|
|
infos = runtime_additional_information or []
|
|
resolved: list[str | None] = []
|
|
for index in range(count):
|
|
info = infos[index] if index < len(infos) else None
|
|
request_id = None
|
|
if isinstance(info, Mapping):
|
|
request_id = info.get("request_id")
|
|
if request_id is None:
|
|
meta = info.get("meta")
|
|
if isinstance(meta, Mapping):
|
|
request_id = meta.get("request_id")
|
|
resolved.append(str(request_id) if request_id is not None else None)
|
|
return resolved
|
|
|
|
def _new_code_suffix(self, request_id: str | None, codes_kf: torch.Tensor) -> torch.Tensor:
|
|
if request_id is None:
|
|
return codes_kf
|
|
|
|
incoming = codes_kf.detach().to(device="cpu", dtype=torch.long)
|
|
previous = self._request_codes.get(request_id)
|
|
if (
|
|
previous is not None
|
|
and incoming.shape[1] >= previous.shape[1]
|
|
and torch.equal(incoming[:, : previous.shape[1]], previous)
|
|
):
|
|
delta = incoming[:, previous.shape[1] :]
|
|
self._request_codes[request_id] = incoming
|
|
return delta
|
|
|
|
self._request_codes[request_id] = incoming if previous is None else torch.cat([previous, incoming], dim=1)
|
|
return incoming
|
|
|
|
def _decode_streaming_frames(self, request_id: str | None, codes_kf: torch.Tensor) -> torch.Tensor:
|
|
codec, ephemeral = self._codec_for_request(request_id)
|
|
if not hasattr(codec, "decode_frame"):
|
|
raise RuntimeError("PersonaPlex Code2Wav requires a streaming Mimi decoder")
|
|
|
|
decode_frames = getattr(codec, "decode_frames", None)
|
|
chunks: list[torch.Tensor] = []
|
|
for start in range(0, codes_kf.shape[1], _MIMI_DECODE_BATCH_FRAMES):
|
|
frame_batch = codes_kf[:, start : start + _MIMI_DECODE_BATCH_FRAMES]
|
|
if frame_batch.shape[1] > 1 and callable(decode_frames):
|
|
chunks.append(self._flatten_wav(decode_frames(frame_batch.unsqueeze(0))))
|
|
else:
|
|
chunks.extend(
|
|
self._flatten_wav(codec.decode_frame(frame_batch[:, frame].unsqueeze(0)))
|
|
for frame in range(frame_batch.shape[1])
|
|
)
|
|
wav = torch.cat(chunks, dim=0) if chunks else codes_kf.new_empty(0, dtype=torch.float32)
|
|
if ephemeral:
|
|
codec.reset_streaming()
|
|
return wav
|
|
|
|
def _set_mimi_codecs(self, codecs: list[nn.Module]) -> None:
|
|
if not codecs:
|
|
raise ValueError("PersonaPlex Code2Wav requires at least one Mimi decoder")
|
|
self.mimi = codecs[0]
|
|
self._additional_mimi = nn.ModuleList(codecs[1:])
|
|
self._request_codec_slots.clear()
|
|
|
|
def _mimi_codecs(self) -> list[nn.Module]:
|
|
if self.mimi is None:
|
|
return []
|
|
return [self.mimi, *self._additional_mimi]
|
|
|
|
def _codec_for_request(self, request_id: str | None) -> tuple[nn.Module, bool]:
|
|
codecs = self._mimi_codecs()
|
|
if not codecs:
|
|
raise RuntimeError("PersonaPlexCode2Wav.forward called before Mimi was loaded in load_weights().")
|
|
occupied = set(self._request_codec_slots.values())
|
|
if request_id is not None:
|
|
slot = self._request_codec_slots.get(request_id)
|
|
if slot is None:
|
|
slot = next((index for index in range(len(codecs)) if index not in occupied), None)
|
|
if slot is None:
|
|
raise RuntimeError(f"PersonaPlex Code2Wav decoder capacity {len(codecs)} is exhausted")
|
|
codecs[slot].streaming_init(1)
|
|
self._request_codec_slots[request_id] = slot
|
|
return codecs[slot], False
|
|
|
|
slot = next((index for index in range(len(codecs)) if index not in occupied), None)
|
|
if slot is None:
|
|
raise RuntimeError(f"PersonaPlex Code2Wav decoder capacity {len(codecs)} is exhausted")
|
|
codecs[slot].streaming_init(1)
|
|
return codecs[slot], True
|
|
|
|
def on_requests_finished(self, finished_req_ids: set[str] | list[str]) -> None:
|
|
for request_id in finished_req_ids:
|
|
state_id = str(request_id)
|
|
self._request_codes.pop(state_id, None)
|
|
slot = self._request_codec_slots.pop(state_id, None)
|
|
codecs = self._mimi_codecs()
|
|
if slot is not None and slot < len(codecs):
|
|
codecs[slot].reset_streaming()
|
|
|
|
@staticmethod
|
|
def _flatten_wav(wav: torch.Tensor) -> torch.Tensor:
|
|
"""Normalize Mimi's decode output to a 1D PCM tensor.
|
|
|
|
Mimi may return ``[B, samples]`` or ``[B, C, samples]`` depending on the
|
|
moshi version; PersonaPlex is mono (``C == 1``), so collapse to 1D.
|
|
"""
|
|
if wav.dim() == 3:
|
|
# [B, C, T] -> take batch 0, channel 0.
|
|
return wav[0, 0]
|
|
if wav.dim() == 2:
|
|
# [B, T] -> batch 0.
|
|
return wav[0]
|
|
return wav.reshape(-1)
|
|
|
|
def make_omni_output(self, model_outputs: torch.Tensor | OmniOutput | tuple, **kwargs: Any) -> OmniOutput:
|
|
if isinstance(model_outputs, OmniOutput):
|
|
return model_outputs
|
|
|
|
if isinstance(model_outputs, tuple) and len(model_outputs) == len(OmniOutput._fields):
|
|
return OmniOutput(*model_outputs)
|
|
|
|
if not (isinstance(model_outputs, tuple) and len(model_outputs) == 2):
|
|
raise TypeError(
|
|
"PersonaPlexCode2Wav expected OmniOutput, OmniOutput tuple, "
|
|
f"or (audio_tensor, sr) outputs, got {type(model_outputs)}"
|
|
)
|
|
|
|
audio_tensor, sr = model_outputs
|
|
return OmniOutput(
|
|
text_hidden_states=None,
|
|
multimodal_outputs={
|
|
"model_outputs": audio_tensor,
|
|
"sr": sr,
|
|
},
|
|
)
|
|
|
|
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
|
"""Construct and load the external Mimi decoder.
|
|
|
|
The primary vLLM weights iterator carries no Code2Wav parameters (Mimi
|
|
owns its own checkpoint format), so it is drained and the Mimi module is
|
|
built from the moshi package's loader.
|
|
"""
|
|
# Drain the primary iterator so callers don't hang on an unconsumed
|
|
# generator; none of these weights belong to this stage.
|
|
for _ in weights:
|
|
pass
|
|
|
|
device = self.vllm_config.device_config.device
|
|
from vllm_omni.model_executor.models.personaplex.personaplex_mimi import (
|
|
PersonaPlexMimiCodec,
|
|
)
|
|
|
|
checkpoint = Path(self.model_path) / (self._mimi_name or "tokenizer-e351c8d8-checkpoint125.safetensors")
|
|
codecs = [
|
|
PersonaPlexMimiCodec(
|
|
checkpoint=str(checkpoint) if checkpoint.is_file() else None,
|
|
device=str(device),
|
|
).eval()
|
|
for _ in range(self._max_codec_sessions)
|
|
]
|
|
self._set_mimi_codecs(codecs)
|
|
self._mimi_device = torch.device(str(device))
|
|
reported_sr = getattr(codecs[0].model.config, "sampling_rate", None)
|
|
if reported_sr is not None:
|
|
self._output_sample_rate = int(reported_sr)
|
|
bytes_per_mib = 1024**2
|
|
per_slot_weight_mib = (
|
|
sum(parameter.numel() * parameter.element_size() for parameter in codecs[0].parameters()) / bytes_per_mib
|
|
)
|
|
total_weight_mib = (
|
|
sum(parameter.numel() * parameter.element_size() for codec in codecs for parameter in codec.parameters())
|
|
/ bytes_per_mib
|
|
)
|
|
logger.info(
|
|
"PersonaPlex Code2Wav loaded %d Mimi stream(s): "
|
|
"per_slot_weight_mib=%.2f total_weight_mib=%.2f "
|
|
"num_codebooks=%d sample_rate=%d samples_per_frame=%d",
|
|
len(codecs),
|
|
per_slot_weight_mib,
|
|
total_weight_mib,
|
|
self._num_codebooks,
|
|
self._output_sample_rate,
|
|
self._samples_per_frame,
|
|
)
|
|
return {name for name, _ in self.named_parameters() if name.startswith(("mimi.", "_additional_mimi."))}
|