diff --git a/requirements/test.in b/requirements/test.in index be4c2e5795f4..8bd00514435b 100644 --- a/requirements/test.in +++ b/requirements/test.in @@ -21,7 +21,6 @@ vocos # required for minicpmo_26 test peft>=0.15.0 # required for phi-4-mm test pqdm ray[cgraph,default]>=2.48.0 # Ray Compiled Graph, required by pipeline parallelism tests -resampy # required for audio tests sentence-transformers>=5.2.0 # required for embedding tests soundfile # required for audio tests jiwer # required for audio tests diff --git a/requirements/test.txt b/requirements/test.txt index 7d3a988a729d..e2f9040beecc 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -544,7 +544,6 @@ numba==0.61.2 # via # -r requirements/test.in # librosa - # resampy numpy==2.2.6 # via # -r requirements/test.in @@ -585,7 +584,6 @@ numpy==2.2.6 # pyogrio # pywavelets # rasterio - # resampy # rioxarray # rouge-score # runai-model-streamer @@ -997,8 +995,6 @@ requests==2.32.3 # tiktoken # transformers # wandb -resampy==0.4.3 - # via -r requirements/test.in responses==0.25.3 # via genai-perf rfc3339-validator==0.1.4 diff --git a/setup.py b/setup.py index 2f251a6a296d..7b5c49e98b6b 100644 --- a/setup.py +++ b/setup.py @@ -987,11 +987,11 @@ def _read_requirements(filename: str) -> list[str]: "instanttensor": ["instanttensor >= 0.1.5"], "runai": ["runai-model-streamer[s3,gcs,azure] >= 0.15.7"], "audio": [ - "av", - "resampy", + "librosa", "scipy", "soundfile", "mistral_common[audio]", + "av", ], # Required for audio processing "video": [], # Kept for backwards compatibility "flashinfer": [], # Kept for backwards compatibility diff --git a/tests/entrypoints/openai/speech_to_text/test_transcription_validation.py b/tests/entrypoints/openai/speech_to_text/test_transcription_validation.py index 4ac48699a022..e9bde638d4a3 100644 --- a/tests/entrypoints/openai/speech_to_text/test_transcription_validation.py +++ b/tests/entrypoints/openai/speech_to_text/test_transcription_validation.py @@ -152,5 +152,5 @@ async def test_basic_audio_foscolo(foscolo, rocm_aiter_fa_attention, model_name) model_name, foscolo, language="it", - expected_text="ove il mio corpo fanciulletto", + expected_text="ove il mio corpo fanciulletto giacque", ) diff --git a/tests/entrypoints/openai/test_run_batch.py b/tests/entrypoints/openai/test_run_batch.py index bf670105bbc4..cf7e2a7b0c07 100644 --- a/tests/entrypoints/openai/test_run_batch.py +++ b/tests/entrypoints/openai/test_run_batch.py @@ -275,7 +275,7 @@ ] ) -MINIMAL_WAV_BASE64 = "UklGRigAAABXQVZFZm10IBAAAAABAAEAgD4AAAB9AAACABAAZGF0YQQAAAAAAP9/" +MINIMAL_WAV_BASE64 = "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=" INPUT_TRANSCRIPTION_BATCH = ( json.dumps( { diff --git a/tests/models/multimodal/generation/vlm_utils/builders.py b/tests/models/multimodal/generation/vlm_utils/builders.py index 1b7e2347be2f..47852453c058 100644 --- a/tests/models/multimodal/generation/vlm_utils/builders.py +++ b/tests/models/multimodal/generation/vlm_utils/builders.py @@ -323,7 +323,10 @@ def build_audio_inputs_from_test_info( test_info.audio_idx_to_prompt, test_info.prompt_formatter, ) - resampler = AudioResampler(target_sr=16000) + resampler = AudioResampler( + target_sr=16000, + method="librosa", + ) audios = [asset.audio_and_sample_rate for asset in audio_assets] resampled_audios = [ ( diff --git a/tests/multimodal/media/test_audio.py b/tests/multimodal/media/test_audio.py index 4361066ab885..18f142008c31 100644 --- a/tests/multimodal/media/test_audio.py +++ b/tests/multimodal/media/test_audio.py @@ -10,8 +10,6 @@ from vllm.multimodal.media import AudioMediaIO -from ...conftest import AudioTestAssets - pytestmark = pytest.mark.cpu_test ASSETS_DIR = Path(__file__).parent.parent / "assets" @@ -24,32 +22,40 @@ def dummy_audio(): @pytest.fixture -def dummy_audio_bytes(audio_assets: AudioTestAssets): - with open(audio_assets[0].get_local_path(), "rb") as f: - return f.read() +def dummy_audio_bytes(): + return b"FAKEAUDIOBYTES" def test_audio_media_io_load_bytes(dummy_audio_bytes): audio_io = AudioMediaIO() - out = audio_io.load_bytes(dummy_audio_bytes) - assert isinstance(out[0], np.ndarray) - assert out[1] == 16000 + with patch("librosa.load") as mock_load: + mock_load.return_value = (np.array([0.1, 0.2]), 16000) + out = audio_io.load_bytes(dummy_audio_bytes) + mock_load.assert_called_once() + assert isinstance(out[0], np.ndarray) + assert out[1] == 16000 def test_audio_media_io_load_base64(dummy_audio_bytes): audio_io = AudioMediaIO() encoded = base64.b64encode(dummy_audio_bytes).decode("utf-8") - out = audio_io.load_base64("audio/wav", encoded) - assert isinstance(out[0], np.ndarray) - assert out[1] == 16000 + with patch.object(AudioMediaIO, "load_bytes") as mock_load_bytes: + mock_load_bytes.return_value = (np.array([0.1, 0.2]), 16000) + out = audio_io.load_base64("audio/wav", encoded) + mock_load_bytes.assert_called_once() + assert isinstance(out[0], np.ndarray) + assert out[1] == 16000 -def test_audio_media_io_load_file(audio_assets: AudioTestAssets): +def test_audio_media_io_load_file(): audio_io = AudioMediaIO() - path = audio_assets[0].get_local_path() - out = audio_io.load_file(path) - assert isinstance(out[0], np.ndarray) - assert out[1] == 16000 + path = Path("/fake/path.wav") + with patch("librosa.load") as mock_load: + mock_load.return_value = (np.array([0.1, 0.2]), 16000) + out = audio_io.load_file(path) + mock_load.assert_called_once_with(path, sr=None) + assert isinstance(out[0], np.ndarray) + assert out[1] == 16000 def test_audio_media_io_encode_base64(dummy_audio): diff --git a/tests/multimodal/test_audio.py b/tests/multimodal/test_audio.py index 0bc8988452f0..3cc6bcadbec4 100644 --- a/tests/multimodal/test_audio.py +++ b/tests/multimodal/test_audio.py @@ -14,7 +14,7 @@ AudioSpec, ChannelReduction, normalize_audio, - resample_audio_pyav, + resample_audio_librosa, resample_audio_scipy, split_audio, ) @@ -25,14 +25,14 @@ def dummy_audio(): return np.array([0.0, 0.1, 0.2, 0.3, 0.4], dtype=float) -def test_resample_audio_pyav(dummy_audio): - out_down = resample_audio_pyav(dummy_audio, orig_sr=4, target_sr=2) - out_up = resample_audio_pyav(dummy_audio, orig_sr=2, target_sr=4) - out_same = resample_audio_pyav(dummy_audio, orig_sr=4, target_sr=4) - - assert len(out_down) == 3 - assert len(out_up) == 10 - assert np.all(out_same == dummy_audio) +def test_resample_audio_librosa(dummy_audio): + with patch("vllm.multimodal.audio.librosa.resample") as mock_resample: + mock_resample.return_value = dummy_audio * 2 + out = resample_audio_librosa(dummy_audio, orig_sr=44100, target_sr=22050) + mock_resample.assert_called_once_with( + dummy_audio, orig_sr=44100, target_sr=22050 + ) + assert np.all(out == dummy_audio * 2) def test_resample_audio_scipy(dummy_audio): @@ -56,9 +56,9 @@ def test_resample_audio_scipy_non_integer_ratio(dummy_audio): assert np.isfinite(out).all() -def test_audio_resampler_pyav_calls_resample(dummy_audio): - resampler = AudioResampler(target_sr=22050, method="pyav") - with patch("vllm.multimodal.audio.resample_audio_pyav") as mock_resample: +def test_audio_resampler_librosa_calls_resample(dummy_audio): + resampler = AudioResampler(target_sr=22050, method="librosa") + with patch("vllm.multimodal.audio.resample_audio_librosa") as mock_resample: mock_resample.return_value = dummy_audio out = resampler.resample(dummy_audio, orig_sr=44100) mock_resample.assert_called_once_with( @@ -423,13 +423,13 @@ def test_soundfile_format_normalized_to_mono_e2e(self): # Verify channel averaging: mean of [0.5, -0.5] = 0.0 np.testing.assert_array_almost_equal(audio_output, np.zeros(16000), decimal=5) - def test_pyav_mono_passthrough_e2e(self): - """Full pipeline: pyav mono format → preserved as mono.""" + def test_librosa_mono_passthrough_e2e(self): + """Full pipeline: librosa mono format → preserved as mono.""" from vllm.multimodal.parse import MultiModalDataParser - # Simulate pyav output: already mono (time,) format - mono_pyav = np.random.randn(16000).astype(np.float32) - assert mono_pyav.shape == (16000,) + # Simulate librosa output: already mono (time,) format + mono_librosa = np.random.randn(16000).astype(np.float32) + assert mono_librosa.shape == (16000,) # Create parser with mono normalization parser = MultiModalDataParser( @@ -438,7 +438,7 @@ def test_pyav_mono_passthrough_e2e(self): ) # Process audio through the parser - result = parser._parse_audio_data((mono_pyav, 16000)) + result = parser._parse_audio_data((mono_librosa, 16000)) audio_output = result.get(0) # Verify output is still mono 1D @@ -446,7 +446,7 @@ def test_pyav_mono_passthrough_e2e(self): assert audio_output.shape == (16000,) # Verify audio content is preserved - np.testing.assert_array_almost_equal(audio_output, mono_pyav) + np.testing.assert_array_almost_equal(audio_output, mono_librosa) def test_multichannel_5_1_surround_to_mono_e2e(self): """Full pipeline: 5.1 surround (6 channels) → mono output.""" diff --git a/vllm/assets/audio.py b/vllm/assets/audio.py index 24a5b9bee3f5..b527ffcf9b18 100644 --- a/vllm/assets/audio.py +++ b/vllm/assets/audio.py @@ -8,10 +8,15 @@ import numpy.typing as npt -from vllm.multimodal.media.audio import load_audio +from vllm.utils.import_utils import PlaceholderModule from .base import VLLM_S3_BUCKET_URL, get_vllm_public_assets +try: + import librosa +except ImportError: + librosa = PlaceholderModule("librosa") # type: ignore[assignment] + ASSET_DIR = "multimodal_asset" AudioAssetName = Literal["winning_call", "mary_had_lamb"] @@ -28,7 +33,7 @@ def filename(self) -> str: @property def audio_and_sample_rate(self) -> tuple[npt.NDArray, float]: audio_path = get_vllm_public_assets(filename=self.filename, s3_prefix=ASSET_DIR) - return load_audio(audio_path, sr=None) + return librosa.load(audio_path, sr=None) def get_local_path(self) -> Path: return get_vllm_public_assets(filename=self.filename, s3_prefix=ASSET_DIR) diff --git a/vllm/assets/video.py b/vllm/assets/video.py index f5e443db978f..d025368cbd43 100644 --- a/vllm/assets/video.py +++ b/vllm/assets/video.py @@ -10,10 +10,15 @@ from huggingface_hub import hf_hub_download from PIL import Image -from vllm.multimodal.media.audio import load_audio_pyav +from vllm.utils.import_utils import PlaceholderModule from .base import get_cache_dir +try: + import librosa +except ImportError: + librosa = PlaceholderModule("librosa") # type: ignore[assignment] + @lru_cache def download_video_asset(filename: str) -> str: @@ -141,4 +146,4 @@ def get_audio(self, sampling_rate: float | None = None) -> npt.NDArray: See also: examples/offline_inference/qwen2_5_omni/only_thinker.py """ - return load_audio_pyav(self.video_path, sr=sampling_rate)[0] + return librosa.load(self.video_path, sr=sampling_rate)[0] diff --git a/vllm/benchmarks/datasets.py b/vllm/benchmarks/datasets.py index 8304e8703b55..1e0a63dd6eb3 100644 --- a/vllm/benchmarks/datasets.py +++ b/vllm/benchmarks/datasets.py @@ -38,7 +38,6 @@ from vllm.lora.request import LoRARequest from vllm.lora.utils import get_adapter_absolute_path from vllm.multimodal import MultiModalDataDict -from vllm.multimodal.audio import get_audio_duration from vllm.multimodal.image import convert_image_mode from vllm.tokenizers import TokenizerLike from vllm.utils.argparse_utils import FlexibleArgumentParser @@ -55,6 +54,10 @@ except ImportError: pd = PlaceholderModule("pandas") +try: + import librosa +except ImportError: + librosa = PlaceholderModule("librosa") logger = logging.getLogger(__name__) @@ -3250,7 +3253,7 @@ def sample( break audio = item["audio"] y, sr = audio["array"], audio["sampling_rate"] - duration_s = get_audio_duration(y=y, sr=sr) + duration_s = librosa.get_duration(y=y, sr=sr) if duration_s < asr_min_audio_len_sec or duration_s > asr_max_audio_len_sec: skipped += 1 continue diff --git a/vllm/entrypoints/openai/speech_to_text/speech_to_text.py b/vllm/entrypoints/openai/speech_to_text/speech_to_text.py index bf58273f7504..4a6030d71b63 100644 --- a/vllm/entrypoints/openai/speech_to_text/speech_to_text.py +++ b/vllm/entrypoints/openai/speech_to_text/speech_to_text.py @@ -42,13 +42,32 @@ from vllm.logger import init_logger from vllm.logprobs import FlatLogprobs, Logprob from vllm.model_executor.models import SupportsTranscription -from vllm.multimodal.audio import get_audio_duration, split_audio -from vllm.multimodal.media.audio import load_audio +from vllm.multimodal.audio import split_audio +from vllm.multimodal.media.audio import extract_audio_from_video_bytes from vllm.outputs import RequestOutput from vllm.renderers.inputs import DictPrompt, EncoderDecoderDictPrompt from vllm.renderers.inputs.preprocess import parse_enc_dec_prompt, parse_model_prompt from vllm.sampling_params import BeamSearchParams, SamplingParams from vllm.tokenizers import get_tokenizer +from vllm.utils.import_utils import PlaceholderModule + +try: + import librosa +except ImportError: + librosa = PlaceholderModule("librosa") # type: ignore[assignment] + +try: + import soundfile as sf +except ImportError: + sf = PlaceholderModule("soundfile") # type: ignore[assignment] + +# Public libsndfile error codes exposed via `soundfile.LibsndfileError.code`, soundfile +# being librosa's main backend. Used to validate if an audio loading error is due to a +# server error vs a client error (invalid audio file). +# 1 = unrecognised format (file is not a supported audio container) +# 3 = malformed file (corrupt or structurally invalid audio) +# 4 = unsupported encoding (codec not supported by this libsndfile build) +_BAD_SF_CODES = {1, 3, 4} SpeechToTextResponse: TypeAlias = TranscriptionResponse | TranslationResponse SpeechToTextResponseVerbose: TypeAlias = ( @@ -195,13 +214,32 @@ async def _preprocess_speech_to_text( # pre-requisite for chunking, as it assumes Whisper SR. try: with io.BytesIO(audio_data) as buf: - y, sr = load_audio(buf, sr=self.asr_config.sample_rate) - except Exception as exc: - raise ValueError("Invalid or unsupported audio file.") from exc + y, sr = librosa.load(buf, sr=self.asr_config.sample_rate) # type: ignore[return-value] + except sf.LibsndfileError as exc: + # Only fall back for known format-detection failures. + # Re-raise anything else (e.g. corrupt but recognised format). + if exc.code not in _BAD_SF_CODES: + raise + logger.debug( + "librosa/soundfile could not decode audio from BytesIO " + "(code=%s: %s); falling back to pyav in-process decode", + exc.code, + exc, + ) + try: + native_y, native_sr = extract_audio_from_video_bytes(audio_data) + sr = self.asr_config.sample_rate + y = librosa.resample(native_y, orig_sr=native_sr, target_sr=sr) + except Exception as pyav_exc: + logger.debug( + "pyAV fallback also failed: %s", + pyav_exc, + ) + raise ValueError("Invalid or unsupported audio file.") from pyav_exc - duration = get_audio_duration(y=y, sr=sr) - do_split_audio = self.asr_config.allow_audio_chunking and ( - self.asr_config.max_audio_clip_s is not None + duration = librosa.get_duration(y=y, sr=sr) + do_split_audio = ( + self.asr_config.allow_audio_chunking and duration > self.asr_config.max_audio_clip_s ) diff --git a/vllm/model_executor/models/nano_nemotron_vl.py b/vllm/model_executor/models/nano_nemotron_vl.py index 1741e18fdda6..5ff9c5f04b5e 100644 --- a/vllm/model_executor/models/nano_nemotron_vl.py +++ b/vllm/model_executor/models/nano_nemotron_vl.py @@ -12,7 +12,6 @@ import warnings from collections.abc import Iterable, Mapping, Sequence from functools import cached_property -from io import BytesIO from typing import Annotated, Literal, TypeAlias import torch @@ -54,7 +53,7 @@ MultiModalKwargsItems, VideoItem, ) -from vllm.multimodal.media.audio import load_audio_pyav +from vllm.multimodal.media.audio import extract_audio_from_video_bytes from vllm.multimodal.parse import ( AudioProcessorItems, ImageEmbeddingItems, @@ -554,7 +553,7 @@ def _extract_audio_from_videos( "video must be loaded with keep_video_bytes=True (e.g. via " "the chat API with a model that sets use_audio_in_video)." ) - audio_items.append(load_audio_pyav(BytesIO(video_bytes))) + audio_items.append(extract_audio_from_video_bytes(video_bytes)) # Create a new VideoProcessorItems with metadata that does not contain # the large video bytes, to avoid modifying the input `mm_items`. diff --git a/vllm/multimodal/audio.py b/vllm/multimodal/audio.py index 0a748a6d15c6..28f066d112ed 100644 --- a/vllm/multimodal/audio.py +++ b/vllm/multimodal/audio.py @@ -12,35 +12,17 @@ from vllm.utils.import_utils import PlaceholderModule try: - import av as av + import librosa except ImportError: - av = PlaceholderModule("av") # type: ignore[assignment] + librosa = PlaceholderModule("librosa") # type: ignore[assignment] -try: - import resampy -except ImportError: - resampy = PlaceholderModule("resampy") # type: ignore[assignment] try: import scipy.signal as scipy_signal except ImportError: scipy_signal = PlaceholderModule("scipy").placeholder_attr("signal") # type: ignore[assignment] - # ============================================================ -# Aligned with `librosa.get_duration` function -def get_audio_duration(*, y: npt.NDArray[np.floating], sr: float = 22050) -> float: - """Get the duration of an audio array in seconds. - - Args: - y: Audio time series. Can be 1D (samples,) or 2D (channels, samples). - sr: Sample rate of the audio in Hz. - - Returns: - Duration of the audio in seconds. - """ - n_samples = y.shape[-1] - return float(n_samples) / sr class ChannelReduction(str, Enum): @@ -171,71 +153,13 @@ def normalize_audio( # ============================================================ -def resample_audio_pyav( +def resample_audio_librosa( audio: npt.NDArray[np.floating], *, orig_sr: float, target_sr: float, ) -> npt.NDArray[np.floating]: - """Resample audio using PyAV (libswresample via FFmpeg). - - Args: - audio: Input audio. Can be: - - 1D array ``(samples,)``: mono audio - - 2D array ``(channels, samples)``: stereo audio - orig_sr: Original sample rate in Hz. - target_sr: Target sample rate in Hz. - - Returns: - Resampled audio with the same shape as the input (1D → 1D, 2D → 2D). - """ - orig_sr_int = int(round(orig_sr)) - target_sr_int = int(round(target_sr)) - - if orig_sr_int == target_sr_int: - return audio - - if audio.ndim == 2: - # Resample each channel independently and re-stack. - return np.stack( - [ - resample_audio_pyav(ch, orig_sr=orig_sr, target_sr=target_sr) - for ch in audio - ], - axis=0, - ) - - expected_len = int(math.ceil(audio.shape[-1] * target_sr_int / orig_sr_int)) - - # from_ndarray expects shape (channels, samples) for planar formats. - # libswresample requires a minimum number of input samples to produce - # output frames; pad short inputs with zeros so we always get output, - # then trim to the expected output length. - _MIN_SAMPLES = 1024 - audio_f32 = np.asarray(audio, dtype=np.float32) - if len(audio_f32) < _MIN_SAMPLES: - audio_f32 = np.pad(audio_f32, (0, _MIN_SAMPLES - len(audio_f32))) - audio_f32 = audio_f32.reshape(1, -1) - - resampler = av.AudioResampler(format="fltp", layout="mono", rate=target_sr_int) - - frame = av.AudioFrame.from_ndarray(audio_f32, format="fltp", layout="mono") - frame.sample_rate = orig_sr_int - - out_frames = resampler.resample(frame) - out_frames.extend(resampler.resample(None)) # flush buffered samples - - result = np.concatenate([f.to_ndarray() for f in out_frames], axis=1).squeeze(0) - return result[:expected_len] - - -def resample_audio_resampy( - audio: npt.NDArray[np.floating], - *, - orig_sr: float, - target_sr: float, -) -> npt.NDArray[np.floating]: - return resampy.resample(audio, sr_orig=orig_sr, sr_new=target_sr) + return librosa.resample(audio, orig_sr=orig_sr, target_sr=target_sr) def resample_audio_scipy( @@ -243,7 +167,7 @@ def resample_audio_scipy( *, orig_sr: float, target_sr: float, -) -> npt.NDArray[np.floating]: +): if orig_sr > target_sr: return scipy_signal.resample_poly(audio, 1, orig_sr // target_sr) elif orig_sr < target_sr: @@ -257,7 +181,7 @@ class AudioResampler: def __init__( self, target_sr: float | None = None, - method: Literal["pyav", "resampy", "scipy"] = "resampy", + method: Literal["librosa", "scipy"] = "librosa", ): self.target_sr = target_sr self.method = method @@ -279,10 +203,8 @@ def resample( abs_tol=1e-6, ): return audio - if self.method == "pyav": - return resample_audio_pyav(audio, orig_sr=orig_sr, target_sr=self.target_sr) - if self.method == "resampy": - return resample_audio_resampy( + if self.method == "librosa": + return resample_audio_librosa( audio, orig_sr=orig_sr, target_sr=self.target_sr ) elif self.method == "scipy": @@ -292,7 +214,7 @@ def resample( else: raise ValueError( f"Invalid resampling method: {self.method}. " - "Supported methods are 'pyav' and 'scipy'." + "Supported methods are 'librosa' and 'scipy'." ) diff --git a/vllm/multimodal/media/audio.py b/vllm/multimodal/media/audio.py index ae0a9f55bdce..88dcb0b0186a 100644 --- a/vllm/multimodal/media/audio.py +++ b/vllm/multimodal/media/audio.py @@ -1,6 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -import math from io import BytesIO from pathlib import Path @@ -15,80 +14,58 @@ from .base import MediaIO try: - import av + import librosa except ImportError: - av = PlaceholderModule("av") # type: ignore[assignment] + librosa = PlaceholderModule("librosa") # type: ignore[assignment] try: import soundfile except ImportError: soundfile = PlaceholderModule("soundfile") # type: ignore[assignment] - try: - import resampy + import av except ImportError: - resampy = PlaceholderModule("resampy") # type: ignore[assignment] - - -# Public libsndfile error codes exposed via `soundfile.LibsndfileError.code`, soundfile -# being librosa's main backend. Used to validate if an audio loading error is due to a -# server error vs a client error (invalid audio file). -# 1 = unrecognised format (file is not a supported audio container) -# 3 = malformed file (corrupt or structurally invalid audio) -# 4 = unsupported encoding (codec not supported by this libsndfile build) -_BAD_SF_CODES = {1, 3, 4} + av = PlaceholderModule("av") # type: ignore[assignment] -def load_audio_pyav( - path: BytesIO | Path | str, - *, - sr: float | None = 22050, - mono: bool = True, +def extract_audio_from_video_bytes( + data: bytes, ) -> tuple[npt.NDArray, float]: - """Load an audio file using PyAV (FFmpeg), returning float32 mono waveform. + """Extract the audio track from raw video bytes using PyAV. + + PyAV wraps FFmpeg's C libraries in-process — no subprocess is + spawned, which is critical to avoid crashing CUDA-active vLLM + worker processes. - Decodes the audio stream at its native sample rate. Channel reduction to - mono is performed by averaging across channels. Resampling to a - model-specific rate is left to the downstream :class:`AudioResampler`. + The returned waveform is at the native sample rate of the video's + audio stream. Resampling to a model-specific rate is left to the + downstream :class:`AudioResampler` in the parsing pipeline. Args: - path: A :class:`~io.BytesIO` buffer, a filesystem - :class:`~pathlib.Path`, or a string path. + data: Raw video file bytes (e.g. from an mp4 file). Returns: - ``(waveform, sample_rate)`` where *waveform* is a 1-D float32 - NumPy array and *sample_rate* is the native sample rate in Hz. + A tuple of ``(waveform, sample_rate)`` suitable for use as an + :class:`AudioItem`. """ - native_sr = None + if data is None or len(data) == 0: + raise ValueError( + "Cannot extract audio: video bytes are missing or empty. " + "Ensure video was loaded with keep_video_bytes=True for " + "audio-in-video extraction." + ) try: - with av.open(path) as container: + with av.open(BytesIO(data)) as container: if not container.streams.audio: - raise ValueError("No audio stream found.") + raise ValueError("No audio stream found in the video.") stream = container.streams.audio[0] - stream.thread_type = "AUTO" native_sr = stream.rate - sr = sr or native_sr chunks: list[npt.NDArray] = [] - needs_resampling = not math.isclose( - float(sr), - float(native_sr), - rel_tol=0.0, - abs_tol=1e-6, - ) - resampler = ( - av.AudioResampler(format="fltp", layout="mono", rate=sr) - if needs_resampling - else None - ) - for frame in container.decode(stream): - if needs_resampling: - assert resampler is not None - for out_frame in resampler.resample(frame): - chunks.append(out_frame.to_ndarray()) - else: - chunks.append(frame.to_ndarray()) + for frame in container.decode(audio=0): + arr = frame.to_ndarray() + chunks.append(arr.mean(axis=0) if arr.ndim > 1 else arr) except ValueError: raise except Exception as e: @@ -100,54 +77,37 @@ def load_audio_pyav( if not chunks: raise ValueError("No audio found in the video.") - audio = np.concatenate(chunks, axis=-1).astype(np.float32) - if mono and audio.ndim > 1: - audio = np.mean(audio, axis=0) + audio = np.concatenate(chunks).astype(np.float32) + return audio, float(native_sr) - return audio, sr +def is_video(data: bytes) -> bool: + """Check if the fetched bytes are video""" + if len(data) < 12: + return False -def load_audio_soundfile( - path: BytesIO | Path | str, - *, - sr: float | None = 22050, - mono: bool = True, -) -> tuple[np.ndarray, int]: - """Load audio via soundfile""" - with soundfile.SoundFile(path) as f: - native_sr = f.samplerate - y = f.read(dtype="float32", always_2d=False).T + box_type = data[4:8] + major_brand = data[8:12] - if mono and y.ndim > 1: - y = np.mean(y, axis=tuple(range(y.ndim - 1))) + MP4_BRANDS = { + b"mp41", + b"mp42", # MP4 + b"isom", # ISO Base Media + b"iso2", + b"iso4", + b"iso5", + b"iso6", + b"M4V ", + b"M4A ", # Apple + b"avc1", # H.264 + b"dash", # DASH + b"mmp4", + b"MSNV", + } - if sr is not None and sr != native_sr: - y = resampy.resample(y, sr_orig=native_sr, sr_new=sr) - return y, int(sr) - return y, native_sr - - -def load_audio( - path: BytesIO | Path | str, - *, - sr: float | None = 22050, - mono: bool = True, -): - try: - return load_audio_soundfile(path, sr=sr, mono=mono) - except soundfile.LibsndfileError as exc: - # Only fall back for known format-detection failures. - # Re-raise anything else (e.g. corrupt but recognised format). - if exc.code not in _BAD_SF_CODES: - raise - # soundfile may have advanced the BytesIO seek position before failing; - # reset it so PyAV can read from the beginning. - if isinstance(path, BytesIO): - path.seek(0) - try: - return load_audio_pyav(path, sr=sr, mono=mono) - except Exception as pyav_exc: - raise ValueError("Invalid or unsupported audio file.") from pyav_exc + is_avi = data[:4] == b"RIFF" and major_brand == b"AVI " + is_mp4 = box_type == b"ftyp" and major_brand in MP4_BRANDS + return is_mp4 or is_avi class AudioMediaIO(MediaIO[tuple[npt.NDArray, float]]): @@ -168,7 +128,9 @@ def __init__(self, **kwargs) -> None: self.kwargs = kwargs def load_bytes(self, data: bytes) -> tuple[npt.NDArray, float]: - return load_audio(BytesIO(data), sr=None) + if is_video(data): + return extract_audio_from_video_bytes(data) + return librosa.load(BytesIO(data), sr=None) def load_base64( self, @@ -178,7 +140,7 @@ def load_base64( return self.load_bytes(pybase64.b64decode(data)) def load_file(self, filepath: Path) -> tuple[npt.NDArray, float]: - return load_audio(filepath, sr=None) + return librosa.load(filepath, sr=None) def encode_base64( self, diff --git a/vllm/multimodal/parse.py b/vllm/multimodal/parse.py index 9e1774e3921b..6a588dad0207 100644 --- a/vllm/multimodal/parse.py +++ b/vllm/multimodal/parse.py @@ -497,7 +497,7 @@ def __init__( *, target_sr: float | None = None, target_channels: int | None = None, - audio_resample_method: Literal["pyav", "scipy"] = "pyav", + audio_resample_method: Literal["librosa", "scipy"] = "librosa", video_needs_metadata: bool = False, expected_hidden_size: int | None = None, ) -> None: diff --git a/vllm/renderers/base.py b/vllm/renderers/base.py index 63946e8fdd22..b468712adb0c 100644 --- a/vllm/renderers/base.py +++ b/vllm/renderers/base.py @@ -172,6 +172,9 @@ def warmup(self, chat_params: ChatParams) -> None: For chat requests: - Jinja2 template compilation + + For multi-modal requests: + - Importing libraries such as librosa triggers JIT compilation. """ from vllm.entrypoints.chat_utils import ChatTemplateResolutionError diff --git a/vllm/transformers_utils/processors/fireredasr2.py b/vllm/transformers_utils/processors/fireredasr2.py index bba7e7ee0495..4bde53015003 100644 --- a/vllm/transformers_utils/processors/fireredasr2.py +++ b/vllm/transformers_utils/processors/fireredasr2.py @@ -188,7 +188,7 @@ def padding_position_is_0(padded_input, input_lengths): for speech in raw_speech: """ We must multiply by 32768 here because FireRedASR2 loads audio data - using kaldiio.load_mat, while vLLM loads audio data using pyav. + using kaldiio.load_mat, while vLLM loads audio data using librosa. """ speech = speech * 32768 fbank = self.fbank(sampling_rate, speech)