[Bugfix] Use Seed-TTS reference audio with MiniCPM-o 4.5 (#6628)
Signed-off-by: akshatvishu <akshatnayak197@gmail.com>
This commit is contained in:
1 parent
4cca28e9bd
commit
f321cdbe16
10 files changed
+529
-56
No files matched your search
@@ -16,9 +16,14 @@ import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from vllm.benchmarks.lib.endpoint_request_func import RequestFuncInput
|
||||
|
||||
from vllm_omni.benchmarks.data_modules.seed_tts_dataset import (
|
||||
SeedTTSSampleRequest,
|
||||
SeedTTSTextSampleRequest,
|
||||
)
|
||||
from vllm_omni.benchmarks.patch.patch import (
|
||||
MixRequestFuncOutput,
|
||||
_apply_stage0_token_timings,
|
||||
_attach_seed_tts_to_request_func_input,
|
||||
async_request_openai_chat_omni_completions,
|
||||
async_request_openai_realtime_duplex,
|
||||
should_request_stage_metrics,
|
||||
@@ -28,6 +33,74 @@ from vllm_omni.experimental.fullduplex.client import RealtimeEventCollector
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.benchmark, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _seed_tts_request_func_input() -> RequestFuncInput:
|
||||
return RequestFuncInput(
|
||||
model="test-model",
|
||||
model_name="test-model",
|
||||
prompt="target text",
|
||||
api_url="http://test.com/v1/chat/completions",
|
||||
prompt_len=2,
|
||||
output_len=20,
|
||||
extra_body={"modalities": ["text", "audio"]},
|
||||
)
|
||||
|
||||
|
||||
def test_seed_tts_chat_request_carries_reference_audio_once() -> None:
|
||||
reference_audio_url = "data:audio/wav;base64,AAAA"
|
||||
sample = SeedTTSSampleRequest(
|
||||
prompt="target text",
|
||||
prompt_len=2,
|
||||
expected_output_len=20,
|
||||
multi_modal_data=None,
|
||||
seed_tts_system_prompt="Clone the supplied voice.",
|
||||
seed_tts_speech_extra={
|
||||
"ref_audio": reference_audio_url,
|
||||
"ref_text": "reference text",
|
||||
"task_type": "Base",
|
||||
},
|
||||
)
|
||||
request_func_input = _seed_tts_request_func_input()
|
||||
|
||||
_attach_seed_tts_to_request_func_input(sample, request_func_input)
|
||||
|
||||
assert request_func_input.omni_chat_messages == [
|
||||
{
|
||||
"role": "system",
|
||||
"content": [{"type": "text", "text": "Clone the supplied voice."}],
|
||||
},
|
||||
{"role": "user", "content": [{"type": "text", "text": "target text"}]},
|
||||
]
|
||||
assert request_func_input.extra_body["ref_audio"] == reference_audio_url
|
||||
serialized_request = json.dumps(
|
||||
{"messages": request_func_input.omni_chat_messages, **request_func_input.extra_body},
|
||||
)
|
||||
assert serialized_request.count(reference_audio_url) == 1
|
||||
|
||||
|
||||
def test_seed_tts_text_chat_messages_do_not_add_reference_audio() -> None:
|
||||
sample = SeedTTSTextSampleRequest(
|
||||
prompt="target text",
|
||||
prompt_len=2,
|
||||
expected_output_len=20,
|
||||
multi_modal_data=None,
|
||||
seed_tts_system_prompt="Use the configured voice.",
|
||||
seed_tts_speech_extra=None,
|
||||
)
|
||||
request_func_input = _seed_tts_request_func_input()
|
||||
|
||||
_attach_seed_tts_to_request_func_input(sample, request_func_input)
|
||||
|
||||
assert getattr(request_func_input, "seed_tts_row", False) is True
|
||||
assert request_func_input.omni_chat_messages == [
|
||||
{
|
||||
"role": "system",
|
||||
"content": [{"type": "text", "text": "Use the configured voice."}],
|
||||
},
|
||||
{"role": "user", "content": [{"type": "text", "text": "target text"}]},
|
||||
]
|
||||
assert "ref_audio" not in request_func_input.extra_body
|
||||
|
||||
|
||||
class MockResponse:
|
||||
"""Mock aiohttp response for testing"""
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Tests for SeedTTSTextDataset, SeedTTSTextSampleRequest, SeedTTSDesignDataset,
|
||||
and SeedTTSDesignSampleRequest.
|
||||
|
||||
@@ -234,34 +237,6 @@ def test_seed_tts_design_dataset_rejects_missing_description(seed_tts_design_roo
|
||||
assert req.seed_tts_utterance_id == "ok"
|
||||
|
||||
|
||||
def test_attach_sets_seed_tts_row_even_without_extra_body():
|
||||
"""seed_tts_row=True must be set for SeedTTSTextSampleRequest (no extra body)."""
|
||||
from vllm_omni.benchmarks.data_modules.seed_tts_dataset import SeedTTSTextSampleRequest
|
||||
|
||||
req = SeedTTSTextSampleRequest(
|
||||
prompt="hello world",
|
||||
prompt_len=2,
|
||||
expected_output_len=100,
|
||||
multi_modal_data=None,
|
||||
request_id="test-0",
|
||||
seed_tts_speech_extra=None,
|
||||
seed_tts_ref_wav_path="",
|
||||
)
|
||||
assert req.seed_tts_speech_extra is None
|
||||
assert req.seed_tts_ref_wav_path == ""
|
||||
# The fix ensures that even with speech_extra=None, the function
|
||||
# sets seed_tts_row=True. We verify the source code has the fix.
|
||||
import inspect
|
||||
|
||||
import vllm_omni.benchmarks.patch.patch as patch_mod
|
||||
|
||||
src = inspect.getsource(patch_mod._attach_seed_tts_to_request_func_input)
|
||||
# seed_tts_row must be set BEFORE the 'if not ex: return' check
|
||||
row_pos = src.index("seed_tts_row")
|
||||
not_ex_pos = src.index("if not ex:")
|
||||
assert row_pos < not_ex_pos, "seed_tts_row must be set before 'if not ex: return'"
|
||||
|
||||
|
||||
def test_seed_tts_whisper_transcribe_passes_attention_mask(monkeypatch):
|
||||
from vllm_omni.benchmarks.data_modules import seed_tts_eval
|
||||
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""E2E online tests for MiniCPM-o 4.5 multimodal input and audio/text output.
|
||||
|
||||
Exercises async chunk streaming (``--async-chunk``) across separate Thinker,
|
||||
Talker, and Code2Wav stages.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from vllm.logger import DEFAULT_LOGGING_CONFIG
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.media import generate_synthetic_audio, generate_synthetic_image, generate_synthetic_video
|
||||
@@ -23,6 +31,23 @@ _CI_DEPLOY = modify_stage_config(
|
||||
},
|
||||
)
|
||||
|
||||
_PROMPT_LOG_TEMP_DIR = tempfile.TemporaryDirectory(prefix="minicpmo45-prompt-selection-")
|
||||
_PROMPT_SELECTION_LOG = Path(_PROMPT_LOG_TEMP_DIR.name) / "prompt-selection.log"
|
||||
_PROMPT_LOGGING_CONFIG_PATH = Path(_PROMPT_LOG_TEMP_DIR.name) / "logging.json"
|
||||
_PROMPT_LOGGING_CONFIG = deepcopy(DEFAULT_LOGGING_CONFIG)
|
||||
_PROMPT_LOGGING_CONFIG["handlers"]["minicpmo_prompt_selection"] = {
|
||||
"class": "logging.FileHandler",
|
||||
"filename": str(_PROMPT_SELECTION_LOG),
|
||||
"formatter": "vllm",
|
||||
"level": "DEBUG",
|
||||
}
|
||||
_PROMPT_LOGGING_CONFIG["loggers"]["vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_code2wav"] = {
|
||||
"handlers": ["minicpmo_prompt_selection"],
|
||||
"level": "DEBUG",
|
||||
"propagate": True,
|
||||
}
|
||||
_PROMPT_LOGGING_CONFIG_PATH.write_text(json.dumps(_PROMPT_LOGGING_CONFIG), encoding="utf-8")
|
||||
|
||||
test_params = [
|
||||
pytest.param(
|
||||
OmniServerParams(
|
||||
@@ -30,6 +55,10 @@ test_params = [
|
||||
stage_config_path=_CI_DEPLOY,
|
||||
use_stage_cli=False,
|
||||
server_args=["--trust-remote-code", "--async-chunk"],
|
||||
env_dict={
|
||||
"VLLM_CONFIGURE_LOGGING": "1",
|
||||
"VLLM_LOGGING_CONFIG_PATH": str(_PROMPT_LOGGING_CONFIG_PATH),
|
||||
},
|
||||
),
|
||||
id="async_chunk",
|
||||
),
|
||||
@@ -74,6 +103,18 @@ _TTS_EXTRA_BODY = {
|
||||
}
|
||||
|
||||
|
||||
def _prompt_selection_log_offset() -> int:
|
||||
return _PROMPT_SELECTION_LOG.stat().st_size if _PROMPT_SELECTION_LOG.exists() else 0
|
||||
|
||||
|
||||
def _read_prompt_selection_log(offset: int) -> str:
|
||||
if not _PROMPT_SELECTION_LOG.exists():
|
||||
return ""
|
||||
with open(_PROMPT_SELECTION_LOG, "rb") as log_file:
|
||||
log_file.seek(offset)
|
||||
return log_file.read().decode("utf-8")
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.omni
|
||||
@@ -126,6 +167,51 @@ def test_text_to_audio_001(omni_server, openai_client) -> None:
|
||||
openai_client.send_omni_request(request_config)
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_text_to_audio_with_reference_audio(omni_server, openai_client) -> None:
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
"messages": dummy_messages_from_mix_data(system_prompt=get_system_prompt(), content_text=get_prompt()),
|
||||
"stream": True,
|
||||
"modalities": ["text", "audio"],
|
||||
"extra_body": {
|
||||
**_TTS_EXTRA_BODY,
|
||||
"ref_audio": f"data:audio/wav;base64,{generate_synthetic_audio(5, 1)['base64']}",
|
||||
},
|
||||
}
|
||||
|
||||
log_offset = _prompt_selection_log_offset()
|
||||
responses = openai_client.send_omni_request(request_config)
|
||||
prompt_log = _read_prompt_selection_log(log_offset)
|
||||
|
||||
assert responses[0].success
|
||||
assert responses[0].audio_bytes
|
||||
assert "prompt_cache_id=runtime-ref-" in prompt_log
|
||||
assert "minicpmo45_ref_" in prompt_log
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_text_to_audio_with_default_reference(omni_server, openai_client) -> None:
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
"messages": dummy_messages_from_mix_data(system_prompt=get_system_prompt(), content_text=get_prompt()),
|
||||
"stream": True,
|
||||
"modalities": ["text", "audio"],
|
||||
"extra_body": _TTS_EXTRA_BODY,
|
||||
}
|
||||
|
||||
responses = openai_client.send_omni_request(request_config)
|
||||
|
||||
assert responses[0].success
|
||||
assert responses[0].audio_bytes
|
||||
|
||||
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Regression tests for MiniCPM-o 4.5 chat reference audio transport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from pytest_mock import MockerFixture
|
||||
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
|
||||
from vllm.inputs import TokensPrompt
|
||||
|
||||
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.pipeline import MINICPMO45_REFERENCE_AUDIO_KEY
|
||||
from vllm_omni.model_executor.stage_input_processors.minicpmo_4_5_omni import (
|
||||
llm2tts,
|
||||
tts2code2wav_async_chunk,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _EngineClientStub:
|
||||
stage_configs: list[dict[str, object]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ModelConfigStub:
|
||||
allowed_local_media_path: str = ""
|
||||
allowed_media_domains: list[str] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ThinkerCompletionStub:
|
||||
token_ids: list[int]
|
||||
text: str
|
||||
hidden_states: torch.Tensor
|
||||
multimodal_output: dict[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ThinkerRequestOutputStub:
|
||||
request_id: str
|
||||
prompt_token_ids: list[int]
|
||||
outputs: list[_ThinkerCompletionStub]
|
||||
multimodal_output: dict[str, object] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Code2WavRequestStub:
|
||||
external_req_id: str
|
||||
request_id: str
|
||||
model_intermediate_buffer: dict[str, object]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _TransferManagerStub:
|
||||
request_payload: dict[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def serving_chat() -> OmniOpenAIServingChat:
|
||||
return object.__new__(OmniOpenAIServingChat)
|
||||
|
||||
|
||||
def _stage_config(model_arch: str) -> dict[str, object]:
|
||||
return {"engine_args": {"model_arch": model_arch}}
|
||||
|
||||
|
||||
def _chat_request(reference_audio_url: str) -> ChatCompletionRequest:
|
||||
return ChatCompletionRequest(
|
||||
model="test-model",
|
||||
messages=[{"role": "user", "content": "target text"}],
|
||||
ref_audio=reference_audio_url,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_reference_audio_reaches_code2wav_transport(
|
||||
serving_chat: OmniOpenAIServingChat,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
reference_audio_url = "data:audio/wav;base64,AAAA"
|
||||
reference_waveform = np.array([0.25, -0.5], dtype=np.float32)
|
||||
serving_chat.engine_client = _EngineClientStub(
|
||||
[_stage_config("MiniCPMO45OmniForConditionalGeneration")],
|
||||
)
|
||||
serving_chat.model_config = _ModelConfigStub()
|
||||
engine_prompt: TokensPrompt = {"type": "tokens", "prompt_token_ids": [1]}
|
||||
media_connector_class = mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.serving_chat.MediaConnector",
|
||||
)
|
||||
media_connector = media_connector_class.return_value
|
||||
media_connector.fetch_audio_async = mocker.AsyncMock(return_value=(reference_waveform, 16000))
|
||||
|
||||
await serving_chat._attach_minicpmo45_reference_audio(
|
||||
engine_prompt,
|
||||
_chat_request(reference_audio_url),
|
||||
)
|
||||
|
||||
media_connector_class.assert_called_once_with(
|
||||
media_io_kwargs=None,
|
||||
allowed_local_media_path="",
|
||||
allowed_media_domains=None,
|
||||
)
|
||||
media_connector.fetch_audio_async.assert_awaited_once_with(reference_audio_url)
|
||||
preserved_reference = cast(dict[str, object], engine_prompt)[MINICPMO45_REFERENCE_AUDIO_KEY]
|
||||
assert isinstance(preserved_reference, tuple)
|
||||
preserved_waveform, preserved_sample_rate = preserved_reference
|
||||
assert isinstance(preserved_waveform, np.ndarray)
|
||||
assert isinstance(preserved_sample_rate, int)
|
||||
np.testing.assert_array_equal(preserved_waveform, reference_waveform)
|
||||
assert preserved_sample_rate == 16000
|
||||
|
||||
thinker_output = _ThinkerRequestOutputStub(
|
||||
request_id="request-0",
|
||||
prompt_token_ids=[10],
|
||||
outputs=[
|
||||
_ThinkerCompletionStub(
|
||||
token_ids=[20],
|
||||
text="target text",
|
||||
hidden_states=torch.zeros((2, 4)),
|
||||
)
|
||||
],
|
||||
)
|
||||
talker_prompt = llm2tts([thinker_output], prompt=engine_prompt)[0]
|
||||
talker_request = _Code2WavRequestStub(
|
||||
external_req_id="request-0",
|
||||
request_id="request-0",
|
||||
model_intermediate_buffer=talker_prompt["model_intermediate_buffer"],
|
||||
)
|
||||
transfer_manager = _TransferManagerStub()
|
||||
|
||||
code2wav_payload = tts2code2wav_async_chunk(
|
||||
transfer_manager,
|
||||
{"codes": {"audio": torch.arange(7).reshape(-1, 1)}},
|
||||
talker_request,
|
||||
True,
|
||||
)
|
||||
|
||||
assert code2wav_payload is not None
|
||||
torch.testing.assert_close(code2wav_payload.codes.ref, torch.from_numpy(reference_waveform))
|
||||
assert code2wav_payload.meta.ref_audio_sr == 16000
|
||||
|
||||
|
||||
@pytest.mark.parametrize("request_shape", ["extra_body", "model_extra"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_reference_audio_accepts_request_extra_mappings(
|
||||
serving_chat: OmniOpenAIServingChat,
|
||||
mocker: MockerFixture,
|
||||
request_shape: str,
|
||||
) -> None:
|
||||
reference_audio_url = "data:audio/wav;base64,AAAA"
|
||||
reference_waveform = np.array([0.25, -0.5], dtype=np.float32)
|
||||
serving_chat.engine_client = _EngineClientStub(
|
||||
[_stage_config("MiniCPMO45OmniForConditionalGeneration")],
|
||||
)
|
||||
serving_chat.model_config = _ModelConfigStub()
|
||||
engine_prompt: TokensPrompt = {"type": "tokens", "prompt_token_ids": [1]}
|
||||
if request_shape == "extra_body":
|
||||
request = ChatCompletionRequest(
|
||||
model="test-model",
|
||||
messages=[{"role": "user", "content": "target text"}],
|
||||
extra_body={"ref_audio": reference_audio_url},
|
||||
)
|
||||
else:
|
||||
request = SimpleNamespace(model_extra={"ref_audio": reference_audio_url})
|
||||
assert not hasattr(request, "ref_audio")
|
||||
media_connector = mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.serving_chat.MediaConnector",
|
||||
).return_value
|
||||
media_connector.fetch_audio_async = mocker.AsyncMock(return_value=(reference_waveform, 16000))
|
||||
|
||||
await serving_chat._attach_minicpmo45_reference_audio(engine_prompt, request)
|
||||
|
||||
media_connector.fetch_audio_async.assert_awaited_once_with(reference_audio_url)
|
||||
preserved_reference = cast(dict[str, object], engine_prompt)[MINICPMO45_REFERENCE_AUDIO_KEY]
|
||||
assert isinstance(preserved_reference, tuple)
|
||||
np.testing.assert_array_equal(preserved_reference[0], reference_waveform)
|
||||
assert preserved_reference[1] == 16000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reference_audio_is_ignored_for_other_models(
|
||||
serving_chat: OmniOpenAIServingChat,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
reference_audio_url = "data:audio/wav;base64,AAAA"
|
||||
serving_chat.engine_client = _EngineClientStub([_stage_config("SomeOtherOmniForConditionalGeneration")])
|
||||
engine_prompt: TokensPrompt = {"type": "tokens", "prompt_token_ids": [1]}
|
||||
media_connector_class = mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.serving_chat.MediaConnector",
|
||||
)
|
||||
|
||||
await serving_chat._attach_minicpmo45_reference_audio(
|
||||
engine_prompt,
|
||||
_chat_request(reference_audio_url),
|
||||
)
|
||||
|
||||
media_connector_class.assert_not_called()
|
||||
assert MINICPMO45_REFERENCE_AUDIO_KEY not in cast(dict[str, object], engine_prompt)
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Unit tests for the MiniCPM-o 4.5 stage 0 -> stage 1 bridge.
|
||||
|
||||
Covers ``vllm_omni.model_executor.stage_input_processors.minicpmo_4_5_omni.llm2tts``:
|
||||
@@ -25,6 +25,7 @@ import torch
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni import (
|
||||
MiniCPMO45OmniForConditionalGeneration,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.pipeline import MINICPMO45_REFERENCE_AUDIO_KEY
|
||||
from vllm_omni.model_executor.stage_input_processors.minicpmo_4_5_omni import llm2tts
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
@@ -313,6 +314,44 @@ class TestPromptAndMultiModal:
|
||||
)
|
||||
assert out[0]["multi_modal_data"] == mm
|
||||
|
||||
def test_reference_audio_is_added_to_stage_handoff(self) -> None:
|
||||
hidden_states = torch.zeros((2, _HIDDEN_DIM))
|
||||
waveform = torch.tensor([0.25, -0.5], dtype=torch.float32)
|
||||
|
||||
stage_outputs = llm2tts(
|
||||
[_make_thinker_output(prompt_token_ids=[10], output_token_ids=[20], hidden_states=hidden_states)],
|
||||
prompt={"multi_modal_data": {"audio": (waveform, 16000)}},
|
||||
)
|
||||
|
||||
intermediate_buffer = stage_outputs[0]["model_intermediate_buffer"]
|
||||
assert intermediate_buffer["codes"]["ref"] == [0.25, -0.5]
|
||||
assert intermediate_buffer["meta"]["ref_audio_sr"] == 16000
|
||||
|
||||
def test_serving_reference_audio_is_added_to_stage_handoff(self) -> None:
|
||||
hidden_states = torch.zeros((2, _HIDDEN_DIM))
|
||||
waveform = torch.tensor([0.125, -0.25], dtype=torch.float32)
|
||||
|
||||
stage_outputs = llm2tts(
|
||||
[_make_thinker_output(prompt_token_ids=[10], output_token_ids=[20], hidden_states=hidden_states)],
|
||||
prompt={MINICPMO45_REFERENCE_AUDIO_KEY: (waveform, 24000)},
|
||||
)
|
||||
|
||||
intermediate_buffer = stage_outputs[0]["model_intermediate_buffer"]
|
||||
assert intermediate_buffer["codes"]["ref"] == [0.125, -0.25]
|
||||
assert intermediate_buffer["meta"]["ref_audio_sr"] == 24000
|
||||
|
||||
def test_missing_reference_audio_does_not_add_stage_handoff_fields(self) -> None:
|
||||
hidden_states = torch.zeros((2, _HIDDEN_DIM))
|
||||
|
||||
stage_outputs = llm2tts(
|
||||
[_make_thinker_output(prompt_token_ids=[10], output_token_ids=[20], hidden_states=hidden_states)],
|
||||
prompt={"multi_modal_data": {}},
|
||||
)
|
||||
|
||||
intermediate_buffer = stage_outputs[0]["model_intermediate_buffer"]
|
||||
assert "codes" not in intermediate_buffer
|
||||
assert "ref_audio_sr" not in intermediate_buffer.get("meta", {})
|
||||
|
||||
def test_internal_streaming_context_is_accepted(self) -> None:
|
||||
hidden = torch.zeros((2, _HIDDEN_DIM))
|
||||
out = llm2tts(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from collections import defaultdict
|
||||
from types import SimpleNamespace
|
||||
@@ -168,10 +168,14 @@ def test_duplex_turn_end_waits_for_terminal_codec_flush() -> None:
|
||||
def test_first_chunk_forwards_reference_voice_and_duplex_identity() -> None:
|
||||
manager = _manager()
|
||||
request = _request("req")
|
||||
request.additional_information = {
|
||||
request.model_intermediate_buffer = {
|
||||
"codes": {"ref": [0.1, -0.1]},
|
||||
"meta": {"ref_audio_sr": 16000},
|
||||
}
|
||||
request.additional_information = {
|
||||
"codes": {"ref": [0.9]},
|
||||
"meta": {"ref_audio_sr": 8000},
|
||||
}
|
||||
|
||||
payload = tts2code2wav_async_chunk(
|
||||
manager,
|
||||
@@ -193,10 +197,33 @@ def test_first_chunk_forwards_reference_voice_and_duplex_identity() -> None:
|
||||
assert payload.meta.turn_end is True
|
||||
|
||||
|
||||
def test_first_chunk_falls_back_to_legacy_reference_fields() -> None:
|
||||
manager = _manager()
|
||||
request = _request("req")
|
||||
request.model_intermediate_buffer = {
|
||||
"meta": {"ref_audio_sr": 16000},
|
||||
}
|
||||
request.additional_information = {
|
||||
"codes": {"ref": [0.9]},
|
||||
"meta": {"ref_audio_sr": 8000},
|
||||
}
|
||||
|
||||
payload = tts2code2wav_async_chunk(
|
||||
manager,
|
||||
_duplex_delta(*range(7), turn_end=True),
|
||||
request,
|
||||
True,
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
assert payload.codes.ref.tolist() == pytest.approx([0.9])
|
||||
assert payload.meta.ref_audio_sr == 16000
|
||||
|
||||
|
||||
def test_full_payload_forwards_all_codes_and_request_metadata() -> None:
|
||||
manager = _manager()
|
||||
request = _request("req")
|
||||
request.additional_information = {
|
||||
request.model_intermediate_buffer = {
|
||||
"codes": {"ref": [0.1, -0.1]},
|
||||
"meta": {
|
||||
"ref_audio_sr": 16000,
|
||||
|
||||
@@ -42,6 +42,7 @@ from vllm_omni.metrics.modality import (
|
||||
observe_audio_first_packet,
|
||||
observe_audio_streaming_finalize,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.pipeline import MINICPMO45_REFERENCE_AUDIO_KEY
|
||||
from vllm_omni.model_extras import (
|
||||
get_extra_body_params,
|
||||
get_extra_output_params,
|
||||
@@ -249,6 +250,44 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _attach_minicpmo45_reference_audio(
|
||||
self,
|
||||
engine_prompt: TokPrompt,
|
||||
request: ChatLikeRequest | ResponsesRequest,
|
||||
) -> None:
|
||||
if not self._has_minicpmo45_stage():
|
||||
return
|
||||
|
||||
reference_audio_source = getattr(request, "ref_audio", None)
|
||||
extra_body = getattr(request, "extra_body", None)
|
||||
model_extra = getattr(request, "model_extra", None)
|
||||
if reference_audio_source is None and isinstance(extra_body, dict):
|
||||
reference_audio_source = extra_body.get("ref_audio")
|
||||
if reference_audio_source is None and isinstance(model_extra, dict):
|
||||
nested_extra_body = model_extra.get("extra_body")
|
||||
if isinstance(nested_extra_body, dict):
|
||||
reference_audio_source = nested_extra_body.get("ref_audio")
|
||||
if reference_audio_source is None:
|
||||
reference_audio_source = model_extra.get("ref_audio")
|
||||
if reference_audio_source is None:
|
||||
return
|
||||
if not isinstance(reference_audio_source, str):
|
||||
raise ValueError("MiniCPM-o 4.5 chat ref_audio must be a URI string")
|
||||
|
||||
model_config = self.model_config
|
||||
media_connector = MediaConnector(
|
||||
media_io_kwargs=getattr(request, "media_io_kwargs", None),
|
||||
allowed_local_media_path=getattr(model_config, "allowed_local_media_path", "") or "",
|
||||
allowed_media_domains=getattr(model_config, "allowed_media_domains", None),
|
||||
)
|
||||
reference_waveform, reference_sample_rate = await media_connector.fetch_audio_async(reference_audio_source)
|
||||
# The renderer keeps processed Stage-0 features, not the source waveform.
|
||||
# Keep the decoded audio only on the original prompt for the downstream bridge.
|
||||
cast(dict[str, object], engine_prompt)[MINICPMO45_REFERENCE_AUDIO_KEY] = (
|
||||
reference_waveform,
|
||||
reference_sample_rate,
|
||||
)
|
||||
|
||||
def _fix_minicpmo45_audio_stream_output_kinds(
|
||||
self,
|
||||
sampling_params_list: list[Any],
|
||||
@@ -941,6 +980,8 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
|
||||
prompt_additional_information = self._ensure_prompt_additional_information(engine_prompt)
|
||||
prompt_additional_information["deferred_multi_modal_data"] = deferred_multi_modal_data
|
||||
|
||||
await self._attach_minicpmo45_reference_audio(engine_prompt, request)
|
||||
|
||||
speaker = getattr(request, "voice", None) or getattr(request, "speaker", None)
|
||||
normalized = validate_requested_speaker(speaker, self._get_supported_speakers())
|
||||
if normalized is not None:
|
||||
|
||||
@@ -272,6 +272,11 @@ class MiniCPMO45Code2Wav(nn.Module):
|
||||
ref_audio,
|
||||
meta.get("ref_audio_sr"),
|
||||
)
|
||||
logger.debug(
|
||||
"MiniCPM-o Code2Wav selected runtime reference prompt_cache_id=%s prompt_wav=%s",
|
||||
entry.cache_id,
|
||||
entry.path,
|
||||
)
|
||||
return entry.cache_id, entry.path, cache_key
|
||||
|
||||
if previous is not None:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""MiniCPM-o 4.5 pipeline topology (frozen).
|
||||
|
||||
Stage 0: Thinker — multimodal understanding + text generation.
|
||||
@@ -17,6 +17,7 @@ from vllm_omni.config.stage_config import (
|
||||
)
|
||||
|
||||
_PROC = "vllm_omni.model_executor.stage_input_processors.minicpmo_4_5_omni"
|
||||
MINICPMO45_REFERENCE_AUDIO_KEY = "_minicpmo45_reference_audio"
|
||||
|
||||
|
||||
MINICPMO_4_5_PIPELINE = PipelineConfig(
|
||||
|
||||
@@ -17,6 +17,7 @@ from vllm_omni.experimental.fullduplex.engine.intermediate import (
|
||||
)
|
||||
from vllm_omni.inputs.data import OmniTokensPrompt
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5 import MINICPMO45_DUPLEX_CODEC_TOKENS_PER_CHUNK
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.pipeline import MINICPMO45_REFERENCE_AUDIO_KEY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_MINICPMO45_ASYNC_STATE = "_minicpmo45_async_codec_state"
|
||||
@@ -71,6 +72,23 @@ def _extract_first_audio_ref(multi_modal_data):
|
||||
return waveform.reshape(-1).cpu(), int(sample_rate)
|
||||
|
||||
|
||||
def _extract_prompt_reference_audio(
|
||||
prompt_item: OmniTokensPrompt | TextPrompt | None,
|
||||
) -> tuple[torch.Tensor, int] | None:
|
||||
if isinstance(prompt_item, Mapping):
|
||||
multi_modal_data = prompt_item.get("multi_modal_data")
|
||||
serving_reference_audio = prompt_item.get(MINICPMO45_REFERENCE_AUDIO_KEY)
|
||||
else:
|
||||
multi_modal_data = getattr(prompt_item, "multi_modal_data", None)
|
||||
serving_reference_audio = getattr(prompt_item, MINICPMO45_REFERENCE_AUDIO_KEY, None)
|
||||
|
||||
reference_audio = _extract_first_audio_ref(multi_modal_data)
|
||||
if reference_audio is not None:
|
||||
return reference_audio
|
||||
|
||||
return _extract_first_audio_ref({"audio": serving_reference_audio})
|
||||
|
||||
|
||||
def _extract_native_runtime_ref_audio(data_plane_metadata):
|
||||
if not isinstance(data_plane_metadata, dict):
|
||||
return None
|
||||
@@ -142,6 +160,17 @@ def _codec_config(transfer_manager: Any) -> tuple[int, int]:
|
||||
return chunk_frames, left_context_frames
|
||||
|
||||
|
||||
def _request_intermediate_section(request: object, section: str) -> dict[str, object]:
|
||||
merged: dict[str, object] = {}
|
||||
for attribute in ("additional_information", "model_intermediate_buffer"):
|
||||
information = getattr(request, attribute, None)
|
||||
if isinstance(information, Mapping):
|
||||
values = information.get(section)
|
||||
if isinstance(values, Mapping):
|
||||
merged.update(values)
|
||||
return merged
|
||||
|
||||
|
||||
def _codec_scalars(value: Any) -> list[int]:
|
||||
"""Normalize one request-routed codec delta to CPU scalar token IDs."""
|
||||
if value is None:
|
||||
@@ -349,15 +378,13 @@ def tts2code2wav_async_chunk(
|
||||
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()
|
||||
codes_info = _request_intermediate_section(request, "codes")
|
||||
meta_info = _request_intermediate_section(request, "meta")
|
||||
raw_ref_audio = codes_info.get("ref")
|
||||
raw_ref_audio_sr = meta_info.get("ref_audio_sr")
|
||||
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)
|
||||
payload = OmniPayloadStruct(
|
||||
codes=CodesStruct(
|
||||
@@ -409,18 +436,9 @@ def tts2code2wav_full_payload(
|
||||
context = [_MINICPMO45_SILENCE_CODE] * left_context_frames if codes else []
|
||||
output_codes = [*context, *codes]
|
||||
|
||||
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 = {}
|
||||
codes_info = _request_intermediate_section(request, "codes")
|
||||
meta_info = _request_intermediate_section(request, "meta")
|
||||
duplex_info = _request_intermediate_section(request, "duplex")
|
||||
|
||||
ref_audio = codes_info.get("ref")
|
||||
finished = torch.tensor(True, dtype=torch.bool)
|
||||
@@ -722,11 +740,13 @@ def llm2tts(
|
||||
prompt = [prompt]
|
||||
|
||||
multi_modal_data = {}
|
||||
reference_audio_by_request_id = {}
|
||||
for llm_output, p in zip(llm_outputs, prompt):
|
||||
if isinstance(p, dict):
|
||||
multi_modal_data[llm_output.request_id] = p.get("multi_modal_data", None)
|
||||
else:
|
||||
multi_modal_data[llm_output.request_id] = getattr(p, "multi_modal_data", None)
|
||||
reference_audio_by_request_id[llm_output.request_id] = _extract_prompt_reference_audio(p)
|
||||
|
||||
for llm_output in llm_outputs:
|
||||
output = llm_output.outputs[0]
|
||||
@@ -961,8 +981,7 @@ def llm2tts(
|
||||
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)
|
||||
ref_audio = _extract_first_audio_ref(req_mm_data)
|
||||
ref_audio = reference_audio_by_request_id.get(llm_output.request_id)
|
||||
if ref_audio is None:
|
||||
ref_audio = _extract_native_runtime_ref_audio(
|
||||
model_intermediate_buffer.get("duplex"),
|
||||
|
||||
Reference in new issue
Block a user