Audio-to-Audio
Transformers
Moshi
full-duplex
speech-to-speech
spoken-dialogue
tokenizer
multilingual
personaplex
qwen3
Instructions to use RetentionLabs/PolyPlex-11B-CJKE with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use RetentionLabs/PolyPlex-11B-CJKE with Transformers:
# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("RetentionLabs/PolyPlex-11B-CJKE", device_map="auto") - Moshi
How to use RetentionLabs/PolyPlex-11B-CJKE with Moshi:
# pip install moshi # Run the interactive web server python -m moshi.server --hf-repo "RetentionLabs/PolyPlex-11B-CJKE" # Then open https://localhost:8998 in your browser
# pip install moshi import torch from moshi.models import loaders # Load checkpoint info from HuggingFace checkpoint = loaders.CheckpointInfo.from_hf_repo("RetentionLabs/PolyPlex-11B-CJKE") # Load the Mimi audio codec mimi = checkpoint.get_mimi(device="cuda") mimi.set_num_codebooks(8) # Encode audio (24kHz, mono) wav = torch.randn(1, 1, 24000 * 10) # [batch, channels, samples] with torch.no_grad(): codes = mimi.encode(wav.cuda()) decoded = mimi.decode(codes) - Notebooks
- Google Colab
- Kaggle
File size: 15,094 Bytes
fd5b6ce | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 | # Copyright 2026 The LatentForge Team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Processor class for PolyPlex."""
import numpy as np
import torch
from transformers.feature_extraction_utils import BatchFeature
from transformers.processing_utils import ProcessingKwargs, ProcessorMixin
# The persona prefix the PersonaPlex checkpoints were trained on. Every stage is one frame per column, and the three
# streams stay frame-aligned throughout:
#
# stage | text | assistant stream | user stream
# ---------------|-----------------|---------------------|-------------
# voice prompt | padding | the voice sample | sine
# silence | padding | silence | sine
# text prompt | one token/frame | silence | sine
# silence | padding | silence | sine
#
# Both fillers are single frames of the reference implementation repeated for as long as the stage lasts. Mimi is a
# streaming codec, so encoding a tone or silence again does not reproduce them frame for frame.
SILENCE_TOKENS = [948, 243, 1178, 546, 1736, 1030, 1978, 2008]
SINE_TOKENS = [430, 1268, 381, 1611, 1095, 1495, 56, 472]
SILENCE_DURATION = 0.5
VOICE_PROMPT_LUFS = -24.0
class PolyplexProcessorKwargs(ProcessingKwargs, total=False):
_defaults = {
"audio_kwargs": {"sampling_rate": 24000},
"common_kwargs": {"return_tensors": "pt"},
}
class PolyplexProcessor(ProcessorMixin):
r"""
Constructs a PolyPlex processor, which builds the frame-aligned inputs of a full-duplex speech model: one text
stream and two Mimi audio streams (the user's and the assistant's), 12.5 frames per second.
Text is cut by the PolyPlex tokenizer. Audio goes through Mimi's feature extractor and is encoded into
`num_codebooks` codes per frame. A stream the caller leaves out is filled with encoded silence, and the text
stream is padded with `PAD` to one token per audio frame. A persona prefix (a voice sample and a `<system>`
instruction) can be laid in front of the inputs, as PersonaPlex was trained.
Args:
feature_extractor (`EncodecFeatureExtractor`, *optional*):
Mimi's feature extractor. Loaded from `kyutai/mimi` when omitted.
tokenizer (`PreTrainedTokenizerFast`):
The PolyPlex text tokenizer.
codec (`MimiModel`, *optional*):
The Mimi codec. Loaded from `codec_name_or_path` when omitted.
codec_name_or_path (`str`, *optional*, defaults to `"kyutai/mimi"`):
Where the codec and its feature extractor are loaded from. The processor config records this rather
than the codec weights.
num_codebooks (`int`, *optional*, defaults to 8):
Codebooks each audio stream uses.
silence_codes (`list[int]`, *optional*):
One frame of assistant-stream silence in the persona prefix. Defaults to the reference constants.
sine_codes (`list[int]`, *optional*):
One frame of the user-stream tone in the persona prefix. Defaults to the reference constants.
"""
feature_extractor_class = "EncodecFeatureExtractor"
tokenizer_class = "AutoTokenizer"
valid_processor_kwargs = PolyplexProcessorKwargs
def __init__(
self,
feature_extractor=None,
tokenizer=None,
codec=None,
codec_name_or_path: str = "kyutai/mimi",
num_codebooks: int = 8,
silence_codes: list[int] | None = None,
sine_codes: list[int] | None = None,
):
if tokenizer is None:
raise ValueError("`tokenizer` is required: it is the checkpoint's own text tokenizer.")
if feature_extractor is None:
from transformers import AutoFeatureExtractor
feature_extractor = AutoFeatureExtractor.from_pretrained(codec_name_or_path)
if codec is None:
from transformers import MimiModel
codec = MimiModel.from_pretrained(codec_name_or_path)
# Upstream `ProcessorMixin` takes only registered audio tokenizers as components, so the codec is a plain
# attribute and the config records where it comes from.
self.audio_codec = codec
self.codec_name_or_path = codec_name_or_path
self.num_codebooks = num_codebooks
self.silence_codes = list(SILENCE_TOKENS if silence_codes is None else silence_codes)
self.sine_codes = list(SINE_TOKENS if sine_codes is None else sine_codes)
for name, codes in (("silence_codes", self.silence_codes), ("sine_codes", self.sine_codes)):
if len(codes) != num_codebooks:
raise ValueError(f"`{name}` has {len(codes)} codes but each frame holds {num_codebooks}.")
super().__init__(feature_extractor, tokenizer)
@property
def audio_tokenizer(self):
r"""
Returns:
`MimiModel`: The codec.
"""
return self.audio_codec
def __call__(
self,
text=None,
audio=None,
assistant_audio=None,
voice_prompt=None,
text_prompt: str | None = None,
**kwargs,
) -> BatchFeature:
r"""
Args:
text (`str` or `list[str]`, *optional*):
Text of the assistant stream, cut by the tokenizer and padded to the audio frames.
audio (`AudioInput`, *optional*):
The user's speech, encoded into `user_audio_codes`.
assistant_audio (`AudioInput`, *optional*):
The assistant's own speech, encoded into `assistant_audio_codes`.
voice_prompt (`AudioInput`, *optional*):
A sample of the voice to speak in. Loudness-normalized (needs `pyloudnorm`) and placed on the
assistant stream of the persona prefix.
text_prompt (`str`, *optional*):
The persona instruction. Wrapped in `<system>` tags when it is not already.
Returns:
[`BatchFeature`]: `input_ids` and `attention_mask` for the text stream, and `user_audio_codes` and
`assistant_audio_codes` of shape `(batch_size, num_codebooks, frames)` when audio is involved.
Raises:
ValueError: If nothing is passed, or if the text is longer than the audio frames.
"""
if text is None and audio is None and assistant_audio is None and voice_prompt is None and text_prompt is None:
raise ValueError("Pass at least one of `text`, `audio`, `assistant_audio`, `voice_prompt` or `text_prompt`.")
merged = self._merge_kwargs(PolyplexProcessorKwargs, tokenizer_init_kwargs=self.tokenizer.init_kwargs, **kwargs)
return_tensors = merged["text_kwargs"].get("return_tensors")
data = self._streams(text, audio, assistant_audio, merged) if (
text is not None or audio is not None or assistant_audio is not None
) else {}
if voice_prompt is not None or text_prompt is not None:
prefix = self._persona_prefix(voice_prompt, text_prompt, merged)
data = {
key: torch.cat([value, torch.as_tensor(data[key]).to(value.device)], dim=-1) if key in data else value
for key, value in prefix.items()
}
frames = max(codes.shape[-1] for key, codes in data.items() if key.endswith("_audio_codes"))
data["input_ids"], data["attention_mask"] = self._pad_text_to_frames(
data["input_ids"], data.get("attention_mask"), frames
)
return BatchFeature(data=data, tensor_type=return_tensors)
def _streams(self, text, audio, assistant_audio, merged) -> dict:
data = {}
if text is not None:
data.update(self.tokenizer(text, **merged["text_kwargs"]))
for prefix, stream in (("user", audio), ("assistant", assistant_audio)):
if stream is None:
continue
values = self.feature_extractor(stream, **merged["audio_kwargs"])["input_values"]
values = torch.as_tensor(np.asarray(values), dtype=torch.float32).to(self.audio_tokenizer.device)
data[f"{prefix}_audio_codes"] = self._encode(values)
streams = [codes for key, codes in data.items() if key.endswith("_audio_codes")]
if streams:
# The model always listens to both streams; a stream left out was silent, and silence is ordinary audio
# to Mimi, so it is encoded rather than filled with a reserved id.
frames, batch_size = max(codes.shape[-1] for codes in streams), streams[0].shape[0]
for key in ("user_audio_codes", "assistant_audio_codes"):
if key not in data:
data[key] = self.get_silence_audio_codes(frames, batch_size=batch_size)
if "input_ids" in data:
data["input_ids"], data["attention_mask"] = self._pad_text_to_frames(
data["input_ids"], data.get("attention_mask"), frames
)
return data
def _pad_text_to_frames(self, input_ids, attention_mask, frames: int):
input_ids = torch.as_tensor(input_ids)
input_ids = input_ids.unsqueeze(0) if input_ids.ndim == 1 else input_ids
if attention_mask is not None:
attention_mask = torch.as_tensor(attention_mask)
attention_mask = attention_mask.unsqueeze(0) if attention_mask.ndim == 1 else attention_mask
if input_ids.shape[-1] > frames:
raise ValueError(
f"The text stream holds {input_ids.shape[-1]} tokens for {frames} audio frames; the model reads one "
"text token per frame."
)
if input_ids.shape[-1] < frames:
padding = input_ids.new_full((input_ids.shape[0], frames - input_ids.shape[-1]), self.tokenizer.pad_token_id)
input_ids = torch.cat([input_ids, padding], dim=-1)
# Padding frames are predicted like any other frame, so they stay attended.
attention_mask = None if attention_mask is None else torch.cat([attention_mask, torch.ones_like(padding)], -1)
return input_ids, attention_mask
def _persona_prefix(self, voice_prompt, text_prompt, merged) -> dict:
config = self.audio_tokenizer.config
frame_size = int(config.sampling_rate / config.frame_rate)
silence_frames = int(SILENCE_DURATION * config.frame_rate)
if voice_prompt is not None:
waveform = self._normalize_voice(voice_prompt, config.sampling_rate, merged)
voice_frames = int(np.ceil(waveform.shape[-1] / frame_size))
waveform = np.pad(waveform, (0, voice_frames * frame_size - waveform.shape[-1]))
else:
voice_frames = 0
prompt_ids = self._encode_text_prompt(text_prompt)
total = voice_frames + silence_frames + prompt_ids.shape[-1] + silence_frames
assistant = self._repeat_frame(self.silence_codes, total)
if voice_frames:
values = torch.as_tensor(waveform, dtype=torch.float32, device=self.audio_tokenizer.device)
assistant[:, :, :voice_frames] = self._encode(values.reshape(1, 1, -1))
input_ids = prompt_ids.new_full((1, total), self.tokenizer.pad_token_id)
start = voice_frames + silence_frames
input_ids[:, start : start + prompt_ids.shape[-1]] = prompt_ids
return {
"input_ids": input_ids,
"attention_mask": torch.ones_like(input_ids),
"user_audio_codes": self._repeat_frame(self.sine_codes, total),
"assistant_audio_codes": assistant,
}
def _encode_text_prompt(self, text_prompt: str | None) -> torch.Tensor:
if text_prompt is None:
return torch.empty((1, 0), dtype=torch.long)
cleaned = text_prompt.strip()
if not (cleaned.startswith("<system>") and cleaned.endswith("<system>")):
cleaned = f"<system> {cleaned} <system>"
# `<system>` is ordinary text, cut into several tokens exactly as in training.
return self.tokenizer(cleaned, return_tensors="pt", add_special_tokens=False)["input_ids"]
def _normalize_voice(self, voice_prompt, sampling_rate: int, merged) -> np.ndarray:
values = self.feature_extractor(voice_prompt, **merged["audio_kwargs"])["input_values"]
waveform = np.asarray(values, dtype=np.float32).reshape(-1)
try:
import pyloudnorm
except ImportError as error:
raise ImportError("The voice prompt is loudness-normalized with `pyloudnorm`: `pip install pyloudnorm`.") from error
loudness = pyloudnorm.Meter(sampling_rate).integrated_loudness(waveform)
return np.asarray(pyloudnorm.normalize.loudness(waveform, loudness, VOICE_PROMPT_LUFS), dtype=np.float32)
def _repeat_frame(self, codes: list[int], frames: int) -> torch.Tensor:
frame = torch.tensor(codes, dtype=torch.long, device=self.audio_tokenizer.device)
return frame.view(1, -1, 1).expand(1, -1, frames).contiguous()
def _encode(self, values: torch.Tensor) -> torch.Tensor:
values = values.to(self.audio_tokenizer.device, self.audio_tokenizer.dtype)
return self.audio_tokenizer.encode(values, num_quantizers=self.num_codebooks).audio_codes
def get_silence_audio_codes(self, num_frames: int, batch_size: int = 1) -> torch.Tensor:
r"""
Encodes `num_frames` frames of silence. Mimi is a streaming codec, so silence does not quantize to one
repeated frame; the whole span is encoded at once.
Args:
num_frames (`int`):
Frames to produce.
batch_size (`int`, *optional*, defaults to 1):
Identical rows to return.
Returns:
`torch.Tensor` of shape `(batch_size, num_codebooks, num_frames)`.
"""
config = self.audio_tokenizer.config
silence = torch.zeros((1, 1, num_frames * int(config.sampling_rate / config.frame_rate)))
return self._encode(silence).expand(batch_size, -1, -1)
def decode_audio(self, audio_codes: torch.Tensor, **kwargs) -> torch.Tensor:
r"""
Args:
audio_codes (`torch.Tensor` of shape `(batch_size, num_codebooks, frames)`):
Generated audio codes.
Returns:
`torch.Tensor` of shape `(batch_size, 1, samples)`: The waveform at 24 kHz.
"""
return self.audio_tokenizer.decode(audio_codes.to(self.audio_tokenizer.device), **kwargs).audio_values
__all__ = ["PolyplexProcessor"]
|