Signed-off-by: Mengjie Zhao <zmj0129@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
173 lines
7.3 KiB
Python
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
|