Skip to content
28 changes: 23 additions & 5 deletions src/mobius/_configs/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,11 +338,11 @@ def _extract_mrope_fields(config) -> dict:
)
if mrope_interleaved:
result["mrope_interleaved"] = True
section = rope_scaling.get("mrope_section", None) or rope_parameters.get(
"mrope_section", None
)
if section is not None:
result["mrope_section"] = section
section = rope_scaling.get("mrope_section", None) or rope_parameters.get(
"mrope_section", None
)
if section is not None:
result["mrope_section"] = section
return result


Expand Down Expand Up @@ -481,6 +481,7 @@ class BaseModelConfig:

vocab_size: int = DEFAULT_INT
hidden_size: int = DEFAULT_INT
embedding_size: int | None = None
intermediate_size: int = DEFAULT_INT
num_hidden_layers: int = DEFAULT_INT
num_attention_heads: int = DEFAULT_INT
Expand Down Expand Up @@ -774,6 +775,9 @@ class ArchitectureConfig(BaseModelConfig):
mrope_section: list[int] | None = None
mrope_interleaved: bool = False

# Qwen2.5-Omni uses independent Thinker and Talker decoder dimensions.
talker: ArchitectureConfig | None = None

# Standalone vision config
image_size: int = 224
patch_size: int = 16
Expand Down Expand Up @@ -1016,6 +1020,7 @@ def _per_layer_value(attribute: str) -> int | None:
or 0
),
hidden_size=_as_int(hidden_size),
embedding_size=getattr(config, "embedding_size", None),
intermediate_size=_as_int(
getattr(config, "intermediate_size", None)
or getattr(config, "mlp_hidden_size", None)
Expand Down Expand Up @@ -1090,6 +1095,7 @@ def _per_layer_value(attribute: str) -> int | None:
"qwen2",
"qwen2_5_vl_text",
"qwen2_5_omni_text",
"qwen2_5_omni_talker",
"qwen2_moe",
"qwen2_vl_text",
),
Expand Down Expand Up @@ -1502,6 +1508,18 @@ def _per_layer_value(attribute: str) -> int | None:
upsampling_ratios=list(getattr(ec, "upsampling_ratios", [8, 6, 5, 4])),
)

if model_type == "qwen2_5_omni_text" and parent_config is not None:
talker_config = getattr(parent_config, "talker_config", None)
enable_audio_output = getattr(
parent_config,
"enable_audio_output",
getattr(parent_config, "enable_talker", True),
)
if enable_audio_output and talker_config is not None:
if isinstance(talker_config, dict):
talker_config = type("TalkerConfig", (), talker_config)()
options["talker"] = ArchitectureConfig.from_transformers(talker_config)
Comment on lines +1511 to +1521

# Model dtype
resolved = _resolve_dtype(config)
if resolved is not None:
Expand Down
1 change: 1 addition & 0 deletions src/mobius/_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -1439,6 +1439,7 @@ def _create_default_registry() -> ModelRegistry:
"moonshine": "moonshine-ai/moonshine-tiny",
"moonshine_streaming": "moonshine-ai/moonshine-streaming-tiny",
"whisper": "openai/whisper-tiny",
"qwen2_5_omni": "Qwen/Qwen2.5-Omni-7B",
"qwen3_asr": "Qwen/Qwen3-ASR-0.6B",
"fun_asr": "justinchuby/Fun-ASR-Nano-2512",
"glmasr": "zai-org/GLM-ASR-Nano-2512",
Expand Down
11 changes: 10 additions & 1 deletion src/mobius/integrations/transformers/_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,16 @@ def build_transformers_model(
)
is_gptoss_mxfp4_source = _is_native_gptoss_mxfp4(config)
if dtype is not None:
config = dataclasses.replace(config, dtype=resolve_dtype(dtype))
resolved_dtype = resolve_dtype(dtype)
talker_config = getattr(config, "talker", None)
if talker_config is not None:
config = dataclasses.replace(
config,
dtype=resolved_dtype,
talker=dataclasses.replace(talker_config, dtype=resolved_dtype),
)
else:
config = dataclasses.replace(config, dtype=resolved_dtype)
elif compressed_tensors_config is not None and keep_quantized:
# The pinned Microsoft block-weight ABI is W4A16/W8A16 with FP16 A/Y.
config = dataclasses.replace(config, dtype=ir.DataType.FLOAT16)
Expand Down
8 changes: 7 additions & 1 deletion src/mobius/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@
"PhiCausalLMModel",
"Qwen25VLCausalLMModel",
"Qwen25OmniThinkerForConditionalGeneration",
"Qwen25OmniTalkerForConditionalGeneration",
"Qwen25OmniTalkerModel",
"Qwen25VLDecoderModel",
"Qwen25VLEmbeddingModel",
"Qwen25VLTextModel",
Expand Down Expand Up @@ -413,7 +415,11 @@
Qwen4ExpCausalLMModel,
Qwen4ExpForConditionalGeneration,
)
from mobius.models.qwen25_omni import Qwen25OmniThinkerForConditionalGeneration
from mobius.models.qwen25_omni import (
Qwen25OmniTalkerForConditionalGeneration,
Qwen25OmniTalkerModel,
Qwen25OmniThinkerForConditionalGeneration,
)
from mobius.models.qwen35 import (
Qwen35CausalLMModel,
Qwen35MoECausalLMModel,
Expand Down
117 changes: 106 additions & 11 deletions src/mobius/models/qwen25_omni.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

"""Qwen2.5-Omni Thinker: audio + vision + text.
"""Qwen2.5-Omni: Thinker and Talker models.

Architecture (Thinker only):
- Audio encoder: Conv1d x2 → sinusoidal PE → 32 encoder layers → AvgPool → proj
Expand Down Expand Up @@ -419,21 +419,101 @@ def forward(

hidden_states = self.norm(op, hidden_states)
logits = self.lm_head(op, hidden_states)
return logits, present_key_values
return logits, hidden_states, present_key_values


class Qwen25OmniTalkerModel(nn.Module):
"""Talker backbone with codec embedding, MRoPE decoder layers, and final norm."""

def __init__(self, config: ArchitectureConfig):
super().__init__()
embedding_size = config.embedding_size or config.hidden_size
self._dtype = config.dtype
self.embed_tokens = Embedding(config.vocab_size, embedding_size)
self.layers = nn.ModuleList(
[DecoderLayer(config) for _ in range(config.num_hidden_layers)]
)
self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
self.rotary_emb = initialize_rope(config)

def forward(
self,
op: OpBuilder,
inputs_embeds: ir.Value,
attention_mask: ir.Value,
position_ids: ir.Value,
past_key_values=None,
):
hidden_states = inputs_embeds
position_embeddings = (
self.rotary_emb(op, position_ids) if self.rotary_emb is not None else None
)
attention_bias = create_attention_bias(
op,
input_ids=inputs_embeds,
attention_mask=attention_mask,
dtype=self._dtype,
)

present_key_values = []
past_kvs = past_key_values or [None] * len(self.layers)
for layer, past_kv in zip(self.layers, past_kvs):
hidden_states, present_kv = layer(
op,
hidden_states=hidden_states,
attention_bias=attention_bias,
position_embeddings=position_embeddings,
past_key_value=past_kv,
)
present_key_values.append(present_kv)

return self.norm(op, hidden_states), present_key_values


class Qwen25OmniTalkerForConditionalGeneration(nn.Module):
"""Generate codec-token logits from Thinker-width input embeddings."""

def __init__(self, config: ArchitectureConfig):
super().__init__()
embedding_size = config.embedding_size or config.hidden_size
self.thinker_to_talker_proj = Linear(embedding_size, config.hidden_size, bias=True)
self.model = Qwen25OmniTalkerModel(config)
self.codec_head = Linear(config.hidden_size, config.vocab_size, bias=False)

def forward(
self,
op: OpBuilder,
inputs_embeds: ir.Value,
attention_mask: ir.Value,
position_ids: ir.Value,
past_key_values=None,
):
# The host combines Thinker reply states, text embeddings, and codec
# embeddings in the shared embedding space before this projection.
hidden_states = self.thinker_to_talker_proj(op, inputs_embeds)
Comment on lines +491 to +493
hidden_states, present_key_values = self.model(
op,
inputs_embeds=hidden_states,
attention_mask=attention_mask,
position_ids=position_ids,
past_key_values=past_key_values,
)
return self.codec_head(op, hidden_states), present_key_values


class Qwen25OmniThinkerForConditionalGeneration(nn.Module):
"""Qwen2.5-Omni Thinker: composite audio + vision + text model.
"""Qwen2.5-Omni composite Thinker and optional Talker model.

Builds four separate ONNX models:
Builds four Thinker models and, when configured, two Talker models:

- ``decoder``: Qwen2.5 text decoder taking ``inputs_embeds``
- ``vision_encoder``: Qwen2.5-VL ViT (pixel_values + grid_thw → image features)
- ``audio_encoder``: 2x Conv1d + transformer audio tower (mel → audio features)
- ``embedding``: word embedding + multimodal feature fusion
- ``talker_embedding``: codec token embedding in the Thinker-width space
- ``talker``: projection + speech-token decoder + codec logits

HuggingFace class: ``Qwen2_5OmniForConditionalGeneration`` (Thinker only —
the Talker / streaming code generation head is out of scope for now).
HuggingFace class: ``Qwen2_5OmniForConditionalGeneration``.
"""

default_task: str = "qwen25-omni"
Expand All @@ -451,12 +531,17 @@ def __init__(self, config: ArchitectureConfig):
self.audio_encoder: Qwen25OmniAudioEncoder | None = (
Qwen25OmniAudioEncoder(config) if config.audio is not None else None
)
self.talker: Qwen25OmniTalkerForConditionalGeneration | None = (
Qwen25OmniTalkerForConditionalGeneration(config.talker)
if config.talker is not None
else None
)

def forward(self, op: OpBuilder, **kwargs):
raise NotImplementedError(
"Qwen25OmniThinkerForConditionalGeneration is a multi-model split; the corresponding "
"Qwen25OmniTask builds each sub-module (decoder, embedding, vision_encoder, "
"audio_encoder) "
"audio_encoder, talker, and talker_embedding) "
"separately."
)

Expand All @@ -474,17 +559,27 @@ def preprocess_weights(
- ``thinker.lm_head.*`` → ``decoder.lm_head.*``
- ``thinker.model.rotary_emb.*`` → ``decoder.rotary_emb.*``

The Talker sub-tree (``talker.*``) and the audio-output codec head
are not consumed by this model and are silently dropped.
Talker keys already align with the nested ``talker.*`` module and are
retained when audio output is enabled.
"""
cleaned: dict[str, torch.Tensor] = {}
for key, value in state_dict.items():
if key == "talker.model.embed_tokens.weight":
# The embedding component traces this nested module directly,
# so its initializer has no outer Talker scopes.
cleaned["embed_tokens.weight"] = value
continue
if key.startswith("talker."):
if self.talker is not None:
cleaned[key] = value
continue

# Strip the thinker. prefix if present.
if key.startswith("thinker."):
key = key[len("thinker.") :]

# Drop talker.* and any codec output keys — not part of Thinker.
if key.startswith(("talker.", "token2wav.", "code_predictor.")):
# Token2wav is exported independently from the Talker.
if key.startswith(("token2wav.", "code_predictor.")):
continue

if key.startswith("audio_tower."):
Expand Down
Loading