Files
vllm/vllm/multimodal/audio.py
2026-01-15 11:52:12 +00:00

211 lines
6.8 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from dataclasses import dataclass
from enum import Enum
from typing import Literal
import numpy as np
import numpy.typing as npt
import torch
from vllm.utils.import_utils import PlaceholderModule
try:
import librosa
except ImportError:
librosa = PlaceholderModule("librosa") # type: ignore[assignment]
try:
import scipy.signal as scipy_signal
except ImportError:
scipy_signal = PlaceholderModule("scipy").placeholder_attr("signal") # type: ignore[assignment]
# ============================================================
class ChannelReduction(str, Enum):
"""Method to reduce multi-channel audio to target channels."""
MEAN = "mean" # Average across channels (default, preserves energy balance)
FIRST = "first" # Take first channel only
MAX = "max" # Take max value across channels
SUM = "sum" # Sum across channels
@dataclass
class AudioSpec:
"""Specification for target audio format.
This dataclass defines the expected audio format for a model's feature
extractor. It is used to normalize audio data before processing.
Attributes:
target_channels: Number of output channels. None means passthrough
(no normalization). 1 = mono, 2 = stereo, etc.
channel_reduction: Method to reduce channels when input has more
channels than target. Only used when reducing channels.
"""
target_channels: int | None = 1
channel_reduction: ChannelReduction = ChannelReduction.MEAN
@property
def needs_normalization(self) -> bool:
"""Whether audio normalization is needed."""
return self.target_channels is not None
def __repr__(self) -> str:
if self.target_channels is None:
return "AudioSpec(passthrough)"
return (
f"AudioSpec(channels={self.target_channels}, "
f"reduction={self.channel_reduction.value})"
)
# Pre-defined specs for common use cases
MONO_AUDIO_SPEC = AudioSpec(target_channels=1, channel_reduction=ChannelReduction.MEAN)
PASSTHROUGH_AUDIO_SPEC = AudioSpec(target_channels=None)
def normalize_audio(
audio: npt.NDArray[np.floating] | torch.Tensor,
spec: AudioSpec,
) -> npt.NDArray[np.floating] | torch.Tensor:
"""Normalize audio to the specified format.
This function handles channel reduction for multi-channel audio,
supporting both numpy arrays and torch tensors.
Args:
audio: Input audio data. Can be:
- 1D array/tensor: (time,) - already mono
- 2D array/tensor: (channels, time) - standard format from torchaudio
- 2D array/tensor: (time, channels) - format from soundfile
(will be auto-detected and transposed if time > channels)
spec: AudioSpec defining the target format.
Returns:
Normalized audio in the same type as input (numpy or torch).
For mono output (target_channels=1), returns 1D array/tensor.
Raises:
ValueError: If audio has unsupported dimensions or channel expansion
is requested (e.g., mono to stereo).
"""
if not spec.needs_normalization:
return audio
# Handle 1D audio (already mono)
if audio.ndim == 1:
if spec.target_channels == 1:
return audio
raise ValueError(f"Cannot expand mono audio to {spec.target_channels} channels")
# Handle 2D audio
if audio.ndim != 2:
raise ValueError(f"Unsupported audio shape: {audio.shape}. Expected 1D or 2D.")
# Auto-detect format: if shape[0] > shape[1], assume (time, channels)
# This handles soundfile format where time dimension is typically much larger
if audio.shape[0] > audio.shape[1]:
# Transpose from (time, channels) to (channels, time)
audio = audio.T if isinstance(audio, np.ndarray) else audio.T
num_channels = audio.shape[0]
# No reduction needed if already at target
if num_channels == spec.target_channels:
return audio
# Cannot expand channels
if num_channels < spec.target_channels:
raise ValueError(
f"Cannot expand {num_channels} channels to {spec.target_channels}"
)
# Reduce channels
is_numpy = isinstance(audio, np.ndarray)
if spec.target_channels == 1:
# Reduce to mono
if spec.channel_reduction == ChannelReduction.MEAN:
result = np.mean(audio, axis=0) if is_numpy else audio.mean(dim=0)
elif spec.channel_reduction == ChannelReduction.FIRST:
result = audio[0]
elif spec.channel_reduction == ChannelReduction.MAX:
result = np.max(audio, axis=0) if is_numpy else audio.max(dim=0).values
elif spec.channel_reduction == ChannelReduction.SUM:
result = np.sum(audio, axis=0) if is_numpy else audio.sum(dim=0)
else:
raise ValueError(f"Unknown reduction method: {spec.channel_reduction}")
return result
else:
# Reduce to N channels (take first N and apply reduction if needed)
# For now, just take first N channels
return audio[: spec.target_channels]
# ============================================================
# Audio Resampling
# ============================================================
def resample_audio_librosa(
audio: npt.NDArray[np.floating],
*,
orig_sr: float,
target_sr: float,
) -> npt.NDArray[np.floating]:
return librosa.resample(audio, orig_sr=orig_sr, target_sr=target_sr)
def resample_audio_scipy(
audio: npt.NDArray[np.floating],
*,
orig_sr: float,
target_sr: float,
):
if orig_sr > target_sr:
return scipy_signal.resample_poly(audio, 1, orig_sr // target_sr)
elif orig_sr < target_sr:
return scipy_signal.resample_poly(audio, target_sr // orig_sr, 1)
return audio
class AudioResampler:
"""Resample audio data to a target sample rate."""
def __init__(
self,
target_sr: float | None = None,
method: Literal["librosa", "scipy"] = "librosa",
):
self.target_sr = target_sr
self.method = method
def resample(
self,
audio: npt.NDArray[np.floating],
*,
orig_sr: float,
) -> npt.NDArray[np.floating]:
if self.target_sr is None:
raise RuntimeError(
"Audio resampling is not supported when `target_sr` is not provided"
)
if self.method == "librosa":
return resample_audio_librosa(
audio, orig_sr=orig_sr, target_sr=self.target_sr
)
elif self.method == "scipy":
return resample_audio_scipy(
audio, orig_sr=orig_sr, target_sr=self.target_sr
)
else:
raise ValueError(
f"Invalid resampling method: {self.method}. "
"Supported methods are 'librosa' and 'scipy'."
)