[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,
@@ -21,6 +21,7 @@ logger = logging.getLogger(__name__)
_MINICPMO45_ASYNC_STATE = "_minicpmo45_async_codec_state"
_MINICPMO45_STREAM_RECORD = "_minicpmo45_async_stream_record"
_MINICPMO45_SILENCE_CODE = 4218
_MINICPMO45_MIN_STREAM_BODY_FRAMES = 5
class _MiniCPMO45MetaStruct(MetaStruct):
@@ -28,6 +29,7 @@ class _MiniCPMO45MetaStruct(MetaStruct):
ref_audio_sr: int | None = None
native_duplex_segment_text: str | None = None
llm_output_text_utf8: torch.Tensor | None = None
duplex_turn_id: int | None = None
duplex_epoch: int | None = None
segment_end: bool | None = None
@@ -212,6 +214,16 @@ def tts2code2wav_async_chunk(
internal_id = getattr(request, "request_id", None)
request_id = str(external_id if external_id is not None else internal_id)
internal_id = str(internal_id if internal_id is not None else request_id)
output_meta = multimodal_output.get("meta") if isinstance(multimodal_output, Mapping) else None
output_meta = output_meta if isinstance(output_meta, Mapping) else {}
native_duplex = bool(_coerce_int(output_meta.get("native_duplex")))
duplex_epoch = _coerce_int(output_meta.get("duplex_epoch"))
duplex_turn_id = _coerce_int(output_meta.get("duplex_turn_id"))
segment_text_utf8 = output_meta.get("llm_output_text_utf8")
if not isinstance(segment_text_utf8, torch.Tensor):
segment_text_utf8 = None
turn_end = bool(_coerce_int(output_meta.get("turn_end")))
duplex_turn_key = (duplex_epoch, duplex_turn_id)
request_payload = getattr(transfer_manager, "request_payload", None)
if request_payload is None:
@@ -227,7 +239,9 @@ def tts2code2wav_async_chunk(
record = {
"internal_id": internal_id,
"cache_epoch": 0,
"chunk_seq": 0,
"retired_internal_ids": set(),
"last_terminal_turn": None,
}
container[_MINICPMO45_STREAM_RECORD] = record
elif internal_id in record["retired_internal_ids"]:
@@ -236,6 +250,8 @@ def tts2code2wav_async_chunk(
record["retired_internal_ids"].add(record["internal_id"])
record["internal_id"] = internal_id
record["cache_epoch"] = int(record["cache_epoch"]) + 1
record["chunk_seq"] = 0
record["last_terminal_turn"] = None
_drop_codec_state(transfer_manager, request_id)
if _is_aborted(request):
@@ -243,44 +259,46 @@ def tts2code2wav_async_chunk(
_drop_codec_state(transfer_manager, request_id)
return None
if native_duplex and turn_end and record.get("last_terminal_turn") == duplex_turn_key:
return None
state = container.get(_MINICPMO45_ASYNC_STATE)
if not isinstance(state, dict):
request_info = getattr(request, "additional_information", None)
if not isinstance(request_info, Mapping):
request_info = {}
codes_info = request_info.get("codes")
if not isinstance(codes_info, Mapping):
codes_info = {}
meta_info = request_info.get("meta")
if not isinstance(meta_info, Mapping):
meta_info = {}
duplex_info = request_info.get("duplex")
if not isinstance(duplex_info, Mapping):
duplex_info = {}
state = {
"internal_id": internal_id,
"pending": [],
"pending_text_utf8": [],
"segment_text_recorded": False,
"left_context": [],
"codec_end": 0,
"ref_audio": codes_info.get("ref"),
"ref_audio_sr": meta_info.get("ref_audio_sr"),
"segment_text": meta_info.get("native_duplex_segment_text"),
"segment_end": bool(meta_info.get("segment_end", False)),
"turn_end": bool(meta_info.get("turn_end", False)),
"duplex_turn_id": duplex_info.get("model_turn_id", duplex_info.get("turn_id")),
"duplex_epoch": duplex_info.get("epoch"),
}
container[_MINICPMO45_ASYNC_STATE] = state
pending = state["pending"]
pending.extend(_extract_codec_delta(multimodal_output, request_id))
pending_text_utf8 = state.setdefault("pending_text_utf8", [])
current_text_utf8 = (
segment_text_utf8.detach().to(device="cpu", dtype=torch.uint8).reshape(-1).tolist()
if isinstance(segment_text_utf8, torch.Tensor)
else []
)
if native_duplex and current_text_utf8 and not state.get("segment_text_recorded", False):
# Talker repeats the unit text on every codec step. Queue it once for
# the first Code2Wav payload that can carry audio for this unit.
pending_text_utf8.extend(current_text_utf8)
state["segment_text_recorded"] = True
request_finished = getattr(request, "is_finished", None)
finished = bool(is_finished or (callable(request_finished) and request_finished()))
chunk_frames, left_context_frames = _codec_config(transfer_manager)
if not finished and len(pending) < chunk_frames:
flush_pending = finished
last_chunk = bool(flush_pending and (not native_duplex or turn_end))
if not flush_pending and len(pending) < chunk_frames:
return None
new_token_count = len(pending) if finished else chunk_frames
hold_short_unit = (
native_duplex and flush_pending and not last_chunk and 0 < len(pending) < _MINICPMO45_MIN_STREAM_BODY_FRAMES
)
new_token_count = 0 if hold_short_unit else (len(pending) if flush_pending else chunk_frames)
new_codes = pending[:new_token_count]
del pending[:new_token_count]
codec_start = int(state["codec_end"])
@@ -292,53 +310,79 @@ def tts2code2wav_async_chunk(
else:
context = list(state["left_context"])
output_codes = [*context, *new_codes]
history = [*state["left_context"], *new_codes]
history = output_codes
state["left_context"] = history[-left_context_frames:] if left_context_frames else []
elif finished and codec_start > 0 and state["left_context"]:
elif last_chunk and codec_start > 0 and state["left_context"]:
context = list(state["left_context"])
output_codes = context
else:
context = []
output_codes = []
state["codec_end"] = codec_end
code_flat_numel = len(output_codes)
if native_duplex and code_flat_numel > 0:
segment_text_utf8 = torch.tensor(pending_text_utf8, dtype=torch.uint8)
pending_text_utf8.clear()
if native_duplex and finished:
state["segment_text_recorded"] = False
if flush_pending and not last_chunk and code_flat_numel == 0:
# Keep the generic generation connector model-agnostic: a real token
# makes this control-only TTS boundary schedulable, while the explicit
# zero length tells Code2Wav to discard the placeholder.
output_codes = [0]
last_chunk = bool(finished and not pending)
if last_chunk:
if last_chunk and not native_duplex:
record["retired_internal_ids"].add(internal_id)
_drop_codec_state(transfer_manager, request_id)
chunk_seq = int(getattr(transfer_manager, "put_req_chunk", {}).get(request_id, 0))
chunk_seq = int(record["chunk_seq"])
record["chunk_seq"] = chunk_seq + 1
ref_audio = None
ref_audio_sr = None
if int(record["cache_epoch"]) == 0 and chunk_seq == 0:
request_info = getattr(request, "additional_information", None)
if isinstance(request_info, Mapping):
codes_info = request_info.get("codes")
meta_info = request_info.get("meta")
raw_ref_audio = codes_info.get("ref") if isinstance(codes_info, Mapping) else None
raw_ref_audio_sr = meta_info.get("ref_audio_sr") if isinstance(meta_info, Mapping) else None
ref_audio_sr = _coerce_int(raw_ref_audio_sr)
if raw_ref_audio is not None:
ref_audio = torch.as_tensor(raw_ref_audio, dtype=torch.float32).reshape(-1).cpu()
finished_tensor = torch.tensor(last_chunk, dtype=torch.bool)
ref_audio = state.get("ref_audio") if codec_start == 0 else None
return OmniPayloadStruct(
payload = OmniPayloadStruct(
codes=CodesStruct(
audio=torch.tensor(output_codes, dtype=torch.long),
ref=torch.as_tensor(ref_audio, dtype=torch.float32).reshape(-1) if ref_audio is not None else None,
ref=ref_audio,
),
meta=_MiniCPMO45MetaStruct(
request_id=request_id,
chunk_seq=chunk_seq,
cache_epoch=int(record["cache_epoch"]),
code_flat_numel=len(output_codes),
code_flat_numel=code_flat_numel,
codec_chunk_frames=new_token_count,
codec_left_context_frames=len(context),
left_context_size=len(context),
last_chunk=last_chunk,
stream_finished=finished_tensor,
finished=finished_tensor,
is_segment_finished=finished_tensor,
req_id=[request_id],
ref_audio_sr=_coerce_int(state.get("ref_audio_sr")),
native_duplex_segment_text=(
str(state["segment_text"]) if isinstance(state.get("segment_text"), str) else None
),
duplex_turn_id=_coerce_int(state.get("duplex_turn_id")),
duplex_epoch=_coerce_int(state.get("duplex_epoch")),
segment_end=bool(state.get("segment_end", False)),
turn_end=bool(state.get("turn_end", False)),
tts_is_last_chunk=last_chunk,
duplex_epoch=duplex_epoch,
duplex_turn_id=duplex_turn_id,
llm_output_text_utf8=segment_text_utf8,
tts_is_last_chunk=flush_pending,
turn_end=turn_end and last_chunk,
ref_audio_sr=ref_audio_sr,
),
request_id=request_id,
)
if last_chunk and native_duplex:
record["last_terminal_turn"] = duplex_turn_key
record["cache_epoch"] = int(record["cache_epoch"]) + 1
record["chunk_seq"] = 0
_drop_codec_state(transfer_manager, request_id)
return payload
def tts2code2wav_full_payload(
@@ -496,13 +540,33 @@ def _native_tts_boundary_token_ids(special_token_ids):
}
def _decode_native_duplex_token_ids(
token_ids: list[int],
streaming_context,
*,
request_id: str,
) -> str | None:
decode_token_ids = getattr(streaming_context, "source_token_decoder", None)
if not isinstance(decode_token_ids, Callable):
return None
decode_ids = [int(token_id) for token_id in token_ids]
try:
try:
return str(decode_token_ids(decode_ids, skip_special_tokens=True))
except TypeError:
return str(decode_token_ids(decode_ids))
except Exception:
logger.exception("Failed to decode MiniCPM-o duplex token delta for request_id=%s", request_id)
return ""
def _native_duplex_segment_output_ids(
output_ids: list[int],
output_text: str,
streaming_context,
*,
request_id: str,
) -> tuple[list[int], str]:
) -> tuple[list[int], str, bool]:
"""Slice the cumulative thinker output down to the current segment.
Tracks how many output tokens were already handed to the talker in the
@@ -511,7 +575,7 @@ def _native_duplex_segment_output_ids(
"""
bridge_states = getattr(streaming_context, "bridge_states", None)
if not isinstance(bridge_states, dict):
return output_ids, output_text
return output_ids, output_text, True
state = bridge_states.setdefault("minicpmo45_tts_handoff", {})
duplex_state = bridge_states.get("duplex")
turn_id = duplex_state.get("model_turn_id", duplex_state.get("turn_id")) if isinstance(duplex_state, dict) else None
@@ -521,17 +585,12 @@ def _native_duplex_segment_output_ids(
state["request_id"] = request_id
state["sent_output_len"] = 0
state["sent_output_ids"] = []
state["acc_tts_ids"] = []
state["acc_tts_hidden"] = []
previous_turn_id = state.get("turn_id")
sent_len = state.get("sent_output_len", 0)
prev_output_ids = state.get("sent_output_ids", [])
prev_turn_id = state.get("turn_id")
if not isinstance(sent_len, int) or sent_len < 0 or sent_len > len(output_ids):
# Shrunken cumulative output = epoch reset after barge-in: the talker
# condition history is stale too.
# Shrunken cumulative output = epoch reset after barge-in.
sent_len = 0
state["acc_tts_ids"] = []
state["acc_tts_hidden"] = []
elif sent_len and isinstance(prev_output_ids, list):
prev_prefix = prev_output_ids[:sent_len]
current_prefix = output_ids[:sent_len]
@@ -540,27 +599,15 @@ def _native_duplex_segment_output_ids(
# others keep returning cumulative ids. Detect restart from the
# token prefix instead of clearing the cursor at turn_eos.
sent_len = 0
state["acc_tts_ids"] = []
state["acc_tts_hidden"] = []
if isinstance(prev_turn_id, int) and isinstance(turn_id, int) and prev_turn_id != turn_id:
# The thinker output list is cumulative across clean turns, so the
# output cursor must stay put. The talker condition is per assistant
# turn, though; carrying it across turn_id boundaries makes stage 1
# replay the previous turn after its consumed cursor has been reset.
state["acc_tts_ids"] = []
state["acc_tts_hidden"] = []
turn_start = sent_len == 0 or previous_turn_id != turn_id
segment_ids = output_ids[sent_len:]
decode_token_ids = getattr(streaming_context, "source_token_decoder", None)
if isinstance(decode_token_ids, Callable):
decode_ids = [int(token_id) for token_id in segment_ids]
try:
try:
segment_text = str(decode_token_ids(decode_ids, skip_special_tokens=True))
except TypeError:
segment_text = str(decode_token_ids(decode_ids))
except Exception:
logger.exception("Failed to decode MiniCPM-o duplex token delta for request_id=%s", request_id)
segment_text = ""
decoded_segment_text = _decode_native_duplex_token_ids(
segment_ids,
streaming_context,
request_id=request_id,
)
if decoded_segment_text is not None:
segment_text = decoded_segment_text
elif sent_len == 0:
# Non-orchestrator unit tests and legacy callers may not install a
# decoder. Never slice cumulative text with a stale character cursor.
@@ -575,22 +622,7 @@ def _native_duplex_segment_output_ids(
state["sent_output_len"] = len(output_ids)
state["sent_output_ids"] = list(output_ids)
state["turn_id"] = turn_id
return segment_ids, segment_text
def _reset_native_tts_handoff(streaming_context) -> None:
bridge_states = getattr(streaming_context, "bridge_states", None)
if not isinstance(bridge_states, dict):
return
state = bridge_states.get("minicpmo45_tts_handoff")
if not isinstance(state, dict):
return
# Keep the output cursor: the thinker can keep reporting cumulative
# output after turn_eos on the same resumable request. If a runtime really
# restarts token ids, _native_duplex_segment_output_ids detects the prefix
# mismatch and resets the slice cursor there.
state["acc_tts_ids"] = []
state["acc_tts_hidden"] = []
return segment_ids, segment_text, turn_start
def _native_duplex_data_plane_metadata(streaming_context) -> dict[str, object] | None:
@@ -623,32 +655,6 @@ def _native_duplex_data_plane_metadata(streaming_context) -> dict[str, object] |
return metadata
def _accumulate_native_tts_handoff(streaming_context, new_ids, new_hidden):
"""Hand the talker the FULL accumulated condition on every handoff.
The runner's streaming buffer update is not merge-safe for a resumable
stage-1 request: in-place updates merge sub-keys, but a resume prefill
REPLACES the buffer, silently dropping every earlier segment's tts
tokens/hiddens (observed losing alternating reply segments — the talker
vocalized text it never saw between islands it did). Accumulate here so
the latest handoff always carries the complete history and downstream
replace semantics are lossless; the talker consumes by cursor.
"""
bridge_states = getattr(streaming_context, "bridge_states", None)
if not isinstance(bridge_states, dict):
return new_ids, new_hidden
state = bridge_states.setdefault("minicpmo45_tts_handoff", {})
acc_ids = state.setdefault("acc_tts_ids", [])
acc_hidden = state.setdefault("acc_tts_hidden", [])
if new_ids:
acc_ids.extend(int(t) for t in new_ids)
if new_hidden:
acc_hidden.extend(new_hidden)
if not acc_ids:
return None, None
return list(acc_ids), list(acc_hidden)
def _build_tts_scheduler_prompt_token_ids(
tts_token_ids: torch.Tensor | None,
llm_output_ids: list[int],
@@ -723,6 +729,7 @@ def llm2tts(
# buffer overflows.
llm_output_ids = list(llm_output_ids)
thinker_text = getattr(output, "text", "") or ""
native_turn_start = False
if _has_native_duplex_prompt_metadata(mm_output):
# The thinker's resumable duplex request reports cumulative
# output ids/text, but earlier segments are already folded into
@@ -732,7 +739,7 @@ def llm2tts(
# alignment holds, the talker prompt grows linearly with new
# tokens instead of quadratically, and downstream transcripts
# carry per-unit deltas instead of re-sending the whole reply.
llm_output_ids, thinker_text = _native_duplex_segment_output_ids(
llm_output_ids, thinker_text, native_turn_start = _native_duplex_segment_output_ids(
llm_output_ids,
thinker_text,
_streaming_context,
@@ -875,6 +882,18 @@ def llm2tts(
.to(torch.float32)
.contiguous()
)
handoff_ids = _coerce_token_id_list(tts_token_ids_slice) if tts_token_ids_slice is not None else None
if is_native_duplex_handoff and handoff_ids:
handoff_text = _decode_native_duplex_token_ids(
handoff_ids,
_streaming_context,
request_id=str(llm_output.request_id),
)
if handoff_text is not None:
# Match the released streaming_generate contract: transcript
# text comes from total_ids_in_unit, the same slice that
# conditions the Talker.
thinker_text = handoff_text
model_intermediate_buffer = build_duplex_intermediate_buffer(
request_id=str(llm_output.request_id),
prompt_token_ids=prompt_token_ids,
@@ -900,6 +919,7 @@ def llm2tts(
# The talker detects turn end from <|turn_eos|> inside the
# handed condition (official conditions on its embedding).
meta["turn_eos_token_id"] = int(turn_eos_id)
meta["turn_start"] = native_turn_start
if native_segment_end:
meta["segment_end"] = True
req_mm_data = multi_modal_data.get(llm_output.request_id)
@@ -911,30 +931,27 @@ def llm2tts(
if ref_audio is not None:
ref_waveform, ref_sr = ref_audio
set_ref_audio(model_intermediate_buffer, _to_transport_list(ref_waveform), ref_sr)
handoff_ids = _coerce_token_id_list(tts_token_ids_slice) if tts_token_ids_slice is not None else None
handoff_hidden = _to_transport_list(tts_hidden_slice) if tts_hidden_slice is not None else None
native_turn_end_handoff = False
if is_native_duplex_handoff:
turn_eos_id = special_token_ids.get("turn_eos_token_id")
native_turn_end_handoff = turn_eos_id is not None and handoff_ids is not None and turn_eos_id in handoff_ids
handoff_ids, handoff_hidden = _accumulate_native_tts_handoff(
_streaming_context,
handoff_ids,
handoff_hidden,
)
if not handoff_ids:
continue
set_tts_handoff(model_intermediate_buffer, handoff_ids, handoff_hidden)
if native_turn_end_handoff:
model_intermediate_buffer.setdefault("meta", {})["turn_end"] = True
_reset_native_tts_handoff(_streaming_context)
if handoff_ids is not None and handoff_hidden is not None:
condition_length = max(len(handoff_ids), len(handoff_hidden)) + 2
condition_suffix_length = 1 if is_native_duplex_handoff else 2
condition_length = max(len(handoff_ids), len(handoff_hidden)) + condition_suffix_length
scheduler_prompt_token_ids = [0] * condition_length
handoff_meta = model_intermediate_buffer.setdefault("meta", {})
handoff_meta["replace_streaming_prompt"] = True
handoff_meta["next_stage_prompt_len"] = condition_length
# Native duplex resumes one Talker request within a turn, but a new
# assistant turn must discard the previous turn's prompt and KV.
if not is_native_duplex_handoff or native_turn_start:
handoff_meta["replace_streaming_prompt"] = True
else:
scheduler_prompt_token_ids = _build_tts_scheduler_prompt_token_ids(
tts_token_ids_slice,
@@ -685,19 +685,32 @@ def talker2code2wav_async_chunk(
"""
Multimodal output version.
"""
if not isinstance(multimodal_output, Mapping):
return None
talker_codes = multimodal_output.get("codes", {})
if not isinstance(talker_codes, dict):
return None
code_predictor_codes = talker_codes.get("audio")
if code_predictor_codes is None:
return None
request_id = request.external_req_id
code_predictor_codes = None
if isinstance(multimodal_output, Mapping):
talker_codes = multimodal_output.get("codes", {})
if isinstance(talker_codes, dict):
code_predictor_codes = talker_codes.get("audio")
if code_predictor_codes.numel() == 0:
return None
sampling_params = getattr(request, "sampling_params", None)
stop_token_ids = set(getattr(sampling_params, "stop_token_ids", None) or [])
stop_token_id = getattr(sampling_params, "stop_token_id", None)
if stop_token_id is not None:
stop_token_ids.add(stop_token_id)
if not code_predictor_codes.any():
append_codes = (
isinstance(code_predictor_codes, torch.Tensor)
and code_predictor_codes.numel() > 0
and bool(code_predictor_codes.any())
)
if append_codes:
first_codebook = int(code_predictor_codes[0, 0].item())
if first_codebook in stop_token_ids:
logger.debug("skip stop-token codec frame: first_codebook=%s", first_codebook)
append_codes = False
if append_codes:
transfer_manager.code_prompt_token_ids[request_id].append(code_predictor_codes)
elif not is_finished:
return None
connector = getattr(transfer_manager, "connector", None)
@@ -707,20 +720,10 @@ def talker2code2wav_async_chunk(
left_context_size_config = int(cfg.get("codec_left_context_frames", 25))
configured_initial_chunk_size = int(cfg.get("initial_codec_chunk_frames") or 0)
sampling_params = getattr(request, "sampling_params", None)
stop_token_ids = set(getattr(sampling_params, "stop_token_ids", None) or [])
stop_token_id = getattr(sampling_params, "stop_token_id", None)
if stop_token_id is not None:
stop_token_ids.add(stop_token_id)
first_codebook = int(code_predictor_codes[0, 0].item())
if first_codebook in stop_token_ids:
logger.debug("skip stop-token codec frame: first_codebook=%s", first_codebook)
return None
request_id = request.external_req_id
chunk_id = transfer_manager.put_req_chunk[request_id]
transfer_manager.code_prompt_token_ids[request_id].append(code_predictor_codes)
length = len(transfer_manager.code_prompt_token_ids[request_id])
if length <= 0:
return None
if configured_initial_chunk_size > 0:
if chunk_id == 0:
@@ -732,6 +735,9 @@ def talker2code2wav_async_chunk(
if chunk_length != 0 and not is_finished:
return None
if is_finished and not append_codes and chunk_length == 0:
return None
context_length = chunk_length if chunk_length != 0 else chunk_size_config
# ensure left context does not exceed available length
if configured_initial_chunk_size > 0 and chunk_id == 1: