[Model][JoyAI-VL-Interaction] Add native multi-stage pipeline (#5352)

Signed-off-by: Shuolei Wang <shuoleiwang123@gmail.com>
This commit is contained in:
Shuolei Wang authored and GitHub committed 2026-09-08 05:07:08 -07:00
1 parent d7740336ff
commit 5378b77be4
11 files changed
+638 -30

No files matched your search

+48
View File
@@ -219,3 +219,51 @@ the audio-track caveat.
5 chunks, `key_frames=0` = summarize all chunk frames) are aligned to the JoyVL
reference adapter so per-tick behavior matches the released model; the framework only
supplies the serving structure.
## Native multi-stage pipeline (experimental)
An experimental native speech path is available alongside the existing Day-0 serving path:
```text
one request with image/video and text
-> JoyAI complete action text
-> Qwen3-TTS Talker
-> Code2Wav
-> 24 kHz audio
```
It runs behind one `vllm serve --omni` endpoint:
```bash
vllm serve jdopensource/JoyAI-VL-Interaction-Preview --omni \
--deploy-config vllm_omni/deploy/joyai_vl_interaction.yaml \
--port 8091
```
With `modalities: ["text", "audio"]`, its behavior is:
| JoyAI action | Native output |
|---|---|
| `</response> <text>` | action text + speech for `<text>` |
| `</silence>` | action text + empty audio output (zero samples) |
| `</response> <note> </delegation> <question>` | action text + speech for `<note>` only |
Native speech supports the Qwen3-TTS `CustomVoice` task. The request-level `voice` and
`language` fields select the speaker and language; when omitted, they default to
`Vivian` and `Auto`. Values supplied as `tts_speaker` and `tts_language` inside
`additional_information` take priority.
This experimental native multi-stage pipeline is request-scoped, stateless, and
all-sync. It currently does not include:
- continuous frame sessions, persistent standing instructions, or session memory;
- audio input / ASR, full-duplex interaction, or barge-in;
- background Agent execution for delegated questions;
- async-chunk support from Talker to Code2Wav;
- integration with the existing WebUI.
For actions with spoken text, the Talker starts only after the complete JoyAI action is
available, and Code2Wav starts only after the Talker completes. Silence actions skip both
TTS stages and return an empty audio output with zero samples. All three stages are placed
on GPU 0 by the provided
[`deploy config`](../../vllm_omni/deploy/joyai_vl_interaction.yaml).
+11
View File
@@ -532,6 +532,17 @@ def test_from_pipeline_config_dispatches_async_chunk_processors_without_mutating
assert pipeline.get_stage(1).custom_process_input_func is None
def test_joyai_code2wav_waits_for_full_payload():
config = _from_pipeline_key("joyai_vl_interaction")
talker = config.stage_by_id(1)
code2wav = config.stage_by_id(2)
assert talker.custom_process_next_stage_input_func.endswith("talker2code2wav_full_payload")
assert code2wav.custom_process_input_func.endswith("talker2code2wav_token_only")
assert code2wav.connector_config.async_chunk is False
assert code2wav.model_config.requires_full_payload_input is True
def test_vllm_omni_stage_config_public_fields_use_typed_stage_realizations():
assert not hasattr(BaseVllmOmniStageConfig, "from_stage_config")
assert not hasattr(BaseVllmOmniStageConfig, "to_legacy_stage_config")
@@ -16,7 +16,7 @@ from types import SimpleNamespace
import janus
import pytest
from vllm.sampling_params import SamplingParams
from vllm.sampling_params import RequestOutputKind, SamplingParams
from vllm.v1.engine.exceptions import EngineDeadError
from vllm.v1.serial_utils import MsgpackEncoder
@@ -33,11 +33,16 @@ from vllm_omni.engine.orchestrator import (
cleanup_request_artifact_dirs,
)
from vllm_omni.engine.stage_pool import StageUnavailableError
from vllm_omni.errors import OmniClientError
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.model_executor.stage_input_processors import (
joyai_vl_interaction as joyai_bridge,
)
from vllm_omni.outputs import OmniRequestOutput
from .test_orchestrator import (
FakeOutputProcessor,
FakeRunningCounter,
FakeStageClient,
OrchestratorFixture,
_build_harness,
@@ -45,6 +50,7 @@ from .test_orchestrator import (
_build_stage_pools,
_engine_core_outputs,
_enqueue_add_request,
_get_output_message,
_wait_for,
)
@@ -935,6 +941,139 @@ async def test_diffusion_client_error_output_propagates_status_code(orchestrator
orchestrator_fixture.thread.join(timeout=5)
@pytest.mark.asyncio
@pytest.mark.parametrize("event_driven", [False, True], ids=["legacy", "event-driven"])
@pytest.mark.parametrize("first_speaker", ["Ryan", "not-a-real-speaker"])
async def test_joyai_tts_request_validation_keeps_orchestrator_running(
orchestrator_factory,
monkeypatch: pytest.MonkeyPatch,
first_speaker: str,
event_driven: bool,
) -> None:
"""Check that the same orchestrator can handle the next valid request."""
monkeypatch.setenv("VLLM_OMNI_EVENT_DRIVEN_ORCH", "1" if event_driven else "0")
# Use a fixed token count so the test does not need tokenizer files.
class FakeTokenizer:
def encode(self, _text: str, *, add_special_tokens: bool) -> list[int]:
return list(range(12))
talker_model_config = SimpleNamespace(
max_model_len=64,
hf_config=SimpleNamespace(
talker_config=SimpleNamespace(spk_id={"vivian": 0, "ryan": 1}, codec_language_id={}, spk_is_dialect={})
),
)
monkeypatch.setattr(joyai_bridge, "cached_tokenizer_from_config", lambda _: FakeTokenizer())
# Use the real bridge so speaker validation is part of the test.
class JoyAITalkerStage(FakeStageClient):
def process_engine_inputs(self, source_outputs, prompt=None, streaming_context=None):
self.bridge_inputs = joyai_bridge.joyai_action_to_tts(
source_outputs, prompt, self.requires_multimodal_data, target_model_config=talker_model_config
)
return self.bridge_inputs
stage0 = FakeStageClient(stage_type="llm", final_output=False)
stage1 = JoyAITalkerStage(stage_type="llm", final_output=True)
stage0_processor, stage1_processor = FakeOutputProcessor(), FakeOutputProcessor()
fixture = orchestrator_factory(
[stage0, stage1],
output_processors=[stage0_processor, stage1_processor],
stage_vllm_configs=[
SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)),
SimpleNamespace(model_config=talker_model_config),
],
)
counter = FakeRunningCounter()
fixture.orchestrator._running_counter = counter
# With a valid speaker, task_type should be changed from Base to CustomVoice.
# An unknown speaker should cause a request error.
first_info = {"tts_task_type": "Base", "speaker": "Vivian", "tts_speaker": first_speaker}
talker_submissions = 0
# Send both requests to the same orchestrator. The second uses the default Vivian voice.
for index, (request_id, info) in enumerate((("first", first_info), ("good", {})), start=1):
await _enqueue_add_request(
fixture,
request_id=request_id,
prompt=SimpleNamespace(request_id=request_id, prompt_token_ids=[1, 2]),
original_prompt={"additional_information": info},
sampling_params_list=[_sampling_params(), _sampling_params()],
final_stage_id=1,
)
await _wait_for(lambda: len(stage0.add_request_calls) == index and counter.value == 1)
stage0_processor.request_outputs = [_build_request_output(request_id, text="</response> Hello.")]
stage0.push_engine_core_outputs(_engine_core_outputs(f"{request_id}-raw", float(index)))
if request_id == "first" and first_speaker == "not-a-real-speaker":
# Reject this request before Talker runs, while keeping the engine alive.
error = await _wait_for_error_message(fixture, request_id=request_id)
assert error.status_code == 400 and error.error_type == "BadRequestError"
assert error.fatal is False and error.stage_id == 1
assert "not-a-real-speaker" in error.error
assert stage1.add_request_calls == []
else:
talker_submissions += 1
await _wait_for(lambda: len(stage1.add_request_calls) == talker_submissions or fixture.result_future.done())
assert not fixture.result_future.done()
assert stage1.add_request_calls[-1][0].request_id == request_id
metadata = stage1.bridge_inputs[0]["additional_information"]
assert metadata["task_type"] == ["CustomVoice"]
assert metadata["speaker"] == [first_speaker if request_id == "first" else "Vivian"]
# Finish the valid request and check its final output.
stage1_processor.request_outputs = [_build_request_output(request_id, text="Completed.")]
stage1.push_engine_core_outputs(_engine_core_outputs(f"{request_id}-talker", float(index)))
output = await _get_output_message(fixture)
assert output.request_id == request_id and output.stage_id == 1
assert output.finished is True
assert output.engine_outputs.outputs[0].text == "Completed."
# Wait for full cleanup before sending the next request.
await _wait_for(lambda: request_id not in fixture.orchestrator.request_states and counter.value == 0)
assert all(pool.get_bound_replica_id(request_id) is None for pool in fixture.orchestrator.stage_pools)
assert fixture.thread.is_alive() and not fixture.result_future.done()
@pytest.mark.asyncio
async def test_streaming_input_processor_client_error_does_not_forward_terminal_update() -> None:
"""Do not send a second update after the first one fails and clears the request."""
processor_calls = []
class FailingStage(FakeStageClient):
def process_engine_inputs(self, *args, **kwargs):
processor_calls.append((args, kwargs))
raise OmniClientError("Input rejected")
orchestrator, queues = _build_bare_orchestrator(
_build_stage_pools([[FakeStageClient()], [FailingStage(final_output=True)]])
)
state = OrchestratorRequestState(
request_id="bad-stream",
prompt={"prompt_token_ids": [1]},
sampling_params_list=[SamplingParams(output_kind=RequestOutputKind.CUMULATIVE), _sampling_params()],
final_stage_id=1,
)
state.streaming.enabled = True
assert state.duplex_identity is None
orchestrator.request_states[state.request_id] = state
try:
await orchestrator._route_output(0, 0, _build_request_output(state.request_id), state, stage_metrics=None)
assert len(processor_calls) == 1
error = queues[1].sync_q.get_nowait()
assert isinstance(error, ErrorMessage)
assert error.request_id == state.request_id
assert error.stage_id == 1
assert error.status_code == 400
assert error.fatal is False
assert queues[1].sync_q.empty()
assert state.request_id not in orchestrator.request_states
assert all(pool.get_bound_replica_id(state.request_id) is None for pool in orchestrator.stage_pools)
finally:
for q in queues:
q.close()
@pytest.mark.asyncio
async def test_duplex_input_processor_failure_is_request_scoped(orchestrator_factory, monkeypatch) -> None:
class FailingStage(FakeStageClient):
@@ -0,0 +1,98 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Tests for JoyAI action-to-TTS conversion."""
from types import SimpleNamespace
import pytest
from vllm_omni.engine.stage_engine_core_client import StageEngineCoreClient
from vllm_omni.model_executor.stage_input_processors import (
joyai_vl_interaction as joyai_bridge,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_joyai_action_to_tts_bridge(monkeypatch: pytest.MonkeyPatch) -> None:
"""Convert each JoyAI action through the real stage-client entry point."""
target_model_config = SimpleNamespace(
hf_config=SimpleNamespace(talker_config=SimpleNamespace(spk_id={"vivian": 1, "ryan": 2}))
)
prompt_length_calls: list[tuple[dict[str, list[str]], object]] = []
prompt_lengths = {
"Colored squares are visible.": 12,
"I am checking this request now.": 18,
}
def compute_prompt_length(metadata, model_config):
prompt_length_calls.append((metadata, model_config))
return prompt_lengths[metadata["text"][0]]
monkeypatch.setattr(
joyai_bridge,
"_compute_talker_prompt_length",
compute_prompt_length,
)
client = object.__new__(StageEngineCoreClient)
client.custom_process_input_func = joyai_bridge.joyai_action_to_tts
client.requires_multimodal_data = False
client.vllm_config = SimpleNamespace(model_config=target_model_config)
cases = [
(
"</response> Colored squares are visible.",
None,
{
"tts_task_type": "Base",
"language": ["Chinese"],
"speaker": ["Ryan"],
},
("Colored squares are visible.", "Chinese", "Ryan"),
),
("</silence>", None, {}, None),
(
"",
(
"</response> I am checking this request now. "
"</delegation> Why is the purple elephant dancing beside a volcano?"
),
{
"language": "Chinese",
"speaker": "Ryan",
"tts_language": "English",
"tts_speaker": "Vivian",
},
("I am checking this request now.", "English", "Vivian"),
),
]
for text, cumulative_text, additional_information, expected in cases:
completion = SimpleNamespace(text=text)
if cumulative_text is not None:
completion.cumulative_text = cumulative_text
call_count = len(prompt_length_calls)
talker_inputs = client.process_engine_inputs(
[SimpleNamespace(outputs=[completion])],
prompt={"additional_information": additional_information},
)
if expected is None:
assert talker_inputs == []
assert len(prompt_length_calls) == call_count
continue
spoken_text, language, speaker = expected
[talker_input] = talker_inputs
metadata = talker_input["additional_information"]
assert talker_input["prompt_token_ids"] == [0] * prompt_lengths[spoken_text]
assert metadata == {
"task_type": ["CustomVoice"],
"language": [language],
"speaker": [speaker],
"instruct": [""],
"text": [spoken_text],
}
assert prompt_length_calls[-1] == (metadata, target_model_config)
+4
View File
@@ -67,6 +67,9 @@ from vllm_omni.model_executor.models.indextts2.pipeline import (
INDEXTTS2_PIPELINE,
INDEXTTS25_PIPELINE,
)
from vllm_omni.model_executor.models.joyai_vl_interaction.pipeline import (
JOYAI_VL_INTERACTION_PIPELINE,
)
from vllm_omni.model_executor.models.lance.pipeline import LANCE_PIPELINE
from vllm_omni.model_executor.models.lingbot_world.pipeline import LINGBOT_WORLD_PIPELINE
from vllm_omni.model_executor.models.mammoth_moda2.pipeline import (
@@ -122,6 +125,7 @@ PipelineResolverFunc: TypeAlias = Callable[[PretrainedConfig | None], PipelineCo
# --- Multi-stage omni pipelines (LLM-centric; audio / video I/O) ---
OMNI_PIPELINES: dict[str, PipelineConfig | PipelineResolverFunc] = {
"aura_omni": AURA_OMNI_PIPELINE,
"joyai_vl_interaction": JOYAI_VL_INTERACTION_PIPELINE,
"qwen2_5_omni": QWEN2_5_OMNI_PIPELINE,
"qwen2_5_omni_thinker_only": QWEN2_5_OMNI_THINKER_ONLY_PIPELINE,
"personaplex": PERSONAPLEX_PIPELINE,
@@ -0,0 +1,79 @@
# Single-GPU JoyAI-VL-Interaction -> Qwen3-TTS all-sync profile.
pipeline: joyai_vl_interaction
async_chunk: false
trust_remote_code: true
connectors:
connector_of_shared_memory:
name: SharedMemoryConnector
extra:
codec_streaming: true
connector_get_sleep_s: 0.01
connector_get_max_wait_first_chunk: 3000
connector_get_max_wait: 300
codec_chunk_frames: 25
codec_left_context_frames: 72
initial_codec_chunk_frames: 1
# Qwen3-TTS Code2Wav buckets: 25, 72+1, 72+25, 72+72+25, and 300+25 frames.
decode_cudagraph_capture_sizes: [25, 73, 97, 169, 325]
stages:
# GPU 0: JoyAI action model.
- stage_id: 0
model: jdopensource/JoyAI-VL-Interaction-Preview
devices: "0"
gpu_memory_utilization: 0.5
max_num_seqs: 4
# Some OmniInteract video prompts contain more than 12K input tokens.
max_model_len: 16384
max_num_batched_tokens: 16384
limit_mm_per_prompt: {image: 256, video: 1}
enable_prefix_caching: true
default_sampling_params:
temperature: 0.8
top_p: 0.9
top_k: 40
max_tokens: 256
repetition_penalty: 1.1
presence_penalty: 1.5
# GPU 0: Qwen3-TTS Talker.
- stage_id: 1
model: Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
devices: "0"
gpu_memory_utilization: 0.2
max_num_seqs: 8
max_model_len: 4096
max_num_batched_tokens: 512
enable_prefix_caching: false
output_connectors:
to_stage_2: connector_of_shared_memory
default_sampling_params:
temperature: 0.9
top_k: 50
min_tokens: 2
max_tokens: 4096
repetition_penalty: 1.05
subtalker_sampling_params:
do_sample: true
temperature: 0.9
top_k: 50
top_p: 1.0
# GPU 0: Qwen3-TTS Code2Wav.
- stage_id: 2
model: Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
devices: "0"
gpu_memory_utilization: 0.2
max_num_seqs: 8
max_model_len: 65536
max_num_batched_tokens: 65536
enable_prefix_caching: false
input_connectors:
from_stage_1: connector_of_shared_memory
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 65536
repetition_penalty: 1.0
+18 -4
View File
@@ -75,7 +75,7 @@ from vllm_omni.engine.messages import (
from vllm_omni.engine.orchestrator_monitor import create_orch_monitor, replica_key
from vllm_omni.engine.serialization import serialize_additional_information
from vllm_omni.engine.stage_pool import StagePool, StageUnavailableError
from vllm_omni.errors import DEFAULT_CLIENT_ERROR_TYPE
from vllm_omni.errors import DEFAULT_CLIENT_ERROR_TYPE, OmniClientError
from vllm_omni.metrics import definitions as metric_defs
from vllm_omni.metrics.prometheus import OmniRequestCounter
from vllm_omni.metrics.stat_logger import OmniPrometheusStatLogger
@@ -1647,9 +1647,11 @@ class Orchestrator:
stage_id: int,
error: str,
*,
status_code: int = HTTPStatus.BAD_REQUEST.value,
error_type: str = DEFAULT_CLIENT_ERROR_TYPE,
close_duplex_sessions: bool = False,
) -> None:
"""Fail one request with a non-fatal 400 (bad input, engine survives).
"""Fail one request with a non-fatal client error (400 by default).
The non-fatal counterpart of `_fail_request_dead_stage`: emits a
client-error ErrorMessage (default `fatal=False`) so the engine keeps
@@ -1658,8 +1660,8 @@ class Orchestrator:
await self.output_async_queue.put(
ErrorMessage(
error=error,
status_code=HTTPStatus.BAD_REQUEST.value,
error_type=DEFAULT_CLIENT_ERROR_TYPE,
status_code=status_code,
error_type=error_type,
request_id=req_id,
stage_id=stage_id,
)
@@ -2073,6 +2075,8 @@ class Orchestrator:
)
if (
req_state.streaming.enabled
# A failed forward may already have cleaned up this request.
and self.request_states.get(req_id) is req_state
and finished
and not final_only_finished
and not self._is_duplex_session_request(req_state)
@@ -2734,6 +2738,16 @@ class Orchestrator:
req_state.prompt,
streaming_context=req_state.streaming,
)
except OmniClientError as exc:
await self._fail_request_client_error(
req_id,
next_logical,
str(exc),
status_code=exc.status_code,
error_type=exc.error_type,
close_duplex_sessions=self._is_duplex_session_request(req_state),
)
return
except Exception as exc:
logger.exception(
"[Orchestrator] req=%s process_engine_inputs FAILED for stage-%s",
+43 -25
View File
@@ -396,36 +396,54 @@ class StageEngineCoreClientBase(StageClientBase):
and the original prompt.
"""
if self.custom_process_input_func is not None:
signature = inspect.signature(self.custom_process_input_func)
extra_kwargs: dict[str, Any] = {}
if "next_stage_hf_config" in signature.parameters:
# Let a processor size the next stage's prompt from that
# stage's model config (e.g. a talker whose engine positions
# must cover a speaker-prompt prefill).
extra_kwargs["next_stage_hf_config"] = self._stage_hf_config
# Match the context parameter by name, including the
# underscore-prefixed spelling some processors use (e.g.
# MiniCPM-o's ``llm2tts(..., _streaming_context)``), so bridge
# state keeps flowing to them.
if "streaming_context" in signature.parameters or "_streaming_context" in signature.parameters:
return self.custom_process_input_func(
source_outputs,
prompt,
self.requires_multimodal_data,
streaming_context,
**extra_kwargs,
)
return self.custom_process_input_func(
source_outputs,
prompt,
self.requires_multimodal_data,
**extra_kwargs,
)
return self._call_custom_process_input(source_outputs, prompt, streaming_context)
if not self.engine_input_source:
raise ValueError(f"engine_input_source empty for stage {self.stage_id}")
return _default_process_engine_inputs(source_outputs, prompt, self.requires_multimodal_data)
def _call_custom_process_input(
self,
source_outputs: list[Any],
prompt: Any,
streaming_context: Any | None,
) -> list[OmniTokensPrompt]:
"""Call a stage input processor with its explicitly requested context."""
processor = self.custom_process_input_func
assert processor is not None
signature = inspect.signature(processor)
extra_kwargs: dict[str, Any] = {}
if "next_stage_hf_config" in signature.parameters:
# Let a processor size the next stage's prompt from that
# stage's model config (e.g. a talker whose engine positions
# must cover a speaker-prompt prefill).
extra_kwargs["next_stage_hf_config"] = self._stage_hf_config
target_model_config = signature.parameters.get("target_model_config")
if target_model_config is not None:
# The JoyAI bridge needs the Talker tokenizer and model config
# to calculate the exact prompt length.
if target_model_config.kind is not inspect.Parameter.KEYWORD_ONLY:
raise TypeError("target_model_config must be a keyword-only parameter")
extra_kwargs["target_model_config"] = self.vllm_config.model_config
# Match the context parameter by name, including the
# underscore-prefixed spelling some processors use (e.g.
# MiniCPM-o's ``llm2tts(..., _streaming_context)``), so bridge
# state keeps flowing to them.
if "streaming_context" in signature.parameters or "_streaming_context" in signature.parameters:
return processor(
source_outputs,
prompt,
self.requires_multimodal_data,
streaming_context,
**extra_kwargs,
)
return processor(
source_outputs,
prompt,
self.requires_multimodal_data,
**extra_kwargs,
)
async def collective_rpc_async(
self,
method: str,
@@ -0,0 +1,3 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""JoyAI-VL-Interaction native pipeline."""
@@ -0,0 +1,65 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""JoyAI-VL-Interaction -> Qwen3-TTS native all-sync pipeline."""
from vllm_omni.config.stage_config import (
PipelineConfig,
StageExecutionType,
StagePipelineConfig,
)
_JOYAI_INPUT_PROCESSOR = "vllm_omni.model_executor.stage_input_processors.joyai_vl_interaction"
_QWEN3_TTS_INPUT_PROCESSOR = "vllm_omni.model_executor.stage_input_processors.qwen3_tts"
JOYAI_VL_INTERACTION_PIPELINE = PipelineConfig(
model_type="joyai_vl_interaction",
model_arch="Qwen3VLForConditionalGeneration",
default_deploy_config_name="joyai_vl_interaction.yaml",
stages=(
StagePipelineConfig(
stage_id=0,
model_stage="joyvl",
execution_type=StageExecutionType.LLM_AR,
input_sources=(),
final_output=True,
final_output_type="text",
owns_tokenizer=True,
requires_multimodal_data=True,
engine_output_type="text",
sampling_constraints={
"detokenize": True,
"skip_special_tokens": False,
},
),
StagePipelineConfig(
stage_id=1,
model_stage="qwen3_tts",
execution_type=StageExecutionType.LLM_AR,
input_sources=(0,),
owns_tokenizer=True,
engine_output_type="latent",
model_arch="Qwen3TTSTalkerForConditionalGeneration",
custom_process_input_func=f"{_JOYAI_INPUT_PROCESSOR}.joyai_action_to_tts",
custom_process_next_stage_input_func=f"{_QWEN3_TTS_INPUT_PROCESSOR}.talker2code2wav_full_payload",
sampling_constraints={
"detokenize": False,
"stop_token_ids": [2150],
},
),
StagePipelineConfig(
stage_id=2,
model_stage="code2wav",
execution_type=StageExecutionType.LLM_GENERATION,
input_sources=(1,),
final_output=True,
final_output_type="audio",
engine_output_type="audio",
model_arch="Qwen3TTSCode2Wav",
sync_process_input_func=f"{_QWEN3_TTS_INPUT_PROCESSOR}.talker2code2wav_token_only",
requires_full_payload_input=True,
sampling_constraints={"detokenize": True},
extras={"tts_args": {"max_instructions_length": 500}},
),
),
)
@@ -0,0 +1,129 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Build Qwen3-TTS Talker inputs from completed JoyAI actions."""
from typing import Any
from vllm.outputs import RequestOutput
from vllm.tokenizers import cached_tokenizer_from_config
from vllm_omni.errors import OmniClientError
from vllm_omni.experimental.fullduplex.joyvl.decision.output_parser import (
parse_action,
)
from vllm_omni.inputs.data import OmniTokensPrompt
from vllm_omni.model_executor.models.qwen3_tts.prompt_embeds_builder import (
Qwen3TTSPromptEmbedsBuilder,
first_value,
)
# Match the standalone Qwen3-TTS default attributes
_DEFAULT_TTS_TASK_TYPE = "CustomVoice"
_DEFAULT_TTS_LANGUAGE = "Auto"
_DEFAULT_TTS_SPEAKER = "Vivian"
def _extract_completed_action_text(joyai_output: RequestOutput) -> str:
"""Return the final JoyAI action text, using cumulative text when available."""
completion = joyai_output.outputs[0]
action_text = getattr(completion, "cumulative_text", None) or completion.text
return action_text if isinstance(action_text, str) else ""
def _build_tts_metadata(request_prompt: object, spoken_text: str) -> dict[str, list[str]]:
"""Build Qwen3-TTS metadata for the text JoyAI chose to speak."""
prompt_dict = request_prompt if isinstance(request_prompt, dict) else {}
raw_additional_info = prompt_dict.get("additional_information")
additional_info = raw_additional_info if isinstance(raw_additional_info, dict) else {}
# The native pipeline loads CustomVoice checkpoints for both TTS stages.
# Keep client metadata from selecting an incompatible Qwen3-TTS task.
task_type = _DEFAULT_TTS_TASK_TYPE
request_language = first_value(additional_info.get("language"), _DEFAULT_TTS_LANGUAGE)
language = first_value(additional_info.get("tts_language"), request_language)
request_speaker = first_value(additional_info.get("speaker"), _DEFAULT_TTS_SPEAKER)
speaker = first_value(additional_info.get("tts_speaker"), request_speaker)
instruction = first_value(additional_info.get("tts_instruct"), "")
return {
"task_type": [str(task_type)],
"language": [str(language)],
"speaker": [str(speaker)],
"instruct": [str(instruction)],
"text": [spoken_text],
}
def _validate_talker_speaker(tts_metadata: dict[str, list[str]], talker_model_config: Any) -> None:
"""Validate the selected voice using the destination Talker's speaker table."""
talker_config = getattr(talker_model_config.hf_config, "talker_config", None)
if talker_config is None:
raise ValueError("The target stage is not a Qwen3-TTS Talker.")
# Match CustomVoice prompt construction, including case and whitespace.
speaker = str(first_value(tts_metadata.get("speaker"), "")).lower().strip()
speakers = {name.lower() for name in (getattr(talker_config, "spk_id", None) or {})}
if not speaker or speaker not in speakers:
raise OmniClientError(f"Unsupported speaker: {speaker!r}")
def _compute_talker_prompt_length(
tts_metadata: dict[str, list[str]],
talker_model_config: Any,
) -> int:
"""Compute the exact number of prompt positions used by the Talker."""
task_type = str(first_value(tts_metadata.get("task_type"), _DEFAULT_TTS_TASK_TYPE))
if task_type != _DEFAULT_TTS_TASK_TYPE:
raise ValueError("JoyAI native TTS supports only CustomVoice.")
talker_tokenizer = cached_tokenizer_from_config(talker_model_config)
if talker_tokenizer is None:
raise ValueError("The Qwen3-TTS Talker must have an initialized tokenizer.")
talker_config = getattr(talker_model_config.hf_config, "talker_config", None)
if talker_config is None:
raise ValueError("The target stage is not a Qwen3-TTS Talker.")
def tokenize_prompt(text: str) -> list[int]:
return talker_tokenizer.encode(text, add_special_tokens=True)
return Qwen3TTSPromptEmbedsBuilder.estimate_prompt_len_from_additional_information(
additional_information=tts_metadata,
task_type=task_type,
tokenize_prompt=tokenize_prompt,
codec_language_id=getattr(talker_config, "codec_language_id", None),
spk_is_dialect=getattr(talker_config, "spk_is_dialect", None),
)
def joyai_action_to_tts(
source_outputs: list[RequestOutput],
prompt: object | None = None,
requires_multimodal_data: bool = False,
*,
target_model_config: Any,
) -> list[OmniTokensPrompt]:
"""Build Talker inputs for JoyAI actions that contain text to speak."""
del requires_multimodal_data
request_prompts = prompt if isinstance(prompt, list) else [prompt] * len(source_outputs)
talker_inputs: list[OmniTokensPrompt] = []
for request_index, joyai_output in enumerate(source_outputs):
parsed_action = parse_action(_extract_completed_action_text(joyai_output))
if not parsed_action.spoke or not parsed_action.text:
continue
request_prompt = request_prompts[request_index] if request_index < len(request_prompts) else None
tts_metadata = _build_tts_metadata(request_prompt, parsed_action.text)
_validate_talker_speaker(tts_metadata, target_model_config)
talker_prompt_length = _compute_talker_prompt_length(tts_metadata, target_model_config)
talker_inputs.append(
OmniTokensPrompt(
# OmniTokensPrompt reserves vllm scheduler positions with zero IDs;
# the Talker builds the actual prompt from tts_metadata.
prompt_token_ids=[0] * talker_prompt_length,
additional_information=tts_metadata,
)
)
return talker_inputs