File size: 7,243 Bytes
148af80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
"""Frozen, transcription-free Qwen3-ASR audio feature extraction.

This adapter deliberately loads no tokenizer and never calls ``generate`` or
``transcribe``.  It runs only Qwen3-ASR's native audio tower and its official
final audio projection, then pools observed frames for the shared speech/sound
feature-cache contract.
"""
from __future__ import annotations

from dataclasses import dataclass
from math import ceil
from pathlib import Path
from typing import Sequence

import numpy as np
import torch


QWEN_SAMPLE_RATE = 16_000
QWEN_INPUT_HOP_SECONDS = 160 / QWEN_SAMPLE_RATE
QWEN_CONV_STRIDE = 2**3
QWEN_FRAME_SECONDS = QWEN_INPUT_HOP_SECONDS * QWEN_CONV_STRIDE
TAIL_SECONDS = 0.5
TAIL_FRAMES = ceil(TAIL_SECONDS / QWEN_FRAME_SECONDS)
SUPPORTED_MODEL_TYPES = {"qwen3_asr"}
SUPPORTED_AUDIO_MODEL_TYPES = {"qwen3_asr_audio_encoder"}


@dataclass(frozen=True)
class QwenEncoderMetadata:
    """Pinned-model and geometry information needed to reuse feature caches."""

    model_id: str
    revision: str
    model_type: str
    audio_model_type: str
    input_hop_seconds: float
    encoder_frame_seconds: float
    tail_seconds: float
    tail_frames: int
    frame_dim: int
    projected_dim: int
    pooled_dim: int


class QwenAudioEncoder:
    """Qwen3-ASR native audio tower plus padding-safe mean/tail pooling.

    ``encode`` accepts finite, mono 16-kHz waveforms and returns one
    ``[mean(projected frames), mean(last 0.5 s projected frames)]`` feature
    vector per input.  The 0.5-second tail is exactly seven Qwen output frames:
    10-ms log-mel hops and three stride-2 convolutional layers produce 80-ms
    output steps, so ``ceil(0.5 / 0.08) == 7``.
    """

    def __init__(self, model_path: str | Path, *, model_id: str, revision: str, device: str = "cpu"):
        self.model_path = Path(model_path)
        self.model_id = model_id
        self.revision = revision
        self.device = torch.device(device)
        if self.device.type == "cuda" and not torch.cuda.is_available():
            raise RuntimeError("CUDA was requested but is unavailable")
        # The official qwen-asr 0.0.6 transformer backend is vendored beside
        # this adapter.  Its public checkpoint has a ``thinker.*`` state-dict
        # layout that differs from Transformers' generic Qwen implementations.
        try:
            from .qwen_backend import Qwen3ASRConfig, Qwen3ASRForConditionalGeneration
            from transformers import WhisperFeatureExtractor
        except ImportError as exc:
            raise RuntimeError(
                "QwenAudioEncoder requires its vendored official Qwen3-ASR backend."
            ) from exc

        config = Qwen3ASRConfig.from_pretrained(self.model_path, local_files_only=True)
        audio_config = config.thinker_config.audio_config
        if config.model_type not in SUPPORTED_MODEL_TYPES or audio_config.model_type not in SUPPORTED_AUDIO_MODEL_TYPES:
            raise ValueError("checkpoint is not a compatible native Qwen3-ASR audio model")
        if int(audio_config.num_mel_bins) != 128 or int(audio_config.n_window) != 50:
            raise ValueError("unexpected Qwen3-ASR audio encoder frontend/window configuration")

        self.feature_extractor = WhisperFeatureExtractor.from_pretrained(self.model_path, local_files_only=True)
        # The public checkpoint packages the audio tower under the complete
        # conditional-generation model.  We discard the text model immediately
        # after loading and retain only audio_tower; no tokenizer,
        # language model forward, or text decoding is reachable from this API.
        dtype = torch.bfloat16 if self.device.type == "cuda" else torch.float32
        full_model = Qwen3ASRForConditionalGeneration.from_pretrained(
            self.model_path, local_files_only=True, torch_dtype=dtype, attn_implementation="sdpa"
        ).to(self.device).eval()
        self.audio_tower = full_model.thinker.audio_tower
        del full_model
        self.audio_tower.requires_grad_(False)
        frame_dim = int(audio_config.d_model)
        projected_dim = int(audio_config.output_dim)
        self.metadata = QwenEncoderMetadata(
            model_id=model_id, revision=revision, model_type=config.model_type,
            audio_model_type=audio_config.model_type, input_hop_seconds=QWEN_INPUT_HOP_SECONDS,
            encoder_frame_seconds=QWEN_FRAME_SECONDS, tail_seconds=TAIL_SECONDS, tail_frames=TAIL_FRAMES,
            frame_dim=frame_dim, projected_dim=projected_dim, pooled_dim=2 * projected_dim,
        )

    @staticmethod
    def _prepare(waves16k: Sequence[np.ndarray]) -> list[np.ndarray]:
        if not waves16k:
            raise ValueError("at least one waveform is required")
        prepared = []
        max_samples = QWEN_SAMPLE_RATE * 30
        for wave in waves16k:
            value = np.asarray(wave, dtype=np.float32)
            if value.ndim != 1 or value.size == 0 or not np.isfinite(value).all():
                raise ValueError("each waveform must be finite, nonempty, mono 16-kHz audio")
            if value.size > max_samples:
                raise ValueError("Qwen3-ASR feature adapter accepts at most 30 seconds per waveform")
            prepared.append(np.ascontiguousarray(np.clip(value, -1.0, 1.0)))
        return prepared

    @torch.inference_mode()
    def encode(self, waves16k: Sequence[np.ndarray]) -> torch.Tensor:
        """Return padding-excluded projected mean/tail features of shape ``[B, 2 * projected_dim]``."""
        waves = self._prepare(waves16k)
        inputs = self.feature_extractor(waves, sampling_rate=QWEN_SAMPLE_RATE, padding=True,
                                        return_attention_mask=True, return_tensors="pt")
        if "attention_mask" not in inputs or "input_features" not in inputs:
            raise RuntimeError("Qwen audio feature extractor did not provide features and an attention mask")
        mask = inputs["attention_mask"].to(self.device)
        input_features = inputs["input_features"].to(device=self.device, dtype=next(self.audio_tower.parameters()).dtype)
        # The official tower intentionally processes one waveform at a time to
        # preserve precision.  It already includes proj1/GELU/proj2, yielding
        # 1024-dimensional audio features without any language-model call.
        input_lengths = mask.sum(dim=-1).to(torch.long)
        pooled = []
        for feature, length in zip(input_features, input_lengths):
            length = int(length)
            observed = self.audio_tower(feature[:, :length], feature_lens=torch.tensor([length], device=self.device)).last_hidden_state.float()
            if observed.ndim != 2 or observed.shape[-1] != self.metadata.projected_dim:
                raise RuntimeError("Qwen audio tower returned an unexpected feature shape")
            # The official tower uses three stride-2 convolutions.  Its output
            # is intrinsically padding-free for this one waveform.
            if length < 1:
                raise RuntimeError("Qwen audio encoder returned no observed frames")
            pooled.append(torch.cat((observed.mean(dim=0), observed[-min(TAIL_FRAMES, observed.shape[0]):].mean(dim=0))))
        return torch.stack(pooled).cpu()