From 4d1d0db9e51f99978a9fa44f5672c7c8f7b81415 Mon Sep 17 00:00:00 2001 From: Netanel Haber <58652339+netanel-haber@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:47:22 +0200 Subject: [PATCH 01/39] add support for transformers parakeet audio encoder for nemotron-nano-v3 avlm Signed-off-by: Netanel Haber <58652339+netanel-haber@users.noreply.github.com> Signed-off-by: Andrii Skliar --- .../model_executor/models/nano_nemotron_vl.py | 2 + vllm/model_executor/models/parakeet.py | 57 +++++++++++++++++-- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/vllm/model_executor/models/nano_nemotron_vl.py b/vllm/model_executor/models/nano_nemotron_vl.py index 51b36b1cae38..66ae76beb8e2 100644 --- a/vllm/model_executor/models/nano_nemotron_vl.py +++ b/vllm/model_executor/models/nano_nemotron_vl.py @@ -1355,6 +1355,8 @@ def get_replacement_custom(item_idx: int): image_height=image_size.height, max_num_tiles=max_num_tiles, ) + msg = f"Determined num tokens of image: {feature_size=}" + logger.debug(msg) num_patches = None local_image_num_patches = image_num_patches diff --git a/vllm/model_executor/models/parakeet.py b/vllm/model_executor/models/parakeet.py index 8c5539251c45..c86916b0d5ff 100644 --- a/vllm/model_executor/models/parakeet.py +++ b/vllm/model_executor/models/parakeet.py @@ -1,21 +1,44 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -""" -Modules below used for the audio encoder component in: models/nano_nemotron_vl.py -""" + from collections.abc import Iterable -from dataclasses import asdict +from dataclasses import asdict, dataclass import numpy as np import torch import torch.nn as nn from transformers import ParakeetEncoder as HFParakeetEncoder -from transformers import ParakeetFeatureExtractor, PretrainedConfig +from transformers import ( + ParakeetEncoderConfig, + ParakeetFeatureExtractor, + PretrainedConfig, +) from vllm.model_executor.layers.activation import ReLUSquaredActivation from vllm.model_executor.model_loader.weight_utils import default_weight_loader -from vllm.transformers_utils.configs.parakeet import ExtractorConfig, ParakeetConfig + + +class ParakeetConfig(ParakeetEncoderConfig): + llm_hidden_size: int + projection_hidden_size: int + projection_bias: bool + projection_eps: float = 1e-5 + sampling_rate: int + + @staticmethod + def from_hf_config( + config: PretrainedConfig, *, llm_hidden_size: int, max_model_len: int + ) -> "ParakeetConfig": + assert isinstance(config, PretrainedConfig) + return ParakeetConfig( + **config.to_dict(), + scale_input=False, + attention_bias=False, + llm_hidden_size=llm_hidden_size, + max_position_embeddings=max_model_len + + 1, # + 1 because it seems like max_model_len+1 can be passed + ) class ParakeetProjection(nn.Module): @@ -102,6 +125,28 @@ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: return loaded_params +@dataclass(kw_only=True, frozen=True) +class ExtractorConfig: + feature_size: int + sampling_rate: int + subsampling_factor: int + subsampling_conv_kernel_size: int + subsampling_conv_stride: int + clip_duration_s: int = 30 + clip_min_duration_s: float = 0.1 + + @staticmethod + def from_hf_config(config: PretrainedConfig) -> "ExtractorConfig": + assert isinstance(config, PretrainedConfig) + return ExtractorConfig( + feature_size=config.num_mel_bins, + sampling_rate=config.sampling_rate, + subsampling_factor=config.subsampling_factor, + subsampling_conv_kernel_size=config.subsampling_conv_kernel_size, + subsampling_conv_stride=config.subsampling_conv_stride, + ) + + class ParakeetExtractor(ParakeetFeatureExtractor): def __init__(self, config: PretrainedConfig) -> None: self.config = ExtractorConfig.from_hf_config(config) From 3e30def7fe46aba97d7552fdae1b5591a7829e2f Mon Sep 17 00:00:00 2001 From: Netanel Haber <58652339+netanel-haber@users.noreply.github.com> Date: Mon, 23 Feb 2026 07:03:37 -0800 Subject: [PATCH 02/39] move ParakeetConfig and ExtractorConfig to vllm/transformers_utils/configs/parakeet.py Signed-off-by: Netanel Haber <58652339+netanel-haber@users.noreply.github.com> Signed-off-by: Andrii Skliar --- vllm/model_executor/models/parakeet.py | 53 ++------------------------ 1 file changed, 3 insertions(+), 50 deletions(-) diff --git a/vllm/model_executor/models/parakeet.py b/vllm/model_executor/models/parakeet.py index c86916b0d5ff..beeba9ddf28c 100644 --- a/vllm/model_executor/models/parakeet.py +++ b/vllm/model_executor/models/parakeet.py @@ -3,42 +3,17 @@ from collections.abc import Iterable -from dataclasses import asdict, dataclass +from dataclasses import asdict import numpy as np import torch import torch.nn as nn from transformers import ParakeetEncoder as HFParakeetEncoder -from transformers import ( - ParakeetEncoderConfig, - ParakeetFeatureExtractor, - PretrainedConfig, -) +from transformers import ParakeetFeatureExtractor, PretrainedConfig from vllm.model_executor.layers.activation import ReLUSquaredActivation from vllm.model_executor.model_loader.weight_utils import default_weight_loader - - -class ParakeetConfig(ParakeetEncoderConfig): - llm_hidden_size: int - projection_hidden_size: int - projection_bias: bool - projection_eps: float = 1e-5 - sampling_rate: int - - @staticmethod - def from_hf_config( - config: PretrainedConfig, *, llm_hidden_size: int, max_model_len: int - ) -> "ParakeetConfig": - assert isinstance(config, PretrainedConfig) - return ParakeetConfig( - **config.to_dict(), - scale_input=False, - attention_bias=False, - llm_hidden_size=llm_hidden_size, - max_position_embeddings=max_model_len - + 1, # + 1 because it seems like max_model_len+1 can be passed - ) +from vllm.transformers_utils.configs.parakeet import ExtractorConfig, ParakeetConfig class ParakeetProjection(nn.Module): @@ -125,28 +100,6 @@ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: return loaded_params -@dataclass(kw_only=True, frozen=True) -class ExtractorConfig: - feature_size: int - sampling_rate: int - subsampling_factor: int - subsampling_conv_kernel_size: int - subsampling_conv_stride: int - clip_duration_s: int = 30 - clip_min_duration_s: float = 0.1 - - @staticmethod - def from_hf_config(config: PretrainedConfig) -> "ExtractorConfig": - assert isinstance(config, PretrainedConfig) - return ExtractorConfig( - feature_size=config.num_mel_bins, - sampling_rate=config.sampling_rate, - subsampling_factor=config.subsampling_factor, - subsampling_conv_kernel_size=config.subsampling_conv_kernel_size, - subsampling_conv_stride=config.subsampling_conv_stride, - ) - - class ParakeetExtractor(ParakeetFeatureExtractor): def __init__(self, config: PretrainedConfig) -> None: self.config = ExtractorConfig.from_hf_config(config) From b1d4c8d9390cc137ecc0230d88cfcb2d1c622bd8 Mon Sep 17 00:00:00 2001 From: Netanel Haber <58652339+netanel-haber@users.noreply.github.com> Date: Tue, 3 Feb 2026 06:02:03 -0800 Subject: [PATCH 03/39] the model starts at least Signed-off-by: Netanel Haber <58652339+netanel-haber@users.noreply.github.com> Signed-off-by: Andrii Skliar --- .../model_executor/models/nano_nemotron_vl.py | 91 ++++++++----------- vllm/model_executor/models/parakeet.py | 82 ++++++++++------- 2 files changed, 84 insertions(+), 89 deletions(-) diff --git a/vllm/model_executor/models/nano_nemotron_vl.py b/vllm/model_executor/models/nano_nemotron_vl.py index 66ae76beb8e2..fcef76c39e9d 100644 --- a/vllm/model_executor/models/nano_nemotron_vl.py +++ b/vllm/model_executor/models/nano_nemotron_vl.py @@ -23,7 +23,11 @@ import torch.nn as nn import torchvision.transforms as T from PIL import Image -from transformers import BatchFeature, PretrainedConfig, TensorType +from transformers import ( + BatchFeature, + PretrainedConfig, + TensorType, +) from vllm.config import VllmConfig from vllm.config.multimodal import BaseDummyOptions, VideoDummyOptions @@ -44,7 +48,10 @@ ) from vllm.model_executor.models.module_mapping import MultiModelKeys from vllm.model_executor.models.nemotron_h import NemotronHForCausalLM -from vllm.model_executor.models.parakeet import ParakeetExtractor, ProjectedParakeet +from vllm.model_executor.models.parakeet import ( + ParakeetExtractor, + ProjectedParakeet, +) from vllm.model_executor.models.radio import RadioModel, calc_seq_lens from vllm.model_executor.models.utils import ( init_vllm_registered_model, @@ -95,6 +102,9 @@ # Image.MAX_IMAGE_PIXELS = 300000000 # ~300M pixels +AUDIO_CONTEXT = "" + + class NanoNemotronVLAudioFeatureInputs(TensorSchema): """ Dimensions: @@ -1001,8 +1011,10 @@ def _preprocess_audio( audio_index = 0 for idx, part in enumerate(parts): if part == AUDIO_CONTEXT: - audio_repl = self.get_audio_repl(audios[audio_index]) - parts[idx] = audio_repl.full + audio = audios[audio_index] + audio_len = len(audio) + num_tokens = extractor.audio_token_count(audio_len) + parts[idx] = AUDIO_CONTEXT * num_tokens audio_index += 1 text = ["".join(parts)] audio_inputs = extractor( @@ -1235,20 +1247,6 @@ def supports_video(self): def audio_extractor(self) -> ParakeetExtractor | None: return self.get_hf_processor().audio_extractor - def get_data_parser(self): - target_sr = None - target_channels = None - if extractor := self.audio_extractor: - target_sr = extractor.sampling_rate - target_channels = 1 - - return MultiModalDataParser( - video_needs_metadata=True, - target_sr=target_sr, - target_channels=target_channels, - expected_hidden_size=self._get_expected_hidden_size(), - ) - def get_supported_mm_limits(self): video_limit = {"video": None} if self.supports_video else {} audio_limit = {"audio": None} if self.audio_extractor is not None else {} @@ -1383,6 +1381,18 @@ class NanoNemotronVLMultiModalProcessor( ): """MultiModalProcessor extended for video support""" + def _get_data_parser(self) -> MultiModalDataParser: + target_sr = None + target_channels = None + if extractor := self.info.audio_extractor: + target_sr = extractor.sampling_rate + target_channels = 1 + return MultiModalDataParser( + video_needs_metadata=True, + target_sr=target_sr, + target_channels=target_channels, + ) + def _get_mm_fields_config( self, hf_inputs: BatchFeature, @@ -1481,9 +1491,12 @@ def get_video_replacement_internvl(item_idx: int): ), ] - def get_audio_replacement(item_idx: int): + def get_audio_replacement(item_idx: int, extractor: ParakeetExtractor): audios = mm_items.get_items("audio", AudioProcessorItems) - return hf_processor.get_audio_repl(audios.get(item_idx)) + audio_len = audios.get_audio_length(item_idx) + num_tokens = extractor.audio_token_count(audio_len) + repl_full = AUDIO_CONTEXT * num_tokens + return PromptUpdateDetails.select_text(repl_full, AUDIO_CONTEXT) if self.info.audio_extractor is not None: prompt_repl = [ @@ -1491,7 +1504,9 @@ def get_audio_replacement(item_idx: int): PromptReplacement( modality="audio", target=AUDIO_CONTEXT, - replacement=get_audio_replacement, + replacement=lambda item_idx: get_audio_replacement( + item_idx, self.info.audio_extractor + ), ), ] @@ -1664,7 +1679,6 @@ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""): self.ps_version = config.ps_version self.image_tag_type = config.image_tag_type self.video_pruning_rate = multimodal_config.video_pruning_rate - with self._mark_language_model(vllm_config): self.language_model = init_vllm_registered_model( vllm_config=vllm_config, @@ -1952,43 +1966,12 @@ def _process_audio_input( input_audio_features = audio_input.input_audio_features feature_attention_mask = audio_input.feature_attention_mask target_device = next(self.sound_encoder.parameters()).device - - # When cross-request batching combines audio clips with different - # time dimensions, _reduce_data returns a list instead of a stacked - # tensor. Pad to the max time dim and stack; the attention mask - # already marks valid positions so zero-padding is safe. - if isinstance(input_audio_features, list): - feature_sizes = [f.shape[-2] for f in input_audio_features] - max_t = max(feature_sizes) - padded_feats = [ - torch.nn.functional.pad(feat, (0, 0, 0, max_t - feat_size)) - for feat, feat_size in zip( - input_audio_features, feature_sizes, strict=True - ) - ] - padded_masks = [ - torch.nn.functional.pad(mask, (0, max_t - mask.shape[-1])) - for mask in feature_attention_mask - ] - input_audio_features = torch.stack(padded_feats) - feature_attention_mask = torch.stack(padded_masks) - input_audio_features = input_audio_features.to( dtype=self.llm_dtype, device=target_device ) feature_attention_mask = feature_attention_mask.to(device=target_device) sound_embeds = self.sound_encoder(input_audio_features, feature_attention_mask) - - valid_input_lens = feature_attention_mask.sum(dim=1) - valid_output_lens = self.sound_encoder.encoder._get_subsampling_output_length( - valid_input_lens - ) - truncated_embeds = [] - for i in range(sound_embeds.shape[0]): - valid_len = valid_output_lens[i].item() - truncated_embeds.append(sound_embeds[i, :valid_len]) - - return tuple(truncated_embeds) + return tuple(sound_embeds.unbind(dim=0)) def _create_final_video_embeddings( self, diff --git a/vllm/model_executor/models/parakeet.py b/vllm/model_executor/models/parakeet.py index beeba9ddf28c..495985d9c8fe 100644 --- a/vllm/model_executor/models/parakeet.py +++ b/vllm/model_executor/models/parakeet.py @@ -2,18 +2,43 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import math from collections.abc import Iterable -from dataclasses import asdict +from dataclasses import asdict, dataclass -import numpy as np import torch import torch.nn as nn from transformers import ParakeetEncoder as HFParakeetEncoder -from transformers import ParakeetFeatureExtractor, PretrainedConfig +from transformers import ( + ParakeetEncoderConfig, + ParakeetFeatureExtractor, + PretrainedConfig, +) from vllm.model_executor.layers.activation import ReLUSquaredActivation from vllm.model_executor.model_loader.weight_utils import default_weight_loader -from vllm.transformers_utils.configs.parakeet import ExtractorConfig, ParakeetConfig + + +class ParakeetConfig(ParakeetEncoderConfig): + llm_hidden_size: int + projection_hidden_size: int + projection_bias: bool + projection_eps: float = 1e-5 + sampling_rate: int + + @staticmethod + def from_hf_config( + config: PretrainedConfig, *, llm_hidden_size: int, max_model_len: int + ) -> "ParakeetConfig": + assert isinstance(config, PretrainedConfig) + return ParakeetConfig( + **config.to_dict(), + scale_input=False, + attention_bias=False, + llm_hidden_size=llm_hidden_size, + max_position_embeddings=max_model_len + + 1, # + 1 because it seems like max_model_len+1 can be passed + ) class ParakeetProjection(nn.Module): @@ -100,44 +125,31 @@ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: return loaded_params +@dataclass(kw_only=True, frozen=True) +class ExtractorConfig: + feature_size: int + sampling_rate: int + subsampling_factor: int + + @staticmethod + def from_hf_config(config: PretrainedConfig) -> "ExtractorConfig": + assert isinstance(config, PretrainedConfig) + return ExtractorConfig( + feature_size=config.num_mel_bins, + sampling_rate=config.sampling_rate, + subsampling_factor=config.subsampling_factor, + ) + + class ParakeetExtractor(ParakeetFeatureExtractor): def __init__(self, config: PretrainedConfig) -> None: self.config = ExtractorConfig.from_hf_config(config) super().__init__(**asdict(self.config)) - self._clip_target_samples = int( - round(self.config.clip_duration_s * self.sampling_rate) - ) - self._tail_min_samples = int( - round(self.config.clip_min_duration_s * self.sampling_rate) - ) - - def _normalize_audio_length(self, audio_len: int) -> int: - # Match mcore's compute_params() logic for clip/minduration handling. - target_len = max(audio_len, self._tail_min_samples) - tail_remainder = target_len % self._clip_target_samples - if 0 < tail_remainder < self._tail_min_samples: - padding = self._tail_min_samples - tail_remainder - target_len += padding - assert isinstance(target_len, int) - return target_len def audio_token_count(self, audio_len: int) -> int: - audio_len = self._normalize_audio_length(audio_len) num_frames = audio_len // self.hop_length - n_tokens = HFParakeetEncoder._get_subsampling_output_length( - self, torch.tensor([num_frames], dtype=torch.float) - ) - return max(1, n_tokens.item()) - - def __call__(self, raw_speech: list[np.ndarray], *args, **kwargs): - padded = [] - for p in raw_speech: - assert p.ndim == 1 - audio_len = int(p.shape[0]) - target_len = self._normalize_audio_length(audio_len) - p = np.pad(p, (0, target_len - audio_len)) - padded.append(p) - return super().__call__(padded, *args, **kwargs) + n_tokens = math.ceil(num_frames / self.config.subsampling_factor) + return max(1, n_tokens) def audio_length(self, audio_tokens: int) -> int: return int(audio_tokens * self.config.subsampling_factor * self.hop_length) From c3443d762bdef060b1a034c3480ed124f435d99d Mon Sep 17 00:00:00 2001 From: Netanel Haber <58652339+netanel-haber@users.noreply.github.com> Date: Tue, 3 Feb 2026 08:58:46 -0800 Subject: [PATCH 04/39] support bs>1? Signed-off-by: Netanel Haber <58652339+netanel-haber@users.noreply.github.com> Signed-off-by: Andrii Skliar --- vllm/model_executor/models/nano_nemotron_vl.py | 12 +++++++++++- vllm/model_executor/models/parakeet.py | 11 ++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/vllm/model_executor/models/nano_nemotron_vl.py b/vllm/model_executor/models/nano_nemotron_vl.py index fcef76c39e9d..dab0ea3d0033 100644 --- a/vllm/model_executor/models/nano_nemotron_vl.py +++ b/vllm/model_executor/models/nano_nemotron_vl.py @@ -1971,7 +1971,17 @@ def _process_audio_input( ) feature_attention_mask = feature_attention_mask.to(device=target_device) sound_embeds = self.sound_encoder(input_audio_features, feature_attention_mask) - return tuple(sound_embeds.unbind(dim=0)) + + valid_input_lens = feature_attention_mask.sum(dim=1) + valid_output_lens = self.sound_encoder.encoder._get_subsampling_output_length( + valid_input_lens + ) + truncated_embeds = [] + for i in range(sound_embeds.shape[0]): + valid_len = valid_output_lens[i].item() + truncated_embeds.append(sound_embeds[i, :valid_len]) + + return tuple(truncated_embeds) def _create_final_video_embeddings( self, diff --git a/vllm/model_executor/models/parakeet.py b/vllm/model_executor/models/parakeet.py index 495985d9c8fe..6b15af362792 100644 --- a/vllm/model_executor/models/parakeet.py +++ b/vllm/model_executor/models/parakeet.py @@ -2,7 +2,6 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -import math from collections.abc import Iterable from dataclasses import asdict, dataclass @@ -130,6 +129,8 @@ class ExtractorConfig: feature_size: int sampling_rate: int subsampling_factor: int + subsampling_conv_kernel_size: int + subsampling_conv_stride: int @staticmethod def from_hf_config(config: PretrainedConfig) -> "ExtractorConfig": @@ -138,6 +139,8 @@ def from_hf_config(config: PretrainedConfig) -> "ExtractorConfig": feature_size=config.num_mel_bins, sampling_rate=config.sampling_rate, subsampling_factor=config.subsampling_factor, + subsampling_conv_kernel_size=config.subsampling_conv_kernel_size, + subsampling_conv_stride=config.subsampling_conv_stride, ) @@ -148,8 +151,10 @@ def __init__(self, config: PretrainedConfig) -> None: def audio_token_count(self, audio_len: int) -> int: num_frames = audio_len // self.hop_length - n_tokens = math.ceil(num_frames / self.config.subsampling_factor) - return max(1, n_tokens) + n_tokens = HFParakeetEncoder._get_subsampling_output_length( + self, torch.tensor([num_frames], dtype=torch.float) + ) + return max(1, n_tokens.item()) def audio_length(self, audio_tokens: int) -> int: return int(audio_tokens * self.config.subsampling_factor * self.hop_length) From 0672abeb65c2c4a520d2ceee4ee8d2ebf3dd61d1 Mon Sep 17 00:00:00 2001 From: Netanel Haber <58652339+netanel-haber@users.noreply.github.com> Date: Tue, 10 Feb 2026 03:50:22 -0800 Subject: [PATCH 05/39] support bs>1 Signed-off-by: Netanel Haber <58652339+netanel-haber@users.noreply.github.com> Signed-off-by: Andrii Skliar --- .../model_executor/models/nano_nemotron_vl.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/vllm/model_executor/models/nano_nemotron_vl.py b/vllm/model_executor/models/nano_nemotron_vl.py index dab0ea3d0033..949b09682e4b 100644 --- a/vllm/model_executor/models/nano_nemotron_vl.py +++ b/vllm/model_executor/models/nano_nemotron_vl.py @@ -1966,6 +1966,27 @@ def _process_audio_input( input_audio_features = audio_input.input_audio_features feature_attention_mask = audio_input.feature_attention_mask target_device = next(self.sound_encoder.parameters()).device + + # When cross-request batching combines audio clips with different + # time dimensions, _reduce_data returns a list instead of a stacked + # tensor. Pad to the max time dim and stack; the attention mask + # already marks valid positions so zero-padding is safe. + if isinstance(input_audio_features, list): + feature_sizes = [f.shape[-2] for f in input_audio_features] + max_t = max(feature_sizes) + padded_feats = [ + torch.nn.functional.pad(feat, (0, 0, 0, max_t - feat_size)) + for feat, feat_size in zip( + input_audio_features, feature_sizes, strict=True + ) + ] + padded_masks = [ + torch.nn.functional.pad(mask, (0, max_t - mask.shape[-1])) + for mask in feature_attention_mask + ] + input_audio_features = torch.stack(padded_feats) + feature_attention_mask = torch.stack(padded_masks) + input_audio_features = input_audio_features.to( dtype=self.llm_dtype, device=target_device ) From fa583b05fb5130eeb1a52ea0a0225b08c739db33 Mon Sep 17 00:00:00 2001 From: Netanel Haber <58652339+netanel-haber@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:30:39 -0800 Subject: [PATCH 06/39] encase with and Signed-off-by: Netanel Haber <58652339+netanel-haber@users.noreply.github.com> Signed-off-by: Andrii Skliar --- .../model_executor/models/nano_nemotron_vl.py | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/vllm/model_executor/models/nano_nemotron_vl.py b/vllm/model_executor/models/nano_nemotron_vl.py index 949b09682e4b..a7490c6a5c47 100644 --- a/vllm/model_executor/models/nano_nemotron_vl.py +++ b/vllm/model_executor/models/nano_nemotron_vl.py @@ -102,9 +102,6 @@ # Image.MAX_IMAGE_PIXELS = 300000000 # ~300M pixels -AUDIO_CONTEXT = "" - - class NanoNemotronVLAudioFeatureInputs(TensorSchema): """ Dimensions: @@ -1011,10 +1008,8 @@ def _preprocess_audio( audio_index = 0 for idx, part in enumerate(parts): if part == AUDIO_CONTEXT: - audio = audios[audio_index] - audio_len = len(audio) - num_tokens = extractor.audio_token_count(audio_len) - parts[idx] = AUDIO_CONTEXT * num_tokens + audio_repl = self.get_audio_repl(audios[audio_index]) + parts[idx] = audio_repl.full audio_index += 1 text = ["".join(parts)] audio_inputs = extractor( @@ -1491,12 +1486,9 @@ def get_video_replacement_internvl(item_idx: int): ), ] - def get_audio_replacement(item_idx: int, extractor: ParakeetExtractor): + def get_audio_replacement(item_idx: int): audios = mm_items.get_items("audio", AudioProcessorItems) - audio_len = audios.get_audio_length(item_idx) - num_tokens = extractor.audio_token_count(audio_len) - repl_full = AUDIO_CONTEXT * num_tokens - return PromptUpdateDetails.select_text(repl_full, AUDIO_CONTEXT) + return hf_processor.get_audio_repl(audios.get(item_idx)) if self.info.audio_extractor is not None: prompt_repl = [ @@ -1504,9 +1496,7 @@ def get_audio_replacement(item_idx: int, extractor: ParakeetExtractor): PromptReplacement( modality="audio", target=AUDIO_CONTEXT, - replacement=lambda item_idx: get_audio_replacement( - item_idx, self.info.audio_extractor - ), + replacement=get_audio_replacement, ), ] From 47f1772bcd84778a8f6e76220c2856ceb71ff2f1 Mon Sep 17 00:00:00 2001 From: Netanel Haber <58652339+netanel-haber@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:31:22 -0800 Subject: [PATCH 07/39] don't truncate hop_length remainder, pad to 0.1s instead Signed-off-by: Netanel Haber <58652339+netanel-haber@users.noreply.github.com> Signed-off-by: Andrii Skliar --- vllm/model_executor/models/parakeet.py | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/vllm/model_executor/models/parakeet.py b/vllm/model_executor/models/parakeet.py index 6b15af362792..c86916b0d5ff 100644 --- a/vllm/model_executor/models/parakeet.py +++ b/vllm/model_executor/models/parakeet.py @@ -5,6 +5,7 @@ from collections.abc import Iterable from dataclasses import asdict, dataclass +import numpy as np import torch import torch.nn as nn from transformers import ParakeetEncoder as HFParakeetEncoder @@ -131,6 +132,8 @@ class ExtractorConfig: subsampling_factor: int subsampling_conv_kernel_size: int subsampling_conv_stride: int + clip_duration_s: int = 30 + clip_min_duration_s: float = 0.1 @staticmethod def from_hf_config(config: PretrainedConfig) -> "ExtractorConfig": @@ -148,13 +151,40 @@ class ParakeetExtractor(ParakeetFeatureExtractor): def __init__(self, config: PretrainedConfig) -> None: self.config = ExtractorConfig.from_hf_config(config) super().__init__(**asdict(self.config)) + self._clip_target_samples = int( + round(self.config.clip_duration_s * self.sampling_rate) + ) + self._tail_min_samples = int( + round(self.config.clip_min_duration_s * self.sampling_rate) + ) + + def _normalize_audio_length(self, audio_len: int) -> int: + # Match mcore's compute_params() logic for clip/minduration handling. + target_len = max(audio_len, self._tail_min_samples) + tail_remainder = target_len % self._clip_target_samples + if 0 < tail_remainder < self._tail_min_samples: + padding = self._tail_min_samples - tail_remainder + target_len += padding + assert isinstance(target_len, int) + return target_len def audio_token_count(self, audio_len: int) -> int: + audio_len = self._normalize_audio_length(audio_len) num_frames = audio_len // self.hop_length n_tokens = HFParakeetEncoder._get_subsampling_output_length( self, torch.tensor([num_frames], dtype=torch.float) ) return max(1, n_tokens.item()) + def __call__(self, raw_speech: list[np.ndarray], *args, **kwargs): + padded = [] + for p in raw_speech: + assert p.ndim == 1 + audio_len = int(p.shape[0]) + target_len = self._normalize_audio_length(audio_len) + p = np.pad(p, (0, target_len - audio_len)) + padded.append(p) + return super().__call__(padded, *args, **kwargs) + def audio_length(self, audio_tokens: int) -> int: return int(audio_tokens * self.config.subsampling_factor * self.hop_length) From 6d13da59aaf32c6f817479aea6606c75ad9989ad Mon Sep 17 00:00:00 2001 From: Netanel Haber <58652339+netanel-haber@users.noreply.github.com> Date: Sun, 15 Feb 2026 06:30:43 -0800 Subject: [PATCH 08/39] allow configuring static and dynamic resolution per request, via max_num_tiles, for passing images as video frames Signed-off-by: Netanel Haber <58652339+netanel-haber@users.noreply.github.com> Signed-off-by: Andrii Skliar --- .../model_executor/models/nano_nemotron_vl.py | 43 ++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/vllm/model_executor/models/nano_nemotron_vl.py b/vllm/model_executor/models/nano_nemotron_vl.py index a7490c6a5c47..6d0e0c9a5c83 100644 --- a/vllm/model_executor/models/nano_nemotron_vl.py +++ b/vllm/model_executor/models/nano_nemotron_vl.py @@ -13,7 +13,6 @@ from abc import ABC, abstractmethod from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass -from functools import cached_property from typing import Annotated, Any, Literal, TypeAlias, TypeVar import einops @@ -743,17 +742,26 @@ def _images_to_pixel_values_lst( for idx, image in enumerate(images) ] + def use_dynamic_tiler( + self, max_num_tiles: int | None = None + ) -> DynamicResolutionImageTiler | None: + """If `max_num_tiles` is explicitly defined on a request, e.g.: + `"mm_processor_kwargs": { "max_num_tiles": 1 }`, force static resolution""" + if max_num_tiles is not None: + return None + return self.dynamic_tiler + def _preprocess_image( self, text: list[str], images: list[Image.Image], - max_num_tiles: int, + max_num_tiles: int | None, ) -> tuple[list[str], dict[str, Any]]: if len(images) == 0: image_inputs = {} return text, image_inputs - if tiler := self.dynamic_tiler: + if tiler := self.use_dynamic_tiler(max_num_tiles): sans_images = text[0].replace("", "") text_prompt_length = len( self.tokenizer(sans_images, add_special_tokens=False).input_ids @@ -774,6 +782,7 @@ def _preprocess_image( "num_tokens_per_image": num_tokens_per_image, } else: + max_num_tiles = max_num_tiles or self.max_num_tiles pixel_values_lst = self._images_to_pixel_values_lst(images, max_num_tiles) image_num_patches = torch.tensor([len(item) for item in pixel_values_lst]) pixel_values_flat = input_conditioner( @@ -1037,10 +1046,6 @@ def __call__( return_tensors: str | TensorType | None = None, max_num_tiles: int | None = None, ) -> BatchFeature: - # Use default if not provided - if max_num_tiles is None: - max_num_tiles = self.max_num_tiles - text, images, videos, audios = [ self._make_batch_input(x) for x in (text, images, videos, audios) ] @@ -1283,16 +1288,17 @@ def get_hf_processor(self, **kwargs: object) -> NanoNemotronVLProcessor: class NanoNemotronBaseVLMultiModalProcessor(BaseMultiModalProcessor[_I]): """Basic image-only MultiModalProcessor for InternVL-style models.""" - @cached_property - def is_dynamic_tiler(self) -> bool: - return self.info.get_hf_processor().dynamic_tiler is not None + def use_dynamic_tiler(self, hf_processor_mm_kwargs: dict[str, Any]): + return self.info.get_hf_processor().use_dynamic_tiler( + hf_processor_mm_kwargs.get("max_num_tiles") + ) def _get_mm_fields_config( self, hf_inputs: BatchFeature, hf_processor_mm_kwargs: Mapping[str, object], ) -> Mapping[str, MultiModalFieldConfig]: - if self.is_dynamic_tiler: + if self.use_dynamic_tiler(hf_processor_mm_kwargs): pixel_values_flat = MultiModalFieldConfig.batched("image") else: image_num_patches = hf_inputs.get("image_num_patches", torch.empty(0)) @@ -1334,7 +1340,7 @@ def get_replacement_custom(item_idx: int): if isinstance(images, ImageEmbeddingItems): feature_size = images.get_feature_size(item_idx) - elif tiler := hf_processor.dynamic_tiler: + elif tiler := self.use_dynamic_tiler(hf_processor_mm_kwargs): image = images.get(item_idx) feature_size = tiler.get_cached_feature_size(image) else: @@ -1844,7 +1850,15 @@ def _parse_and_validate_image_input( data=image_embeds, ) - if self.dynamic_resolution: + has_static = "image_num_patches" in kwargs + has_dynamic = "imgs_sizes" in kwargs and "num_tokens_per_image" in kwargs + assert has_static != has_dynamic, ( + "Expected either static or dynamic resolution in batch, " + f"but got keys={list(kwargs.keys())} indicating both" + ) + + if has_dynamic: + assert self.dynamic_resolution pixel_values_flat = DynamicResolutionImageTiler.stack( kwargs.pop("pixel_values_flat"), self.patch_size ) @@ -2133,8 +2147,7 @@ def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings: image_input = modalities["images"] if image_input["type"] == "image_embeds": image_embeddings = image_input["data"] - elif self.dynamic_resolution: - assert image_input["type"] == "pixel_values_dynamic" + elif image_input["type"] == "pixel_values_dynamic": image_embeddings = self._process_image_input_dynamic(image_input) else: image_embeddings = self._process_image_input(image_input) From e527e44b60f0c239ef474ce559d7b470aa5d3f16 Mon Sep 17 00:00:00 2001 From: Andrii Skliar Date: Wed, 18 Feb 2026 06:30:34 -0800 Subject: [PATCH 09/39] initial implementation Signed-off-by: Andrii Skliar --- .../model_executor/models/nano_nemotron_vl.py | 157 ++++++++++++++++++ vllm/multimodal/media/audio.py | 35 ++++ vllm/multimodal/media/video.py | 14 +- 3 files changed, 205 insertions(+), 1 deletion(-) diff --git a/vllm/model_executor/models/nano_nemotron_vl.py b/vllm/model_executor/models/nano_nemotron_vl.py index 6d0e0c9a5c83..559adafeab15 100644 --- a/vllm/model_executor/models/nano_nemotron_vl.py +++ b/vllm/model_executor/models/nano_nemotron_vl.py @@ -65,9 +65,12 @@ AudioItem, MultiModalDataDict, MultiModalFieldConfig, + MultiModalInputs, MultiModalKwargsItems, + MultiModalUUIDDict, VideoItem, ) +from vllm.multimodal.media.audio import extract_audio_from_video_bytes from vllm.multimodal.parse import ( AudioProcessorItems, ImageEmbeddingItems, @@ -75,6 +78,7 @@ ImageSize, MultiModalDataItems, MultiModalDataParser, + VideoProcessorItems, ) from vllm.multimodal.processing import BaseDummyInputsBuilder from vllm.multimodal.processing.processor import ( @@ -1394,6 +1398,159 @@ def _get_data_parser(self) -> MultiModalDataParser: target_channels=target_channels, ) + def _extract_audio_from_videos( + self, + mm_data: MultiModalDataDict, + max_audio_duration: float | None = None, + ) -> tuple[MultiModalDataDict, list[AudioItem]]: + """Extract audio tracks from video bytes in *mm_data*. + + Args: + mm_data: The multimodal data dict containing video items. + max_audio_duration: If set, only extract the first N seconds + of audio from each video. + + Returns: + The (possibly augmented) *mm_data* and the list of + extracted audio items. + """ + video_items = self._get_data_parser().parse_mm_data( + {"video": mm_data["video"]} + ) + videos = video_items.get_items("video", VideoProcessorItems) + metadata_list = ( + videos.metadata if isinstance(videos.metadata, list) else [] + ) + + target_sr = None + if extractor := self.info.audio_extractor: + target_sr = extractor.sampling_rate + + audio_items: list[AudioItem] = [] + for idx, metadata in enumerate(metadata_list): + video_bytes = ( + metadata.get("original_video_bytes") if metadata else None + ) + if video_bytes is None: + raise ValueError( + "`use_audio_in_video=True` requires the raw video " + "bytes to be available in metadata. Make sure the " + "server was started with " + '--media-io-kwargs \'{"video": ' + '{"keep_video_bytes": true}}\' ' + f"(missing for video index {idx})." + ) + audio_items.append( + extract_audio_from_video_bytes( + video_bytes, + sr=target_sr, + max_duration=max_audio_duration, + ) + ) + metadata.pop("original_video_bytes", None) + + mm_data = dict(mm_data) + mm_data["audio"] = audio_items + return mm_data, audio_items + + def apply( + self, + prompt: str | list[int], + mm_data: MultiModalDataDict, + hf_processor_mm_kwargs: Mapping[str, object], + tokenization_kwargs: Mapping[str, object] | None = None, + *, + mm_uuids: MultiModalUUIDDict | None = None, + ) -> MultiModalInputs: + use_audio_in_video = bool( + hf_processor_mm_kwargs.get("use_audio_in_video", False) + ) + max_audio_duration = hf_processor_mm_kwargs.get( + "max_audio_duration", None + ) + if max_audio_duration is not None: + max_audio_duration = float(max_audio_duration) + + # Strip our custom kwargs so they don't reach the HF processor + _custom_keys = {"use_audio_in_video", "max_audio_duration"} + hf_processor_mm_kwargs = { + k: v + for k, v in hf_processor_mm_kwargs.items() + if k not in _custom_keys + } + + if not ( + use_audio_in_video + and "video" in mm_data + and "audio" not in mm_data + ): + return super().apply( + prompt, + mm_data, + hf_processor_mm_kwargs, + tokenization_kwargs, + mm_uuids=mm_uuids, + ) + + mm_data, audio_items = self._extract_audio_from_videos( + mm_data, max_audio_duration=max_audio_duration + ) + + if not isinstance(prompt, str): + tokenizer = self.info.get_tokenizer() + prompt = tokenizer.decode(prompt, skip_special_tokens=False) + + for _ in audio_items: + prompt = prompt.replace( + "