@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 657e9c0e18
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 10, 10:15 UTC

[Fix] Introduce audio channels spec (#31595)

Signed-off-by: Jeremy Teboul <jeremyte@meta.com>

Jeremy Teboul committed Jan 9, 2026 at 11:34 UTC 657e9c0e183d4bdb157b07db987644d2d9d36245
9 files changed +717 -189
docs/features/multimodal_inputs.md
+38
@@ -356,6 +356,44 @@ You can pass a tuple `(array, sampling_rate)` to the `'audio'` field of the mult
356
357 Full example: [examples/offline_inference/audio_language.py](../../examples/offline_inference/audio_language.py)
358
359 +#### Automatic Audio Channel Normalization
360 +
361 +vLLM automatically normalizes audio channels for models that require specific audio formats. When loading audio with libraries like `torchaudio`, stereo files return shape `[channels, time]`, but many audio models (particularly Whisper-based models) expect mono audio with shape `[time]`.
362 +
363 +**Supported models with automatic mono conversion:**
364 +
365 +- **Whisper** and all Whisper-based models
366 +- **Qwen2-Audio**
367 +- **Qwen2.5-Omni** / **Qwen3-Omni** (inherits from Qwen2.5-Omni)
368 +- **Ultravox**
369 +
370 +For these models, vLLM automatically:
371 +
372 +1. Detects if the model requires mono audio via the feature extractor
373 +2. Converts multi-channel audio to mono using channel averaging
374 +3. Handles both `(channels, time)` format (torchaudio) and `(time, channels)` format (soundfile)
375 +
376 +**Example with stereo audio:**
377 +
378 +```python
379 +import torchaudio
380 +from vllm import LLM
381 +
382 +# Load stereo audio file - returns (channels, time) shape
383 +audio, sr = torchaudio.load("stereo_audio.wav")
384 +print(f"Original shape: {audio.shape}") # e.g., torch.Size([2, 16000])
385 +
386 +# vLLM automatically converts to mono for Whisper-based models
387 +llm = LLM(model="openai/whisper-large-v3")
388 +
389 +outputs = llm.generate({
390 + "prompt": "",
391 + "multi_modal_data": {"audio": (audio.numpy(), sr)},
392 +})
393 +```
394 +
395 +No manual conversion is needed - vLLM handles the channel normalization automatically based on the model's requirements.
396 +
397 ### Embedding Inputs
398
399 To input pre-computed embeddings belonging to a data type (i.e. image, video, or audio) directly to the language model,
tests/models/multimodal/processing/test_qwen3_omni.py
+4 -185
@@ -28,10 +28,9 @@ def test_processor_with_audio_sample_rate(
28 """
29 Test that vLLM's processor generates expected outputs with audio_sample_rate.
30
31 - This validates the reviewer's request that we test the actual processor
32 - can handle different audio_sample_rate values and generate audio tokens.
31 + This validates that the processor correctly handles audio_sample_rate
32 + passed via hf_processor_mm_kwargs and generates audio tokens.
33 """
34 - # Setup: Build model context and processor
34 ctx = build_model_context(
35 model_id,
36 limit_mm_per_prompt={"audio": 1, "image": 0, "video": 0},
@@ -48,18 +47,17 @@ def test_processor_with_audio_sample_rate(
47 prompt = "<|audio_start|><|audio_pad|><|audio_end|>"
48 mm_data = {"audio": [(audio_data, audio_sample_rate)]}
49
51 - # Execute: Apply processor with audio_sample_rate in mm_kwargs
50 + # Apply processor with audio_sample_rate in mm_kwargs
51 hf_processor_mm_kwargs: dict[str, Any] = {
52 "audio_sample_rate": audio_sample_rate,
53 }
54 processed_inputs = processor.apply(prompt, mm_data, hf_processor_mm_kwargs)
55
57 - # Assert: Verify audio tokens are generated
56 + # Verify audio tokens are generated
57 hf_processor = processor.info.get_hf_processor(**hf_processor_mm_kwargs)
58 audio_token_id = tokenizer.convert_tokens_to_ids(hf_processor.audio_token)
59 aud_tok_count = processed_inputs["prompt_token_ids"].count(audio_token_id)
60
62 - # Audio should generate at least 1 token
61 assert aud_tok_count >= 1, (
62 f"Expected at least 1 audio token but got {aud_tok_count}. "
63 f"sample_rate: {audio_sample_rate}Hz, duration: {audio_duration_sec}s"
@@ -97,189 +95,10 @@ def test_longer_audio_generates_more_tokens(model_id: str) -> None:
95 audio_token_id = tokenizer.convert_tokens_to_ids(hf_proc.audio_token)
96 return processed["prompt_token_ids"].count(audio_token_id)
97
100 - # Get token counts for different durations
98 short_tokens = get_token_count(1.0)
99 long_tokens = get_token_count(2.0)
100
104 - # Longer audio should produce more tokens
101 assert long_tokens > short_tokens, (
102 f"Expected longer audio (2s) to have more tokens than shorter (1s). "
103 f"Got short={short_tokens}, long={long_tokens}"
104 )
109 -
110 -
111 -class TestQwen3OmniAudioSampleRatePreservation:
112 - """Test that audio_sample_rate is preserved during kwargs restructuring.
113 -
114 - These tests validate the fix for the audio_sample_rate bug in Qwen3 Omni
115 - where the parameter was lost during kwargs restructuring.
116 - """
117 -
118 - @staticmethod
119 - def _process_kwargs(
120 - mm_kwargs: dict[str, Any],
121 - tok_kwargs: dict[str, Any],
122 - transformers_version: str = "4.57.0",
123 - ) -> dict[str, Any]:
124 - """
125 - Helper method to simulate kwargs processing logic from production code.
126 -
127 - This method simulates the kwargs restructuring that happens in the
128 - Qwen3 Omni model when transformers < 4.58.0. By centralizing this
129 - logic, we make tests easier to maintain if the production logic changes.
130 -
131 - Args:
132 - mm_kwargs: Multimodal kwargs (e.g., audio_sample_rate, truncation)
133 - tok_kwargs: Tokenizer kwargs (e.g., truncation)
134 - transformers_version: Version string to test against (default: "4.57.0")
135 -
136 - Returns:
137 - Processed kwargs dictionary with restructured audio_kwargs and text_kwargs
138 - """
139 - from packaging.version import Version
140 -
141 - mm_kwargs_copy = dict(mm_kwargs)
142 - tok_kwargs_copy = dict(tok_kwargs)
143 -
144 - if Version(transformers_version) < Version("4.58.0"):
145 - # Extract audio_sample_rate before restructuring (THE FIX)
146 - audio_sample_rate = mm_kwargs_copy.pop("audio_sample_rate", None)
147 -
148 - # Restructure kwargs
149 - mm_kwargs_copy["audio_kwargs"] = {
150 - "truncation": mm_kwargs_copy.pop("truncation", False)
151 - }
152 - mm_kwargs_copy["text_kwargs"] = {
153 - "truncation": tok_kwargs_copy.pop("truncation", False)
154 - }
155 -
156 - # Put audio_sample_rate into audio_kwargs (THE FIX)
157 - if audio_sample_rate is not None:
158 - mm_kwargs_copy["audio_kwargs"]["audio_sample_rate"] = audio_sample_rate
159 -
160 - return mm_kwargs_copy
161 -
162 - def test_audio_sample_rate_preserved_in_audio_kwargs(self) -> None:
163 - """
164 - Test that audio_sample_rate is moved from top-level mm_kwargs
165 - into audio_kwargs during kwargs restructuring.
166 -
167 - This is the core fix: when transformers < 4.58.0, the code
168 - restructures kwargs into audio_kwargs and text_kwargs, and
169 - audio_sample_rate must be preserved in audio_kwargs.
170 - """
171 - # Setup: Create mm_kwargs with audio_sample_rate at top level
172 - mm_kwargs: dict[str, Any] = {
173 - "audio_sample_rate": 16000,
174 - "truncation": True,
175 - }
176 - tok_kwargs: dict[str, Any] = {
177 - "truncation": False,
178 - }
179 -
180 - # Execute: Process kwargs using helper method
181 - result = self._process_kwargs(mm_kwargs, tok_kwargs)
182 -
183 - # Assert: Verify audio_sample_rate is in audio_kwargs
184 - assert "audio_kwargs" in result
185 - assert "audio_sample_rate" in result["audio_kwargs"]
186 - assert result["audio_kwargs"]["audio_sample_rate"] == 16000
187 -
188 - # Assert: Verify truncation is also in audio_kwargs
189 - assert result["audio_kwargs"]["truncation"] is True
190 -
191 - # Assert: Verify text_kwargs is created correctly
192 - assert "text_kwargs" in result
193 - assert result["text_kwargs"]["truncation"] is False
194 -
195 - def test_audio_sample_rate_absent_when_not_provided(self) -> None:
196 - """
197 - Test that when audio_sample_rate is not provided in mm_kwargs,
198 - the restructured audio_kwargs doesn't contain it.
199 - """
200 - # Setup: Create mm_kwargs WITHOUT audio_sample_rate
201 - mm_kwargs: dict[str, Any] = {
202 - "truncation": True,
203 - }
204 - tok_kwargs: dict[str, Any] = {
205 - "truncation": False,
206 - }
207 -
208 - # Execute: Process kwargs using helper method
209 - result = self._process_kwargs(mm_kwargs, tok_kwargs)
210 -
211 - # Assert: Verify audio_sample_rate is NOT in audio_kwargs
212 - assert "audio_kwargs" in result
213 - assert "audio_sample_rate" not in result["audio_kwargs"]
214 -
215 - # Assert: Verify truncation is still in audio_kwargs
216 - assert result["audio_kwargs"]["truncation"] is True
217 -
218 - @pytest.mark.parametrize("sample_rate", [8000, 16000, 22050, 24000, 44100, 48000])
219 - def test_various_audio_sample_rates_preserved(self, sample_rate: int) -> None:
220 - """
221 - Test that various common audio sample rates are preserved.
222 -
223 - Common sample rates:
224 - - 8000: Telephone quality
225 - - 16000: Wideband speech (Qwen3 Omni default)
226 - - 22050: Low-quality audio
227 - - 24000: High-quality speech
228 - - 44100: CD quality
229 - - 48000: Professional audio
230 - """
231 - # Setup: Create mm_kwargs with specific sample rate
232 - mm_kwargs: dict[str, Any] = {
233 - "audio_sample_rate": sample_rate,
234 - "truncation": True,
235 - }
236 - tok_kwargs: dict[str, Any] = {"truncation": False}
237 -
238 - # Execute: Process kwargs using helper method
239 - result = self._process_kwargs(mm_kwargs, tok_kwargs)
240 -
241 - # Assert: Verify the specific sample rate is preserved
242 - assert result["audio_kwargs"]["audio_sample_rate"] == sample_rate
243 -
244 - def test_kwargs_unchanged_for_newer_transformers_version(self) -> None:
245 - """
246 - Test that kwargs structure remains unchanged for transformers >= 4.58.0.
247 -
248 - This test ensures that when transformers version is 4.58.0 or higher,
249 - the kwargs restructuring is bypassed and audio_sample_rate remains
250 - at the top level as originally passed.
251 - """
252 - from packaging.version import Version
253 -
254 - # Setup: Create mm_kwargs with audio_sample_rate at top level
255 - mm_kwargs: dict[str, Any] = {
256 - "audio_sample_rate": 16000,
257 - "truncation": True,
258 - }
259 - tok_kwargs: dict[str, Any] = {
260 - "truncation": False,
261 - }
262 -
263 - # Execute: Simulate with transformers >= 4.58.0
264 - mm_kwargs_copy = dict(mm_kwargs)
265 - tok_kwargs_copy = dict(tok_kwargs)
266 -
267 - transformers_ver = "4.58.0" # Version that bypasses restructuring
268 - if Version(transformers_ver) < Version("4.58.0"):
269 - # This block should NOT execute for >= 4.58.0
270 - audio_sample_rate = mm_kwargs_copy.pop("audio_sample_rate", None)
271 - mm_kwargs_copy["audio_kwargs"] = {
272 - "truncation": mm_kwargs_copy.pop("truncation", False)
273 - }
274 - mm_kwargs_copy["text_kwargs"] = {
275 - "truncation": tok_kwargs_copy.pop("truncation", False)
276 - }
277 - if audio_sample_rate is not None:
278 - mm_kwargs_copy["audio_kwargs"]["audio_sample_rate"] = audio_sample_rate
279 -
280 - # Assert: Verify kwargs structure is unchanged
281 - assert "audio_kwargs" not in mm_kwargs_copy
282 - assert "text_kwargs" not in mm_kwargs_copy
283 - assert mm_kwargs_copy["audio_sample_rate"] == 16000
284 - assert mm_kwargs_copy["truncation"] is True
285 - assert tok_kwargs_copy["truncation"] is False
tests/multimodal/test_audio.py
+503
@@ -7,10 +7,16 @@ from unittest.mock import patch
7
8 import numpy as np
9 import pytest
10 +import torch
11
12 from vllm.multimodal.audio import (
13 + MONO_AUDIO_SPEC,
14 + PASSTHROUGH_AUDIO_SPEC,
15 AudioMediaIO,
16 AudioResampler,
17 + AudioSpec,
18 + ChannelReduction,
19 + normalize_audio,
20 resample_audio_librosa,
21 resample_audio_scipy,
22 )
@@ -137,3 +143,500 @@ def test_audio_media_io_encode_base64(dummy_audio):
143 decoded = base64.b64decode(out)
144 assert decoded == b"dummy_wav_data"
145 mock_write.assert_called_once()
146 +
147 +
148 +# ============================================================
149 +# Tests for normalize_audio function
150 +# ============================================================
151 +
152 +
153 +class TestNormalizeAudio:
154 + """Tests for normalize_audio function with different specs."""
155 +
156 + def test_passthrough_preserves_audio(self):
157 + """Passthrough spec should not modify audio."""
158 + stereo = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float32)
159 + result = normalize_audio(stereo, PASSTHROUGH_AUDIO_SPEC)
160 + np.testing.assert_array_equal(result, stereo)
161 +
162 + def test_mono_spec_with_numpy_stereo(self):
163 + """Mono spec should reduce stereo numpy array to 1D."""
164 + stereo = np.array([[1.0, 2.0], [-1.0, 0.0]], dtype=np.float32)
165 + result = normalize_audio(stereo, MONO_AUDIO_SPEC)
166 + assert result.ndim == 1
167 + np.testing.assert_array_almost_equal(result, [0.0, 1.0])
168 +
169 + def test_mono_spec_with_torch_stereo(self):
170 + """Mono spec should reduce stereo torch tensor to 1D."""
171 + stereo = torch.tensor([[1.0, 2.0], [-1.0, 0.0]])
172 + result = normalize_audio(stereo, MONO_AUDIO_SPEC)
173 + assert result.ndim == 1
174 + torch.testing.assert_close(result, torch.tensor([0.0, 1.0]))
175 +
176 + def test_mono_passthrough_for_1d_numpy(self):
177 + """1D numpy array should pass through unchanged with mono spec."""
178 + mono = np.array([1.0, 2.0, 3.0], dtype=np.float32)
179 + result = normalize_audio(mono, MONO_AUDIO_SPEC)
180 + assert result.ndim == 1
181 + np.testing.assert_array_equal(result, mono)
182 +
183 + def test_mono_passthrough_for_1d_torch(self):
184 + """1D torch tensor should pass through unchanged with mono spec."""
185 + mono = torch.tensor([1.0, 2.0, 3.0])
186 + result = normalize_audio(mono, MONO_AUDIO_SPEC)
187 + assert result.ndim == 1
188 + torch.testing.assert_close(result, mono)
189 +
190 + def test_first_channel_reduction(self):
191 + """FIRST reduction should take only the first channel."""
192 + spec = AudioSpec(target_channels=1, channel_reduction=ChannelReduction.FIRST)
193 + stereo = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32)
194 + result = normalize_audio(stereo, spec)
195 + np.testing.assert_array_equal(result, [1.0, 2.0])
196 +
197 + def test_max_channel_reduction(self):
198 + """MAX reduction should take max across channels."""
199 + spec = AudioSpec(target_channels=1, channel_reduction=ChannelReduction.MAX)
200 + stereo = np.array([[1.0, 4.0], [3.0, 2.0]], dtype=np.float32)
201 + result = normalize_audio(stereo, spec)
202 + np.testing.assert_array_equal(result, [3.0, 4.0])
203 +
204 + def test_sum_channel_reduction(self):
205 + """SUM reduction should sum across channels."""
206 + spec = AudioSpec(target_channels=1, channel_reduction=ChannelReduction.SUM)
207 + stereo = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32)
208 + result = normalize_audio(stereo, spec)
209 + np.testing.assert_array_equal(result, [4.0, 6.0])
210 +
211 + def test_invalid_3d_array_raises(self):
212 + """3D arrays should raise ValueError."""
213 + audio_3d = np.random.randn(2, 3, 4).astype(np.float32)
214 + with pytest.raises(ValueError, match="Unsupported audio"):
215 + normalize_audio(audio_3d, MONO_AUDIO_SPEC)
216 +
217 + def test_channel_expansion_raises(self):
218 + """Expanding from mono to stereo should raise ValueError."""
219 + mono = np.array([1.0, 2.0, 3.0], dtype=np.float32)
220 + spec = AudioSpec(target_channels=2)
221 + with pytest.raises(ValueError, match="Cannot expand"):
222 + normalize_audio(mono, spec)
223 +
224 + def test_time_channels_format_numpy(self):
225 + """Audio in (time, channels) format should be transposed to (channels, time).
226 +
227 + This handles the case where audio loaders like soundfile return
228 + (time, channels) format instead of (channels, time) like torchaudio.
229 + """
230 + # Create audio in (time, channels) format: 1000 samples, 2 channels
231 + audio_time_channels = np.array(
232 + [[1.0, -1.0]] * 1000, # 1000 time steps, 2 channels
233 + dtype=np.float32,
234 + )
235 + assert audio_time_channels.shape == (1000, 2) # (time, channels)
236 +
237 + result = normalize_audio(audio_time_channels, MONO_AUDIO_SPEC)
238 +
239 + # Should be reduced to mono 1D
240 + assert result.ndim == 1
241 + assert result.shape == (1000,)
242 + # Mean of [1.0, -1.0] at each time step should be 0.0
243 + np.testing.assert_array_almost_equal(result, np.zeros(1000))
244 +
245 + def test_time_channels_format_torch(self):
246 + """Torch tensor in (time, channels) format should be transposed."""
247 + # Create audio in (time, channels) format: 1000 samples, 2 channels
248 + audio_time_channels = torch.tensor(
249 + [[1.0, -1.0]] * 1000, # 1000 time steps, 2 channels
250 + )
251 + assert audio_time_channels.shape == (1000, 2) # (time, channels)
252 +
253 + result = normalize_audio(audio_time_channels, MONO_AUDIO_SPEC)
254 +
255 + # Should be reduced to mono 1D
256 + assert result.ndim == 1
257 + assert result.shape == (1000,)
258 + # Mean of [1.0, -1.0] at each time step should be 0.0
259 + torch.testing.assert_close(result, torch.zeros(1000))
260 +
261 + def test_channels_time_format_preserved(self):
262 + """Audio already in (channels, time) format should work correctly."""
263 + # Create audio in standard (channels, time) format: 2 channels, 1000 samples
264 + audio_channels_time = np.array(
265 + [[1.0] * 1000, [-1.0] * 1000], # 2 channels, 1000 time steps
266 + dtype=np.float32,
267 + )
268 + assert audio_channels_time.shape == (2, 1000) # (channels, time)
269 +
270 + result = normalize_audio(audio_channels_time, MONO_AUDIO_SPEC)
271 +
272 + # Should be reduced to mono 1D
273 + assert result.ndim == 1
274 + assert result.shape == (1000,)
275 + # Mean of [1.0, -1.0] at each time step should be 0.0
276 + np.testing.assert_array_almost_equal(result, np.zeros(1000))
277 +
278 + def test_ambiguous_square_audio_numpy(self):
279 + """Square audio arrays (N, N) should use shape[0] > shape[1] heuristic.
280 +
281 + For a square array, shape[0] == shape[1], so no transpose happens
282 + and we assume (channels, time) format.
283 + """
284 + # Create square audio: 4 channels, 4 samples
285 + audio_square = np.array(
286 + [
287 + [1.0, 2.0, 3.0, 4.0],
288 + [5.0, 6.0, 7.0, 8.0],
289 + [9.0, 10.0, 11.0, 12.0],
290 + [13.0, 14.0, 15.0, 16.0],
291 + ],
292 + dtype=np.float32,
293 + )
294 + assert audio_square.shape == (4, 4)
295 +
296 + result = normalize_audio(audio_square, MONO_AUDIO_SPEC)
297 +
298 + # Should be reduced to mono 1D with mean across channels (axis 0)
299 + assert result.ndim == 1
300 + assert result.shape == (4,)
301 + # Mean across 4 channels: [1+5+9+13, 2+6+10+14, ...] / 4
302 + expected = np.array([7.0, 8.0, 9.0, 10.0])
303 + np.testing.assert_array_almost_equal(result, expected)
304 +
305 +
306 +# ============================================================
307 +# Tests for MultiModalDataParser integration with target_channels
308 +# ============================================================
309 +
310 +
311 +class TestMultiModalDataParserChannelNormalization:
312 + """Tests for MultiModalDataParser.target_channels integration.
313 +
314 + These tests verify that the target_channels parameter is properly used
315 + in the _parse_audio_data method to normalize audio channels.
316 + """
317 +
318 + def test_parser_normalizes_stereo_to_mono(self):
319 + """Parser should normalize stereo to mono when target_channels=1."""
320 + from vllm.multimodal.parse import MultiModalDataParser
321 +
322 + # Create parser with mono normalization enabled
323 + parser = MultiModalDataParser(
324 + target_sr=16000,
325 + target_channels=1,
326 + )
327 +
328 + # Create stereo audio (simulating torchaudio output)
329 + stereo_audio = np.array(
330 + [[1.0, 1.0, 1.0], [-1.0, -1.0, -1.0]], # 2 channels, 3 samples
331 + dtype=np.float32,
332 + )
333 +
334 + # Parse audio data
335 + result = parser._parse_audio_data((stereo_audio, 16000))
336 +
337 + # Check that result is mono (1D)
338 + audio_item = result.get(0)
339 + assert audio_item.ndim == 1, f"Expected 1D mono audio, got {audio_item.ndim}D"
340 + assert audio_item.shape == (3,), f"Expected shape (3,), got {audio_item.shape}"
341 + # Channel average of [1, 1, 1] and [-1, -1, -1] should be [0, 0, 0]
342 + np.testing.assert_array_almost_equal(audio_item, np.zeros(3))
343 +
344 + def test_parser_preserves_stereo_when_target_channels_none(self):
345 + """Parser should preserve stereo when target_channels=None."""
346 + from vllm.multimodal.parse import MultiModalDataParser
347 +
348 + # Create parser without channel normalization
349 + parser = MultiModalDataParser(
350 + target_sr=16000,
351 + target_channels=None,
352 + )
353 +
354 + # Create stereo audio
355 + stereo_audio = np.array(
356 + [[1.0, 1.0, 1.0], [-1.0, -1.0, -1.0]],
357 + dtype=np.float32,
358 + )
359 +
360 + # Parse audio data
361 + result = parser._parse_audio_data((stereo_audio, 16000))
362 +
363 + # Check that result preserves original shape (after resampling)
364 + audio_item = result.get(0)
365 + # When target_channels=None, stereo audio should be preserved
366 + assert audio_item.ndim == 2, f"Expected 2D stereo audio, got {audio_item.ndim}D"
367 +
368 + def test_parser_mono_passthrough_when_target_channels_1(self):
369 + """Parser should pass through mono audio unchanged when target_channels=1."""
370 + from vllm.multimodal.parse import MultiModalDataParser
371 +
372 + # Create parser with mono normalization enabled
373 + parser = MultiModalDataParser(
374 + target_sr=16000,
375 + target_channels=1,
376 + )
377 +
378 + # Create mono audio (already 1D)
379 + mono_audio = np.random.randn(16000).astype(np.float32)
380 +
381 + # Parse audio data
382 + result = parser._parse_audio_data((mono_audio, 16000))
383 +
384 + # Check that result is still mono (1D)
385 + audio_item = result.get(0)
386 + assert audio_item.ndim == 1
387 + assert audio_item.shape == (16000,)
388 +
389 + def test_parser_with_target_channels_2(self):
390 + """Parser should reduce 6-channel to 2-channel when target_channels=2."""
391 + from vllm.multimodal.parse import MultiModalDataParser
392 +
393 + # Create parser with stereo target
394 + parser = MultiModalDataParser(
395 + target_sr=16000,
396 + target_channels=2,
397 + )
398 +
399 + # Create 6-channel audio (5.1 surround)
400 + surround_audio = np.random.randn(6, 1000).astype(np.float32)
401 +
402 + # Parse audio data
403 + result = parser._parse_audio_data((surround_audio, 16000))
404 +
405 + # Check that result is stereo (2 channels)
406 + audio_item = result.get(0)
407 + assert audio_item.ndim == 2
408 + assert audio_item.shape[0] == 2 # 2 channels
409 +
410 +
411 +# ============================================================
412 +# End-to-End Audio Pipeline Tests
413 +# ============================================================
414 +
415 +
416 +class TestAudioPipelineE2E:
417 + """End-to-end tests for audio normalization in the full pipeline.
418 +
419 + These tests verify the complete flow from raw audio input through
420 + the MultiModalDataParser, simulating different audio loader formats.
421 + """
422 +
423 + def test_stereo_audio_normalized_to_mono_e2e(self):
424 + """Full pipeline: stereo audio (torchaudio format) → mono output."""
425 + from vllm.multimodal.parse import MultiModalDataParser
426 +
427 + # Simulate torchaudio output: (channels, time) format
428 + # Stereo audio with left channel = 1.0, right channel = -1.0
429 + stereo_torchaudio = np.array(
430 + [[1.0] * 16000, [-1.0] * 16000], # 2 channels, 1 second at 16kHz
431 + dtype=np.float32,
432 + )
433 + assert stereo_torchaudio.shape == (2, 16000)
434 +
435 + # Create parser with mono normalization (like Whisper models)
436 + parser = MultiModalDataParser(
437 + target_sr=16000,
438 + target_channels=1,
439 + )
440 +
441 + # Process audio through the parser
442 + result = parser._parse_audio_data((stereo_torchaudio, 16000))
443 + audio_output = result.get(0)
444 +
445 + # Verify output is mono 1D
446 + assert audio_output.ndim == 1, f"Expected 1D, got {audio_output.ndim}D"
447 + assert audio_output.shape == (16000,)
448 +
449 + # Verify channel averaging: mean of [1.0, -1.0] = 0.0
450 + np.testing.assert_array_almost_equal(audio_output, np.zeros(16000), decimal=5)
451 +
452 + def test_soundfile_format_normalized_to_mono_e2e(self):
453 + """Full pipeline: soundfile format (time, channels) → mono output."""
454 + from vllm.multimodal.parse import MultiModalDataParser
455 +
456 + # Simulate soundfile output: (time, channels) format
457 + # 16000 samples, 2 channels
458 + stereo_soundfile = np.array(
459 + [[0.5, -0.5]] * 16000, # Each row is [left, right]
460 + dtype=np.float32,
461 + )
462 + assert stereo_soundfile.shape == (16000, 2)
463 +
464 + # Create parser with mono normalization
465 + parser = MultiModalDataParser(
466 + target_sr=16000,
467 + target_channels=1,
468 + )
469 +
470 + # Process audio through the parser
471 + result = parser._parse_audio_data((stereo_soundfile, 16000))
472 + audio_output = result.get(0)
473 +
474 + # Verify output is mono 1D
475 + assert audio_output.ndim == 1, f"Expected 1D, got {audio_output.ndim}D"
476 + assert audio_output.shape == (16000,)
477 +
478 + # Verify channel averaging: mean of [0.5, -0.5] = 0.0
479 + np.testing.assert_array_almost_equal(audio_output, np.zeros(16000), decimal=5)
480 +
481 + def test_librosa_mono_passthrough_e2e(self):
482 + """Full pipeline: librosa mono format → preserved as mono."""
483 + from vllm.multimodal.parse import MultiModalDataParser
484 +
485 + # Simulate librosa output: already mono (time,) format
486 + mono_librosa = np.random.randn(16000).astype(np.float32)
487 + assert mono_librosa.shape == (16000,)
488 +
489 + # Create parser with mono normalization
490 + parser = MultiModalDataParser(
491 + target_sr=16000,
492 + target_channels=1,
493 + )
494 +
495 + # Process audio through the parser
496 + result = parser._parse_audio_data((mono_librosa, 16000))
497 + audio_output = result.get(0)
498 +
499 + # Verify output is still mono 1D
500 + assert audio_output.ndim == 1
501 + assert audio_output.shape == (16000,)
502 +
503 + # Verify audio content is preserved
504 + np.testing.assert_array_almost_equal(audio_output, mono_librosa)
505 +
506 + def test_multichannel_5_1_surround_to_mono_e2e(self):
507 + """Full pipeline: 5.1 surround (6 channels) → mono output."""
508 + from vllm.multimodal.parse import MultiModalDataParser
509 +
510 + # Simulate 5.1 surround audio: 6 channels
511 + surround_audio = np.array(
512 + [
513 + [1.0] * 8000, # Front Left
514 + [2.0] * 8000, # Front Right
515 + [3.0] * 8000, # Center
516 + [4.0] * 8000, # LFE (subwoofer)
517 + [5.0] * 8000, # Rear Left
518 + [6.0] * 8000, # Rear Right
519 + ],
520 + dtype=np.float32,
521 + )
522 + assert surround_audio.shape == (6, 8000)
523 +
524 + # Create parser with mono normalization
525 + parser = MultiModalDataParser(
526 + target_sr=16000,
527 + target_channels=1,
528 + )
529 +
530 + # Process audio through the parser
531 + result = parser._parse_audio_data((surround_audio, 16000))
532 + audio_output = result.get(0)
533 +
534 + # Verify output is mono 1D
535 + assert audio_output.ndim == 1
536 +
537 + # Verify channel averaging: mean of [1,2,3,4,5,6] = 3.5
538 + expected_value = (1.0 + 2.0 + 3.0 + 4.0 + 5.0 + 6.0) / 6
539 + np.testing.assert_array_almost_equal(
540 + audio_output, np.full(8000, expected_value), decimal=5
541 + )
542 +
543 + def test_torch_tensor_input_e2e(self):
544 + """Full pipeline: torch.Tensor stereo input → mono numpy output."""
545 + from vllm.multimodal.parse import MultiModalDataParser
546 +
547 + # Simulate torch tensor input (from torchaudio)
548 + stereo_torch = torch.tensor(
549 + [[1.0] * 8000, [-1.0] * 8000], # 2 channels
550 + dtype=torch.float32,
551 + )
552 + assert stereo_torch.shape == (2, 8000)
553 +
554 + # Create parser with mono normalization
555 + parser = MultiModalDataParser(
556 + target_sr=16000,
557 + target_channels=1,
558 + )
559 +
560 + # Process audio through the parser
561 + # Note: Parser expects numpy, so we convert first (simulating real usage)
562 + result = parser._parse_audio_data((stereo_torch.numpy(), 16000))
563 + audio_output = result.get(0)
564 +
565 + # Verify output is mono 1D numpy array
566 + assert audio_output.ndim == 1
567 + assert isinstance(audio_output, np.ndarray)
568 +
569 + # Verify channel averaging
570 + np.testing.assert_array_almost_equal(audio_output, np.zeros(8000), decimal=5)
571 +
572 + def test_passthrough_preserves_stereo_e2e(self):
573 + """Full pipeline: stereo with target_channels=None → stereo preserved."""
574 + from vllm.multimodal.parse import MultiModalDataParser
575 +
576 + # Stereo audio
577 + stereo_audio = np.array(
578 + [[1.0] * 8000, [-1.0] * 8000],
579 + dtype=np.float32,
580 + )
581 +
582 + # Create parser WITHOUT mono normalization (passthrough)
583 + parser = MultiModalDataParser(
584 + target_sr=16000,
585 + target_channels=None, # Passthrough - no normalization
586 + )
587 +
588 + # Process audio through the parser
589 + result = parser._parse_audio_data((stereo_audio, 16000))
590 + audio_output = result.get(0)
591 +
592 + # Verify output preserves stereo (2D)
593 + assert audio_output.ndim == 2
594 + assert audio_output.shape == (2, 8000)
595 +
596 + def test_resampling_with_channel_normalization_e2e(self):
597 + """Full pipeline: resample + channel normalize in single pass."""
598 + from vllm.multimodal.parse import MultiModalDataParser
599 +
600 + # Stereo audio at 48kHz (common recording rate)
601 + stereo_48k = np.array(
602 + [[1.0] * 48000, [-1.0] * 48000], # 1 second at 48kHz
603 + dtype=np.float32,
604 + )
605 +
606 + # Create parser with both resampling and mono normalization
607 + parser = MultiModalDataParser(
608 + target_sr=16000, # Resample to 16kHz
609 + target_channels=1, # Normalize to mono
610 + )
611 +
612 + # Process audio through the parser
613 + result = parser._parse_audio_data((stereo_48k, 48000))
614 + audio_output = result.get(0)
615 +
616 + # Verify output is mono 1D at target sample rate
617 + assert audio_output.ndim == 1
618 + # After resampling from 48kHz to 16kHz, length should be ~16000
619 + assert audio_output.shape[0] == 16000
620 +
621 + def test_very_short_audio_e2e(self):
622 + """Full pipeline: very short audio (< 1 frame) handled correctly."""
623 + from vllm.multimodal.parse import MultiModalDataParser
624 +
625 + # Very short stereo audio (10 samples)
626 + short_stereo = np.array(
627 + [[1.0] * 10, [-1.0] * 10],
628 + dtype=np.float32,
629 + )
630 +
631 + parser = MultiModalDataParser(
632 + target_sr=16000,
633 + target_channels=1,
634 + )
635 +
636 + result = parser._parse_audio_data((short_stereo, 16000))
637 + audio_output = result.get(0)
638 +
639 + # Should still produce mono output
640 + assert audio_output.ndim == 1
641 + assert audio_output.shape == (10,)
642 + np.testing.assert_array_almost_equal(audio_output, np.zeros(10))
vllm/model_executor/models/qwen2_5_omni_thinker.py
+5
@@ -226,6 +226,10 @@ class Qwen2_5OmniThinkerProcessingInfo(
226 assert isinstance(feature_extractor, WhisperFeatureExtractor)
227 return feature_extractor
228
229 + def get_target_channels(self) -> int:
230 + """Return target audio channels for Qwen2.5 Omni models (mono)."""
231 + return 1
232 +
233 def get_supported_mm_limits(self) -> Mapping[str, int | None]:
234 return {"audio": None, "image": None, "video": None}
235
@@ -310,6 +314,7 @@ class Qwen2_5OmniThinkerMultiModalProcessor(
314 return Qwen2_5OmniThinkerMultiModalDataParser(
315 spatial_merge_size=self.info.get_hf_config().vision_config.spatial_merge_size,
316 target_sr=feature_extractor.sampling_rate,
317 + target_channels=self.info.get_target_channels(),
318 )
319
320 def _call_hf_processor(
vllm/model_executor/models/qwen2_audio.py
+8 -1
@@ -140,6 +140,10 @@ class Qwen2AudioProcessingInfo(BaseProcessingInfo):
140 assert isinstance(feature_extractor, WhisperFeatureExtractor)
141 return feature_extractor
142
143 + def get_target_channels(self) -> int:
144 + """Return target audio channels for Qwen2 Audio models (mono)."""
145 + return 1
146 +
147 def get_supported_mm_limits(self) -> Mapping[str, int | None]:
148 return {"audio": None}
149
@@ -201,7 +205,10 @@ class Qwen2AudioMultiModalDataParser(MultiModalDataParser):
205 class Qwen2AudioMultiModalProcessor(BaseMultiModalProcessor[Qwen2AudioProcessingInfo]):
206 def _get_data_parser(self) -> MultiModalDataParser:
207 feature_extractor = self.info.get_feature_extractor()
204 - return Qwen2AudioMultiModalDataParser(target_sr=feature_extractor.sampling_rate)
208 + return Qwen2AudioMultiModalDataParser(
209 + target_sr=feature_extractor.sampling_rate,
210 + target_channels=self.info.get_target_channels(),
211 + )
212
213 def _call_hf_processor(
214 self,
vllm/model_executor/models/ultravox.py
+8 -1
@@ -133,6 +133,10 @@ class UltravoxProcessingInfo(BaseProcessingInfo):
133 assert isinstance(feature_extractor, WhisperFeatureExtractor)
134 return feature_extractor
135
136 + def get_target_channels(self) -> int:
137 + """Return target audio channels for Ultravox models (mono)."""
138 + return 1
139 +
140 def get_supported_mm_limits(self) -> Mapping[str, int | None]:
141 return {"audio": None}
142
@@ -169,7 +173,10 @@ class UltravoxDummyInputsBuilder(BaseDummyInputsBuilder[UltravoxProcessingInfo])
173 class UltravoxMultiModalProcessor(BaseMultiModalProcessor[UltravoxProcessingInfo]):
174 def _get_data_parser(self) -> MultiModalDataParser:
175 feature_extractor = self.info.get_feature_extractor()
172 - return MultiModalDataParser(target_sr=feature_extractor.sampling_rate)
176 + return MultiModalDataParser(
177 + target_sr=feature_extractor.sampling_rate,
178 + target_channels=self.info.get_target_channels(),
179 + )
180
181 def _call_hf_processor(
182 self,
vllm/model_executor/models/whisper.py
+8 -1
@@ -690,6 +690,10 @@ class WhisperProcessingInfo(BaseProcessingInfo):
690 assert isinstance(feature_extractor, WhisperFeatureExtractor)
691 return feature_extractor
692
693 + def get_target_channels(self) -> int:
694 + """Return target audio channels for Whisper models (mono)."""
695 + return 1
696 +
697 def get_num_audio_tokens(self) -> int:
698 return self.get_hf_config().max_source_positions
699
@@ -724,7 +728,10 @@ class WhisperDummyInputsBuilder(BaseDummyInputsBuilder[WhisperProcessingInfo]):
728 class WhisperMultiModalProcessor(EncDecMultiModalProcessor[WhisperProcessingInfo]):
729 def _get_data_parser(self) -> MultiModalDataParser:
730 feature_extractor = self.info.get_feature_extractor()
727 - return MultiModalDataParser(target_sr=feature_extractor.sampling_rate)
731 + return MultiModalDataParser(
732 + target_sr=feature_extractor.sampling_rate,
733 + target_channels=self.info.get_target_channels(),
734 + )
735
736 @property
737 def pad_dummy_encoder_prompt(self) -> bool:
vllm/multimodal/audio.py
+132
@@ -1,6 +1,8 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import base64
4 +from dataclasses import dataclass
5 +from enum import Enum
6 from io import BytesIO
7 from pathlib import Path
8 from typing import Literal
@@ -25,6 +27,136 @@ try:
27 except ImportError:
28 soundfile = PlaceholderModule("soundfile") # type: ignore[assignment]
29
30 +# ============================================================
31 +
32 +
33 +class ChannelReduction(str, Enum):
34 + """Method to reduce multi-channel audio to target channels."""
35 +
36 + MEAN = "mean" # Average across channels (default, preserves energy balance)
37 + FIRST = "first" # Take first channel only
38 + MAX = "max" # Take max value across channels
39 + SUM = "sum" # Sum across channels
40 +
41 +
42 +@dataclass
43 +class AudioSpec:
44 + """Specification for target audio format.
45 +
46 + This dataclass defines the expected audio format for a model's feature
47 + extractor. It is used to normalize audio data before processing.
48 +
49 + Attributes:
50 + target_channels: Number of output channels. None means passthrough
51 + (no normalization). 1 = mono, 2 = stereo, etc.
52 + channel_reduction: Method to reduce channels when input has more
53 + channels than target. Only used when reducing channels.
54 + """
55 +
56 + target_channels: int | None = 1
57 + channel_reduction: ChannelReduction = ChannelReduction.MEAN
58 +
59 + @property
60 + def needs_normalization(self) -> bool:
61 + """Whether audio normalization is needed."""
62 + return self.target_channels is not None
63 +
64 + def __repr__(self) -> str:
65 + if self.target_channels is None:
66 + return "AudioSpec(passthrough)"
67 + return (
68 + f"AudioSpec(channels={self.target_channels}, "
69 + f"reduction={self.channel_reduction.value})"
70 + )
71 +
72 +
73 +# Pre-defined specs for common use cases
74 +MONO_AUDIO_SPEC = AudioSpec(target_channels=1, channel_reduction=ChannelReduction.MEAN)
75 +PASSTHROUGH_AUDIO_SPEC = AudioSpec(target_channels=None)
76 +
77 +
78 +def normalize_audio(
79 + audio: npt.NDArray[np.floating] | torch.Tensor,
80 + spec: AudioSpec,
81 +) -> npt.NDArray[np.floating] | torch.Tensor:
82 + """Normalize audio to the specified format.
83 +
84 + This function handles channel reduction for multi-channel audio,
85 + supporting both numpy arrays and torch tensors.
86 +
87 + Args:
88 + audio: Input audio data. Can be:
89 + - 1D array/tensor: (time,) - already mono
90 + - 2D array/tensor: (channels, time) - standard format from torchaudio
91 + - 2D array/tensor: (time, channels) - format from soundfile
92 + (will be auto-detected and transposed if time > channels)
93 + spec: AudioSpec defining the target format.
94 +
95 + Returns:
96 + Normalized audio in the same type as input (numpy or torch).
97 + For mono output (target_channels=1), returns 1D array/tensor.
98 +
99 + Raises:
100 + ValueError: If audio has unsupported dimensions or channel expansion
101 + is requested (e.g., mono to stereo).
102 + """
103 + if not spec.needs_normalization:
104 + return audio
105 +
106 + # Handle 1D audio (already mono)
107 + if audio.ndim == 1:
108 + if spec.target_channels == 1:
109 + return audio
110 + raise ValueError(f"Cannot expand mono audio to {spec.target_channels} channels")
111 +
112 + # Handle 2D audio
113 + if audio.ndim != 2:
114 + raise ValueError(f"Unsupported audio shape: {audio.shape}. Expected 1D or 2D.")
115 +
116 + # Auto-detect format: if shape[0] > shape[1], assume (time, channels)
117 + # This handles soundfile format where time dimension is typically much larger
118 + if audio.shape[0] > audio.shape[1]:
119 + # Transpose from (time, channels) to (channels, time)
120 + audio = audio.T if isinstance(audio, np.ndarray) else audio.T
121 +
122 + num_channels = audio.shape[0]
123 +
124 + # No reduction needed if already at target
125 + if num_channels == spec.target_channels:
126 + return audio
127 +
128 + # Cannot expand channels
129 + if num_channels < spec.target_channels:
130 + raise ValueError(
131 + f"Cannot expand {num_channels} channels to {spec.target_channels}"
132 + )
133 +
134 + # Reduce channels
135 + is_numpy = isinstance(audio, np.ndarray)
136 +
137 + if spec.target_channels == 1:
138 + # Reduce to mono
139 + if spec.channel_reduction == ChannelReduction.MEAN:
140 + result = np.mean(audio, axis=0) if is_numpy else audio.mean(dim=0)
141 + elif spec.channel_reduction == ChannelReduction.FIRST:
142 + result = audio[0]
143 + elif spec.channel_reduction == ChannelReduction.MAX:
144 + result = np.max(audio, axis=0) if is_numpy else audio.max(dim=0).values
145 + elif spec.channel_reduction == ChannelReduction.SUM:
146 + result = np.sum(audio, axis=0) if is_numpy else audio.sum(dim=0)
147 + else:
148 + raise ValueError(f"Unknown reduction method: {spec.channel_reduction}")
149 + return result
150 + else:
151 + # Reduce to N channels (take first N and apply reduction if needed)
152 + # For now, just take first N channels
153 + return audio[: spec.target_channels]
154 +
155 +
156 +# ============================================================
157 +# Audio Resampling
158 +# ============================================================
159 +
160
161 def resample_audio_librosa(
162 audio: npt.NDArray[np.floating],
vllm/multimodal/parse.py
+11 -1
@@ -22,7 +22,7 @@ from typing_extensions import assert_never
22 from vllm.utils.collection_utils import is_list_of
23 from vllm.utils.import_utils import LazyLoader
24
25 -from .audio import AudioResampler
25 +from .audio import AudioResampler, AudioSpec, normalize_audio
26 from .base import MediaWithBytes
27 from .inputs import (
28 AudioItem,
@@ -456,6 +456,9 @@ class MultiModalDataParser:
456 Args:
457 target_sr (float, optional): Enables automatic resampling of audio
458 items to the model's expected sampling rate.
459 + target_channels (int, optional): Target number of audio channels.
460 + If provided, normalizes audio to this many channels (e.g., 1 for mono).
461 + If None, audio channels are passed through unchanged.
462 expected_hidden_size (int, optional): Expected hidden dimension for
463 embedding inputs. If provided, validates that user-supplied
464 embeddings have the correct hidden size to prevent crashes
@@ -466,6 +469,7 @@ class MultiModalDataParser:
469 self,
470 *,
471 target_sr: float | None = None,
472 + target_channels: int | None = None,
473 audio_resample_method: Literal["librosa", "scipy"] = "librosa",
474 video_needs_metadata: bool = False,
475 expected_hidden_size: int | None = None,
@@ -476,6 +480,7 @@ class MultiModalDataParser:
480 target_sr=target_sr,
481 method=audio_resample_method,
482 )
483 + self.target_channels = target_channels
484 self.video_needs_metadata = video_needs_metadata
485 self.expected_hidden_size = expected_hidden_size
486
@@ -565,6 +570,11 @@ class MultiModalDataParser:
570 else:
571 new_audio = self.audio_resampler.resample(audio, orig_sr=orig_sr)
572
573 + # Apply channel normalization if target_channels is set
574 + if self.target_channels is not None:
575 + spec = AudioSpec(target_channels=self.target_channels)
576 + new_audio = normalize_audio(new_audio, spec)
577 +
578 new_audios.append(new_audio)
579
580 return AudioProcessorItems(new_audios)