[WIP][Bugfix][MiniCPM-o] Fix Full duplex bugs for minicpm-challenge (#5496)

Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
Sy03 authored and GitHub committed 2026-07-28 15:59:26 +08:00
1 parent fb89ab43ea
commit ffa9a6af69
60 files changed
+3696 -634

No files matched your search

@@ -348,9 +348,16 @@ class BatchedToken2Wav(nn.Module):
previous: torch.Tensor,
window: torch.Tensor,
) -> torch.Tensor:
overlap = int(window.shape[0] // 2)
overlap = min(
int(window.shape[0] // 2),
int(speech.shape[-1]),
int(previous.shape[-1]),
)
result = speech.clone()
result[..., :overlap] = result[..., :overlap] * window[:overlap] + previous[..., -overlap:] * window[overlap:]
if overlap > 0:
result[..., :overlap] = (
result[..., :overlap] * window[:overlap] + previous[..., -overlap:] * window[-overlap:]
)
return result
def decode_batch(
@@ -360,6 +367,7 @@ class BatchedToken2Wav(nn.Module):
states: list[BatchedToken2WavState],
*,
last_chunk: bool,
flush_encoder: bool = False,
) -> tuple[list[torch.Tensor], list[BatchedToken2WavState]]:
batch_size = int(tokens.shape[0])
if batch_size != len(states):
@@ -382,7 +390,7 @@ class BatchedToken2Wav(nn.Module):
with self._autocast(tokens.device):
hidden, conformer_cnn, conformer_att = self._encode_chunk(
tokens,
last_chunk=last_chunk,
last_chunk=last_chunk or flush_encoder,
cnn_cache=flow_cache["conformer_cnn_cache"],
att_cache=flow_cache["conformer_att_cache"],
)
@@ -4,12 +4,12 @@
from __future__ import annotations
import hashlib
import json
import os
import tempfile
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path
from typing import Any
@@ -77,6 +77,13 @@ class _RequestState:
token2wav: BatchedToken2WavState
@dataclass
class _RuntimePrompt:
cache_id: str
path: str
owners: set[str]
@dataclass(frozen=True)
class _WorkItem:
output_index: int
@@ -89,9 +96,11 @@ class _WorkItem:
last_chunk: bool
tokens: torch.Tensor
previous: _RequestState | None
segment_text: str
duplex_turn_id: int | None
duplex_epoch: int | None
runtime_prompt_key: str | None
duplex_epoch: int
duplex_turn_id: int
segment_text_utf8: torch.Tensor
tts_is_last_chunk: bool
segment_end: bool
turn_end: bool
has_payload: bool = True
@@ -120,12 +129,17 @@ class MiniCPMO45Code2Wav(nn.Module):
self.model_path = str(vllm_config.model_config.model)
self.backend: BatchedToken2Wav | None = None
self._states: dict[str, _RequestState] = {}
self._owned_prompt_wavs: dict[str, tuple[str, str]] = {}
self._runtime_prompts: dict[str, _RuntimePrompt] = {}
self._request_prompt_keys: dict[str, str] = {}
self._runtime_prompt_dir = tempfile.TemporaryDirectory(
prefix="minicpmo45-runtime-prompts-",
)
extra = self._extra_config()
self._min_batch_size = int(extra.get("code2wav_min_batch_size", 1))
if self._min_batch_size < 1:
raise ValueError("MiniCPM-o Code2Wav code2wav_min_batch_size must be >= 1")
self._default_prompt_id = str(extra.get("prompt_cache_id", "HT_ref_audio"))
self._prompt_wav_explicit = "prompt_wav" in extra
self._default_prompt_wav = str(
extra.get(
"prompt_wav",
@@ -133,6 +147,34 @@ class MiniCPMO45Code2Wav(nn.Module):
)
)
def _resolve_model_root(self) -> Path:
model_root = Path(self.model_path)
if model_root.is_dir():
return model_root
from vllm_omni.model_executor.model_loader.weight_utils import (
download_weights_from_hf_specific,
)
model_config = self.vllm_config.model_config
load_config = getattr(self.vllm_config, "load_config", None)
model_root = Path(
download_weights_from_hf_specific(
self.model_path,
getattr(load_config, "download_dir", None),
allow_patterns=[
"assets/HT_ref_audio.wav",
"assets/token2wav/*",
],
revision=getattr(model_config, "revision", None),
require_all=True,
)
)
self.model_path = str(model_root)
if not self._prompt_wav_explicit:
self._default_prompt_wav = str(model_root / "assets" / "HT_ref_audio.wav")
return model_root
def _extra_config(self) -> dict[str, Any]:
model_config = getattr(self.vllm_config, "model_config", None)
connector = getattr(model_config, "stage_connector_config", None)
@@ -142,63 +184,122 @@ class MiniCPMO45Code2Wav(nn.Module):
extra = getattr(connector, "extra", None)
return dict(extra) if isinstance(extra, Mapping) else {}
def _release_prompt(self, state_id: str) -> None:
owned = self._owned_prompt_wavs.pop(state_id, None)
if owned is None:
return
prompt_cache_id, prompt_wav = owned
if self.backend is not None:
self.backend.evict_prompt(prompt_cache_id, prompt_wav)
try:
os.unlink(prompt_wav)
except FileNotFoundError:
pass
def _write_reference_prompt(
self,
state_id: str,
reference: object,
sample_rate: object,
) -> tuple[str, str]:
waveform = torch.as_tensor(reference, dtype=torch.float32).reshape(-1).cpu()
if waveform.numel() == 0:
return self._default_prompt_id, self._default_prompt_wav
sample_rate_hz = int(_scalar(sample_rate, 24000))
if sample_rate_hz <= 0:
raise _batch_error(
"invalid_ref_audio_sample_rate",
request_id=state_id,
sample_rate=sample_rate_hz,
)
digest = hashlib.sha256()
digest.update(str(sample_rate_hz).encode("ascii"))
digest.update(waveform.numpy().tobytes())
prompt_cache_id = f"{state_id}:{digest.hexdigest()}"
current = self._owned_prompt_wavs.get(state_id)
if current is not None and current[0] == prompt_cache_id and os.path.exists(current[1]):
return current
self._release_prompt(state_id)
handle = tempfile.NamedTemporaryFile(prefix="minicpmo45-ref-", suffix=".wav", delete=False)
prompt_wav = handle.name
handle.close()
try:
sf.write(prompt_wav, waveform.numpy(), sample_rate_hz, format="WAV")
except Exception:
try:
os.unlink(prompt_wav)
except FileNotFoundError:
pass
raise
self._owned_prompt_wavs[state_id] = (prompt_cache_id, prompt_wav)
return prompt_cache_id, prompt_wav
def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor:
return torch.zeros((input_ids.numel(), 1), device=input_ids.device, dtype=torch.float32)
def compute_logits(self, hidden_states: Any, sampling_metadata: Any = None) -> None:
return None
def _materialize_runtime_prompt(
self,
ref_audio: Any,
sample_rate: Any,
) -> tuple[str, _RuntimePrompt]:
sample_rate_hz = int(_scalar(sample_rate, 0))
waveform = torch.as_tensor(ref_audio, dtype=torch.float32).reshape(-1).cpu().contiguous()
if sample_rate_hz <= 0:
raise _batch_error("invalid_ref_audio_sample_rate", sample_rate=sample_rate_hz)
if waveform.numel() == 0:
raise _batch_error("empty_ref_audio")
if not bool(torch.isfinite(waveform).all().item()):
raise _batch_error("non_finite_ref_audio")
digest = sha256()
digest.update(waveform.numpy().tobytes())
digest.update(str(sample_rate_hz).encode())
cache_key = digest.hexdigest()
cache_id = f"runtime-ref-{cache_key[:24]}-{sample_rate_hz}"
path = str(Path(self._runtime_prompt_dir.name) / f"minicpmo45_ref_{cache_key[:24]}_{sample_rate_hz}.wav")
entry = self._runtime_prompts.get(cache_key)
if entry is None:
entry = _RuntimePrompt(cache_id=cache_id, path=path, owners=set())
self._runtime_prompts[cache_key] = entry
prompt_path = Path(entry.path)
if not prompt_path.is_file():
with tempfile.NamedTemporaryFile(
dir=prompt_path.parent,
prefix=f".{prompt_path.stem}-",
suffix=".wav",
delete=False,
) as handle:
temporary_path = Path(handle.name)
try:
sf.write(
temporary_path,
waveform.numpy(),
sample_rate_hz,
format="WAV",
)
os.replace(temporary_path, prompt_path)
finally:
temporary_path.unlink(missing_ok=True)
return cache_key, entry
def _resolve_prompt(
self,
state_id: str,
info: Mapping[str, Any],
meta: Mapping[str, Any],
previous: _RequestState | None,
) -> tuple[str, str, str | None]:
codes = info.get("codes")
ref_audio = codes.get("ref") if isinstance(codes, Mapping) else None
if ref_audio is not None:
cache_key, entry = self._materialize_runtime_prompt(
ref_audio,
meta.get("ref_audio_sr"),
)
return entry.cache_id, entry.path, cache_key
if previous is not None:
return previous.prompt_cache_id, previous.prompt_wav, self._request_prompt_keys.get(state_id)
cache_key = self._request_prompt_keys.get(state_id)
entry = self._runtime_prompts.get(cache_key) if cache_key is not None else None
if entry is not None:
return entry.cache_id, entry.path, cache_key
return (
str(_scalar(meta.get("prompt_cache_id"), self._default_prompt_id)),
str(_scalar(meta.get("prompt_wav"), self._default_prompt_wav)),
None,
)
def _release_request_prompt(self, state_id: str) -> None:
cache_key = self._request_prompt_keys.pop(state_id, None)
entry = self._runtime_prompts.get(cache_key) if cache_key is not None else None
if entry is None:
return
entry.owners.discard(state_id)
if entry.owners:
return
if self.backend is not None:
self.backend.evict_prompt(entry.cache_id, entry.path)
Path(entry.path).unlink(missing_ok=True)
self._runtime_prompts.pop(cache_key, None)
def _commit_runtime_prompt_owners(self, items: list[_WorkItem]) -> None:
for item in items:
cache_key = item.runtime_prompt_key
if cache_key is None:
continue
previous_key = self._request_prompt_keys.get(item.state_id)
if previous_key != cache_key:
self._release_request_prompt(item.state_id)
entry = self._runtime_prompts.get(cache_key)
if entry is not None:
entry.owners.add(item.state_id)
self._request_prompt_keys[item.state_id] = cache_key
def _prune_unowned_runtime_prompts(self) -> None:
for cache_key, entry in list(self._runtime_prompts.items()):
if entry.owners:
continue
if self.backend is not None:
self.backend.evict_prompt(entry.cache_id, entry.path)
Path(entry.path).unlink(missing_ok=True)
self._runtime_prompts.pop(cache_key, None)
@staticmethod
def _split_segments(input_ids: torch.Tensor, counts: Any) -> list[torch.Tensor]:
flat = input_ids.reshape(-1)
@@ -247,9 +348,11 @@ class MiniCPMO45Code2Wav(nn.Module):
last_chunk=False,
tokens=segment.new_empty(0, dtype=torch.long),
previous=None,
segment_text="",
duplex_turn_id=None,
duplex_epoch=None,
runtime_prompt_key=None,
duplex_epoch=-1,
duplex_turn_id=-1,
segment_text_utf8=torch.empty(0, dtype=torch.uint8),
tts_is_last_chunk=False,
segment_end=False,
turn_end=False,
has_payload=False,
@@ -266,14 +369,15 @@ class MiniCPMO45Code2Wav(nn.Module):
chunk_seq=chunk_seq,
)
last_chunk = bool(_scalar(meta.get("last_chunk"), False))
tts_is_last_chunk = bool(_scalar(meta.get("tts_is_last_chunk"), False))
codes = info.get("codes")
audio = codes.get("audio") if isinstance(codes, Mapping) else None
reference = codes.get("ref") if isinstance(codes, Mapping) else None
tokens = _codec_tensor(audio, segment)
if last_chunk and int(_scalar(meta.get("code_flat_numel"), tokens.numel())) == 0:
if int(_scalar(meta.get("code_flat_numel"), tokens.numel())) == 0:
# The generation scheduler reserves one placeholder token for an
# empty terminal chunk. The producer's explicit length is the
# authority, so do not decode that placeholder as codec data.
# empty terminal or segment-boundary chunk. The producer's
# explicit length is the authority, so do not decode that
# placeholder as codec data.
tokens = segment.new_empty(0, dtype=torch.long)
previous = self._states.get(state_id)
if previous is None:
@@ -299,7 +403,6 @@ class MiniCPMO45Code2Wav(nn.Module):
cache_epoch=cache_epoch,
chunk_seq=chunk_seq,
)
self._release_prompt(state_id)
previous = None
elif chunk_seq != previous.chunk_seq + 1:
raise _batch_error(
@@ -308,22 +411,12 @@ class MiniCPMO45Code2Wav(nn.Module):
expected=previous.chunk_seq + 1,
actual=chunk_seq,
)
explicit_prompt_id = _scalar(meta.get("prompt_cache_id"))
explicit_prompt_wav = _scalar(meta.get("prompt_wav"))
if previous is not None and explicit_prompt_id is None and explicit_prompt_wav is None:
prompt_cache_id = previous.prompt_cache_id
prompt_wav = previous.prompt_wav
elif reference is not None:
prompt_cache_id, prompt_wav = self._write_reference_prompt(
state_id,
reference,
meta.get("ref_audio_sr"),
)
else:
prompt_cache_id = str(explicit_prompt_id or self._default_prompt_id)
prompt_wav = str(explicit_prompt_wav or self._default_prompt_wav)
prompt_cache_id, prompt_wav, runtime_prompt_key = self._resolve_prompt(
state_id,
info,
meta,
previous,
)
if previous is not None and prompt_cache_id != previous.prompt_cache_id:
raise _batch_error(
"prompt_changed_midstream",
@@ -338,8 +431,9 @@ class MiniCPMO45Code2Wav(nn.Module):
expected=previous.prompt_wav,
actual=prompt_wav,
)
duplex_turn_id = _scalar(meta.get("duplex_turn_id"))
duplex_epoch = _scalar(meta.get("duplex_epoch"))
segment_text_utf8 = meta.get("llm_output_text_utf8")
if not isinstance(segment_text_utf8, torch.Tensor):
segment_text_utf8 = torch.empty(0, dtype=torch.uint8)
return _WorkItem(
output_index=index,
state_id=state_id,
@@ -351,9 +445,11 @@ class MiniCPMO45Code2Wav(nn.Module):
last_chunk=last_chunk,
tokens=tokens,
previous=previous,
segment_text=str(_scalar(meta.get("native_duplex_segment_text"), "")),
duplex_turn_id=int(duplex_turn_id) if duplex_turn_id is not None else None,
duplex_epoch=int(duplex_epoch) if duplex_epoch is not None else None,
runtime_prompt_key=runtime_prompt_key,
duplex_epoch=int(_scalar(meta.get("duplex_epoch"), -1)),
duplex_turn_id=int(_scalar(meta.get("duplex_turn_id"), -1)),
segment_text_utf8=segment_text_utf8,
tts_is_last_chunk=tts_is_last_chunk,
segment_end=bool(_scalar(meta.get("segment_end"), False)),
turn_end=bool(_scalar(meta.get("turn_end"), False)),
)
@@ -371,6 +467,7 @@ class MiniCPMO45Code2Wav(nn.Module):
int(item.tokens.numel()),
cache_signature,
item.last_chunk,
item.tts_is_last_chunk,
item.cache_epoch,
)
@@ -435,26 +532,37 @@ class MiniCPMO45Code2Wav(nn.Module):
request_ids=len(state_ids),
)
items: list[_WorkItem] = []
for index, (state_id, segment, info) in enumerate(
zip(state_ids, segments, runtime_additional_information, strict=True)
):
if not isinstance(info, Mapping):
raise _batch_error(
"invalid_runtime_info",
output_index=index,
value_type=type(info).__name__,
)
items.append(self._parse_item(index, str(state_id), segment, info))
try:
for index, (state_id, segment, info) in enumerate(
zip(state_ids, segments, runtime_additional_information, strict=True)
):
if not isinstance(info, Mapping):
raise _batch_error(
"invalid_runtime_info",
output_index=index,
value_type=type(info).__name__,
)
items.append(self._parse_item(index, str(state_id), segment, info))
except Exception:
self._prune_unowned_runtime_prompts()
raise
state_ids = [item.state_id for item in items]
if len(state_ids) != len(set(state_ids)):
self._prune_unowned_runtime_prompts()
raise _batch_error("duplicate_request_in_forward", request_ids=state_ids)
outputs = [empty for _ in segments]
sentinels = [item for item in items if item.last_chunk and item.tokens.numel() == 0]
segment_markers = [
item for item in items if not item.last_chunk and item.tts_is_last_chunk and item.tokens.numel() == 0
]
compute_items = [item for item in items if item.tokens.numel() > 0]
invalid_empty = [
item.request_id for item in items if item.has_payload and not item.last_chunk and item.tokens.numel() == 0
item.request_id
for item in items
if item.has_payload and not item.last_chunk and not item.tts_is_last_chunk and item.tokens.numel() == 0
]
if invalid_empty:
self._prune_unowned_runtime_prompts()
raise _batch_error("empty_nonfinal_chunk", request_ids=invalid_empty)
buckets: dict[tuple[Any, ...], list[_WorkItem]] = {}
@@ -470,6 +578,7 @@ class MiniCPMO45Code2Wav(nn.Module):
if len(bucket) < self._min_batch_size
]
if undersized:
self._prune_unowned_runtime_prompts()
raise _batch_error(
"exact_shape_bucket_below_minimum",
minimum=self._min_batch_size,
@@ -477,6 +586,58 @@ class MiniCPMO45Code2Wav(nn.Module):
)
pending: dict[str, _RequestState | None] = {item.state_id: None for item in sentinels}
pending.update(
{
item.state_id: _RequestState(
cache_epoch=item.cache_epoch,
chunk_seq=item.chunk_seq,
prompt_cache_id=item.prompt_cache_id,
prompt_wav=item.prompt_wav,
token2wav=item.previous.token2wav,
)
for item in segment_markers
if item.previous is not None
}
)
initial_marker_buckets: dict[tuple[str, str], list[_WorkItem]] = {}
for item in segment_markers:
if item.previous is None:
initial_marker_buckets.setdefault(
(item.prompt_cache_id, item.prompt_wav),
[],
).append(item)
for bucket in initial_marker_buckets.values():
try:
features = self.backend.prepare_prompt(
bucket[0].prompt_cache_id,
bucket[0].prompt_wav,
)
states = self.backend.setup_batch(features, len(bucket))
except Exception as exc:
self._prune_unowned_runtime_prompts()
if isinstance(exc, RuntimeError) and str(exc).startswith("MiniCPMO45Code2WavBatchError "):
raise
raise _batch_error(
"backend_unsupported_or_failed",
request_ids=[item.request_id for item in bucket],
error_type=type(exc).__name__,
error=str(exc),
) from exc
if len(states) != len(bucket):
self._prune_unowned_runtime_prompts()
raise _batch_error(
"backend_result_size_mismatch",
expected=len(bucket),
states=len(states),
)
for item, state in zip(bucket, states, strict=True):
pending[item.state_id] = _RequestState(
cache_epoch=item.cache_epoch,
chunk_seq=item.chunk_seq,
prompt_cache_id=item.prompt_cache_id,
prompt_wav=item.prompt_wav,
token2wav=state,
)
for bucket in buckets.values():
batch_size = len(bucket)
try:
@@ -496,6 +657,7 @@ class MiniCPMO45Code2Wav(nn.Module):
last_chunk=bucket[0].last_chunk,
)
except Exception as exc:
self._prune_unowned_runtime_prompts()
if isinstance(exc, RuntimeError) and str(exc).startswith("MiniCPMO45Code2WavBatchError "):
raise
raise _batch_error(
@@ -505,6 +667,7 @@ class MiniCPMO45Code2Wav(nn.Module):
error=str(exc),
) from exc
if len(audios) != batch_size or len(next_states) != batch_size:
self._prune_unowned_runtime_prompts()
raise _batch_error(
"backend_result_size_mismatch",
expected=batch_size,
@@ -525,10 +688,10 @@ class MiniCPMO45Code2Wav(nn.Module):
)
)
self._commit_runtime_prompt_owners(items)
for request_id, state in pending.items():
if state is None:
self._states.pop(request_id, None)
self._release_prompt(request_id)
else:
self._states[request_id] = state
sample_rate_tensor = torch.as_tensor(sample_rate, dtype=torch.int32)
@@ -537,24 +700,15 @@ class MiniCPMO45Code2Wav(nn.Module):
multimodal_outputs={
"model_outputs": outputs,
"sr": [sample_rate_tensor.clone() for _ in outputs],
"meta.llm_output_text_utf8": [
torch.tensor(list(item.segment_text.encode("utf-8")), dtype=torch.uint8) for item in items
],
"meta.tts_is_last_chunk": [torch.tensor(item.last_chunk, dtype=torch.bool) for item in items],
# Generation runner wire payloads are flat and tensor-only.
# Dotted metadata keys are unflattened again by the output
# processor before the full-duplex data plane consumes them.
"meta.duplex_epoch": [torch.tensor(item.duplex_epoch, dtype=torch.int32) for item in items],
"meta.duplex_turn_id": [torch.tensor(item.duplex_turn_id, dtype=torch.int32) for item in items],
"meta.llm_output_text_utf8": [item.segment_text_utf8 for item in items],
"meta.tts_is_last_chunk": [torch.tensor(item.tts_is_last_chunk, dtype=torch.bool) for item in items],
"meta.segment_end": [torch.tensor(item.segment_end, dtype=torch.bool) for item in items],
"meta.turn_end": [torch.tensor(item.turn_end, dtype=torch.bool) for item in items],
"meta.duplex_turn_id": [
torch.tensor(item.duplex_turn_id, dtype=torch.int64)
if item.duplex_turn_id is not None
else torch.empty(0, dtype=torch.int64)
for item in items
],
"meta.duplex_epoch": [
torch.tensor(item.duplex_epoch, dtype=torch.int64)
if item.duplex_epoch is not None
else torch.empty(0, dtype=torch.int64)
for item in items
],
},
)
@@ -562,7 +716,7 @@ class MiniCPMO45Code2Wav(nn.Module):
for request_id in finished_req_ids:
state_id = str(request_id)
self._states.pop(state_id, None)
self._release_prompt(state_id)
self._release_request_prompt(state_id)
def make_omni_output(self, model_outputs: Any, **_: Any) -> OmniOutput:
if isinstance(model_outputs, OmniOutput):
@@ -600,10 +754,11 @@ class MiniCPMO45Code2Wav(nn.Module):
from stepaudio2.token2wav import Token2wav
extra = self._extra_config()
model_root = self._resolve_model_root()
prompt_path = Path(self._default_prompt_wav)
if not prompt_path.is_file():
raise FileNotFoundError(f"MiniCPM-o Code2Wav prompt audio not found: {prompt_path}")
token2wav_path = Path(self.model_path) / "assets" / "token2wav"
token2wav_path = model_root / "assets" / "token2wav"
if not token2wav_path.is_dir():
raise FileNotFoundError(f"MiniCPM-o Code2Wav assets not found: {token2wav_path}")
use_float16 = bool(extra.get("token2wav_float16", False))
@@ -175,7 +175,6 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
token_ids = self._minicpmo45_native_duplex_token_ids()
listen_id = int(token_ids.get("listen_token_id", -1))
tts_bos_id = int(token_ids.get("tts_bos_token_id", -1))
turn_eos_id = int(token_ids.get("turn_eos_token_id", -1))
if listen_id < 0 or listen_id >= logits.shape[-1]:
return
@@ -199,7 +198,6 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
continue
force_listen = payload.get("force_listen") is True
is_speech = payload.get("is_speech")
redirect_listen = False
segment_key = (row.request_id, row.seq if row.seq is not None else -1)
session_key = (row.session_id, row.incarnation) if row.session_id is not None else None
if turn_eos_id >= 0 and session_key is not None:
@@ -209,8 +207,6 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
)
if is_speech is True:
if state is not None:
if not getattr(state, "current_turn_ended", True):
redirect_listen = True
with suppress(Exception):
state.last_terminator_token = None
else:
@@ -218,7 +214,7 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
if turn_ended and not pending_speech_context:
force_listen = True
if not force_listen and not redirect_listen:
if not force_listen:
continue
if force_listen and segment_key in force_listen_segments:
continue
@@ -226,10 +222,6 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
logits[row_idx, :] = float("-inf")
logits[row_idx, listen_id] = 0.0
force_listen_segments.add(segment_key)
elif 0 <= tts_bos_id < logits.shape[-1]:
if logits[row_idx, listen_id] > logits[row_idx, tts_bos_id]:
logits[row_idx, tts_bos_id] = logits[row_idx, listen_id]
logits[row_idx, listen_id] = float("-inf")
# -------------------- Device utilities --------------------
@staticmethod
@@ -730,6 +722,10 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
output_token_ids = getattr(sampling_metadata, "output_token_ids", None) or []
raw_recent_tokens = output_token_ids[row_idx] if row_idx < len(output_token_ids) else []
recent_tokens = [int(token_id) for token_id in raw_recent_tokens if isinstance(token_id, int) and token_id >= 0]
temperature = float(self._sampling_metadata_value(sampling_metadata, "temperature", row_idx, 0.7))
top_k = int(self._sampling_metadata_value(sampling_metadata, "top_k", row_idx, 100))
top_p = float(self._sampling_metadata_value(sampling_metadata, "top_p", row_idx, 0.8))
state = self._minicpmo45_duplex_state_for_row(row_idx)
if chunk_eos_id >= 0 and chunk_eos_id < logits.shape[-1]:
max_speak_tokens = int(
getattr(
@@ -765,23 +761,19 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
if valid_forbidden:
logits[:, valid_forbidden] = float("-inf")
special_ids = self._minicpmo45_native_special_token_ids(token_ids)
state = self._minicpmo45_duplex_state_for_row(row_idx)
generated_text_tokens = getattr(state, "generated_text_tokens", None)
repetition_tokens = generated_text_tokens if generated_text_tokens else recent_tokens
generated_tokens = getattr(state, "generated_tokens", None)
repetition_tokens = generated_tokens if generated_tokens else recent_tokens
repetition_penalty = 1.05
if repetition_penalty != 1.0 and repetition_tokens:
history_size = MiniCPMO45DuplexPolicy.REPETITION_HISTORY_SIZE
for token_id in set(repetition_tokens[-history_size:]):
if token_id in special_ids or token_id < 0 or token_id >= logits.shape[-1]:
if token_id < 0 or token_id >= logits.shape[-1]:
continue
logits[0, token_id] /= repetition_penalty
temperature = float(self._sampling_metadata_value(sampling_metadata, "temperature", row_idx, 0.7))
top_k = int(self._sampling_metadata_value(sampling_metadata, "top_k", row_idx, 100))
top_p = float(self._sampling_metadata_value(sampling_metadata, "top_p", row_idx, 0.8))
if getattr(sampling_metadata, "all_greedy", False) or temperature <= 0:
sampled = int(torch.argmax(logits, dim=-1).item())
self._record_minicpmo45_duplex_generation_token(row_idx, sampled)
sampled = self._maybe_cut_minicpmo45_native_duplex_text_chunk(
sampled,
recent_tokens,
@@ -793,6 +785,7 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
logits = self._top_k_top_p_filter(logits, top_k=top_k, top_p=top_p)
probs = F.softmax(logits, dim=-1)
sampled = int(torch.multinomial(probs, num_samples=1, generator=generator).item())
self._record_minicpmo45_duplex_generation_token(row_idx, sampled)
sampled = self._maybe_cut_minicpmo45_native_duplex_text_chunk(
sampled,
recent_tokens,
@@ -901,6 +894,28 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
return int(tts_bos_id)
return int(sampled)
def _record_minicpmo45_duplex_generation_token(self, row_idx: int, sampled: int) -> None:
"""Track tokens returned by the model-policy decoder.
The released StreamDecoder is constructed without special_token_ids, so
every normally decoded token participates in repetition penalty. Forced
listen bypasses that decoder, while chunk_eos boundary decisions return
before this method is called.
"""
state = self._minicpmo45_duplex_state_for_row(row_idx)
if state is None:
return
payload = self._minicpmo45_duplex_payload_for_row(row_idx)
if isinstance(payload, dict) and payload.get("force_listen") is True:
return
generated_tokens = getattr(state, "generated_tokens", None)
if not isinstance(generated_tokens, list):
generated_tokens = []
state.generated_tokens = generated_tokens
generated_tokens.append(int(sampled))
history_size = MiniCPMO45DuplexPolicy.REPETITION_HISTORY_SIZE
del generated_tokens[:-history_size]
def _record_minicpmo45_duplex_terminator(self, row_idx: int, sampled: int, token_ids: dict[str, int]) -> None:
"""Remember sampled unit state for the next append.
@@ -928,15 +943,6 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
with suppress(Exception):
state.pending_speech_response_open = False
return
special_ids = self._minicpmo45_native_special_token_ids(token_ids)
if sampled not in special_ids:
generated_text_tokens = getattr(state, "generated_text_tokens", None)
if not isinstance(generated_text_tokens, list):
generated_text_tokens = []
state.generated_text_tokens = generated_text_tokens
generated_text_tokens.append(int(sampled))
history_size = MiniCPMO45DuplexPolicy.REPETITION_HISTORY_SIZE
del generated_text_tokens[:-history_size]
if (
sampled == tts_bos_id
and getattr(state, "current_turn_ended", True)
@@ -43,6 +43,7 @@ _CODEC_TOP_K = 25
_CODEC_TOP_P = 0.85
_CODEC_REPETITION_PENALTY = 1.05
_CODEC_MIN_TOKENS = 50
_DUPLEX_CODEC_TOKENS_PER_CHUNK = 26
def _max_audio_tokens(condition_tokens: int) -> int:
@@ -61,12 +62,7 @@ def _max_audio_tokens(condition_tokens: int) -> int:
def _restore_weight_norm_weight(weight_g: torch.Tensor, weight_v: torch.Tensor) -> torch.Tensor:
"""Materialize ``weight_norm(..., dim=0)`` checkpoint parameters."""
row_norm = torch.linalg.vector_norm(
weight_v.float(),
dim=1,
keepdim=True,
).clamp_min(torch.finfo(torch.float32).tiny)
return weight_v * (weight_g.to(dtype=weight_v.dtype) / row_norm.to(dtype=weight_v.dtype))
return torch._weight_norm(weight_v, weight_g, dim=0)
def _apply_repetition_penalty(
@@ -218,6 +214,8 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP):
self,
tts_token_ids: torch.Tensor,
tts_hidden_states: torch.Tensor,
*,
native_duplex: bool = False,
) -> torch.Tensor:
if tts_token_ids.numel() == 0 or tts_hidden_states.numel() == 0:
# The thinker can legally emit an empty speech segment (<|tts_bos|>
@@ -239,7 +237,12 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP):
hidden_embeds = self.projector_semantic(hidden)
if self._normalize:
hidden_embeds = F.normalize(hidden_embeds, p=2, dim=-1)
return torch.cat([text_embeds + hidden_embeds, self._boundary_embeddings()], dim=0)
audio_bos = self.emb_text(torch.tensor([self._tts_bos_id], device=device, dtype=torch.long))
condition = text_embeds + hidden_embeds
if native_duplex:
# Match MiniCPMTTS.generate_chunk's streaming condition.
return torch.cat([condition, audio_bos], dim=0)
return torch.cat([condition, self._boundary_embeddings()], dim=0)
def preprocess(
self,
@@ -281,18 +284,22 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP):
"MiniCPM-o Talker received an empty condition (request %s); this request produces no audio.",
info_dict.get("request_id"),
)
full_embeds = self._build_condition_embeddings(token_ids, hidden_states)
native_duplex = bool(info_dict.get("native_duplex", False))
full_embeds = self._build_condition_embeddings(
token_ids,
hidden_states,
native_duplex=native_duplex,
)
offset = int(info_dict.get("_omni_num_computed_tokens", 0))
if offset == 0 and span_len > full_embeds.shape[0]:
# Async-chunk prewarms Stage 1 with placeholder token IDs. Two
# Thinker handoffs can arrive before the first one executes,
# so the scheduler prompt may already include the newer
# placeholder prefix while additional_information still
# carries the preceding condition. Materialize that prefix as
# the embeddings of its actual zero token IDs and keep the
# complete Talker condition tail-aligned (audio BOS remains
# the final prefill token).
prefix_len = span_len - full_embeds.shape[0]
request_id = str(info_dict.get("request_id", "0"))
meta = info_dict.get("meta")
# The handoff rebuilds only the tail-aligned Talker condition.
# Materialize zero-token embeddings for any scheduler prompt
# prefix so chunked prefill can slice from a non-zero offset.
prompt_len = info_dict.get("_omni_prompt_len")
target_len = int(prompt_len) if prompt_len is not None else offset + span_len
prefix_len = target_len - full_embeds.shape[0]
if prefix_len > 0:
placeholder_ids = torch.zeros(
prefix_len,
dtype=torch.long,
@@ -308,13 +315,21 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP):
f"tts_ids={token_ids.shape[0]} tts_hidden={hidden_states.shape[0]} "
f"prompt_len={info_dict.get('_omni_prompt_len')}"
)
max_tokens = _max_audio_tokens(int(token_ids.numel()))
duplex_boundary = isinstance(meta, dict) and (
bool(meta.get("turn_start", False)) or bool(meta.get("turn_end", False))
)
if native_duplex:
max_tokens = _DUPLEX_CODEC_TOKENS_PER_CHUNK
min_tokens = 0 if duplex_boundary else _DUPLEX_CODEC_TOKENS_PER_CHUNK
else:
max_tokens = _max_audio_tokens(int(token_ids.numel()))
min_tokens = self._codec_min_tokens
state = {
"step": 0,
"max_tokens": max_tokens,
"min_tokens": min_tokens,
"finished": empty_condition,
}
request_id = str(info_dict.get("request_id", "0"))
request_states = getattr(self, "_request_audio_states", None)
if request_states is None:
request_states = {}
@@ -370,7 +385,12 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP):
penalty=self._codec_repetition_penalty,
window_size=_REPETITION_WINDOW,
)
if step < self._codec_min_tokens:
request_states = getattr(self, "_request_audio_states", {})
state = request_states.get(request_id)
min_tokens = (
int(state.get("min_tokens", self._codec_min_tokens)) if isinstance(state, dict) else self._codec_min_tokens
)
if step < min_tokens:
logits[..., eos_id] = float("-inf")
logits = _apply_top_k_top_p(
logits,
@@ -404,12 +424,61 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP):
raise RuntimeError(
f"MiniCPM-o continuous Talker received {len(sample_eligible)} sampling flags for {len(infos)} requests"
)
emit_duplex_metadata = any(isinstance(info, dict) and info.get("native_duplex") is True for info in infos)
stop_rows: list[torch.Tensor] = []
codec_deltas: list[torch.Tensor] = []
terminal_flags: list[torch.Tensor] = []
native_duplex_flags: list[torch.Tensor] = []
duplex_epochs: list[torch.Tensor] = []
duplex_turn_ids: list[torch.Tensor] = []
segment_texts_utf8: list[torch.Tensor] = []
turn_end_flags: list[torch.Tensor] = []
empty_delta = hidden.new_empty((0, 1), dtype=torch.long)
for index, info in enumerate(infos):
info_dict = info if isinstance(info, dict) else {}
native_duplex = info_dict.get("native_duplex") is True
if emit_duplex_metadata:
duplex_info = info_dict.get("duplex")
if not isinstance(duplex_info, dict):
duplex_info = {}
epoch = duplex_info.get("epoch", -1)
turn_id = duplex_info.get("turn_id", -1)
if native_duplex and not all(
isinstance(value, int) and not isinstance(value, bool) and value >= 0 for value in (epoch, turn_id)
):
raise RuntimeError(
"MiniCPM-o native duplex Talker requires non-negative integer "
f"epoch and turn_id, got epoch={epoch!r}, turn_id={turn_id!r}"
)
meta_info = info_dict.get("meta")
if not isinstance(meta_info, dict):
meta_info = {}
segment_text = meta_info.get("native_duplex_segment_text", "") if native_duplex else ""
if not isinstance(segment_text, str):
segment_text = ""
turn_eos_id = meta_info.get("turn_eos_token_id")
ids_info = info_dict.get("ids")
tts_ids = ids_info.get("tts") if native_duplex and isinstance(ids_info, dict) else None
if isinstance(tts_ids, torch.Tensor):
contains_turn_eos = isinstance(turn_eos_id, int) and bool(
torch.any(tts_ids.reshape(-1) == turn_eos_id).item()
)
elif isinstance(tts_ids, (list, tuple)):
contains_turn_eos = isinstance(turn_eos_id, int) and turn_eos_id in tts_ids
else:
contains_turn_eos = False
native_duplex_flags.append(torch.tensor(native_duplex, dtype=torch.bool))
duplex_epochs.append(torch.tensor(epoch if isinstance(epoch, int) else -1, dtype=torch.long))
duplex_turn_ids.append(torch.tensor(turn_id if isinstance(turn_id, int) else -1, dtype=torch.long))
segment_texts_utf8.append(
torch.tensor(
list(segment_text.encode("utf-8")),
dtype=torch.uint8,
)
)
turn_end_flags.append(torch.tensor(native_duplex and contains_turn_eos, dtype=torch.bool))
if not isinstance(info, dict):
stop_rows.append(hidden.new_tensor([0.0, float("-inf")]))
codec_deltas.append(empty_delta)
@@ -453,12 +522,15 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP):
codes = codes.to(device=hidden.device, dtype=torch.long).reshape(-1)
step = int(state.get("step", 0))
sampled = self._sample_audio_code(hidden[end - 1 : end], codes, request_id, step)
is_eos = int(sampled.item()) == self._num_audio_tokens - 1
sampled_id = int(sampled.item())
is_eos = sampled_id == self._num_audio_tokens - 1
state["step"] = int(state.get("step", 0)) + 1
reached_limit = int(state["step"]) >= int(state.get("max_tokens", 2048))
finished = is_eos or reached_limit
state["finished"] = finished
if not is_eos:
# MiniCPMTTS.generate_chunk consumes the boundary sample but
# returns only codes that were fed into the retained KV state.
if not is_eos and not reached_limit:
codes = torch.cat([codes[-(_REPETITION_WINDOW - 1) :], sampled.reshape(1)])
delta = sampled.reshape(1, 1)
else:
@@ -476,9 +548,20 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP):
self._batch_stop_logits = torch.stack(stop_rows, dim=0) if stop_rows else hidden.new_empty((0, 2))
# Lists are deliberate: the runner routes element i to request i,
# preserving compaction alignment while emitting only this step's code.
meta_outputs = {"finished": terminal_flags}
if emit_duplex_metadata:
meta_outputs.update(
{
"native_duplex": native_duplex_flags,
"duplex_epoch": duplex_epochs,
"duplex_turn_id": duplex_turn_ids,
"llm_output_text_utf8": segment_texts_utf8,
"turn_end": turn_end_flags,
}
)
multimodal_outputs: dict[str, Any] = {
"codes": {"audio": codec_deltas},
"meta": {"finished": terminal_flags},
"meta": meta_outputs,
}
return OmniOutput(
text_hidden_states=hidden,