diff --git a/docs/source/en/_toctree.yml b/docs/source/en/_toctree.yml
index 181fb35a0c21..dd600f421167 100644
--- a/docs/source/en/_toctree.yml
+++ b/docs/source/en/_toctree.yml
@@ -1103,6 +1103,8 @@
title: Audio Spectrogram Transformer
- local: model_doc/bark
title: Bark
+ - local: model_doc/canary
+ title: Canary
- local: model_doc/clap
title: CLAP
- local: model_doc/cohere_asr
diff --git a/docs/source/en/model_doc/audioflamingo3.md b/docs/source/en/model_doc/audioflamingo3.md
index 8e1a3fa4a870..45f9177b02d3 100644
--- a/docs/source/en/model_doc/audioflamingo3.md
+++ b/docs/source/en/model_doc/audioflamingo3.md
@@ -289,7 +289,6 @@ conversation = [
inputs = processor.apply_chat_template(
conversation,
tokenize=True,
- add_generation_prompt=True,
return_dict=True,
processor_kwargs={"output_labels": True},
).to(model.device, dtype=model.dtype)
diff --git a/docs/source/en/model_doc/canary.md b/docs/source/en/model_doc/canary.md
new file mode 100644
index 000000000000..d5b794364832
--- /dev/null
+++ b/docs/source/en/model_doc/canary.md
@@ -0,0 +1,159 @@
+
+*This model was published in HF papers on 2025-09-17 and contributed to Hugging Face Transformers on 2026-08-04.*
+
+
+

+
+
+# Canary
+
+## Overview
+
+Canary-1B-v2 was proposed in [Canary-1B-v2 & Parakeet-TDT-0.6B-v3: Efficient and High-Performance Models for Multilingual ASR and AST](https://huggingface.co/papers/2509.14128) by Monica Sekoyan, Nithin Rao Koluguri, Nune Tadevosyan, Piotr Zelasko, Travis Bartley, Nikolay Karpov, Jagadeesh Balam, and Boris Ginsburg.
+
+The abstract from the paper is the following:
+
+*This report introduces Canary-1B-v2, a fast, robust multilingual model for Automatic Speech Recognition (ASR) and Speech-to-Text Translation (AST). Built with a FastConformer encoder and Transformer decoder, it supports 25 European languages. The model was trained on 1.7M hours of total data samples, including Granary and NeMo ASR Set 3.0, with non-speech audio added to reduce hallucinations for ASR and AST. We describe its two-stage pre-training and fine-tuning process with dynamic data balancing, as well as experiments with an nGPT encoder. Results show nGPT scales well with massive data, while FastConformer excels after fine-tuning. For timestamps, Canary-1B-v2 uses the NeMo Forced Aligner (NFA) with an auxiliary CTC model, providing reliable segment-level timestamps for ASR and AST. Evaluations show Canary-1B-v2 outperforms Whisper-large-v3 on English ASR while being 10× faster, and delivers competitive multilingual ASR and AST performance against larger models like Seamless-M4T-v2-large and LLM-based systems. We also release Parakeet-TDT-0.6B-v3, a successor to v2, offering multilingual ASR across the same 25 languages with just 600M parameters.*
+
+Canary reuses the [Fast Conformer](https://huggingface.co/papers/2305.05084) encoder from [Parakeet](./parakeet.md) (loaded through [`ParakeetEncoder`] / [`ParakeetEncoderConfig`]) and pairs it with a Transformer decoder that uses fixed sinusoidal positional embeddings, cross-attention to the encoder outputs and tied input/output embeddings. The task is selected through a decoder prompt prefix built by [`CanaryProcessor`] of the form `<|startofcontext|> <|startoftranscript|> <|emo:undefined|> <|noitn|> <|notimestamp|> <|nodiarize|>`, where `source_lang == target_lang` selects transcription and otherwise selects translation.
+
+The original implementation can be found in [NVIDIA NeMo](https://github.com/NVIDIA/NeMo). A model checkpoint is available at [harshaljanjani/canary-1b-v2-hf](https://huggingface.co/harshaljanjani/canary-1b-v2-hf).
+
+This model was contributed by [Harshal Janjani](https://huggingface.co/harshaljanjani).
+
+## Usage
+
+### Transcription
+
+The simplest way to transcribe audio is with `apply_transcription_request`, which builds the multitask decoder prompt for you (it is a convenience wrapper for `apply_chat_template`).
+
+```python
+from datasets import load_dataset, Audio
+from transformers import AutoProcessor, AutoModelForSpeechSeq2Seq
+
+processor = AutoProcessor.from_pretrained("harshaljanjani/canary-1b-v2-hf")
+model = AutoModelForSpeechSeq2Seq.from_pretrained("harshaljanjani/canary-1b-v2-hf", device_map="auto")
+
+ds = load_dataset("hf-internal-testing/librispeech_asr_dummy", "clean", split="validation")
+ds = ds.cast_column("audio", Audio(sampling_rate=processor.feature_extractor.sampling_rate))
+
+inputs = processor.apply_transcription_request(audio=ds[0]["audio"]["array"], source_language="en").to(model.device)
+generated_ids = model.generate(**inputs, max_new_tokens=128)
+print(processor.decode(generated_ids, skip_special_tokens=True)[0])
+```
+
+### Translation
+
+Set `target_language` to a different language than `source_language` for speech-to-text translation.
+
+```python
+inputs = processor.apply_transcription_request(
+ audio=ds[0]["audio"]["array"], source_language="en", target_language="de"
+).to(model.device)
+generated_ids = model.generate(**inputs, max_new_tokens=128)
+print(processor.decode(generated_ids, skip_special_tokens=True)[0])
+```
+
+### Batch inference
+
+Pass a list of audios and, optionally, a list of `source_language` / `target_language`.
+
+```python
+audios = [ds[0]["audio"]["array"], ds[1]["audio"]["array"]]
+inputs = processor.apply_transcription_request(
+ audio=audios, source_language="en", target_language=["en", "de"]
+).to(model.device)
+generated_ids = model.generate(**inputs, max_new_tokens=128)
+for text in processor.decode(generated_ids, skip_special_tokens=True):
+ print(text)
+```
+
+### Torch compile
+
+For autoregressive transcription, `torch.compile` accelerates the per-token forward passes inside `generate` by providing a `CompileConfig` object.
+
+```python
+from transformers import CompileConfig
+
+inputs = processor.apply_transcription_request(audio=ds[0]["audio"]["array"], source_language="en").to(model.device)
+compile_config = CompileConfig()
+
+# Warmup
+for _ in range(3):
+ _ = model.generate(**inputs, max_new_tokens=128, cache_implementation="static", compile_config=compile_config)
+
+# Apply model
+generated_ids = model.generate(**inputs, max_new_tokens=128, cache_implementation="static", compile_config=compile_config)
+print(processor.decode(generated_ids, skip_special_tokens=True)[0])
+```
+
+### Training
+
+Canary can be trained with the loss outputted by the model. Put the target transcript in the assistant turn and pass `output_labels=True`. Padding positions are masked automatically.
+
+```python
+model.train()
+transcription = "mister Quilter is the apostle of the middle classes, and we are glad to welcome his gospel."
+
+conversation = [
+ [
+ {
+ "role": "user",
+ "content": [
+ {"type": "audio", "audio": ds[0]["audio"]["array"]},
+ {"type": "text", "source_language": "en", "target_language": "en", "punctuation": True},
+ ],
+ },
+ {"role": "assistant", "content": transcription},
+ ]
+]
+
+inputs = processor.apply_chat_template(
+ conversation,
+ tokenize=True,
+ return_dict=True,
+ processor_kwargs={"output_labels": True},
+).to(model.device)
+
+outputs = model(**inputs)
+outputs.loss.backward()
+```
+
+> [!NOTE]
+> Segment-level timestamps for Canary-1B-v2 are produced by the external NeMo Forced Aligner (NFA) with an auxiliary CTC model, not by the decoder, so they are not part of the `generate` output.
+
+## CanaryConfig
+
+[[autodoc]] CanaryConfig
+
+## CanaryDecoderConfig
+
+[[autodoc]] CanaryDecoderConfig
+
+## CanaryProcessor
+
+[[autodoc]] CanaryProcessor
+
+## CanaryModel
+
+[[autodoc]] CanaryModel
+ - forward
+
+## CanaryForConditionalGeneration
+
+[[autodoc]] CanaryForConditionalGeneration
+ - forward
diff --git a/src/transformers/audio_utils.py b/src/transformers/audio_utils.py
index ece33f081799..e29ffe4e6377 100644
--- a/src/transformers/audio_utils.py
+++ b/src/transformers/audio_utils.py
@@ -445,6 +445,104 @@ def make_list_of_audio_chat_template(
return make_list_of_audio(audio)
+def make_audio_chat_template_content(audio_item) -> dict:
+ """
+ Build a chat-template content dict for a single audio item.
+
+ Args:
+ audio_item (`str` or array-like):
+ A single audio item. Strings are treated as local paths or URLs; other values (numpy/torch arrays) are
+ forwarded directly.
+
+ Returns:
+ `dict`: A chat-template content dict, e.g. `{"type": "audio", "path": ...}` for strings or
+ `{"type": "audio", "audio": ...}` otherwise.
+ """
+ if isinstance(audio_item, str):
+ return {"type": "audio", "path": audio_item}
+ return {"type": "audio", "audio": audio_item}
+
+
+def resolve_language(language: str | None, code_to_name: dict[str, str], return_code: bool = True) -> str | None:
+ """
+ Map a language code or name to its canonical form, with validation.
+
+ Accepts either a language code (e.g. ``"zh"``, ``"en"``) or a full name (e.g. ``"Chinese"``, ``"English"``) and
+ returns the canonical code or name depending on ``return_code``. ``None`` passes through unchanged (auto-detect).
+
+ Args:
+ language (`str` or `None`):
+ The language code or full name to resolve. ``None`` is returned unchanged.
+ code_to_name (`dict[str, str]`):
+ Mapping from language code to full language name for the model's supported languages.
+ return_code (`bool`, *optional*, defaults to `True`):
+ Whether to return the canonical language ``code``. If ``False``, returns the full language ``name``.
+
+ Returns:
+ `str` or `None`: The canonical language code or name, or ``None`` if ``language`` is ``None``.
+
+ Raises:
+ `ValueError`: If the language is not recognized.
+ """
+ if language is None:
+ return None
+
+ language_lower = language.lower()
+ # Try code lookup first, then full-name lookup (both case-insensitive)
+ for code, name in code_to_name.items():
+ if language_lower == code.lower() or language_lower == name.lower():
+ return code if return_code else name
+
+ raise ValueError(
+ f"Unsupported language: {language!r}. Use a language code "
+ f"(e.g. 'en', 'zh') or full name (e.g. 'English', 'Chinese'). "
+ f"Supported codes: {sorted(code_to_name.keys())}. "
+ f"Supported names: {sorted(set(code_to_name.values()))}."
+ )
+
+
+def prepare_language_inputs(
+ language: str | list[str] | None,
+ batch_size: int,
+ code_to_name: dict[str, str],
+ allow_broadcast: bool = False,
+ return_code: bool = True,
+) -> list[str | None]:
+ """
+ Broadcast and validate a language argument to match ``batch_size``.
+
+ Accepts language codes (e.g. ``"zh"``, ``"en"``) or full names (e.g. ``"Chinese"``, ``"English"``). Each value is
+ resolved to its canonical form via [`resolve_language`].
+
+ Args:
+ language (`str`, `list[str]`, or `None`):
+ The language hint(s). A single value is broadcast to the whole batch; a list must match ``batch_size``
+ (unless ``allow_broadcast`` is set). ``None`` disables language hints for the whole batch.
+ batch_size (`int`):
+ The number of samples in the batch.
+ code_to_name (`dict[str, str]`):
+ Mapping from language code to full language name for the model's supported languages.
+ allow_broadcast (`bool`, *optional*, defaults to `False`):
+ Whether a single-element list may be broadcast to the whole batch.
+ return_code (`bool`, *optional*, defaults to `True`):
+ Whether to return canonical language ``code``s. If ``False``, returns full language ``name``s.
+
+ Returns:
+ `list[str | None]`: The resolved language for each sample.
+ """
+ if language is None:
+ return [None] * batch_size
+ if isinstance(language, str):
+ return [resolve_language(language, code_to_name, return_code)] * batch_size
+ if isinstance(language, (list, tuple)):
+ if allow_broadcast and len(language) == 1 and batch_size > 1:
+ return [resolve_language(language[0], code_to_name, return_code)] * batch_size
+ if len(language) != batch_size:
+ raise ValueError(f"Got {len(language)} language(s) for {batch_size} sample(s); counts must match.")
+ return [resolve_language(lang, code_to_name, return_code) for lang in language]
+ raise TypeError("`language` must be a string, a list of strings, or `None`.")
+
+
def hertz_to_mel(freq: float | np.ndarray, mel_scale: str = "htk") -> float | np.ndarray:
"""
Convert frequency from hertz to mels.
diff --git a/src/transformers/convert_slow_tokenizer.py b/src/transformers/convert_slow_tokenizer.py
index 84037a76e0f6..ac809fa7e5c1 100644
--- a/src/transformers/convert_slow_tokenizer.py
+++ b/src/transformers/convert_slow_tokenizer.py
@@ -1874,6 +1874,17 @@ def tokenizer(self, proto):
return tokenizer
+class CanaryConverter(ParakeetConverter):
+ def __init__(self, vocab_file=None, *args):
+ super().__init__(vocab_file, *args)
+ # Only the `<|...|>` (type 4) control tokens are special; other type-4 pieces (digits, ``) are plain text.
+ self.special_tokens = {
+ piece.piece
+ for piece in self.proto.pieces
+ if piece.type == 4 and piece.piece.startswith("<|") and piece.piece.endswith("|>")
+ }
+
+
def bytes_to_unicode():
"""
Returns list of utf-8 byte and a mapping to unicode strings. We specifically avoids mapping to whitespace/control
diff --git a/src/transformers/models/__init__.py b/src/transformers/models/__init__.py
index 073d59ef3471..86f76b15a2b2 100644
--- a/src/transformers/models/__init__.py
+++ b/src/transformers/models/__init__.py
@@ -58,6 +58,7 @@
from .bros import *
from .byt5 import *
from .camembert import *
+ from .canary import *
from .canine import *
from .chameleon import *
from .chinese_clip import *
diff --git a/src/transformers/models/audioflamingo3/processing_audioflamingo3.py b/src/transformers/models/audioflamingo3/processing_audioflamingo3.py
index 793b535b55a1..26d930d0ecfc 100644
--- a/src/transformers/models/audioflamingo3/processing_audioflamingo3.py
+++ b/src/transformers/models/audioflamingo3/processing_audioflamingo3.py
@@ -16,7 +16,7 @@
import numpy as np
-from ...audio_utils import AudioInput, make_list_of_audio_chat_template
+from ...audio_utils import AudioInput, make_audio_chat_template_content, make_list_of_audio_chat_template
from ...feature_extraction_utils import BatchFeature
from ...processing_utils import ProcessingKwargs, ProcessorMixin, Unpack
from ...tokenization_utils_base import TextInput
@@ -237,9 +237,7 @@ def apply_transcription_request(
"role": "user",
"content": [
{"type": "text", "text": prompt_text},
- {"type": "audio", "path": audio_item}
- if isinstance(audio_item, str)
- else {"type": "audio", "audio": audio_item},
+ make_audio_chat_template_content(audio_item),
],
}
]
diff --git a/src/transformers/models/auto/auto_mappings.py b/src/transformers/models/auto/auto_mappings.py
index b0403d8bef88..bf91da5d2c2f 100644
--- a/src/transformers/models/auto/auto_mappings.py
+++ b/src/transformers/models/auto/auto_mappings.py
@@ -75,6 +75,8 @@
("bridgetower_vision_model", "BridgeTowerVisionConfig"),
("bros", "BrosConfig"),
("camembert", "CamembertConfig"),
+ ("canary", "CanaryConfig"),
+ ("canary_decoder", "CanaryDecoderConfig"),
("canine", "CanineConfig"),
("chameleon", "ChameleonConfig"),
("chameleon_vqgan", "ChameleonVQVAEConfig"),
@@ -733,6 +735,7 @@
("blt_patcher", "blt"),
("bridgetower_text_model", "bridgetower"),
("bridgetower_vision_model", "bridgetower"),
+ ("canary_decoder", "canary"),
("chameleon_vqgan", "chameleon"),
("chinese_clip_text_model", "chinese_clip"),
("chinese_clip_vision_model", "chinese_clip"),
@@ -1026,6 +1029,7 @@
("blip-2", "Blip2Processor"),
("bridgetower", "BridgeTowerProcessor"),
("bros", "BrosProcessor"),
+ ("canary", "CanaryProcessor"),
("chameleon", "ChameleonProcessor"),
("chinese_clip", "ChineseCLIPProcessor"),
("clap", "ClapProcessor"),
diff --git a/src/transformers/models/auto/feature_extraction_auto.py b/src/transformers/models/auto/feature_extraction_auto.py
index ec93f81ce075..6ef7967f4cd3 100644
--- a/src/transformers/models/auto/feature_extraction_auto.py
+++ b/src/transformers/models/auto/feature_extraction_auto.py
@@ -37,6 +37,7 @@
MISSING_FEATURE_EXTRACTOR_MAPPING_NAMES = OrderedDict(
[
("audioflamingo3", "WhisperFeatureExtractor"),
+ ("canary", "ParakeetFeatureExtractor"),
("csm", "EncodecFeatureExtractor"),
("data2vec-audio", "Wav2Vec2FeatureExtractor"),
("glmasr", "WhisperFeatureExtractor"),
diff --git a/src/transformers/models/auto/modeling_auto.py b/src/transformers/models/auto/modeling_auto.py
index f1a09446020f..8697fdb128ba 100644
--- a/src/transformers/models/auto/modeling_auto.py
+++ b/src/transformers/models/auto/modeling_auto.py
@@ -79,6 +79,7 @@ class _BaseModelWithGenerate(PreTrainedModel, GenerationMixin):
("bridgetower", "BridgeTowerModel"),
("bros", "BrosModel"),
("camembert", "CamembertModel"),
+ ("canary", "CanaryModel"),
("canine", "CanineModel"),
("chameleon", "ChameleonModel"),
("chinese_clip", "ChineseCLIPModel"),
@@ -1326,6 +1327,7 @@ class _BaseModelWithGenerate(PreTrainedModel, GenerationMixin):
MODEL_FOR_SPEECH_SEQ_2_SEQ_MAPPING_NAMES = OrderedDict(
[
+ ("canary", "CanaryForConditionalGeneration"),
("cohere_asr", "CohereAsrForConditionalGeneration"),
("dia", "DiaForConditionalGeneration"),
("granite_speech", "GraniteSpeechForConditionalGeneration"),
diff --git a/src/transformers/models/auto/tokenization_auto.py b/src/transformers/models/auto/tokenization_auto.py
index 272445c2128e..00416a944834 100644
--- a/src/transformers/models/auto/tokenization_auto.py
+++ b/src/transformers/models/auto/tokenization_auto.py
@@ -90,6 +90,7 @@
("bros", "BertTokenizer" if is_tokenizers_available() else None),
("byt5", "ByT5Tokenizer"),
("camembert", "CamembertTokenizer" if is_tokenizers_available() else None),
+ ("canary", "TokenizersBackend" if is_tokenizers_available() else None),
("canine", "CanineTokenizer"),
("chinese_clip", "BertTokenizer" if is_tokenizers_available() else None),
("clap", "RobertaTokenizer"),
diff --git a/src/transformers/models/canary/__init__.py b/src/transformers/models/canary/__init__.py
new file mode 100644
index 000000000000..40138c0d4905
--- /dev/null
+++ b/src/transformers/models/canary/__init__.py
@@ -0,0 +1,28 @@
+# Copyright 2026 The HuggingFace Team. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+from typing import TYPE_CHECKING
+
+from ...utils import _LazyModule
+from ...utils.import_utils import define_import_structure
+
+
+if TYPE_CHECKING:
+ from .configuration_canary import *
+ from .modeling_canary import *
+ from .processing_canary import *
+else:
+ import sys
+
+ _file = globals()["__file__"]
+ sys.modules[__name__] = _LazyModule(__name__, _file, define_import_structure(_file), module_spec=__spec__)
diff --git a/src/transformers/models/canary/configuration_canary.py b/src/transformers/models/canary/configuration_canary.py
new file mode 100644
index 000000000000..fd9b07308ad0
--- /dev/null
+++ b/src/transformers/models/canary/configuration_canary.py
@@ -0,0 +1,119 @@
+# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+from huggingface_hub.dataclasses import strict
+
+from ...configuration_utils import PreTrainedConfig
+from ...utils import auto_docstring
+from ..auto import CONFIG_MAPPING, AutoConfig
+
+
+@auto_docstring(checkpoint="harshaljanjani/canary-1b-v2-hf")
+@strict
+class CanaryDecoderConfig(PreTrainedConfig):
+ model_type = "canary_decoder"
+
+ vocab_size: int = 16384
+ hidden_size: int = 1024
+ num_hidden_layers: int = 8
+ num_attention_heads: int = 8
+ num_key_value_heads: int | None = None
+ intermediate_size: int = 4096
+ hidden_act: str = "relu"
+ max_position_embeddings: int = 1024
+ pad_token_id: int | None = 2
+ eos_token_id: int | None = 3
+ bos_token_id: int | None = 4
+ is_encoder_decoder: bool = True
+ use_cache: bool = True
+ initializer_range: float = 0.02
+ attention_dropout: float | int = 0.0
+ attention_bias: bool = True
+ head_dim: int | None = None
+
+ def __post_init__(self, **kwargs):
+ if self.head_dim is None:
+ self.head_dim = self.hidden_size // self.num_attention_heads
+ if self.num_key_value_heads is None:
+ self.num_key_value_heads = self.num_attention_heads
+ super().__post_init__(**kwargs)
+
+
+@auto_docstring(checkpoint="harshaljanjani/canary-1b-v2-hf")
+@strict
+class CanaryConfig(PreTrainedConfig):
+ r"""
+ encoder_config (`Union[dict, ParakeetEncoderConfig]`, *optional*):
+ The config object or dictionary of the FastConformer encoder ([`ParakeetEncoderConfig`]).
+ decoder_config (`Union[dict, CanaryDecoderConfig]`, *optional*):
+ The config object or dictionary of the Transformer decoder ([`CanaryDecoderConfig`]).
+ decoder_start_token_id (`int`, *optional*, defaults to 7):
+ The token id that starts decoding (`<|startofcontext|>`, the first token of the multitask prompt).
+
+ Example:
+
+ ```python
+ >>> from transformers import CanaryForConditionalGeneration, CanaryConfig
+
+ >>> # Initializing a Canary configuration
+ >>> configuration = CanaryConfig()
+
+ >>> # Initializing a model from the configuration
+ >>> model = CanaryForConditionalGeneration(configuration)
+
+ >>> # Accessing the model configuration
+ >>> configuration = model.config
+ ```
+ """
+
+ model_type = "canary"
+ keys_to_ignore_at_inference = ["past_key_values"]
+ sub_configs = {"encoder_config": AutoConfig, "decoder_config": CanaryDecoderConfig}
+
+ encoder_config: dict | PreTrainedConfig | None = None
+ decoder_config: CanaryDecoderConfig | dict | None = None
+ use_cache: bool = True
+ is_encoder_decoder: bool = True
+ tie_word_embeddings: bool = True
+ pad_token_id: int | None = 2
+ bos_token_id: int | None = 4
+ eos_token_id: int | None = 3
+ decoder_start_token_id: int | None = 7
+ initializer_range: float = 0.02
+
+ def __post_init__(self, **kwargs):
+ if isinstance(self.encoder_config, dict):
+ self.encoder_config["model_type"] = self.encoder_config.get("model_type", "parakeet_encoder")
+ self.encoder_config = CONFIG_MAPPING[self.encoder_config["model_type"]](**self.encoder_config)
+ elif self.encoder_config is None:
+ self.encoder_config = CONFIG_MAPPING["parakeet_encoder"](
+ num_hidden_layers=32,
+ num_mel_bins=128,
+ scale_input=False,
+ layerdrop=0.0,
+ dropout_positions=0.0,
+ )
+
+ if isinstance(self.decoder_config, dict):
+ self.decoder_config = CanaryDecoderConfig(**self.decoder_config)
+ elif self.decoder_config is None:
+ self.decoder_config = CanaryDecoderConfig()
+
+ self.vocab_size = self.decoder_config.vocab_size
+ super().__post_init__(**kwargs)
+
+ def get_text_config(self, *args, **kwargs):
+ return self.decoder_config
+
+
+__all__ = ["CanaryConfig", "CanaryDecoderConfig"]
diff --git a/src/transformers/models/canary/convert_canary_nemo_to_hf.py b/src/transformers/models/canary/convert_canary_nemo_to_hf.py
new file mode 100644
index 000000000000..60fdddf2671e
--- /dev/null
+++ b/src/transformers/models/canary/convert_canary_nemo_to_hf.py
@@ -0,0 +1,249 @@
+# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import argparse
+import gc
+import os
+import tarfile
+
+import torch
+import yaml
+
+from transformers import (
+ CanaryConfig,
+ CanaryDecoderConfig,
+ CanaryForConditionalGeneration,
+ CanaryProcessor,
+ ParakeetFeatureExtractor,
+ TokenizersBackend,
+)
+from transformers.convert_slow_tokenizer import CanaryConverter
+from transformers.models.parakeet.convert_nemo_to_hf import convert_encoder_config, convert_key
+from transformers.utils.hub import cached_file
+
+
+CANARY_CHAT_TEMPLATE = (
+ "{{- '<|startofcontext|><|startoftranscript|><|emo:undefined|>' -}}"
+ "{%- for message in messages -%}"
+ "{%- if message['role'] == 'user' -%}"
+ "{%- for content in message['content'] if content['type'] == 'text' -%}"
+ "{{- '<|' ~ content['source_language'] ~ '|>' -}}"
+ "{{- '<|' ~ content['target_language'] ~ '|>' -}}"
+ "{{- '<|pnc|>' if content['punctuation'] else '<|nopnc|>' -}}"
+ "{{- '<|noitn|><|notimestamp|><|nodiarize|>' -}}"
+ "{%- endfor -%}"
+ "{%- elif message['role'] == 'assistant' -%}"
+ "{{- message['content'] ~ '<|endoftext|>' -}}"
+ "{%- endif -%}"
+ "{%- endfor -%}"
+)
+
+
+NEMO_TO_HF_ENCODER_MAPPING = {
+ r"^encoder\.pre_encode\.conv\.": r"encoder.subsampling.layers.",
+ r"^encoder\.pre_encode\.out\.": r"encoder.subsampling.linear.",
+ r"^encoder\.pos_enc\.": r"encoder.encode_positions.",
+ r"^encoder\.layers\.(\d+)\.conv\.batch_norm\.": r"encoder.layers.\1.conv.norm.",
+ r"linear_([kv])": r"\1_proj",
+ r"linear_out": r"o_proj",
+ r"linear_q": r"q_proj",
+ r"pos_bias_([uv])": r"bias_\1",
+ r"linear_pos": r"relative_k_proj",
+}
+
+DECODER_HIDDEN_DIM_INPUT_KEYS = (
+ "embed_tokens.weight",
+ "proj_out.weight",
+ "q_proj.weight",
+ "self_attn.k_proj.weight",
+ "self_attn.v_proj.weight",
+ "mlp.fc1.weight",
+)
+
+DECODER_HIDDEN_DIM_OUTPUT_KEYS = (
+ "o_proj.weight",
+ "o_proj.bias",
+ "mlp.fc2.weight",
+ "mlp.fc2.bias",
+ "norm.weight",
+ "norm.bias",
+)
+
+NEMO_TO_HF_DECODER_MAPPING = {
+ r"^transf_decoder\._embedding\.token_embedding\.": r"decoder.embed_tokens.",
+ r"^transf_decoder\._embedding\.layer_norm\.": r"decoder.embedding_layernorm.",
+ r"^transf_decoder\._decoder\.final_layer_norm\.": r"decoder.norm.",
+ r"^transf_decoder\._decoder\.layers\.(\d+)\.layer_norm_1\.": r"decoder.layers.\1.input_layernorm.",
+ r"^transf_decoder\._decoder\.layers\.(\d+)\.layer_norm_2\.": r"decoder.layers.\1.post_attention_layernorm.",
+ r"^transf_decoder\._decoder\.layers\.(\d+)\.layer_norm_3\.": r"decoder.layers.\1.final_layernorm.",
+ r"^transf_decoder\._decoder\.layers\.(\d+)\.first_sub_layer\.": r"decoder.layers.\1.self_attn.",
+ r"^transf_decoder\._decoder\.layers\.(\d+)\.second_sub_layer\.": r"decoder.layers.\1.encoder_attn.",
+ r"^transf_decoder\._decoder\.layers\.(\d+)\.third_sub_layer\.dense_in\.": r"decoder.layers.\1.mlp.fc1.",
+ r"^transf_decoder\._decoder\.layers\.(\d+)\.third_sub_layer\.dense_out\.": r"decoder.layers.\1.mlp.fc2.",
+ r"query_net": r"q_proj",
+ r"key_net": r"k_proj",
+ r"value_net": r"v_proj",
+ r"out_projection": r"o_proj",
+}
+
+
+def extract_nemo_archive(nemo_file_path: str, extract_dir: str) -> None:
+ """Extract a Canary `.nemo` (tar) archive into `extract_dir`."""
+ print(f"Extracting NeMo archive: {nemo_file_path}")
+ with tarfile.open(nemo_file_path, "r", encoding="utf-8") as tar:
+ # filter="data" sanitizes members (PEP 706) so a malicious archive cannot write outside extract_dir
+ tar.extractall(extract_dir, filter="data")
+
+
+def convert_decoder_config(nemo_config) -> CanaryConfig:
+ """Build a [`CanaryConfig`] from the NeMo `transf_decoder` and `head` config blocks."""
+ decoder_config = nemo_config["transf_decoder"]["config_dict"]
+ head_config = nemo_config["head"]
+ encoder_config = convert_encoder_config(nemo_config)
+
+ return CanaryConfig(
+ encoder_config=encoder_config.to_dict(),
+ decoder_config=CanaryDecoderConfig(
+ vocab_size=head_config["num_classes"],
+ hidden_size=decoder_config["hidden_size"],
+ num_hidden_layers=decoder_config["num_layers"],
+ num_attention_heads=decoder_config["num_attention_heads"],
+ intermediate_size=decoder_config["inner_size"],
+ hidden_act=decoder_config["hidden_act"],
+ max_position_embeddings=decoder_config["max_sequence_length"],
+ attention_dropout=decoder_config["attn_score_dropout"],
+ ),
+ )
+
+
+def permute_for_sinusoids(tensor, key, d_model):
+ """Permute the weights for the concatenated sin/cos formulation."""
+ permutation = torch.empty(d_model, dtype=torch.long)
+ permutation[: d_model // 2] = torch.arange(0, d_model, 2)
+ permutation[d_model // 2 :] = torch.arange(1, d_model, 2)
+ if key.endswith(DECODER_HIDDEN_DIM_INPUT_KEYS):
+ return tensor[:, permutation]
+ if key.endswith(DECODER_HIDDEN_DIM_OUTPUT_KEYS):
+ return tensor[permutation]
+ return tensor
+
+
+def load_and_convert_state_dict(model_files, d_model):
+ """Load the NeMo state dict and convert keys to the HF (`model.encoder.*` / `model.decoder.*`) layout."""
+ state_dict = torch.load(model_files["model_weights"], map_location="cpu", weights_only=True)
+ converted_state_dict = {}
+ for key, value in state_dict.items():
+ if key.startswith("preprocessor.") or key.endswith(".position_embedding.pos_enc"):
+ # featurizer buffers and the fixed sinusoidal table are recomputed, not loaded
+ continue
+ if key.startswith("encoder."):
+ converted_state_dict["model." + convert_key(key, NEMO_TO_HF_ENCODER_MAPPING)] = value
+ elif key.startswith("transf_decoder."):
+ converted_key = "model." + convert_key(key, NEMO_TO_HF_DECODER_MAPPING)
+ converted_state_dict[converted_key] = permute_for_sinusoids(value, converted_key, d_model)
+ elif key == "log_softmax.mlp.layer0.weight":
+ converted_state_dict["proj_out.weight"] = permute_for_sinusoids(value, "proj_out.weight", d_model)
+ elif key == "log_softmax.mlp.layer0.bias":
+ converted_state_dict["proj_out.bias"] = value
+ else:
+ raise ValueError(f"Unhandled NeMo weight key: {key}")
+ return converted_state_dict
+
+
+def write_processor(nemo_config, model_files, output_dir, push_to_repo_id=None):
+ tokenizer_object = CanaryConverter(model_files["tokenizer_model_file"]).converted()
+ tokenizer = TokenizersBackend(
+ tokenizer_object=tokenizer_object,
+ clean_up_tokenization_spaces=False,
+ bos_token="<|startoftranscript|>",
+ eos_token="<|endoftext|>",
+ pad_token="",
+ unk_token="",
+ )
+
+ preprocessor = nemo_config["preprocessor"]
+ feature_extractor = ParakeetFeatureExtractor(
+ feature_size=preprocessor["features"],
+ sampling_rate=preprocessor["sample_rate"],
+ win_length=int(preprocessor["window_size"] * preprocessor["sample_rate"]),
+ hop_length=int(preprocessor["window_stride"] * preprocessor["sample_rate"]),
+ n_fft=preprocessor["n_fft"],
+ )
+
+ processor = CanaryProcessor(
+ feature_extractor=feature_extractor, tokenizer=tokenizer, chat_template=CANARY_CHAT_TEMPLATE
+ )
+ processor.save_pretrained(output_dir)
+ if push_to_repo_id:
+ processor.push_to_hub(push_to_repo_id)
+
+
+def main(hf_repo_id, output_dir, push_to_repo_id=None):
+ nemo_filename = f"{hf_repo_id.split('/')[-1]}.nemo"
+ filepath = cached_file(hf_repo_id, nemo_filename)
+ extract_dir = os.path.dirname(filepath)
+ extract_nemo_archive(filepath, extract_dir)
+
+ nemo_config = yaml.load(open(os.path.join(extract_dir, "model_config.yaml"), "r"), Loader=yaml.FullLoader)
+ tokenizer_model_name = nemo_config["tokenizer"]["model_path"].split("nemo:")[-1]
+ model_files = {
+ "model_weights": os.path.join(extract_dir, "model_weights.ckpt"),
+ "tokenizer_model_file": os.path.join(extract_dir, tokenizer_model_name),
+ }
+
+ write_processor(nemo_config, model_files, output_dir, push_to_repo_id)
+
+ config = convert_decoder_config(nemo_config)
+ print(f"Converted config:\n{config}")
+ converted_state_dict = load_and_convert_state_dict(model_files, config.decoder_config.hidden_size)
+
+ print("Loading the checkpoint in a Canary model.")
+ with torch.device("meta"):
+ model = CanaryForConditionalGeneration(config)
+ model.load_state_dict(converted_state_dict, strict=True, assign=True)
+ print("Checkpoint loaded successfully.")
+
+ model.generation_config.decoder_start_token_id = config.decoder_start_token_id
+ model.generation_config.bos_token_id = config.bos_token_id
+ model.generation_config.eos_token_id = config.eos_token_id
+ model.generation_config.pad_token_id = config.pad_token_id
+
+ print("Saving the model.")
+ model.save_pretrained(output_dir)
+ if push_to_repo_id:
+ model.push_to_hub(push_to_repo_id)
+
+ del model
+ gc.collect()
+ print("Reloading the model to check it was saved correctly.")
+ CanaryForConditionalGeneration.from_pretrained(output_dir, dtype=torch.bfloat16)
+ print("Model reloaded successfully.")
+
+
+"""
+Conversion example:
+```bash
+python src/transformers/models/canary/convert_canary_nemo_to_hf.py \
+ --hf_repo_id nvidia/canary-1b-v2 \
+ --output_dir OUTPUT_DIR \
+ --push_to_repo_id USERNAME/canary-1b-v2-hf
+```
+"""
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--hf_repo_id", required=True, help="Model repo on huggingface.co")
+ parser.add_argument("--output_dir", required=True, help="Output directory for the HuggingFace model")
+ parser.add_argument("--push_to_repo_id", help="Repository ID to push the converted model to on the Hub")
+ args = parser.parse_args()
+ main(args.hf_repo_id, args.output_dir, args.push_to_repo_id)
diff --git a/src/transformers/models/canary/modeling_canary.py b/src/transformers/models/canary/modeling_canary.py
new file mode 100644
index 000000000000..a9cead5439c9
--- /dev/null
+++ b/src/transformers/models/canary/modeling_canary.py
@@ -0,0 +1,699 @@
+# 🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨
+# This file was automatically generated from src/transformers/models/canary/modular_canary.py.
+# Do NOT edit this file manually as any edits will be overwritten by the generation of
+# the file from the modular. If any change should be done, please apply the change to the
+# modular_canary.py file directly. One of our CI enforces this.
+# 🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨
+# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import math
+from collections.abc import Callable
+
+import numpy as np
+import torch
+from torch import nn
+
+from ... import initialization as init
+from ...activations import ACT2FN
+from ...cache_utils import Cache, DynamicCache, EncoderDecoderCache
+from ...generation import GenerationMixin
+from ...masking_utils import create_bidirectional_mask, create_causal_mask
+from ...modeling_layers import GradientCheckpointingLayer
+from ...modeling_outputs import (
+ BaseModelOutput,
+ BaseModelOutputWithPastAndCrossAttentions,
+ Seq2SeqLMOutput,
+ Seq2SeqModelOutput,
+)
+from ...modeling_utils import ALL_ATTENTION_FUNCTIONS, PreTrainedModel
+from ...processing_utils import Unpack
+from ...utils import TransformersKwargs, auto_docstring
+from ...utils.generic import can_return_tuple, merge_with_config_defaults
+from ...utils.output_capturing import OutputRecorder, capture_outputs
+from ..auto.modeling_auto import AutoModel
+from .configuration_canary import CanaryConfig, CanaryDecoderConfig
+
+
+class CanaryPositionalEmbedding(nn.Module):
+ """
+ Identical to [`SinusoidsPositionEmbedding`] except that the timescales and the `1 / sqrt(channels)` scaling match
+ NeMo's `FixedPositionalEncoding`, and it is indexed by `position_ids`.
+ """
+
+ def __init__(self, length: int, channels: int):
+ super().__init__()
+ max_timescale = 10000 ** ((channels - 2) / channels)
+ self.length = length
+ self.channels = channels
+ self.max_timescale = max_timescale
+ if channels % 2 != 0:
+ raise ValueError("CanaryPositionalEmbedding needs even channels input")
+ position_embedding = self.compute_default_singular_positional_embedding()
+ self.positional_embedding = nn.Buffer(position_embedding, persistent=False)
+
+ def compute_default_singular_positional_embedding(self) -> torch.Tensor:
+ log_timescale_increment = np.log(self.max_timescale) / (self.channels // 2 - 1)
+ inv_timescales = torch.exp(-log_timescale_increment * torch.arange(self.channels // 2).float())
+ scaled_time = torch.arange(self.length)[:, np.newaxis] * inv_timescales[np.newaxis, :]
+ emb = torch.cat([torch.sin(scaled_time), torch.cos(scaled_time)], dim=1) / math.sqrt(self.channels)
+ return emb.to(torch.get_default_dtype())
+
+ def forward(self, position_ids: torch.Tensor) -> torch.Tensor:
+ return self.positional_embedding[position_ids]
+
+
+@auto_docstring
+class CanaryPreTrainedModel(PreTrainedModel):
+ config: CanaryConfig
+ base_model_prefix = "model"
+ main_input_name = "input_features"
+ input_modalities = "audio"
+ supports_gradient_checkpointing = True
+ _no_split_modules = ["ParakeetEncoderBlock", "CanaryDecoderLayer"]
+ _supports_flash_attn = True
+ _supports_sdpa = True
+
+ _can_compile_fullgraph = True
+ _keys_to_ignore_on_load_unexpected = [r"preprocessor\.featurizer\..*"]
+
+ @torch.no_grad()
+ def _init_weights(self, module):
+ super()._init_weights(module)
+ if isinstance(module, CanaryPositionalEmbedding):
+ init.copy_(module.positional_embedding, module.compute_default_singular_positional_embedding())
+
+
+class CanaryDecoderMLP(nn.Module):
+ def __init__(self, config):
+ super().__init__()
+ self.config = config
+ self.activation_fn = ACT2FN[config.hidden_act]
+ self.fc1 = nn.Linear(config.hidden_size, config.intermediate_size)
+ self.fc2 = nn.Linear(config.intermediate_size, config.hidden_size)
+
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
+ hidden_states = self.fc1(hidden_states)
+ hidden_states = self.activation_fn(hidden_states)
+ hidden_states = self.fc2(hidden_states)
+ return hidden_states
+
+
+def repeat_kv(hidden_states: torch.Tensor, n_rep: int) -> torch.Tensor:
+ """
+ This is the equivalent of torch.repeat_interleave(x, dim=1, repeats=n_rep). The hidden states go from (batch,
+ num_key_value_heads, seqlen, head_dim) to (batch, num_attention_heads, seqlen, head_dim)
+ """
+ batch, num_key_value_heads, slen, head_dim = hidden_states.shape
+ if n_rep == 1:
+ return hidden_states
+ hidden_states = hidden_states[:, :, None, :, :].expand(batch, num_key_value_heads, n_rep, slen, head_dim)
+ return hidden_states.reshape(batch, num_key_value_heads * n_rep, slen, head_dim)
+
+
+def eager_attention_forward(
+ module: nn.Module,
+ query: torch.Tensor,
+ key: torch.Tensor,
+ value: torch.Tensor,
+ attention_mask: torch.Tensor | None,
+ scaling: float,
+ dropout: float = 0.0,
+ **kwargs: Unpack[TransformersKwargs],
+):
+ key_states = repeat_kv(key, module.num_key_value_groups)
+ value_states = repeat_kv(value, module.num_key_value_groups)
+
+ attn_weights = torch.matmul(query, key_states.transpose(2, 3)) * scaling
+ if attention_mask is not None:
+ attn_weights = attn_weights + attention_mask
+
+ attn_weights = nn.functional.softmax(attn_weights, dim=-1, dtype=torch.float32).to(query.dtype)
+ attn_weights = nn.functional.dropout(attn_weights, p=dropout, training=module.training)
+ attn_output = torch.matmul(attn_weights, value_states)
+ attn_output = attn_output.transpose(1, 2).contiguous()
+
+ return attn_output, attn_weights
+
+
+# Modular automatically inherits RoPE, hence no inheritance for now
+class CanarySelfAttention(nn.Module):
+ def __init__(self, config: CanaryConfig, layer_idx: int):
+ super().__init__()
+ self.config = config
+ self.layer_idx = layer_idx
+ self.head_dim = getattr(config, "head_dim", config.hidden_size // config.num_attention_heads)
+ self.num_key_value_groups = config.num_attention_heads // config.num_key_value_heads
+ self.scaling = self.head_dim**-0.5
+ self.attention_dropout = config.attention_dropout
+ self.is_causal = True
+
+ self.q_proj = nn.Linear(
+ config.hidden_size, config.num_attention_heads * self.head_dim, bias=config.attention_bias
+ )
+ self.k_proj = nn.Linear(
+ config.hidden_size, config.num_key_value_heads * self.head_dim, bias=config.attention_bias
+ )
+ self.v_proj = nn.Linear(
+ config.hidden_size, config.num_key_value_heads * self.head_dim, bias=config.attention_bias
+ )
+ self.o_proj = nn.Linear(
+ config.num_attention_heads * self.head_dim, config.hidden_size, bias=config.attention_bias
+ )
+
+ def forward(
+ self,
+ hidden_states: torch.Tensor,
+ attention_mask: torch.Tensor,
+ past_key_values: Cache | None = None,
+ **kwargs,
+ ):
+ input_shape = hidden_states.shape[:-1]
+ hidden_shape = (*input_shape, -1, self.head_dim)
+
+ query_states = self.q_proj(hidden_states)
+ key_states = self.k_proj(hidden_states)
+ value_states = self.v_proj(hidden_states)
+
+ query_states = query_states.view(hidden_shape).transpose(1, 2)
+ key_states = key_states.view(hidden_shape).transpose(1, 2)
+ value_states = value_states.view(hidden_shape).transpose(1, 2)
+
+ if past_key_values is not None:
+ past_key_values = past_key_values.self_attention_cache
+ key_states, value_states = past_key_values.update(key_states, value_states, self.layer_idx)
+
+ attention_interface: Callable = ALL_ATTENTION_FUNCTIONS.get_interface(
+ self.config._attn_implementation, eager_attention_forward
+ )
+
+ attn_output, attn_weights = attention_interface(
+ self,
+ query_states,
+ key_states,
+ value_states,
+ attention_mask,
+ dropout=0.0 if not self.training else self.attention_dropout,
+ scaling=self.scaling,
+ **kwargs,
+ )
+
+ attn_output = attn_output.reshape(*input_shape, -1).contiguous()
+ attn_output = self.o_proj(attn_output)
+ return attn_output, attn_weights
+
+
+# Modular automatically inherits RoPE, hence no inheritance for now
+class CanaryCrossAttention(nn.Module):
+ def __init__(self, config: CanaryConfig, layer_idx: int):
+ super().__init__()
+ self.config = config
+ self.layer_idx = layer_idx
+ self.head_dim = getattr(config, "head_dim", config.hidden_size // config.num_attention_heads)
+ self.num_key_value_groups = config.num_attention_heads // config.num_key_value_heads
+ self.scaling = self.head_dim**-0.5
+ self.attention_dropout = config.attention_dropout
+ self.is_causal = False
+
+ self.q_proj = nn.Linear(
+ config.hidden_size, config.num_attention_heads * self.head_dim, bias=config.attention_bias
+ )
+ self.k_proj = nn.Linear(
+ config.hidden_size, config.num_key_value_heads * self.head_dim, bias=config.attention_bias
+ )
+ self.v_proj = nn.Linear(
+ config.hidden_size, config.num_key_value_heads * self.head_dim, bias=config.attention_bias
+ )
+ self.o_proj = nn.Linear(
+ config.num_attention_heads * self.head_dim, config.hidden_size, bias=config.attention_bias
+ )
+
+ def forward(
+ self,
+ hidden_states: torch.Tensor,
+ encoder_hidden_states: torch.Tensor | None = None,
+ attention_mask: torch.Tensor | None = None,
+ past_key_values: Cache | None = None,
+ **kwargs: Unpack[TransformersKwargs],
+ ):
+ # determine input shapes
+ bsz, tgt_len = hidden_states.shape[:-1]
+ src_len = encoder_hidden_states.shape[1]
+
+ q_input_shape = (bsz, tgt_len, -1, self.head_dim)
+ kv_input_shape = (bsz, src_len, -1, self.head_dim)
+
+ # get query proj
+ query_states = self.q_proj(hidden_states).view(*q_input_shape).transpose(1, 2)
+
+ is_updated = past_key_values.is_updated.get(self.layer_idx) if past_key_values is not None else False
+ if past_key_values is not None and is_updated:
+ # reuse k,v, cross_attentions
+ key_states = past_key_values.cross_attention_cache.layers[self.layer_idx].keys
+ value_states = past_key_values.cross_attention_cache.layers[self.layer_idx].values
+ else:
+ key_states = self.k_proj(encoder_hidden_states).view(*kv_input_shape).transpose(1, 2)
+ value_states = self.v_proj(encoder_hidden_states).view(*kv_input_shape).transpose(1, 2)
+
+ if past_key_values is not None:
+ # save all states to the cache
+ key_states, value_states = past_key_values.cross_attention_cache.update(
+ key_states, value_states, self.layer_idx
+ )
+ # set flag that curr layer for cross-attn is already updated so we can re-use in subsequent calls
+ past_key_values.is_updated[self.layer_idx] = True
+
+ attention_interface: Callable = ALL_ATTENTION_FUNCTIONS.get_interface(
+ self.config._attn_implementation, eager_attention_forward
+ )
+
+ attn_output, attn_weights = attention_interface(
+ self,
+ query_states,
+ key_states,
+ value_states,
+ attention_mask,
+ dropout=0.0 if not self.training else self.attention_dropout,
+ scaling=self.scaling,
+ **kwargs,
+ )
+ attn_output = attn_output.reshape(bsz, tgt_len, -1).contiguous()
+ attn_output = self.o_proj(attn_output)
+ return attn_output, attn_weights
+
+
+class CanaryDecoderLayer(GradientCheckpointingLayer):
+ def __init__(self, config, layer_idx=None):
+ super().__init__()
+ self.self_attn = CanarySelfAttention(config=config, layer_idx=layer_idx)
+ self.encoder_attn = CanaryCrossAttention(config=config, layer_idx=layer_idx)
+
+ self.mlp = CanaryDecoderMLP(config)
+ self.input_layernorm = nn.LayerNorm(config.hidden_size)
+ self.post_attention_layernorm = nn.LayerNorm(config.hidden_size)
+ self.final_layernorm = nn.LayerNorm(config.hidden_size)
+
+ def forward(
+ self,
+ hidden_states: torch.Tensor,
+ attention_mask: torch.Tensor | None = None,
+ encoder_hidden_states: torch.Tensor | None = None,
+ encoder_attention_mask: torch.Tensor | None = None,
+ position_ids: torch.LongTensor | None = None,
+ encoder_position_ids: torch.LongTensor | None = None,
+ past_key_values: Cache | None = None,
+ **kwargs: Unpack[TransformersKwargs],
+ ) -> tuple[torch.FloatTensor, tuple[torch.FloatTensor, torch.FloatTensor] | None]:
+ residual = hidden_states
+ hidden_states = self.input_layernorm(hidden_states)
+
+ hidden_states, _ = self.self_attn(
+ hidden_states=hidden_states,
+ attention_mask=attention_mask,
+ position_ids=position_ids,
+ past_key_values=past_key_values,
+ **kwargs,
+ )
+ hidden_states = residual + hidden_states
+
+ if encoder_hidden_states is not None:
+ residual = hidden_states
+ hidden_states = self.post_attention_layernorm(hidden_states)
+ hidden_states, _ = self.encoder_attn(
+ hidden_states=hidden_states,
+ encoder_hidden_states=encoder_hidden_states,
+ attention_mask=encoder_attention_mask,
+ past_key_values=past_key_values,
+ )
+ hidden_states = residual + hidden_states
+
+ residual = hidden_states
+ hidden_states = self.final_layernorm(hidden_states)
+ hidden_states = self.mlp(hidden_states)
+ hidden_states = residual + hidden_states
+ return hidden_states
+
+
+@auto_docstring
+class CanaryDecoder(CanaryPreTrainedModel):
+ main_input_name = "input_ids"
+ _can_record_outputs = {
+ "attentions": OutputRecorder(CanarySelfAttention, index=1, layer_name="self_attn"),
+ "hidden_states": CanaryDecoderLayer,
+ "cross_attentions": OutputRecorder(CanaryCrossAttention, index=1, layer_name="encoder_attn"),
+ }
+ config: CanaryDecoderConfig
+
+ def __init__(self, config: CanaryDecoderConfig):
+ super().__init__(config)
+ self.padding_idx = config.pad_token_id
+ self.vocab_size = config.vocab_size
+
+ self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size, self.padding_idx)
+ self.layers = nn.ModuleList([CanaryDecoderLayer(config, idx) for idx in range(config.num_hidden_layers)])
+ self.norm = nn.LayerNorm(config.hidden_size)
+ self.gradient_checkpointing = False
+ self.pos_emb = CanaryPositionalEmbedding(config.max_position_embeddings, config.hidden_size)
+ self.embedding_layernorm = nn.LayerNorm(config.hidden_size)
+ self.proj = nn.Identity()
+
+ # Initialize weights and apply final processing
+ self.post_init()
+
+ @merge_with_config_defaults
+ @capture_outputs
+ def forward(
+ self,
+ input_ids: torch.LongTensor | None = None,
+ attention_mask: torch.Tensor | None = None,
+ position_ids: torch.LongTensor | None = None,
+ past_key_values: Cache | None = None,
+ inputs_embeds: torch.FloatTensor | None = None,
+ use_cache: bool | None = None,
+ encoder_hidden_states: torch.FloatTensor | None = None,
+ encoder_attention_mask: torch.Tensor | None = None,
+ **kwargs: Unpack[TransformersKwargs],
+ ) -> tuple | BaseModelOutputWithPastAndCrossAttentions:
+ r"""
+ encoder_hidden_states (`torch.FloatTensor` of shape `(batch_size, encoder_sequence_length, hidden_size)`, *optional*):
+ Sequence of hidden-states at the output of the last layer of the encoder. Used in the cross-attention
+ of the decoder.
+ encoder_attention_mask (`torch.Tensor` of shape `(batch_size, sequence_length)`, *optional*):
+ Mask to avoid performing attention on padding indices in `encoder_hidden_states`. Mask values selected in `[0, 1]`:
+ - 1 for tokens that are **not masked**,
+ - 0 for tokens that are **masked**.
+ [What are attention masks?](../glossary#attention-mask)
+ """
+ encoder_hidden_states = self.proj(encoder_hidden_states)
+ if (input_ids is None) ^ (inputs_embeds is not None):
+ raise ValueError("You must specify exactly one of input_ids or inputs_embeds")
+
+ if inputs_embeds is None:
+ inputs_embeds = self.embed_tokens(input_ids)
+
+ if use_cache and past_key_values is None:
+ past_key_values = EncoderDecoderCache(DynamicCache(config=self.config), DynamicCache(config=self.config))
+
+ if position_ids is None:
+ past_seen_tokens = past_key_values.get_seq_length() if past_key_values is not None else 0
+ position_ids = torch.arange(inputs_embeds.shape[1], device=inputs_embeds.device) + past_seen_tokens
+ position_ids = position_ids.unsqueeze(0)
+
+ # Fixed sinusoidal position embedding added to token embeddings, then layernorm
+ pos_emb = self.pos_emb(position_ids.squeeze(0))
+ pos_emb = pos_emb.to(inputs_embeds.device)
+ inputs_embeds = self.embedding_layernorm(inputs_embeds + pos_emb)
+
+ causal_mask = create_causal_mask(
+ config=self.config,
+ inputs_embeds=inputs_embeds,
+ attention_mask=attention_mask,
+ past_key_values=past_key_values,
+ position_ids=position_ids,
+ )
+ encoder_attention_mask = create_bidirectional_mask(
+ config=self.config,
+ inputs_embeds=inputs_embeds,
+ attention_mask=encoder_attention_mask,
+ encoder_hidden_states=encoder_hidden_states,
+ )
+
+ hidden_states = inputs_embeds
+ for decoder_layer in self.layers:
+ hidden_states = decoder_layer(
+ hidden_states,
+ causal_mask,
+ encoder_hidden_states, # as a positional argument for gradient checkpointing
+ encoder_attention_mask=encoder_attention_mask,
+ position_ids=position_ids,
+ past_key_values=past_key_values,
+ **kwargs,
+ )
+
+ hidden_states = self.norm(hidden_states)
+
+ return BaseModelOutputWithPastAndCrossAttentions(
+ last_hidden_state=hidden_states,
+ past_key_values=past_key_values if use_cache else None,
+ )
+
+
+@auto_docstring(
+ custom_intro="""
+ The bare Canary model (FastConformer encoder + Transformer decoder) outputting raw hidden-states without any
+ specific head on top.
+ """
+)
+class CanaryModel(CanaryPreTrainedModel):
+ def __init__(self, config: CanaryConfig):
+ super().__init__(config)
+ self.encoder = AutoModel.from_config(config.encoder_config)
+ self.decoder = CanaryDecoder(config.decoder_config)
+ # Initialize weights and apply final processing
+ self.post_init()
+
+ def get_input_embeddings(self):
+ return self.decoder.embed_tokens
+
+ def set_input_embeddings(self, value):
+ self.decoder.embed_tokens = value
+
+ def freeze_encoder(self):
+ """
+ Calling this function will disable the gradient computation for the Canary encoder so that its parameters will
+ not be updated during training.
+ """
+ self.encoder._freeze_parameters()
+
+ def _mask_input_features(self):
+ """
+ Masks extracted features along time axis and/or along feature axis according to
+ [SpecAugment](https://huggingface.co/papers/1904.08779).
+ """
+ raise AttributeError("Not needed for Canary")
+
+ @can_return_tuple
+ @auto_docstring
+ def forward(
+ self,
+ input_features: torch.FloatTensor | None = None,
+ attention_mask: torch.LongTensor | None = None,
+ decoder_input_ids: torch.LongTensor | None = None,
+ decoder_attention_mask: torch.LongTensor | None = None,
+ encoder_outputs: tuple[tuple[torch.FloatTensor]] | None = None,
+ past_key_values: EncoderDecoderCache | None = None,
+ decoder_inputs_embeds: tuple[torch.FloatTensor] | None = None,
+ decoder_position_ids: tuple[torch.LongTensor] | None = None,
+ use_cache: bool | None = None,
+ **kwargs: Unpack[TransformersKwargs],
+ ) -> Seq2SeqModelOutput:
+ r"""
+ input_features (`torch.FloatTensor` of shape `(batch_size, audio_length)`):
+ Float values of the raw speech waveform. Raw speech waveform can be
+ obtained by loading a `.flac` or `.wav` audio file into an array of type `list[float]`, a
+ `numpy.ndarray` or a `torch.Tensor`, *e.g.* via the torchcodec library (`pip install torchcodec`) or
+ the soundfile library (`pip install soundfile`). To prepare the array into
+ `input_features`, the [`AutoFeatureExtractor`] should be used for padding
+ and conversion into a tensor of type `torch.FloatTensor`.
+ decoder_position_ids (`torch.LongTensor` of shape `(batch_size, target_sequence_length)`):
+ Indices of positions of each input sequence tokens in the position embeddings.
+ Used to calculate the position embeddings up to `config.decoder_config.max_position_embeddings`
+
+ Example:
+
+ ```python
+ >>> import torch
+ >>> from transformers import AutoFeatureExtractor, CanaryModel
+ >>> from datasets import load_dataset
+
+ >>> model = CanaryModel.from_pretrained("UsefulSensors/canary-tiny")
+ >>> feature_extractor = AutoFeatureExtractor.from_pretrained("UsefulSensors/canary-tiny")
+ >>> ds = load_dataset("hf-internal-testing/librispeech_asr_dummy", "clean", split="validation")
+ >>> inputs = feature_extractor(ds[0]["audio"]["array"], return_tensors="pt")
+ >>> input_features = inputs.input_features
+ >>> decoder_input_ids = torch.tensor([[1, 1]]) * model.config.decoder_start_token_id
+ >>> last_hidden_state = model(input_features, decoder_input_ids=decoder_input_ids).last_hidden_state
+ >>> list(last_hidden_state.shape)
+ [1, 2, 288]
+ ```
+ """
+ # Main difference: uses `input_features` instead of `input_values`
+ if encoder_outputs is None:
+ encoder_outputs: BaseModelOutput = self.encoder(input_features, attention_mask=attention_mask, **kwargs)
+
+ decoder_outputs: BaseModelOutputWithPastAndCrossAttentions = self.decoder(
+ input_ids=decoder_input_ids,
+ attention_mask=decoder_attention_mask,
+ encoder_hidden_states=encoder_outputs.last_hidden_state,
+ encoder_attention_mask=encoder_outputs.attention_mask,
+ past_key_values=past_key_values,
+ inputs_embeds=decoder_inputs_embeds,
+ position_ids=decoder_position_ids,
+ use_cache=use_cache,
+ **kwargs,
+ )
+
+ return Seq2SeqModelOutput(
+ last_hidden_state=decoder_outputs.last_hidden_state,
+ past_key_values=decoder_outputs.past_key_values,
+ decoder_hidden_states=decoder_outputs.hidden_states,
+ decoder_attentions=decoder_outputs.attentions,
+ cross_attentions=decoder_outputs.cross_attentions,
+ encoder_last_hidden_state=encoder_outputs.last_hidden_state,
+ encoder_hidden_states=encoder_outputs.hidden_states,
+ encoder_attentions=encoder_outputs.attentions,
+ )
+
+
+def shift_tokens_right(input_ids: torch.Tensor, pad_token_id: int, decoder_start_token_id: int):
+ """
+ Shift input ids one token to the right.
+ """
+ shifted_input_ids = input_ids.new_zeros(input_ids.shape)
+ shifted_input_ids[:, 1:] = input_ids[:, :-1].clone()
+ shifted_input_ids[:, 0] = decoder_start_token_id
+
+ if pad_token_id is None:
+ raise ValueError("self.model.config.pad_token_id has to be defined.")
+ # replace possible -100 values in labels by `pad_token_id`
+ shifted_input_ids.masked_fill_(shifted_input_ids == -100, pad_token_id)
+
+ return shifted_input_ids
+
+
+@auto_docstring(
+ custom_intro="""
+ The Canary model with a language modeling head. Can be used for multilingual automatic speech recognition and
+ speech-to-text translation.
+ """
+)
+class CanaryForConditionalGeneration(CanaryPreTrainedModel, GenerationMixin):
+ _tied_weights_keys = {"proj_out.weight": "model.decoder.embed_tokens.weight"}
+
+ def __init__(self, config: CanaryConfig):
+ super().__init__(config)
+ self.model = CanaryModel(config)
+ self.proj_out = nn.Linear(config.decoder_config.hidden_size, config.decoder_config.vocab_size, bias=True)
+
+ # Initialize weights and apply final processing
+ self.post_init()
+
+ def get_output_embeddings(self):
+ return self.proj_out
+
+ def set_output_embeddings(self, new_embeddings):
+ self.proj_out = new_embeddings
+
+ def get_input_embeddings(self) -> nn.Module:
+ return self.model.get_input_embeddings()
+
+ @can_return_tuple
+ @auto_docstring
+ def forward(
+ self,
+ input_features: torch.FloatTensor | None = None,
+ attention_mask: torch.LongTensor | None = None,
+ decoder_input_ids: torch.LongTensor | None = None,
+ decoder_attention_mask: torch.LongTensor | None = None,
+ encoder_outputs: tuple[tuple[torch.FloatTensor]] | None = None,
+ past_key_values: EncoderDecoderCache | None = None,
+ decoder_inputs_embeds: tuple[torch.FloatTensor] | None = None,
+ decoder_position_ids: tuple[torch.LongTensor] | None = None,
+ use_cache: bool | None = None,
+ labels: torch.LongTensor | None = None,
+ **kwargs: Unpack[TransformersKwargs],
+ ) -> Seq2SeqLMOutput:
+ r"""
+ input_features (`torch.FloatTensor` of shape `(batch_size, audio_length)`):
+ Float values of the raw speech waveform. Raw speech waveform can be
+ obtained by loading a `.flac` or `.wav` audio file into an array of type `list[float]`, a
+ `numpy.ndarray` or a `torch.Tensor`, *e.g.* via the torchcodec library (`pip install torchcodec`) or
+ the soundfile library (`pip install soundfile`). To prepare the array into
+ `input_features`, the [`AutoFeatureExtractor`] should be used for padding
+ and conversion into a tensor of type `torch.FloatTensor`.
+ decoder_position_ids (`torch.LongTensor` of shape `(batch_size, target_sequence_length)`):
+ Indices of positions of each input sequence tokens in the position embeddings.
+ Used to calculate the position embeddings up to `config.decoder_config.max_position_embeddings`
+
+ Example:
+
+ ```python
+ >>> import torch
+ >>> from transformers import AutoProcessor, CanaryForConditionalGeneration
+ >>> from datasets import load_dataset
+
+ >>> processor = AutoProcessor.from_pretrained("UsefulSensors/canary-tiny")
+ >>> model = CanaryForConditionalGeneration.from_pretrained("UsefulSensors/canary-tiny")
+
+ >>> ds = load_dataset("hf-internal-testing/librispeech_asr_dummy", "clean", split="validation")
+
+ >>> inputs = processor(ds[0]["audio"]["array"], return_tensors="pt")
+ >>> input_features = inputs.input_features
+
+ >>> generated_ids = model.generate(input_features, max_new_tokens=100)
+
+ >>> transcription = processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
+ >>> transcription
+ 'Mr. Quilter is the apostle of the middle classes, and we are glad to welcome his gospel.'
+ ```"""
+ # Main difference: uses `input_features` instead of `input_values`
+ if labels is not None:
+ if decoder_input_ids is None and decoder_inputs_embeds is None:
+ decoder_input_ids = shift_tokens_right(
+ labels, self.config.pad_token_id, self.config.decoder_start_token_id
+ )
+
+ outputs: Seq2SeqModelOutput = self.model(
+ input_features,
+ attention_mask=attention_mask,
+ decoder_input_ids=decoder_input_ids,
+ encoder_outputs=encoder_outputs,
+ decoder_attention_mask=decoder_attention_mask,
+ past_key_values=past_key_values,
+ decoder_inputs_embeds=decoder_inputs_embeds,
+ decoder_position_ids=decoder_position_ids,
+ use_cache=use_cache,
+ **kwargs,
+ )
+ logits = self.proj_out(outputs.last_hidden_state)
+
+ loss = None
+ if labels is not None:
+ shift_labels = kwargs.pop("shift_labels", labels)
+ loss = self.loss_function(
+ logits=logits,
+ labels=labels,
+ vocab_size=self.config.vocab_size,
+ shift_labels=shift_labels,
+ **kwargs,
+ )
+
+ return Seq2SeqLMOutput(
+ loss=loss,
+ logits=logits,
+ past_key_values=outputs.past_key_values,
+ decoder_hidden_states=outputs.decoder_hidden_states,
+ decoder_attentions=outputs.decoder_attentions,
+ cross_attentions=outputs.cross_attentions,
+ encoder_last_hidden_state=outputs.encoder_last_hidden_state,
+ encoder_hidden_states=outputs.encoder_hidden_states,
+ encoder_attentions=outputs.encoder_attentions,
+ )
+
+ def prepare_inputs_for_generation(self, *args, audio_chunk_index=None, **kwargs):
+ # audio_chunk_index is returned by the processor but not used by the model, absorb it here
+ return super().prepare_inputs_for_generation(*args, **kwargs)
+
+
+__all__ = ["CanaryForConditionalGeneration", "CanaryModel", "CanaryPreTrainedModel"]
diff --git a/src/transformers/models/canary/modular_canary.py b/src/transformers/models/canary/modular_canary.py
new file mode 100644
index 000000000000..e6b7272c7e8c
--- /dev/null
+++ b/src/transformers/models/canary/modular_canary.py
@@ -0,0 +1,107 @@
+# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""PyTorch Canary model."""
+
+import math
+
+import numpy as np
+import torch
+from torch import nn
+
+from ... import initialization as init
+from ...modeling_utils import PreTrainedModel
+from ...utils import auto_docstring, logging
+from ..cohere_asr.modeling_cohere_asr import (
+ CohereAsrDecoder,
+ CohereAsrForConditionalGeneration,
+ CohereAsrModel,
+ CohereAsrPreTrainedModel,
+)
+from ..qwen2_5_omni.modeling_qwen2_5_omni import SinusoidsPositionEmbedding
+from .configuration_canary import CanaryConfig, CanaryDecoderConfig
+
+
+logger = logging.get_logger(__name__)
+
+
+class CanaryPositionalEmbedding(SinusoidsPositionEmbedding):
+ """
+ Identical to [`SinusoidsPositionEmbedding`] except that the timescales and the `1 / sqrt(channels)` scaling match
+ NeMo's `FixedPositionalEncoding`, and it is indexed by `position_ids`.
+ """
+
+ def __init__(self, length: int, channels: int):
+ max_timescale = 10000 ** ((channels - 2) / channels)
+ super().__init__(length, channels, max_timescale)
+
+ def compute_default_singular_positional_embedding(self) -> torch.Tensor:
+ log_timescale_increment = np.log(self.max_timescale) / (self.channels // 2 - 1)
+ inv_timescales = torch.exp(-log_timescale_increment * torch.arange(self.channels // 2).float())
+ scaled_time = torch.arange(self.length)[:, np.newaxis] * inv_timescales[np.newaxis, :]
+ emb = torch.cat([torch.sin(scaled_time), torch.cos(scaled_time)], dim=1) / math.sqrt(self.channels)
+ return emb.to(torch.get_default_dtype())
+
+ def forward(self, position_ids: torch.Tensor) -> torch.Tensor:
+ return self.positional_embedding[position_ids]
+
+
+@auto_docstring
+class CanaryPreTrainedModel(CohereAsrPreTrainedModel):
+ config: CanaryConfig
+ _no_split_modules = ["ParakeetEncoderBlock", "CanaryDecoderLayer"]
+
+ def _get_feat_extract_output_lengths(self):
+ raise AttributeError("Not needed for Canary")
+
+ @torch.no_grad()
+ def _init_weights(self, module):
+ PreTrainedModel._init_weights(self, module)
+ if isinstance(module, CanaryPositionalEmbedding):
+ init.copy_(module.positional_embedding, module.compute_default_singular_positional_embedding())
+
+
+class CanaryDecoder(CohereAsrDecoder):
+ config: CanaryDecoderConfig
+
+ def __init__(self, config: CanaryDecoderConfig):
+ super().__init__(config)
+ self.pos_emb = CanaryPositionalEmbedding(config.max_position_embeddings, config.hidden_size)
+ self.proj = nn.Identity()
+
+
+@auto_docstring(
+ custom_intro="""
+ The bare Canary model (FastConformer encoder + Transformer decoder) outputting raw hidden-states without any
+ specific head on top.
+ """
+)
+class CanaryModel(CohereAsrModel):
+ def __init__(self, config: CanaryConfig):
+ super().__init__(config)
+ self.decoder = CanaryDecoder(config.decoder_config)
+
+
+@auto_docstring(
+ custom_intro="""
+ The Canary model with a language modeling head. Can be used for multilingual automatic speech recognition and
+ speech-to-text translation.
+ """
+)
+class CanaryForConditionalGeneration(CohereAsrForConditionalGeneration):
+ def __init__(self, config: CanaryConfig):
+ super().__init__(config)
+ self.proj_out = nn.Linear(config.decoder_config.hidden_size, config.decoder_config.vocab_size, bias=True)
+
+
+__all__ = ["CanaryForConditionalGeneration", "CanaryModel", "CanaryPreTrainedModel"]
diff --git a/src/transformers/models/canary/processing_canary.py b/src/transformers/models/canary/processing_canary.py
new file mode 100644
index 000000000000..9ab992a84e32
--- /dev/null
+++ b/src/transformers/models/canary/processing_canary.py
@@ -0,0 +1,178 @@
+# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from ...audio_utils import (
+ AudioInput,
+ make_audio_chat_template_content,
+ make_list_of_audio_chat_template,
+ prepare_language_inputs,
+)
+from ...feature_extraction_utils import BatchFeature
+from ...processing_utils import ProcessingKwargs, ProcessorMixin, Unpack
+from ...tokenization_utils_base import PreTokenizedInput, TextInput
+from ...utils import auto_docstring, logging
+from ...utils.import_utils import requires
+
+
+logger = logging.get_logger(__name__)
+
+
+# fmt: off
+# Languages supported by Canary. See https://huggingface.co/nvidia/canary-1b-v2 for details.
+LANGUAGE_CODE_TO_NAME = {
+ "bg": "Bulgarian",
+ "hr": "Croatian",
+ "cs": "Czech",
+ "da": "Danish",
+ "nl": "Dutch",
+ "en": "English",
+ "et": "Estonian",
+ "fi": "Finnish",
+ "fr": "French",
+ "de": "German",
+ "el": "Greek",
+ "hu": "Hungarian",
+ "it": "Italian",
+ "lv": "Latvian",
+ "lt": "Lithuanian",
+ "mt": "Maltese",
+ "pl": "Polish",
+ "pt": "Portuguese",
+ "ro": "Romanian",
+ "sk": "Slovak",
+ "sl": "Slovenian",
+ "es": "Spanish",
+ "sv": "Swedish",
+ "ru": "Russian",
+ "uk": "Ukrainian",
+}
+# fmt: on
+
+
+class CanaryProcessorKwargs(ProcessingKwargs, total=False): # trf-ignore: TRF019
+ _defaults = {
+ "audio_kwargs": {
+ "sampling_rate": 16000,
+ },
+ }
+
+
+@requires(backends=("torch",))
+@auto_docstring
+class CanaryProcessor(ProcessorMixin):
+ valid_processor_kwargs = CanaryProcessorKwargs
+
+ def __init__(self, feature_extractor=None, tokenizer=None, chat_template=None):
+ super().__init__(feature_extractor, tokenizer, chat_template=chat_template)
+
+ @auto_docstring
+ def __call__(
+ self,
+ audio: AudioInput,
+ text: TextInput | PreTokenizedInput | list[TextInput] | list[PreTokenizedInput] | None = None,
+ output_labels: bool = False,
+ **kwargs: Unpack[CanaryProcessorKwargs],
+ ) -> BatchFeature:
+ r"""
+ text (`str`, `list[str]`, *optional*):
+ The decoder prompt(s) produced by the chat template. It is tokenized into `decoder_input_ids`.
+ output_labels (`bool`, *optional*, defaults to `False`):
+ Whether to return labels for training.
+ """
+
+ if "return_tensors" in kwargs and kwargs["return_tensors"] != "pt":
+ raise ValueError(f"{self.__class__.__name__} only supports `return_tensors='pt'`.")
+
+ model_inputs = super().__call__(audio=audio, text=text, **kwargs)
+ model_inputs = BatchFeature(data=model_inputs, tensor_type="pt")
+ if text is not None:
+ input_ids = model_inputs.pop("input_ids")
+ if output_labels:
+ # the decoder inputs are already right-shifted with respect to `labels`
+ model_inputs["decoder_input_ids"] = input_ids[..., :-1]
+ labels = input_ids[..., 1:].clone()
+ labels[labels == self.tokenizer.pad_token_id] = -100
+ model_inputs["labels"] = labels
+ else:
+ model_inputs["decoder_input_ids"] = input_ids
+ return model_inputs
+
+ def apply_transcription_request(
+ self,
+ audio: AudioInput | list[AudioInput],
+ source_language: str | list[str] = "en",
+ target_language: str | list[str] | None = None,
+ punctuation: bool = True,
+ **kwargs: Unpack[CanaryProcessorKwargs],
+ ) -> BatchFeature:
+ r"""
+ Prepare inputs for transcription or translation without manually writing the chat template.
+
+ Args:
+ audio (`AudioInput` or `list[AudioInput]`):
+ Audio to transcribe or translate. Can be a URL string, local path, numpy array, or a list of these.
+ source_language (`str` or `list[str]`, *optional*, defaults to `"en"`):
+ The language of the input speech. Accepts ISO codes (e.g. `"en"`, `"de"`, `"fr"`) or full names
+ (e.g. `"English"`, `"German"`, `"French"`).
+ target_language (`str` or `list[str]`, *optional*):
+ The language of the output text. Accepts ISO codes or full names. Defaults to `source_language`
+ (transcription); set it to a different language for speech-to-text translation.
+ punctuation (`bool`, *optional*, defaults to `True`):
+ Whether to request punctuation and capitalization in the output.
+ **kwargs:
+ Additional keyword arguments forwarded to [`~CanaryProcessor.apply_chat_template`].
+
+ Returns:
+ [`BatchFeature`]: Processor outputs ready to be passed to
+ [`CanaryForConditionalGeneration.generate`].
+ """
+ audio_items = make_list_of_audio_chat_template(audio)
+ batch_size = len(audio_items)
+ if batch_size == 0:
+ raise ValueError("`audio` must contain at least one sample.")
+
+ source_languages = prepare_language_inputs(source_language, batch_size, LANGUAGE_CODE_TO_NAME)
+ if target_language is None:
+ target_languages = list(source_languages)
+ else:
+ target_languages = prepare_language_inputs(target_language, batch_size, LANGUAGE_CODE_TO_NAME)
+
+ conversations = []
+ for source, target, audio_item in zip(source_languages, target_languages, audio_items):
+ content = [
+ make_audio_chat_template_content(audio_item),
+ {
+ "type": "text",
+ "source_language": source,
+ "target_language": target,
+ "punctuation": punctuation,
+ },
+ ]
+ conversations.append([{"role": "user", "content": content}])
+
+ return self.apply_chat_template(
+ conversations,
+ tokenize=True,
+ add_generation_prompt=True,
+ return_dict=True,
+ **kwargs,
+ )
+
+ @property
+ def model_input_names(self):
+ feature_extractor_input_names = self.feature_extractor.model_input_names
+ return feature_extractor_input_names + ["decoder_input_ids", "labels"]
+
+
+__all__ = ["CanaryProcessor"]
diff --git a/src/transformers/models/glmasr/modular_glmasr.py b/src/transformers/models/glmasr/modular_glmasr.py
index 0027dd5992f6..8dd1bcf9591b 100644
--- a/src/transformers/models/glmasr/modular_glmasr.py
+++ b/src/transformers/models/glmasr/modular_glmasr.py
@@ -17,7 +17,7 @@
import numpy as np
from ...activations import ACT2FN
-from ...audio_utils import AudioInput, make_list_of_audio_chat_template
+from ...audio_utils import AudioInput, make_audio_chat_template_content, make_list_of_audio_chat_template
from ...cache_utils import Cache
from ...feature_extraction_utils import BatchFeature
from ...modeling_layers import GradientCheckpointingLayer
@@ -146,9 +146,7 @@ def apply_transcription_request(
{
"role": "user",
"content": [
- {"type": "audio", "path": audio_item}
- if isinstance(audio_item, str)
- else {"type": "audio", "audio": audio_item},
+ make_audio_chat_template_content(audio_item),
{"type": "text", "text": prompt_text},
],
}
diff --git a/src/transformers/models/glmasr/processing_glmasr.py b/src/transformers/models/glmasr/processing_glmasr.py
index 61366a5c47ec..4eb2c3d9fb9a 100644
--- a/src/transformers/models/glmasr/processing_glmasr.py
+++ b/src/transformers/models/glmasr/processing_glmasr.py
@@ -21,7 +21,7 @@
import numpy as np
-from ...audio_utils import AudioInput, make_list_of_audio_chat_template
+from ...audio_utils import AudioInput, make_audio_chat_template_content, make_list_of_audio_chat_template
from ...feature_extraction_utils import BatchFeature
from ...processing_utils import ProcessingKwargs, ProcessorMixin, Unpack
from ...tokenization_utils_base import TextInput
@@ -245,9 +245,7 @@ def apply_transcription_request(
{
"role": "user",
"content": [
- {"type": "audio", "path": audio_item}
- if isinstance(audio_item, str)
- else {"type": "audio", "audio": audio_item},
+ make_audio_chat_template_content(audio_item),
{"type": "text", "text": prompt_text},
],
}
diff --git a/src/transformers/models/qwen3_asr/processing_qwen3_asr.py b/src/transformers/models/qwen3_asr/processing_qwen3_asr.py
index 4fc4e0ab64ad..0ef083f55fb0 100644
--- a/src/transformers/models/qwen3_asr/processing_qwen3_asr.py
+++ b/src/transformers/models/qwen3_asr/processing_qwen3_asr.py
@@ -16,7 +16,12 @@
import numpy as np
-from ...audio_utils import AudioInput, make_list_of_audio_chat_template
+from ...audio_utils import (
+ AudioInput,
+ make_audio_chat_template_content,
+ make_list_of_audio_chat_template,
+ prepare_language_inputs,
+)
from ...feature_extraction_utils import BatchFeature
from ...processing_utils import ProcessingKwargs, ProcessorMixin, Unpack, prepare_prompt_input
from ...tokenization_utils_base import TextInput
@@ -67,63 +72,6 @@
}
# fmt: on
-SUPPORTED_LANGUAGE_NAMES = set(LANGUAGE_CODE_TO_NAME.values())
-
-
-def resolve_language(language: str | None) -> str | None:
- """Map a language code or name to the canonical full name, with validation.
-
- Accepts language codes (e.g. ``"zh"``, ``"en"``) or full names
- (e.g. ``"Chinese"``, ``"English"``). Returns the full name.
- Raises ``ValueError`` if the language is not recognized.
- ``None`` passes through unchanged (auto-detect).
- """
- if language is None:
- return None
- # Try code lookup first
- resolved = LANGUAGE_CODE_TO_NAME.get(language.lower())
- if resolved is not None:
- return resolved
- # Check if it's already a valid full name (case-insensitive)
- for name in SUPPORTED_LANGUAGE_NAMES:
- if language.lower() == name.lower():
- return name
- raise ValueError(
- f"Unsupported language: {language!r}. Use a language code "
- f"(e.g. 'en', 'zh') or full name (e.g. 'English', 'Chinese'). "
- f"Supported codes: {sorted(LANGUAGE_CODE_TO_NAME.keys())}. "
- f"Supported names: {sorted(SUPPORTED_LANGUAGE_NAMES)}."
- )
-
-
-def _prepare_language_inputs(
- language: str | list[str] | None, batch_size: int, allow_broadcast: bool = False
-) -> list[str | None]:
- """Broadcast / validate a language argument to match batch_size.
-
- Accepts language codes (e.g. ``"zh"``, ``"en"``) or full names
- (e.g. ``"Chinese"``, ``"English"``). Each value is resolved to the
- canonical full language name via :func:`resolve_language`.
- """
- if language is None:
- return [None] * batch_size
- if isinstance(language, str):
- return [resolve_language(language)] * batch_size
- if isinstance(language, (list, tuple)):
- if allow_broadcast and len(language) == 1 and batch_size > 1:
- return [resolve_language(language[0])] * batch_size
- if len(language) != batch_size:
- raise ValueError(f"Got {len(language)} language(s) for {batch_size} sample(s); counts must match.")
- return [resolve_language(lang) for lang in language]
- raise TypeError("`language` must be a string, a list of strings, or `None`.")
-
-
-def _audio_content_item(audio_item) -> dict:
- """Build a chat-template content dict for a single audio item."""
- if isinstance(audio_item, str):
- return {"type": "audio", "path": audio_item}
- return {"type": "audio", "audio": audio_item}
-
def _is_cjk_char(char: str) -> bool:
"""
@@ -527,7 +475,7 @@ def apply_transcription_request(
batch_size = len(audio_items)
if batch_size == 0:
raise ValueError("`audio` must contain at least one sample.")
- languages = _prepare_language_inputs(language, batch_size)
+ languages = prepare_language_inputs(language, batch_size, LANGUAGE_CODE_TO_NAME, return_code=False)
prompts = prepare_prompt_input(prompt, batch_size, input_name="prompt")
@@ -536,7 +484,7 @@ def apply_transcription_request(
messages = []
if prompt_text is not None:
messages.append({"role": "system", "content": [{"type": "text", "text": prompt_text}]})
- messages.append({"role": "user", "content": [_audio_content_item(audio_item)]})
+ messages.append({"role": "user", "content": [make_audio_chat_template_content(audio_item)]})
conversations.append(messages)
# The language is forced by prefilling the assistant turn with "language "
@@ -726,7 +674,9 @@ def prepare_forced_aligner_inputs(
if len(transcript) != batch_size:
raise ValueError(f"Got {len(transcript)} transcript(s) but {batch_size} audio(s); they must match 1:1.")
- languages = _prepare_language_inputs(language, batch_size, allow_broadcast=True)
+ languages = prepare_language_inputs(
+ language, batch_size, LANGUAGE_CODE_TO_NAME, allow_broadcast=True, return_code=False
+ )
# Validate that all languages are supported by the forced aligner
for lang in languages:
@@ -744,7 +694,7 @@ def prepare_forced_aligner_inputs(
conversations = []
for wl, audio_item in zip(word_lists, audio_items):
- content = [_audio_content_item(audio_item)]
+ content = [make_audio_chat_template_content(audio_item)]
content.extend({"type": "text", "text": word} for word in wl)
conversations.append([{"role": "user", "content": content}])
diff --git a/src/transformers/models/vibevoice_asr/processing_vibevoice_asr.py b/src/transformers/models/vibevoice_asr/processing_vibevoice_asr.py
index 01880f3a526f..8eed3485fa7d 100644
--- a/src/transformers/models/vibevoice_asr/processing_vibevoice_asr.py
+++ b/src/transformers/models/vibevoice_asr/processing_vibevoice_asr.py
@@ -17,7 +17,7 @@
import numpy as np
-from ...audio_utils import AudioInput, make_list_of_audio_chat_template
+from ...audio_utils import AudioInput, make_audio_chat_template_content, make_list_of_audio_chat_template
from ...feature_extraction_utils import BatchFeature
from ...processing_utils import ProcessingKwargs, ProcessorMixin, Unpack, prepare_prompt_input
from ...tokenization_utils_base import TextInput
@@ -210,11 +210,7 @@ def apply_transcription_request(
conversations = []
for prompt_text, audio_item in zip(prompts, audio_items):
- content = []
- if isinstance(audio_item, str):
- content.append({"type": "audio", "path": audio_item})
- else:
- content.append({"type": "audio", "audio": audio_item})
+ content = [make_audio_chat_template_content(audio_item)]
if prompt_text is not None:
content.append({"type": "text", "text": prompt_text})
diff --git a/tests/fixtures/canary/expected_results_batch.json b/tests/fixtures/canary/expected_results_batch.json
new file mode 100644
index 000000000000..2fcbd261ab45
--- /dev/null
+++ b/tests/fixtures/canary/expected_results_batch.json
@@ -0,0 +1,6 @@
+{
+ "transcriptions": [
+ "mister Quilter is the apostle of the middle classes, and we are glad to welcome his gospel.",
+ "Auch Mister Quilters Manner ist nicht weniger interessant als sein Material."
+ ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/canary/expected_results_transcription.json b/tests/fixtures/canary/expected_results_transcription.json
new file mode 100644
index 000000000000..c57f34bd12ad
--- /dev/null
+++ b/tests/fixtures/canary/expected_results_transcription.json
@@ -0,0 +1,5 @@
+{
+ "transcriptions": [
+ "mister Quilter is the apostle of the middle classes, and we are glad to welcome his gospel."
+ ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/canary/expected_results_translation.json b/tests/fixtures/canary/expected_results_translation.json
new file mode 100644
index 000000000000..323383b6174d
--- /dev/null
+++ b/tests/fixtures/canary/expected_results_translation.json
@@ -0,0 +1,5 @@
+{
+ "transcriptions": [
+ "Mister Quilter ist der Apostel der Mittelschicht, und wir freuen uns, sein Evangelium willkommen zu hei\u00dfen."
+ ]
+}
\ No newline at end of file
diff --git a/tests/models/canary/__init__.py b/tests/models/canary/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/tests/models/canary/test_modeling_canary.py b/tests/models/canary/test_modeling_canary.py
new file mode 100644
index 000000000000..b22a06db4fc6
--- /dev/null
+++ b/tests/models/canary/test_modeling_canary.py
@@ -0,0 +1,558 @@
+# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Testing suite for the PyTorch Canary model."""
+
+import copy
+import json
+import math
+import unittest
+from pathlib import Path
+
+from transformers import CanaryConfig, CanaryDecoderConfig, ParakeetEncoderConfig, is_torch_available
+from transformers.testing_utils import is_flaky, require_torch, slow, torch_device
+
+from ...generation.test_utils import GenerationTesterMixin
+from ...test_configuration_common import ConfigTester
+from ...test_modeling_common import ModelTesterMixin, floats_tensor, ids_tensor
+from ...test_pipeline_mixin import PipelineTesterMixin
+
+
+if is_torch_available():
+ import torch
+
+ from transformers import CanaryForConditionalGeneration, CanaryModel, StaticCache
+
+
+class CanaryModelTester:
+ def __init__(
+ self,
+ parent,
+ batch_size=3, # need batch_size != num_hidden_layers
+ seq_length=80,
+ is_training=False,
+ use_labels=False,
+ num_mel_bins=80,
+ hidden_size=16,
+ intermediate_size=32,
+ num_hidden_layers=2,
+ num_attention_heads=2,
+ subsampling_factor=8,
+ subsampling_conv_channels=16,
+ decoder_seq_length=4,
+ vocab_size=99,
+ max_position_embeddings=40,
+ decoder_start_token_id=7,
+ pad_token_id=2,
+ bos_token_id=4,
+ eos_token_id=3,
+ ):
+ self.parent = parent
+ self.batch_size = batch_size
+ self.seq_length = seq_length
+ self.decoder_seq_length = decoder_seq_length
+ self.decoder_key_length = decoder_seq_length
+ self.is_training = is_training
+ self.use_labels = use_labels
+ self.num_mel_bins = num_mel_bins
+ self.hidden_size = hidden_size
+ self.intermediate_size = intermediate_size
+ self.num_hidden_layers = num_hidden_layers
+ self.num_attention_heads = num_attention_heads
+ self.subsampling_factor = subsampling_factor
+ self.subsampling_conv_channels = subsampling_conv_channels
+ self.vocab_size = vocab_size
+ self.max_position_embeddings = max_position_embeddings
+ self.decoder_start_token_id = decoder_start_token_id
+ self.pad_token_id = pad_token_id
+ self.bos_token_id = bos_token_id
+ self.eos_token_id = eos_token_id
+
+ def get_config(self):
+ encoder_config = ParakeetEncoderConfig(
+ hidden_size=self.hidden_size,
+ num_hidden_layers=self.num_hidden_layers,
+ num_attention_heads=self.num_attention_heads,
+ intermediate_size=self.intermediate_size,
+ num_mel_bins=self.num_mel_bins,
+ subsampling_factor=self.subsampling_factor,
+ subsampling_conv_channels=self.subsampling_conv_channels,
+ scale_input=False,
+ )
+ decoder_config = CanaryDecoderConfig(
+ vocab_size=self.vocab_size,
+ hidden_size=self.hidden_size,
+ intermediate_size=self.intermediate_size,
+ num_hidden_layers=self.num_hidden_layers,
+ num_attention_heads=self.num_attention_heads,
+ max_position_embeddings=self.max_position_embeddings,
+ pad_token_id=self.pad_token_id,
+ )
+ return CanaryConfig(
+ encoder_config=encoder_config,
+ decoder_config=decoder_config,
+ decoder_start_token_id=self.decoder_start_token_id,
+ pad_token_id=self.pad_token_id,
+ bos_token_id=self.bos_token_id,
+ eos_token_id=self.eos_token_id,
+ )
+
+ def prepare_config_and_inputs_for_common(self):
+ config = self.get_config()
+ # `seq_length` is in mel frames; keep it a multiple of `subsampling_factor` so 8x subsampling does not collapse it.
+ input_features = floats_tensor([self.batch_size, self.seq_length, self.num_mel_bins], scale=1.0)
+ attention_mask = torch.ones([self.batch_size, self.seq_length], dtype=torch.long, device=torch_device)
+ decoder_input_ids = ids_tensor([self.batch_size, self.decoder_seq_length], self.vocab_size)
+ decoder_attention_mask = decoder_input_ids.ne(self.pad_token_id)
+ inputs_dict = {
+ "input_features": input_features,
+ "attention_mask": attention_mask,
+ "decoder_input_ids": decoder_input_ids,
+ "decoder_attention_mask": decoder_attention_mask,
+ }
+ return config, inputs_dict
+
+ def get_subsampled_output_lengths(self, input_lengths):
+ """Computes the FastConformer subsampled length, used by the generation test mixin for encoder shapes."""
+ kernel_size, stride = 3, 2
+ padding = (kernel_size - 1) // 2 * 2 - kernel_size
+ for _ in range(int(math.log2(self.subsampling_factor))):
+ input_lengths = (input_lengths + padding) // stride + 1
+ return input_lengths
+
+
+@require_torch
+class CanaryModelTest(ModelTesterMixin, GenerationTesterMixin, PipelineTesterMixin, unittest.TestCase):
+ all_model_classes = (CanaryModel, CanaryForConditionalGeneration) if is_torch_available() else ()
+ all_generative_model_classes = (CanaryForConditionalGeneration,) if is_torch_available() else ()
+ pipeline_model_mapping = (
+ {
+ "automatic-speech-recognition": CanaryForConditionalGeneration,
+ "feature-extraction": CanaryModel,
+ }
+ if is_torch_available()
+ else {}
+ )
+ is_encoder_decoder = True
+ test_pruning = False
+ test_resize_embeddings = True
+ test_headmasking = False
+
+ def setUp(self):
+ self.model_tester = CanaryModelTester(self)
+ self.config_tester = ConfigTester(self, has_text_modality=False, config_class=CanaryConfig)
+
+ def test_config(self):
+ self.config_tester.run_common_tests()
+
+ # Overridden because the FastConformer encoder subsamples the input, so encoder shapes use the subsampled length.
+ def test_hidden_states_output(self):
+ def check_hidden_states_output(inputs_dict, config, model_class):
+ model = model_class(config)
+ model.to(torch_device)
+ model.eval()
+
+ with torch.no_grad():
+ outputs = model(**self._prepare_for_class(inputs_dict, model_class))
+
+ hidden_states = outputs.encoder_hidden_states if config.is_encoder_decoder else outputs.hidden_states
+
+ expected_num_layers = getattr(
+ self.model_tester, "expected_num_hidden_layers", self.model_tester.num_hidden_layers + 1
+ )
+ self.assertEqual(len(hidden_states), expected_num_layers)
+
+ subsampled_seq_length = self.model_tester.get_subsampled_output_lengths(self.model_tester.seq_length)
+ self.assertListEqual(
+ list(hidden_states[0].shape[-2:]),
+ [subsampled_seq_length, self.model_tester.hidden_size],
+ )
+
+ if config.is_encoder_decoder:
+ hidden_states = outputs.decoder_hidden_states
+ self.assertIsInstance(hidden_states, (list, tuple))
+ self.assertEqual(len(hidden_states), expected_num_layers)
+ self.assertListEqual(
+ list(hidden_states[0].shape[-2:]),
+ [self.model_tester.decoder_seq_length, self.model_tester.hidden_size],
+ )
+
+ config, inputs_dict = self.model_tester.prepare_config_and_inputs_for_common()
+
+ for model_class in self.all_model_classes:
+ inputs_dict["output_hidden_states"] = True
+ check_hidden_states_output(inputs_dict, config, model_class)
+
+ del inputs_dict["output_hidden_states"]
+ config.output_hidden_states = True
+ self._set_subconfig_attributes(config, "output_hidden_states", True)
+ check_hidden_states_output(inputs_dict, config, model_class)
+
+ # Overridden for the same subsampling reason as `test_hidden_states_output` (mirrors `WhisperModelTest`).
+ def test_attention_outputs(self):
+ config, inputs_dict = self.model_tester.prepare_config_and_inputs_for_common()
+ config.return_dict = True
+
+ # force eager attention to support output attentions
+ config._attn_implementation = "eager"
+
+ seq_len = getattr(self.model_tester, "seq_length", None)
+ decoder_seq_length = getattr(self.model_tester, "decoder_seq_length", 1)
+ encoder_seq_length = getattr(self.model_tester, "encoder_seq_length", seq_len)
+ decoder_key_length = getattr(self.model_tester, "decoder_key_length", 1)
+ encoder_key_length = getattr(self.model_tester, "key_length", encoder_seq_length)
+
+ for model_class in self.all_model_classes:
+ inputs_dict["output_attentions"] = True
+ inputs_dict["output_hidden_states"] = False
+ config.return_dict = True
+ model = model_class._from_config(config, attn_implementation="eager")
+ config = model.config
+ model.to(torch_device)
+ model.eval()
+
+ subsampled_encoder_seq_length = self.model_tester.get_subsampled_output_lengths(encoder_seq_length)
+ subsampled_encoder_key_length = self.model_tester.get_subsampled_output_lengths(encoder_key_length)
+
+ with torch.no_grad():
+ outputs = model(**self._prepare_for_class(inputs_dict, model_class))
+ attentions = outputs.encoder_attentions if config.is_encoder_decoder else outputs.attentions
+ self.assertEqual(len(attentions), self.model_tester.num_hidden_layers)
+
+ # check that output_attentions also work using config
+ del inputs_dict["output_attentions"]
+ config.output_attentions = True
+ self._set_subconfig_attributes(config, "output_attentions", True)
+ model = model_class(config)
+ model.to(torch_device)
+ model.eval()
+ with torch.no_grad():
+ outputs = model(**self._prepare_for_class(inputs_dict, model_class))
+ attentions = outputs.encoder_attentions if config.is_encoder_decoder else outputs.attentions
+ self.assertEqual(len(attentions), self.model_tester.num_hidden_layers)
+
+ self.assertListEqual(
+ list(attentions[0].shape[-3:]),
+ [self.model_tester.num_attention_heads, subsampled_encoder_seq_length, subsampled_encoder_key_length],
+ )
+ out_len = len(outputs)
+
+ correct_outlen = 5
+
+ # loss is at first position
+ if "labels" in inputs_dict:
+ correct_outlen += 1 # loss is added to beginning
+ if "past_key_values" in outputs:
+ correct_outlen += 1 # past_key_values have been returned
+
+ self.assertEqual(out_len, correct_outlen)
+
+ # decoder attentions
+ decoder_attentions = outputs.decoder_attentions
+ self.assertIsInstance(decoder_attentions, (list, tuple))
+ self.assertEqual(len(decoder_attentions), self.model_tester.num_hidden_layers)
+ self.assertListEqual(
+ list(decoder_attentions[0].shape[-3:]),
+ [self.model_tester.num_attention_heads, decoder_seq_length, decoder_key_length],
+ )
+
+ # cross attentions
+ cross_attentions = outputs.cross_attentions
+ self.assertIsInstance(cross_attentions, (list, tuple))
+ self.assertEqual(len(cross_attentions), self.model_tester.num_hidden_layers)
+ self.assertListEqual(
+ list(cross_attentions[0].shape[-3:]),
+ [self.model_tester.num_attention_heads, decoder_seq_length, subsampled_encoder_key_length],
+ )
+
+ # Check attention is always last and order is fine
+ inputs_dict["output_attentions"] = True
+ inputs_dict["output_hidden_states"] = True
+ model = model_class(config)
+ model.to(torch_device)
+ model.eval()
+ with torch.no_grad():
+ outputs = model(**self._prepare_for_class(inputs_dict, model_class))
+
+ added_hidden_states = 2
+ self.assertEqual(out_len + added_hidden_states, len(outputs))
+
+ self_attentions = outputs.encoder_attentions if config.is_encoder_decoder else outputs.attentions
+ self.assertEqual(len(self_attentions), self.model_tester.num_hidden_layers)
+ self.assertListEqual(
+ list(self_attentions[0].shape[-3:]),
+ [self.model_tester.num_attention_heads, subsampled_encoder_seq_length, subsampled_encoder_key_length],
+ )
+
+ # Overridden because Canary takes `input_features` + `decoder_input_ids`, not `input_ids` (like Whisper).
+ def test_resize_tokens_embeddings(self):
+ original_config, inputs_dict = self.model_tester.prepare_config_and_inputs_for_common()
+ if not self.test_resize_embeddings:
+ self.skipTest(reason="test_resize_embeddings is False")
+
+ for model_class in self.all_model_classes:
+ config = copy.deepcopy(original_config)
+ model = model_class(config)
+ model.to(torch_device)
+ if self.model_tester.is_training is False:
+ model.eval()
+
+ # Retrieve the embeddings and clone theme
+ model_vocab_size = config.get_text_config().vocab_size
+ model_embed = model.resize_token_embeddings(model_vocab_size)
+ cloned_embeddings = model_embed.weight.clone()
+
+ # Check that resizing the token embeddings with a larger vocab size increases the model's vocab size
+ model_embed = model.resize_token_embeddings(model_vocab_size + 10)
+ self.assertEqual(model.config.get_text_config().vocab_size, model_vocab_size + 10)
+ # Check that it actually resizes the embeddings matrix
+ self.assertEqual(model_embed.weight.shape[0], cloned_embeddings.shape[0] + 10)
+ # Check that the model can still do a forward pass successfully (every parameter should be resized)
+ model(**self._prepare_for_class(inputs_dict, model_class))
+
+ # Check that resizing the token embeddings with a smaller vocab size decreases the model's vocab size
+ model_embed = model.resize_token_embeddings(model_vocab_size - 15)
+ self.assertEqual(model.config.get_text_config().vocab_size, model_vocab_size - 15)
+ # Check that it actually resizes the embeddings matrix
+ self.assertEqual(model_embed.weight.shape[0], cloned_embeddings.shape[0] - 15)
+
+ # make sure that decoder_input_ids are resized
+ if "decoder_input_ids" in inputs_dict:
+ inputs_dict["decoder_input_ids"].clamp_(max=model_vocab_size - 15 - 1)
+ model(**self._prepare_for_class(inputs_dict, model_class))
+
+ # Check that adding and removing tokens has not modified the first part of the embedding matrix.
+ models_equal = True
+ for p1, p2 in zip(cloned_embeddings, model_embed.weight):
+ if p1.data.ne(p2.data).sum() > 0:
+ models_equal = False
+ self.assertTrue(models_equal)
+
+ # Overridden for the same audio-model reason as `test_resize_tokens_embeddings` (mirrors `WhisperModelTest`).
+ def test_resize_embeddings_untied(self):
+ original_config, inputs_dict = self.model_tester.prepare_config_and_inputs_for_common()
+ if not self.test_resize_embeddings:
+ self.skipTest(reason="test_resize_embeddings is False")
+
+ original_config.tie_word_embeddings = False
+
+ # if model cannot untied embeddings -> leave test
+ if original_config.tie_word_embeddings:
+ self.skipTest(reason="Model cannot untie embeddings")
+
+ for model_class in self.all_model_classes:
+ config = copy.deepcopy(original_config)
+ model = model_class(config).to(torch_device)
+ model.eval()
+
+ # if no output embeddings -> leave test
+ if model.get_output_embeddings() is None:
+ continue
+
+ # Check that resizing the token embeddings with a larger vocab size increases the model's vocab size
+ model_vocab_size = config.get_text_config().vocab_size
+ model.resize_token_embeddings(model_vocab_size + 10)
+ self.assertEqual(model.config.get_text_config().vocab_size, model_vocab_size + 10)
+ output_embeds = model.get_output_embeddings()
+ self.assertEqual(output_embeds.weight.shape[0], model_vocab_size + 10)
+ # Check bias if present
+ if output_embeds.bias is not None:
+ self.assertEqual(output_embeds.bias.shape[0], model_vocab_size + 10)
+ # Check that the model can still do a forward pass successfully (every parameter should be resized)
+ model(**self._prepare_for_class(inputs_dict, model_class))
+
+ # Check that resizing the token embeddings with a smaller vocab size decreases the model's vocab size
+ model.resize_token_embeddings(model_vocab_size - 15)
+ self.assertEqual(model.config.get_text_config().vocab_size, model_vocab_size - 15)
+ # Check that it actually resizes the embeddings matrix
+ output_embeds = model.get_output_embeddings()
+ self.assertEqual(output_embeds.weight.shape[0], model_vocab_size - 15)
+ # Check bias if present
+ if output_embeds.bias is not None:
+ self.assertEqual(output_embeds.bias.shape[0], model_vocab_size - 15)
+ if "decoder_input_ids" in inputs_dict:
+ inputs_dict["decoder_input_ids"].clamp_(max=model_vocab_size - 15 - 1)
+ # Check that the model can still do a forward pass successfully (every parameter should be resized)
+ model(**self._prepare_for_class(inputs_dict, model_class))
+
+ @unittest.skip(
+ reason="Canary is an encoder-decoder ASR model that requires audio features and cannot generate from input ids only."
+ )
+ def test_generate_without_input_ids(self):
+ pass
+
+ @is_flaky(description="Large difference with A10. Still flaky after setting larger tolerance")
+ def test_generate_continue_from_past_key_values(self):
+ super().test_generate_continue_from_past_key_values()
+
+ @unittest.skip(reason="Decoder can't keep attention grads")
+ def test_retain_grad_hidden_states_attentions(self):
+ pass
+
+ # Overridden because the head count comes from the decoder sub-config (mirrors `DiaModelTest`).
+ def _check_attentions_for_generate(
+ self, batch_size, attentions, prompt_length, output_length, config, decoder_past_key_values
+ ):
+ self.assertIsInstance(attentions, tuple)
+ self.assertListEqual(
+ [isinstance(iter_attentions, tuple) for iter_attentions in attentions], [True] * len(attentions)
+ )
+ self.assertEqual(len(attentions), (output_length - prompt_length))
+
+ use_cache = decoder_past_key_values is not None
+ has_static_cache = isinstance(decoder_past_key_values, StaticCache)
+
+ # When `output_attentions=True`, each iteration of generate appends the attentions corresponding to the new
+ # token(s)
+ for generated_length, iter_attentions in enumerate(attentions):
+ # regardless of using cache, the first forward pass will have the full prompt as input
+ if use_cache and generated_length > 0:
+ model_input_length = 1
+ else:
+ model_input_length = prompt_length + generated_length
+ if has_static_cache:
+ # hybrid caches have layers with no fixed max, so pick the first layer reporting a real length
+ query_length = next(
+ (
+ decoder_past_key_values.get_max_length(i)
+ for i in range(len(decoder_past_key_values))
+ if decoder_past_key_values.get_max_length(i) != -1
+ ),
+ prompt_length + generated_length,
+ )
+ else:
+ query_length = prompt_length + generated_length
+
+ expected_shape = (
+ batch_size,
+ config.decoder_config.num_attention_heads, # Decoder config
+ model_input_length,
+ query_length,
+ )
+ # check attn size
+ self.assertListEqual(
+ [layer_attention.shape for layer_attention in iter_attentions], [expected_shape] * len(iter_attentions)
+ )
+
+ # Overridden for the same sub-config reason as `_check_attentions_for_generate` (mirrors `DiaModelTest`).
+ def _check_encoder_attention_for_generate(self, attentions, batch_size, config, prompt_length):
+ # Encoder config
+ encoder_expected_shape = (batch_size, config.encoder_config.num_attention_heads, prompt_length, prompt_length)
+ self.assertIsInstance(attentions, tuple)
+ self.assertListEqual(
+ [layer_attentions.shape for layer_attentions in attentions],
+ [encoder_expected_shape] * len(attentions),
+ )
+
+ # Overridden for the same sub-config reason as `_check_attentions_for_generate` (mirrors `DiaModelTest`).
+ def _check_hidden_states_for_generate(
+ self, batch_size, hidden_states, prompt_length, output_length, config, use_cache=False
+ ):
+ self.assertIsInstance(hidden_states, tuple)
+ self.assertListEqual(
+ [isinstance(iter_hidden_states, tuple) for iter_hidden_states in hidden_states],
+ [True] * len(hidden_states),
+ )
+ self.assertEqual(len(hidden_states), (output_length - prompt_length))
+
+ # When `output_hidden_states=True`, each iteration of generate appends the hidden states corresponding to the
+ # new token(s)
+ # NOTE: `StaticCache` may have different lengths on different layers, if this test starts failing add more
+ # elaborate checks
+ for generated_length, iter_hidden_states in enumerate(hidden_states):
+ # regardless of using cache, the first forward pass will have the full prompt as input
+ if use_cache and generated_length > 0:
+ model_input_length = 1
+ else:
+ model_input_length = prompt_length + generated_length
+ expected_shape = (batch_size, model_input_length, config.decoder_config.hidden_size) # Decoder config
+ # check hidden size
+ self.assertListEqual(
+ [layer_hidden_states.shape for layer_hidden_states in iter_hidden_states],
+ [expected_shape] * len(iter_hidden_states),
+ )
+
+ # Overridden for the same sub-config reason as `_check_attentions_for_generate` (mirrors `DiaModelTest`).
+ def _check_encoder_hidden_states_for_generate(self, hidden_states, batch_size, config, prompt_length):
+ # Encoder config
+ encoder_expected_shape = (batch_size, prompt_length, config.encoder_config.hidden_size)
+ self.assertIsInstance(hidden_states, tuple)
+ self.assertListEqual(
+ [layer_hidden_states.shape for layer_hidden_states in hidden_states],
+ [encoder_expected_shape] * len(hidden_states),
+ )
+
+
+@require_torch
+@slow
+class CanaryIntegrationTest(unittest.TestCase):
+ checkpoint = "harshaljanjani/canary-1b-v2-hf"
+
+ @classmethod
+ def setUp(cls):
+ from transformers import AutoProcessor
+
+ cls.fixtures_path = Path(__file__).parent.parent.parent / "fixtures/canary"
+ cls.processor = AutoProcessor.from_pretrained(cls.checkpoint)
+ cls.model = CanaryForConditionalGeneration.from_pretrained(cls.checkpoint).to(torch_device).eval()
+
+ def _load_datasamples(self, processor, num_samples):
+ from datasets import Audio, load_dataset
+
+ ds = load_dataset("hf-internal-testing/librispeech_asr_dummy", "clean", split="validation")
+ ds = ds.cast_column("audio", Audio(sampling_rate=processor.feature_extractor.sampling_rate))
+ speech_samples = ds.sort("id")[:num_samples]["audio"]
+ return [x["array"] for x in speech_samples]
+
+ def test_transcription_en(self):
+ """
+ reproducer: https://gist.github.com/harshaljanjani/ff11260652a115da61037ecfc288c74f#file-reproducer_transcription-py
+ """
+ with open(self.fixtures_path / "expected_results_transcription.json") as f:
+ expected_transcriptions = json.load(f)["transcriptions"]
+
+ inputs = self._load_datasamples(self.processor, 1)
+ features = self.processor.apply_transcription_request(audio=inputs, source_language="en").to(torch_device)
+ generated = self.model.generate(**features, max_new_tokens=128)
+ transcriptions = [text.strip() for text in self.processor.decode(generated, skip_special_tokens=True)]
+ self.assertListEqual(transcriptions, expected_transcriptions)
+
+ def test_transcription_en_batched(self):
+ """
+ reproducer: https://gist.github.com/harshaljanjani/d93abd784d09a7f25291080ebcdf805d#file-reproducer_batch-py
+ """
+ with open(self.fixtures_path / "expected_results_batch.json") as f:
+ expected_transcriptions = json.load(f)["transcriptions"]
+
+ inputs = self._load_datasamples(self.processor, 2)
+ features = self.processor.apply_transcription_request(
+ audio=inputs, source_language="en", target_language=["en", "de"]
+ ).to(torch_device)
+ generated = self.model.generate(**features, max_new_tokens=128)
+ transcriptions = [text.strip() for text in self.processor.decode(generated, skip_special_tokens=True)]
+ self.assertListEqual(transcriptions, expected_transcriptions)
+
+ def test_translation_en_to_de(self):
+ """
+ reproducer: https://gist.github.com/harshaljanjani/5b093d7fc25507694b7b6ada08fa7988#file-reproducer_translation-py
+ """
+ with open(self.fixtures_path / "expected_results_translation.json") as f:
+ expected_transcriptions = json.load(f)["transcriptions"]
+
+ inputs = self._load_datasamples(self.processor, 1)
+ features = self.processor.apply_transcription_request(
+ audio=inputs, source_language="en", target_language="de"
+ ).to(torch_device)
+ generated = self.model.generate(**features, max_new_tokens=128)
+ transcriptions = [text.strip() for text in self.processor.decode(generated, skip_special_tokens=True)]
+ self.assertListEqual(transcriptions, expected_transcriptions)
diff --git a/tests/models/canary/test_processing_canary.py b/tests/models/canary/test_processing_canary.py
new file mode 100644
index 000000000000..2b6b4fbe9188
--- /dev/null
+++ b/tests/models/canary/test_processing_canary.py
@@ -0,0 +1,94 @@
+# Copyright 2026 the HuggingFace Team. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import shutil
+import tempfile
+import unittest
+
+import numpy as np
+
+from transformers import AutoProcessor, CanaryProcessor
+from transformers.testing_utils import require_torch
+
+
+def _get_prompt(source: str, target: str, pnc: bool = True) -> str:
+ return (
+ "<|startofcontext|><|startoftranscript|><|emo:undefined|>"
+ f"<|{source}|><|{target}|>"
+ f"{'<|pnc|>' if pnc else '<|nopnc|>'}<|noitn|><|notimestamp|><|nodiarize|>"
+ )
+
+
+@require_torch
+class CanaryProcessorTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.checkpoint = "harshaljanjani/canary-1b-v2-hf"
+ cls.tmpdirname = tempfile.mkdtemp()
+ CanaryProcessor.from_pretrained(cls.checkpoint).save_pretrained(cls.tmpdirname)
+
+ @classmethod
+ def tearDownClass(cls):
+ shutil.rmtree(cls.tmpdirname, ignore_errors=True)
+
+ def get_processor(self):
+ return AutoProcessor.from_pretrained(self.tmpdirname)
+
+ def _audio(self, num_samples: int = 16000):
+ return np.zeros(num_samples, dtype=np.float32)
+
+ def _decode_prompt(self, processor, inputs, index: int = 0) -> str:
+ return processor.tokenizer.decode(inputs["decoder_input_ids"][index], skip_special_tokens=False)
+
+ def test_chat_template_is_loaded(self):
+ self.assertIsNotNone(self.get_processor().chat_template)
+
+ def test_apply_transcription_request_transcription(self):
+ processor = self.get_processor()
+ inputs = processor.apply_transcription_request(audio=self._audio(), source_language="en")
+ self.assertIn("input_features", inputs)
+ self.assertEqual(self._decode_prompt(processor, inputs), _get_prompt("en", "en"))
+
+ def test_apply_transcription_request_translation(self):
+ processor = self.get_processor()
+ inputs = processor.apply_transcription_request(audio=self._audio(), source_language="en", target_language="de")
+ self.assertEqual(self._decode_prompt(processor, inputs), _get_prompt("en", "de"))
+
+ def test_punctuation_flag(self):
+ processor = self.get_processor()
+ inputs = processor.apply_transcription_request(audio=self._audio(), source_language="en", punctuation=False)
+ self.assertEqual(self._decode_prompt(processor, inputs), _get_prompt("en", "en", pnc=False))
+
+ def test_batch_broadcast_and_per_sample(self):
+ processor = self.get_processor()
+ inputs = processor.apply_transcription_request(
+ audio=[self._audio(), self._audio()], source_language="en", target_language=["en", "es"]
+ )
+ self.assertEqual(len(inputs["decoder_input_ids"]), 2)
+ self.assertEqual(self._decode_prompt(processor, inputs, 0), _get_prompt("en", "en"))
+ self.assertEqual(self._decode_prompt(processor, inputs, 1), _get_prompt("en", "es"))
+
+ def test_batch_length_mismatch_raises(self):
+ processor = self.get_processor()
+ with self.assertRaises(ValueError):
+ processor.apply_transcription_request(audio=[self._audio()], source_language=["en", "de"])
+
+ def test_call_output_labels(self):
+ processor = self.get_processor()
+ outputs = processor(audio=self._audio(), text="hello world", output_labels=True)
+ self.assertIn("input_features", outputs)
+ self.assertIn("decoder_input_ids", outputs)
+ self.assertIn("labels", outputs)
+ # the decoder inputs are already right-shifted with respect to `labels`
+ self.assertListEqual(outputs["decoder_input_ids"][..., 1:].tolist(), outputs["labels"][..., :-1].tolist())
diff --git a/tests/test_modeling_common.py b/tests/test_modeling_common.py
index f70d69de89c2..cd58a107d90d 100644
--- a/tests/test_modeling_common.py
+++ b/tests/test_modeling_common.py
@@ -3834,6 +3834,7 @@ def test_sdpa_can_dispatch_on_flash(self):
"kosmos-2",
"mllama",
"lighton_ocr",
+ "canary",
"parakeet_encoder",
"parakeet_ctc",
"pi0",