Instructions to use ServiceNow-AI/Apriel-Voice-27B-preview with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use ServiceNow-AI/Apriel-Voice-27B-preview with Transformers:
# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("ServiceNow-AI/Apriel-Voice-27B-preview", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Download apriel_voice_processing.py from ServiceNow-AI/Apriel-Voice-27B-preview: direct link, hf CLI and curl.
- Browser
- Download file 16.5 kB
-
https://huggingface.co/ServiceNow-AI/Apriel-Voice-27B-preview/resolve/main/apriel_voice_processing.py
- Command line
-
hf download hf://ServiceNow-AI/Apriel-Voice-27B-preview/apriel_voice_processing.py
-
curl -L -o apriel_voice_processing.py https://huggingface.co/ServiceNow-AI/Apriel-Voice-27B-preview/resolve/main/apriel_voice_processing.py
16.5 kB
| """AprielVoiceProcessor — Whisper audio, Gemma4 images, and text processing.""" | |
| import math | |
| import re | |
| from typing import Any, Dict, List, Optional, Tuple, Union | |
| import numpy as np | |
| import torch | |
| import torch.nn.functional as F | |
| import transformers | |
| from .apriel_voice_config import AprielVoiceConfig | |
| _SHORT_AUDIO_THRESHOLD_SECONDS = 2.0 | |
| _SHORT_AUDIO_SILENCE_SECONDS = 3.0 | |
| def _prepend_silence_to_short_audios( | |
| audios: List[np.ndarray], sampling_rate: int | |
| ) -> List[np.ndarray]: | |
| threshold_samples = int(_SHORT_AUDIO_THRESHOLD_SECONDS * sampling_rate) | |
| silence_samples = int(_SHORT_AUDIO_SILENCE_SECONDS * sampling_rate) | |
| return [ | |
| np.pad(audio, (silence_samples, 0), mode="constant") | |
| if len(audio) < threshold_samples | |
| else audio | |
| for audio in audios | |
| ] | |
| class AprielVoiceProcessor(transformers.ProcessorMixin): | |
| """Process audio and images for the AprielVoice multimodal model. | |
| Whisper features use Apriel's audio projector. Images use the wrapped | |
| Gemma4 vision tower, with one placeholder per actual image soft token. | |
| """ | |
| attributes = ["audio_processor", "tokenizer", "image_processor"] | |
| audio_processor_class = ("WhisperFeatureExtractor",) | |
| tokenizer_class = ("PreTrainedTokenizer", "PreTrainedTokenizerFast") | |
| image_processor_class = ("Gemma4ImageProcessor", "Gemma4ImageProcessorPil") | |
| tokenizer: transformers.PreTrainedTokenizerBase | |
| audio_processor: transformers.FeatureExtractionMixin | |
| def __init__( | |
| self, | |
| audio_processor=None, | |
| tokenizer=None, | |
| audio_padding: str = "longest", | |
| encoder_ds_factor: int = 2, | |
| stack_factor: int = 8, | |
| audio_placeholder: str = "<|audio|>", | |
| audio_context_size: Optional[int] = 3000, | |
| image_processor=None, | |
| ): | |
| self.audio_padding = audio_padding | |
| self.encoder_ds_factor = encoder_ds_factor | |
| self.stack_factor = stack_factor | |
| self.audio_placeholder = audio_placeholder | |
| self.audio_context_size = audio_context_size | |
| assert tokenizer.eos_token is not None, "Tokenizer has no EOS token." | |
| self.vocab = tokenizer.get_vocab() | |
| self.audio_token_replacement = tokenizer.eos_token | |
| if tokenizer.pad_token_id is None: | |
| tokenizer.pad_token_id = tokenizer.eos_token_id | |
| if audio_processor is None: | |
| audio_processor = transformers.WhisperFeatureExtractor() | |
| if image_processor is None: | |
| image_processor = transformers.Gemma4ImageProcessor() | |
| self.image_token = getattr(tokenizer, "image_token", "<|image|>") | |
| self.image_token_id = self.vocab.get(self.image_token) | |
| self.boi_token = getattr(tokenizer, "boi_token", "<|image>") | |
| self.eoi_token = getattr(tokenizer, "eoi_token", "<image|>") | |
| super().__init__( | |
| audio_processor=audio_processor, | |
| tokenizer=tokenizer, | |
| image_processor=image_processor, | |
| ) | |
| def from_pretrained(cls, pretrained_model_name_or_path: str, **kwargs): | |
| image_kwargs = dict(kwargs.pop("images_kwargs", {}) or {}) | |
| for key in transformers.Gemma4ImageProcessor.valid_kwargs.__annotations__: | |
| if key in kwargs: | |
| image_kwargs[key] = kwargs.pop(key) | |
| config: AprielVoiceConfig = transformers.AutoConfig.from_pretrained( | |
| pretrained_model_name_or_path, **kwargs | |
| ) | |
| audio_processor = transformers.WhisperFeatureExtractor.from_pretrained( | |
| pretrained_model_name_or_path, **kwargs | |
| ) | |
| tokenizer = transformers.AutoTokenizer.from_pretrained( | |
| pretrained_model_name_or_path, **kwargs | |
| ) | |
| tokenizer.padding_side = "left" | |
| tokenizer.pad_token = tokenizer.eos_token | |
| vision_config = getattr(config.text_config, "vision_config", None) | |
| image_defaults = {} | |
| if vision_config is not None: | |
| image_defaults = { | |
| "patch_size": vision_config.patch_size, | |
| "pooling_kernel_size": vision_config.pooling_kernel_size, | |
| "max_soft_tokens": getattr(vision_config, "default_output_length", 280), | |
| } | |
| image_processor = transformers.Gemma4ImageProcessor( | |
| **{**image_defaults, **image_kwargs} | |
| ) | |
| return cls( | |
| audio_processor=audio_processor, | |
| tokenizer=tokenizer, | |
| stack_factor=config.stack_factor, | |
| image_processor=image_processor, | |
| ) | |
| def _auto_chunk_audios( | |
| self, | |
| text: str, | |
| audios: List[np.ndarray], | |
| sampling_rate: int, | |
| max_chunk_duration: float = 30.0, | |
| ) -> Tuple[str, List[np.ndarray]]: | |
| max_samples = int(max_chunk_duration * sampling_rate) | |
| new_audios: List[np.ndarray] = [] | |
| chunk_counts: List[int] = [] | |
| for audio in audios: | |
| if len(audio) > max_samples: | |
| n_chunks = math.ceil(len(audio) / max_samples) | |
| for i in range(n_chunks): | |
| new_audios.append(audio[i * max_samples : (i + 1) * max_samples]) | |
| chunk_counts.append(n_chunks) | |
| else: | |
| new_audios.append(audio) | |
| chunk_counts.append(1) | |
| if all(c == 1 for c in chunk_counts): | |
| return text, audios | |
| audio_block_re = re.compile( | |
| r"\[AUDIO FILE \d+ START\]\s*<\|audio>\s*<\|audio\|>\s*<audio\|>\s*\[AUDIO FILE \d+ END\]" | |
| ) | |
| blocks = list(audio_block_re.finditer(text)) | |
| if len(blocks) == len(chunk_counts): | |
| new_text_parts: List[str] = [] | |
| prev_end = 0 | |
| file_idx = 1 | |
| for block, count in zip(blocks, chunk_counts): | |
| new_text_parts.append(text[prev_end:block.start()]) | |
| for j in range(count): | |
| if j > 0: | |
| new_text_parts.append(" ") | |
| new_text_parts.append( | |
| f"[AUDIO FILE {file_idx} START] <|audio> <|audio|> <audio|> [AUDIO FILE {file_idx} END]" | |
| ) | |
| file_idx += 1 | |
| prev_end = block.end() | |
| new_text_parts.append(text[prev_end:]) | |
| text = "".join(new_text_parts) | |
| else: | |
| parts = text.split("<|audio|>") | |
| new_parts: List[str] = [] | |
| audio_idx = 0 | |
| for i, part in enumerate(parts): | |
| new_parts.append(part) | |
| if i < len(parts) - 1: | |
| count = chunk_counts[audio_idx] if audio_idx < len(chunk_counts) else 1 | |
| new_parts.append("<|audio|>" * count) | |
| audio_idx += 1 | |
| text = "".join(new_parts) | |
| return text, new_audios | |
| def _chunk_and_pad_audio( | |
| self, | |
| audio_values: torch.Tensor, | |
| audio_lens: torch.Tensor, | |
| include_audio_num_chunks: bool = False, | |
| ) -> Dict[str, Any]: | |
| chunked_audio_values: List[torch.Tensor] = [] | |
| chunked_audio_lens: List[int] = [] | |
| is_continuation_list: List[bool] = [] | |
| num_chunks: List[int] = [] | |
| context_size = self.audio_context_size or audio_values.shape[-1] | |
| for i in range(audio_values.shape[0]): | |
| num_chunks.append((int(audio_lens[i]) + context_size - 1) // context_size) | |
| for offset in range(0, audio_lens[i], context_size): | |
| is_continuation = offset > 0 | |
| chunk = audio_values[i, :, offset : offset + context_size] | |
| if is_continuation and chunk.shape[-1] < context_size: | |
| chunk = F.pad(chunk, (0, context_size - chunk.shape[-1])) | |
| chunked_audio_values.append(chunk) | |
| chunked_audio_lens.append(min(int(audio_lens[i].item()) - offset, context_size)) | |
| is_continuation_list.append(is_continuation) | |
| data = { | |
| "audio_values": torch.stack(chunked_audio_values, dim=0), | |
| "audio_lens": torch.tensor( | |
| chunked_audio_lens, dtype=torch.int64, device=audio_values.device | |
| ), | |
| "audio_is_continuation": torch.tensor( | |
| is_continuation_list, dtype=torch.bool, device=audio_values.device | |
| ), | |
| "audio_batch_size": torch.tensor( | |
| [len(chunked_audio_values)], device=audio_values.device | |
| ), | |
| } | |
| if include_audio_num_chunks: | |
| data["audio_num_chunks"] = torch.tensor( | |
| num_chunks, dtype=torch.int64, device=audio_values.device | |
| ) | |
| return data | |
| def __call__( | |
| self, | |
| text: Optional[str] = None, | |
| audio: Optional[Union[np.ndarray, torch.Tensor]] = None, | |
| audios: Optional[ | |
| Union[List[Union[np.ndarray, torch.Tensor]], Union[np.ndarray, torch.Tensor]] | |
| ] = None, | |
| sampling_rate: Optional[int] = None, | |
| return_tensors: Optional[ | |
| Union[str, transformers.TensorType] | |
| ] = transformers.TensorType.PYTORCH, | |
| include_audio_num_chunks: bool = False, | |
| images=None, | |
| images_kwargs: Optional[Dict[str, Any]] = None, | |
| **kwargs, | |
| ) -> transformers.BatchFeature: | |
| if text is not None and not isinstance(text, str): | |
| raise ValueError("Text must be a string. Batch mode not supported yet.") | |
| if audio is not None and audios is not None: | |
| raise ValueError("Only one of `audio` or `audios` should be provided.") | |
| if audio is not None: | |
| audios = audio | |
| if audios is None: | |
| audios = [] | |
| elif isinstance(audios, (np.ndarray, torch.Tensor)): | |
| audios = [audios] if audios.ndim == 1 else list(audios) | |
| if text is not None and text.count(self.audio_placeholder) != len(audios): | |
| raise ValueError( | |
| f"Expected {len(audios)} audio placeholders, " | |
| f"found {text.count(self.audio_placeholder)}." | |
| ) | |
| image_kwargs = dict(images_kwargs or {}) | |
| for key in self.image_processor.valid_kwargs.__annotations__: | |
| if key in kwargs: | |
| image_kwargs[key] = kwargs.pop(key) | |
| nested_text_kwargs = kwargs.pop("text_kwargs", {}) or {} | |
| nested_audio_kwargs = kwargs.pop("audio_kwargs", {}) or {} | |
| text_kwargs = {**kwargs, **nested_text_kwargs} | |
| audio_kwargs = {**kwargs, **nested_audio_kwargs} | |
| data = {} | |
| image_token_counts = [] | |
| has_images = images is not None and not ( | |
| isinstance(images, (list, tuple)) and len(images) == 0 | |
| ) | |
| if has_images: | |
| if any( | |
| token not in self.vocab | |
| for token in (self.image_token, self.boi_token, self.eoi_token) | |
| ): | |
| raise ValueError("The tokenizer does not define Gemma4 image tokens.") | |
| image_kwargs["return_tensors"] = "pt" | |
| image_inputs = self.image_processor(images=images, **image_kwargs) | |
| image_token_counts = image_inputs.pop("num_soft_tokens_per_image").tolist() | |
| data.update(image_inputs) | |
| if text is not None: | |
| num_placeholders = text.count(self.image_token) | |
| if num_placeholders != len(image_token_counts): | |
| raise ValueError( | |
| f"Expected {len(image_token_counts)} image placeholders, " | |
| f"found {num_placeholders}." | |
| ) | |
| replacements = iter( | |
| self.boi_token + self.image_token * count + self.eoi_token | |
| for count in image_token_counts | |
| ) | |
| # Expand images before tokenizing audio spans so their offsets stay valid. | |
| text = re.sub(re.escape(self.image_token), lambda _: next(replacements), text) | |
| audio_is_continuation = [] | |
| if len(audios) > 0: | |
| audios = [ | |
| x.detach().cpu().numpy() if isinstance(x, torch.Tensor) else x | |
| for x in audios | |
| ] | |
| sr = sampling_rate or self.audio_processor.sampling_rate | |
| audios = _prepend_silence_to_short_audios(audios, sr) | |
| if text is not None and not include_audio_num_chunks: | |
| text, audios = self._auto_chunk_audios(text, audios, sr) | |
| hop_length = self.audio_processor.hop_length | |
| audios = [ | |
| np.pad(x, (0, 2 * hop_length - len(x)), mode="constant") | |
| if len(x) < 2 * hop_length | |
| else x | |
| for x in audios | |
| ] | |
| audio_kwargs.pop("add_special_tokens", None) | |
| audio_kwargs.update( | |
| sampling_rate=sr, | |
| padding=self.audio_padding, | |
| pad_to_multiple_of=hop_length, | |
| truncation=False, | |
| return_attention_mask=True, | |
| ) | |
| x: transformers.BatchFeature = self.audio_processor(audios, **audio_kwargs) | |
| data.update( | |
| self._chunk_and_pad_audio( | |
| audio_values=torch.as_tensor( | |
| x.input_features if "input_features" in x else x.input_values | |
| ), | |
| audio_lens=torch.as_tensor(x.attention_mask).sum(-1), | |
| include_audio_num_chunks=include_audio_num_chunks, | |
| ) | |
| ) | |
| audio_is_continuation = data.pop("audio_is_continuation") | |
| data["audio_token_len"] = torch.ceil( | |
| data["audio_lens"] / (self.encoder_ds_factor * self.stack_factor) | |
| ).to(dtype=torch.int) | |
| if text is not None: | |
| text_kwargs["add_special_tokens"] = False | |
| tokenized_parts = self.tokenizer( | |
| text.split(self.audio_placeholder), | |
| **text_kwargs, | |
| ) | |
| audio_token_start_idx = [] | |
| placeholder_index = -1 | |
| split_input_ids = tokenized_parts["input_ids"] | |
| input_ids: List[int] = [] | |
| audio_replacement_token_id = self.vocab[self.audio_token_replacement] | |
| for i, token_len in enumerate(data.get("audio_token_len", [])): | |
| if not audio_is_continuation[i]: | |
| placeholder_index += 1 | |
| if placeholder_index >= len(split_input_ids): | |
| raise ValueError(f"Too few audio placeholders (expected {len(audios)}).") | |
| input_ids.extend(split_input_ids[placeholder_index]) | |
| audio_token_start_idx.append(len(input_ids)) | |
| input_ids.extend([audio_replacement_token_id] * token_len) | |
| placeholder_index += 1 | |
| if placeholder_index != len(split_input_ids) - 1: | |
| raise ValueError(f"Too many audio placeholders (expected {len(audios)}).") | |
| input_ids.extend(split_input_ids[placeholder_index]) | |
| if "audio_token_len" in data: | |
| data["audio_token_start_idx"] = torch.as_tensor(audio_token_start_idx) | |
| data["input_ids"] = [input_ids] | |
| data["attention_mask"] = [[1] * len(input_ids)] | |
| if image_token_counts: | |
| if input_ids.count(self.image_token_id) != sum(image_token_counts): | |
| raise ValueError("Tokenization changed the number of image placeholders.") | |
| mm_token_type_ids = [ | |
| int(token == self.image_token_id) for token in input_ids | |
| ] | |
| for start, length in zip( | |
| audio_token_start_idx, data.get("audio_token_len", []) | |
| ): | |
| mm_token_type_ids[start : start + int(length)] = [3] * int(length) | |
| data["mm_token_type_ids"] = [mm_token_type_ids] | |
| return transformers.BatchFeature(data=data, tensor_type=return_tensors) | |
| def batch_decode(self, *args, **kwargs): | |
| return self.tokenizer.batch_decode(*args, **kwargs) | |
| def decode(self, *args, **kwargs): | |
| return self.tokenizer.decode(*args, **kwargs) | |
| def model_input_names(self): | |
| return list(dict.fromkeys(self.tokenizer.model_input_names + [ | |
| "audio_values", "audio_lens", "audio_token_len", "audio_token_start_idx", | |
| "audio_batch_size", "pixel_values", "image_position_ids", "mm_token_type_ids", | |
| ])) | |
| AprielVoiceProcessor.register_for_auto_class() | |
| transformers.AutoProcessor.register(AprielVoiceConfig, AprielVoiceProcessor) | |