From 97f4c5b1e786f0bc274a23a9bde4699aa29a8254 Mon Sep 17 00:00:00 2001 From: Yanick Schraner Date: Wed, 25 Feb 2026 17:14:44 +0100 Subject: [PATCH] [Bugfix] Add TTS configuration options (#1177) Signed-off-by: Yanick Schraner --- docs/configuration/stage_configs.md | 12 ++ examples/online_serving/qwen3_tts/README.md | 19 +++ .../openai_api/test_serving_speech.py | 139 +++++++++++++++++- vllm_omni/entrypoints/cli/serve.py | 8 + vllm_omni/entrypoints/omni.py | 3 + vllm_omni/entrypoints/omni_stage.py | 1 + .../entrypoints/openai/serving_speech.py | 45 +++++- .../stage_configs/qwen3_tts.yaml | 2 + .../npu/stage_configs/qwen3_tts.yaml | 2 + 9 files changed, 220 insertions(+), 11 deletions(-) diff --git a/docs/configuration/stage_configs.md b/docs/configuration/stage_configs.md index 1f1a18902..0e671bebf 100644 --- a/docs/configuration/stage_configs.md +++ b/docs/configuration/stage_configs.md @@ -273,3 +273,15 @@ Default: `True` Penalty applied to tokens that have already appeared in the generated sequence. Values greater than 1.0 discourage repetition, while values less than 1.0 encourage it. A value of 1.0 applies no penalty. Default: `1.1` + +### `tts_args` (TTS stages only) + +Configuration for Text-to-Speech specific parameters. This section is only applicable to TTS model stages (e.g., `qwen3_tts`). + +#### `tts_args.max_instructions_length` + +Maximum character length for voice style/emotion instructions. Instructions exceeding this limit will be rejected with a validation error. + +Default: `500` + +This value can be overridden at runtime using the `--tts-max-instructions-length` CLI parameter when starting the server. diff --git a/examples/online_serving/qwen3_tts/README.md b/examples/online_serving/qwen3_tts/README.md index e1ad3edc2..dd4619cc4 100644 --- a/examples/online_serving/qwen3_tts/README.md +++ b/examples/online_serving/qwen3_tts/README.md @@ -90,6 +90,25 @@ curl -X POST http://localhost:8091/v1/audio/speech \ curl http://localhost:8091/v1/audio/voices ``` +## Server Configuration + +### TTS-Specific CLI Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--tts-max-instructions-length` | int | 500 | Maximum character length for voice style instructions | + +**Example with custom instructions length:** + +```bash +vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \ + --omni \ + --port 8000 \ + --tts-max-instructions-length 1000 +``` + +This is useful when you need longer voice style descriptions, especially for the VoiceDesign task type. + ## API Reference ### Endpoint diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index e55ff6812..11d676839 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -291,6 +291,7 @@ class TestTTSMethods: mock_engine_client = MagicMock() mock_engine_client.errored = False mock_engine_client.stage_list = None + mock_engine_client.tts_max_instructions_length = None mock_models = MagicMock() mock_models.is_base_model.return_value = True return OmniOpenAIServingSpeech( @@ -299,16 +300,35 @@ class TestTTSMethods: request_logger=MagicMock(), ) - def test_is_tts_model(self, speech_server): - """Test TTS model detection.""" - # No stage_list -> False - assert speech_server._is_tts_model() is False + def test_is_tts_detection_no_stage(self, speech_server): + """Test TTS model detection when no TTS stage exists.""" + # Fixture creates server with stage_list = None -> _is_tts should be False + assert speech_server._is_tts is False + assert speech_server._tts_stage is None - # With qwen3_tts stage -> True + def test_is_tts_detection_with_tts_stage(self): + """Test TTS model detection when TTS stage exists.""" + mock_engine_client = MagicMock() + mock_engine_client.errored = False + mock_engine_client.tts_max_instructions_length = None + + # Create a TTS stage mock_stage = MagicMock() mock_stage.model_stage = "qwen3_tts" - speech_server.engine_client.stage_list = [mock_stage] - assert speech_server._is_tts_model() is True + mock_stage.tts_args = {} + mock_engine_client.stage_list = [mock_stage] + + mock_models = MagicMock() + mock_models.is_base_model.return_value = True + + server = OmniOpenAIServingSpeech( + engine_client=mock_engine_client, + models=mock_models, + request_logger=MagicMock(), + ) + + assert server._is_tts is True + assert server._tts_stage is mock_stage def test_estimate_prompt_len_fallback(self, speech_server): """Test prompt length estimation falls back to 2048 when model is unavailable.""" @@ -381,3 +401,108 @@ class TestTTSMethods: # Verify speakers are normalized to lowercase assert server.supported_speakers == {"ryan", "vivian", "aiden"} + + def test_max_instructions_length_default(self, speech_server): + """Test default max instructions length (500) when no config provided.""" + # Fixture creates server with no CLI override and no TTS stage + assert speech_server._max_instructions_length == 500 + + def test_max_instructions_length_cli_override(self): + """Test CLI override (stored in engine_client) takes highest priority.""" + mock_engine_client = MagicMock() + mock_engine_client.errored = False + mock_engine_client.stage_list = None + # CLI override is stored in engine_client + mock_engine_client.tts_max_instructions_length = 1000 + mock_models = MagicMock() + mock_models.is_base_model.return_value = True + + server = OmniOpenAIServingSpeech( + engine_client=mock_engine_client, + models=mock_models, + request_logger=MagicMock(), + ) + # Value is cached during __init__ + assert server._max_instructions_length == 1000 + + def test_max_instructions_length_stage_config(self): + """Test stage config value is used when no CLI override.""" + mock_engine_client = MagicMock() + mock_engine_client.errored = False + mock_engine_client.tts_max_instructions_length = None # No CLI override + mock_models = MagicMock() + mock_models.is_base_model.return_value = True + + # Mock stage with tts_args + mock_stage = MagicMock() + mock_stage.model_stage = "qwen3_tts" + mock_stage.tts_args = {"max_instructions_length": 750} + mock_engine_client.stage_list = [mock_stage] + + server = OmniOpenAIServingSpeech( + engine_client=mock_engine_client, + models=mock_models, + request_logger=MagicMock(), + ) + # Value is cached during __init__ + assert server._max_instructions_length == 750 + + def test_max_instructions_length_cli_overrides_stage_config(self): + """Test CLI override (in engine_client) takes precedence over stage config.""" + mock_engine_client = MagicMock() + mock_engine_client.errored = False + # CLI override stored in engine_client + mock_engine_client.tts_max_instructions_length = 2000 + mock_models = MagicMock() + mock_models.is_base_model.return_value = True + + # Mock stage with tts_args + mock_stage = MagicMock() + mock_stage.model_stage = "qwen3_tts" + mock_stage.tts_args = {"max_instructions_length": 750} + mock_engine_client.stage_list = [mock_stage] + + server = OmniOpenAIServingSpeech( + engine_client=mock_engine_client, + models=mock_models, + request_logger=MagicMock(), + ) + # CLI value (2000) should override stage config (750) + assert server._max_instructions_length == 2000 + + def test_validate_instructions_length_uses_cached_value(self): + """Test instructions length validation uses cached _max_instructions_length.""" + mock_engine_client = MagicMock() + mock_engine_client.errored = False + mock_engine_client.stage_list = None + # CLI override with max length of 10 characters + mock_engine_client.tts_max_instructions_length = 10 + mock_models = MagicMock() + mock_models.is_base_model.return_value = True + + server = OmniOpenAIServingSpeech( + engine_client=mock_engine_client, + models=mock_models, + request_logger=MagicMock(), + ) + + # Verify cached value + assert server._max_instructions_length == 10 + + # Instructions within limit should pass + req = OpenAICreateSpeechRequest( + input="Hello", + task_type="VoiceDesign", + instructions="short", + ) + assert server._validate_tts_request(req) is None + + # Instructions exceeding limit should fail + req = OpenAICreateSpeechRequest( + input="Hello", + task_type="VoiceDesign", + instructions="this is too long", + ) + error = server._validate_tts_request(req) + assert error is not None + assert "max 10 characters" in error diff --git a/vllm_omni/entrypoints/cli/serve.py b/vllm_omni/entrypoints/cli/serve.py index 73bb03805..74a4ff7f0 100644 --- a/vllm_omni/entrypoints/cli/serve.py +++ b/vllm_omni/entrypoints/cli/serve.py @@ -290,6 +290,14 @@ class OmniServeCommand(CLISubcommand): type=int, help="The max size of generate image (height * width).", ) + + # TTS-specific parameters + omni_config_group.add_argument( + "--tts-max-instructions-length", + type=int, + default=None, + help="Maximum length for TTS voice style instructions (overrides stage config, default: 500).", + ) return serve_parser diff --git a/vllm_omni/entrypoints/omni.py b/vllm_omni/entrypoints/omni.py index 94409b3e6..dc18978f9 100644 --- a/vllm_omni/entrypoints/omni.py +++ b/vllm_omni/entrypoints/omni.py @@ -257,6 +257,9 @@ class OmniBase: stage_configs_path = kwargs.get("stage_configs_path", None) + # TTS-specific CLI overrides + self.tts_max_instructions_length: int | None = kwargs.get("tts_max_instructions_length", None) + # Load stage configurations from YAML config_path, stage_configs = load_and_resolve_stage_configs( model, diff --git a/vllm_omni/entrypoints/omni_stage.py b/vllm_omni/entrypoints/omni_stage.py index b26e4c0bc..bfd60f619 100644 --- a/vllm_omni/entrypoints/omni_stage.py +++ b/vllm_omni/entrypoints/omni_stage.py @@ -285,6 +285,7 @@ class OmniStage: self.final_output = getattr(stage_config, "final_output", False) self.final_output_type = getattr(stage_config, "final_output_type", None) + self.tts_args = _to_dict(getattr(stage_config, "tts_args", {})) default_sampling_params = getattr(stage_config, "default_sampling_params", {}) # For LLM stage, this can directly be a SamplingParams-compatible dict; # For diffusion stage, this only serves as default values for diffusion kwargs. diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index 201be69da..98f79f87f 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -61,11 +61,48 @@ _TTS_MAX_NEW_TOKENS_MAX = 4096 class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + + # Find and cache the TTS stage (if any) during initialization + self._tts_stage = self._find_tts_stage() + self._is_tts = self._tts_stage is not None + + # Cache TTS configuration values (computed once, reused per request) + self._max_instructions_length = self._compute_max_instructions_length() + # Load supported speakers self.supported_speakers = self._load_supported_speakers() logger.info(f"Loaded {len(self.supported_speakers)} supported speakers: {sorted(self.supported_speakers)}") self._tts_tokenizer = None + def _find_tts_stage(self): + """Find and return the TTS stage from the stage list, or None if not found.""" + stage_list = getattr(self.engine_client, "stage_list", None) + if stage_list is None: + return None + for stage in stage_list: + if getattr(stage, "model_stage", None) in _TTS_MODEL_STAGES: + return stage + return None + + def _compute_max_instructions_length(self) -> int: + """Compute max instructions length with precedence: CLI > stage config > default. + + Called once during initialization; result is cached in self._max_instructions_length. + """ + # 1. CLI override takes highest priority (stored in engine_client) + cli_override = getattr(self.engine_client, "tts_max_instructions_length", None) + if cli_override is not None: + return cli_override + + # 2. Try to get from TTS stage config + if self._tts_stage is not None: + tts_args = getattr(self._tts_stage, "tts_args", {}) + if "max_instructions_length" in tts_args: + return tts_args["max_instructions_length"] + + # 3. Default fallback + return _TTS_MAX_INSTRUCTIONS_LENGTH + def _load_supported_speakers(self) -> set[str]: """Load supported speakers (case-insensitive) from the model configuration.""" try: @@ -164,9 +201,9 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): if task_type == "VoiceDesign" and not request.instructions: return "VoiceDesign task requires 'instructions' to describe the voice" - # Validate instructions length - if request.instructions and len(request.instructions) > _TTS_MAX_INSTRUCTIONS_LENGTH: - return f"Instructions too long (max {_TTS_MAX_INSTRUCTIONS_LENGTH} characters)" + # Validate instructions length (using cached value from initialization) + if request.instructions and len(request.instructions) > self._max_instructions_length: + return f"Instructions too long (max {self._max_instructions_length} characters)" # Validate max_new_tokens range if request.max_new_tokens is not None: @@ -300,7 +337,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): request_id = f"speech-{random_uuid()}" try: - if self._is_tts_model(): + if self._is_tts: # Validate TTS parameters validation_error = self._validate_tts_request(request) if validation_error: diff --git a/vllm_omni/model_executor/stage_configs/qwen3_tts.yaml b/vllm_omni/model_executor/stage_configs/qwen3_tts.yaml index 1f29f0796..90053852a 100644 --- a/vllm_omni/model_executor/stage_configs/qwen3_tts.yaml +++ b/vllm_omni/model_executor/stage_configs/qwen3_tts.yaml @@ -65,6 +65,8 @@ stage_args: # Distributed connector configuration input_connectors: from_stage_0: connector_of_shared_memory + tts_args: + max_instructions_length: 500 default_sampling_params: temperature: 0.0 top_p: 1.0 diff --git a/vllm_omni/platforms/npu/stage_configs/qwen3_tts.yaml b/vllm_omni/platforms/npu/stage_configs/qwen3_tts.yaml index fbfbf10a4..6e37ec018 100644 --- a/vllm_omni/platforms/npu/stage_configs/qwen3_tts.yaml +++ b/vllm_omni/platforms/npu/stage_configs/qwen3_tts.yaml @@ -59,6 +59,8 @@ stage_args: final_output_type: audio input_connectors: from_stage_0: connector_of_shared_memory + tts_args: + max_instructions_length: 500 default_sampling_params: temperature: 0.0 top_p: 1.0