Files
vllm-omni/vllm_omni/model_executor/models/gepard/nanocodec.py
T
2026-08-14 22:06:05 -07:00

173 lines
7.3 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""NeMo NanoCodec wrapper for Gepard — per-dimension FSQ decode.
Gepard's 32 FSQ heads emit per-dimension codes (8 groups x 4 dims), while stock
NeMo decode wants composed per-group mixed-radix indices. ``decode_from_codes``
dequantizes per-dim instead and hands the result to stock ``decode_audio`` — a
few lines on public NeMo API, no fork.
NeMo is a heavy optional dependency, imported lazily inside ``load``. Decode
only; ``encode_to_dequantized`` belongs to the voice-cloning follow-up.
"""
from __future__ import annotations
import os
import torch
import torch.nn as nn
from vllm.logger import init_logger
logger = init_logger(__name__)
# Left-context frames prepended to every streaming decode window. NanoCodec is
# convolutional, so decoding a bare chunk boundary yields a discontinuity; the
# extra frames are decoded for receptive-field context and trimmed afterwards.
# Mirrors the reference server's TTS_LOOKBACK_FRAMES.
LOOKBACK_FRAMES = int(os.environ.get("VLLM_GEPARD_LOOKBACK_FRAMES", "8"))
# End-of-clip shaping. The codec leaves the waveform on a non-zero sample and
# the hard step to silence is an audible click; a linear fade ramps it out.
# Both default to 0.0 (off), matching the reference server's defaults.
END_OF_SPEECH_FADE_MS = float(os.environ.get("VLLM_GEPARD_END_FADE_MS", "0.0"))
END_OF_SPEECH_SILENCE_MS = float(os.environ.get("VLLM_GEPARD_END_SILENCE_MS", "0.0"))
def apply_end_of_speech_tail(
audio: torch.Tensor,
sample_rate: int,
fade_ms: float = END_OF_SPEECH_FADE_MS,
silence_ms: float = END_OF_SPEECH_SILENCE_MS,
) -> torch.Tensor:
"""Fade the final ``fade_ms`` to zero, then append ``silence_ms`` of pad.
The fade is what removes the end-of-clip click; padding alone does not.
"""
if audio.numel() == 0 or (fade_ms <= 0 and silence_ms <= 0):
return audio
out = audio
if fade_ms > 0:
n = min(out.shape[0], int(sample_rate * fade_ms / 1000.0))
if n > 0:
# Copy: the caller's tensor is still referenced by the audio queue.
out = out.clone()
out[-n:] *= torch.linspace(1.0, 0.0, n, dtype=out.dtype, device=out.device)
if silence_ms > 0:
pad = torch.zeros(int(sample_rate * silence_ms / 1000.0), dtype=out.dtype, device=out.device)
out = torch.cat([out, pad], dim=0)
return out
def _make_unfolded_codec_cls():
"""Build the ``UnfoldedCodecModel`` subclass. Imports NeMo (optional dep).
Defined lazily so importing this module never requires NeMo. The subclass
is OUR code layered on stock ``AudioCodecModel`` — not a NeMo fork.
"""
try:
from nemo.collections.tts.models import AudioCodecModel # noqa: PLC0415
from omegaconf import open_dict # noqa: PLC0415
except ImportError as e: # pragma: no cover - env-dependent
raise ImportError(
"Gepard TTS needs NeMo for the NanoCodec decoder. Install it with "
"`pip install nemo_toolkit[tts]` (heavy; NVIDIA Open Model License, "
"commercial use OK), or gate the model behind that extra."
) from e
class UnfoldedCodecModel(AudioCodecModel):
"""AudioCodecModel + direct decode from per-dimension FSQ codes."""
def __init__(self, cfg, trainer=None):
# SLMDiscriminator pulls microsoft/wavlm-base-plus (~360 MB) and is
# training-only — strip it from the config before init.
with open_dict(cfg):
disc = cfg.get("discriminator", None)
if disc is not None and "discriminators" in disc:
disc.discriminators = [d for d in disc.discriminators if "SLM" not in d._target_]
super().__init__(cfg, trainer)
def decode_from_codes(self, codes: torch.Tensor, codes_len: torch.Tensor):
"""Decode per-dimension discrete FSQ codes to a waveform.
codes: (B, D, T) per-dim discrete values, D = num_groups * dims.
codes_len: (B,) valid frame count per batch element.
Returns (audio (B, T_audio), audio_len (B,)).
"""
num_levels = self.vector_quantizer.fsqs[0].num_levels.squeeze()
scale = (num_levels // 2).float().to(codes.device) # e.g. [4,3,3,3]
groups = codes.chunk(self.vector_quantizer.num_groups, dim=1)
dequantized = torch.cat(
[(g - scale[None, :, None]) / scale[None, :, None] for g in groups],
dim=1,
)
return self.decode_audio(inputs=dequantized, input_len=codes_len)
return UnfoldedCodecModel
class NanoCodec(nn.Module):
"""Thin wrapper owning the (optional) NeMo NanoCodec decoder.
Constructing this is cheap and NeMo-free; ``load`` does the heavy import +
weight load so ``load_format=dummy`` and NeMo-less environments don't pay
for it at model construction.
"""
def __init__(self, codec_id: str, sample_rate: int) -> None:
super().__init__()
self.codec_id = codec_id
self.sample_rate = sample_rate
self._codec: nn.Module | None = None
@property
def is_loaded(self) -> bool:
return self._codec is not None
def load(self, device: torch.device) -> None:
"""Import NeMo, build the UnfoldedCodecModel, move to device, eval()."""
cls = _make_unfolded_codec_cls()
codec = cls.from_pretrained(self.codec_id)
codec.to(device=device)
codec.eval()
self._codec = codec
logger.info("Gepard NanoCodec loaded from %s on %s", self.codec_id, device)
@torch.inference_mode()
def decode_from_codes(self, codes: torch.Tensor, codes_len: torch.Tensor) -> torch.Tensor:
"""(B, 32, T) per-dim codes -> waveform (B, T_audio). Returns audio only."""
if self._codec is None:
raise RuntimeError("NanoCodec.decode_from_codes called before load()")
audio, _audio_len = self._codec.decode_from_codes(codes, codes_len)
return audio
@torch.inference_mode()
def decode_stream(
self,
frames: torch.Tensor,
*,
start_idx: int,
lookback: int = LOOKBACK_FRAMES,
is_final: bool = False,
) -> torch.Tensor | None:
"""Decode the tail of a ``(T, 32)`` code history past ``start_idx``.
The window starts ``lookback`` frames earlier for receptive-field
context and those samples are trimmed after, so successive calls
concatenate to a single whole-history decode. None if nothing is new.
"""
count = int(frames.shape[0])
if count <= start_idx:
return None
decode_start = max(0, start_idx - lookback)
window = frames[decode_start:count] # (T_win, 32)
codes = window.t().unsqueeze(0).contiguous().long() # (1, 32, T_win)
codes_len = torch.full((1,), codes.shape[-1], dtype=torch.long, device=codes.device)
audio = self.decode_from_codes(codes, codes_len).reshape(-1)
# Derive samples_per_frame from this decode rather than assuming it.
samples_per_frame = audio.shape[0] // (count - decode_start)
audio = audio[(start_idx - decode_start) * samples_per_frame :]
if is_final:
audio = apply_end_of_speech_tail(audio, self.sample_rate)
return audio