Spaces:
Runtime error
Runtime error
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()
|