6116b1f3c6
Update API Documentation / build-api-docs (push) Has been cancelled
Add complete FunASR codebase including models, runtime, and documentation.
729 lines
29 KiB
Python
729 lines
29 KiB
Python
#!/usr/bin/env python3
|
||
# -*- encoding: utf-8 -*-
|
||
# Copyright FunASR (https://github.com/alibaba-damo-academy/FunASR). All Rights Reserved.
|
||
# MIT License (https://opensource.org/licenses/MIT)
|
||
|
||
"""
|
||
Fun-ASR-Nano vLLM inference engine.
|
||
|
||
Uses vLLM for high-throughput LLM decoding while keeping the audio encoder
|
||
and adaptor in PyTorch. Supports batch inference and tensor-parallel for
|
||
multi-GPU acceleration.
|
||
|
||
Usage:
|
||
from funasr.models.fun_asr_nano.inference_vllm import FunASRNanoVLLM
|
||
|
||
engine = FunASRNanoVLLM.from_pretrained(
|
||
model="FunAudioLLM/Fun-ASR-Nano-2512",
|
||
tensor_parallel_size=2,
|
||
)
|
||
results = engine.generate(["audio1.wav", "audio2.wav"])
|
||
"""
|
||
|
||
import glob
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import shutil
|
||
import time
|
||
from typing import List, Optional, Union
|
||
|
||
import numpy as np
|
||
import torch
|
||
import torch.nn as nn
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
dtype_map = {"bf16": torch.bfloat16, "fp16": torch.float16, "fp32": torch.float32}
|
||
|
||
|
||
def prepare_vllm_model_dir(model_dir: str, output_dir: str = None) -> str:
|
||
"""Extract LLM weights from Fun-ASR-Nano model.pt and save in HuggingFace format.
|
||
|
||
Fun-ASR-Nano stores all weights (audio encoder + adaptor + LLM) in a single
|
||
model.pt file. vLLM needs the LLM weights in standard HuggingFace format.
|
||
This function extracts LLM weights and saves them alongside the config/tokenizer
|
||
files from the Qwen3-0.6B subdirectory.
|
||
|
||
Args:
|
||
model_dir: Path to the Fun-ASR-Nano model directory.
|
||
output_dir: Where to save the extracted LLM. Defaults to model_dir/Qwen3-0.6B-vllm.
|
||
|
||
Returns:
|
||
Path to the directory containing the vLLM-ready LLM model.
|
||
"""
|
||
if output_dir is None:
|
||
output_dir = os.path.join(model_dir, "Qwen3-0.6B-vllm")
|
||
|
||
# Check if already prepared
|
||
safetensors_files = glob.glob(os.path.join(output_dir, "*.safetensors"))
|
||
bin_files = glob.glob(os.path.join(output_dir, "model*.bin"))
|
||
if safetensors_files or bin_files:
|
||
logger.info(f"vLLM model already prepared at {output_dir}")
|
||
return output_dir
|
||
|
||
os.makedirs(output_dir, exist_ok=True)
|
||
|
||
# Copy config and tokenizer from Qwen3-0.6B
|
||
qwen_dir = os.path.join(model_dir, "Qwen3-0.6B")
|
||
if not os.path.isdir(qwen_dir):
|
||
raise FileNotFoundError(f"Qwen3-0.6B config directory not found at {qwen_dir}")
|
||
|
||
for fname in os.listdir(qwen_dir):
|
||
src = os.path.join(qwen_dir, fname)
|
||
dst = os.path.join(output_dir, fname)
|
||
if os.path.isfile(src) and not os.path.exists(dst):
|
||
shutil.copy2(src, dst)
|
||
|
||
# Load model.pt and extract LLM weights
|
||
model_pt = os.path.join(model_dir, "model.pt")
|
||
if not os.path.exists(model_pt):
|
||
raise FileNotFoundError(
|
||
f"model.pt not found at {model_pt}. Make sure the model is fully downloaded."
|
||
)
|
||
|
||
logger.info(f"Loading model.pt from {model_pt}...")
|
||
checkpoint = torch.load(model_pt, map_location="cpu")
|
||
if "state_dict" in checkpoint:
|
||
state_dict = checkpoint["state_dict"]
|
||
else:
|
||
state_dict = checkpoint
|
||
|
||
# Extract LLM weights (prefixed with "llm.")
|
||
llm_state = {}
|
||
for key, value in state_dict.items():
|
||
if key.startswith("llm."):
|
||
new_key = key[len("llm."):]
|
||
llm_state[new_key] = value
|
||
|
||
if not llm_state:
|
||
raise RuntimeError("No LLM weights found in model.pt (expected prefix 'llm.')")
|
||
|
||
logger.info(f"Extracted {len(llm_state)} LLM weight tensors")
|
||
|
||
# Save in safetensors format (preferred by vLLM)
|
||
try:
|
||
from safetensors.torch import save_file
|
||
|
||
save_path = os.path.join(output_dir, "model.safetensors")
|
||
save_file(llm_state, save_path)
|
||
logger.info(f"Saved LLM weights to {save_path}")
|
||
|
||
# Create model index
|
||
index = {
|
||
"metadata": {"total_size": sum(v.numel() * v.element_size() for v in llm_state.values())},
|
||
"weight_map": {k: "model.safetensors" for k in llm_state.keys()},
|
||
}
|
||
with open(os.path.join(output_dir, "model.safetensors.index.json"), "w") as f:
|
||
json.dump(index, f, indent=2)
|
||
except ImportError:
|
||
save_path = os.path.join(output_dir, "model.bin")
|
||
torch.save(llm_state, save_path)
|
||
logger.info(f"Saved LLM weights to {save_path} (install safetensors for faster loading)")
|
||
|
||
return output_dir
|
||
|
||
|
||
class FunASRNanoVLLM:
|
||
"""Fun-ASR-Nano with vLLM backend for high-throughput inference.
|
||
|
||
Architecture:
|
||
Audio -> WavFrontend -> SenseVoiceEncoder -> AudioAdaptor -> audio embeddings
|
||
Text tokens -> LLM embedding layer -> text embeddings
|
||
Combined embeddings -> vLLM (Qwen3-0.6B) -> generated text
|
||
|
||
The audio encoder and adaptor run in PyTorch on a single GPU,
|
||
while vLLM handles the LLM inference with optional tensor parallelism.
|
||
|
||
Args:
|
||
model_dir: Path to the Fun-ASR-Nano model directory.
|
||
device: Device for audio encoder/adaptor (e.g. "cuda:0").
|
||
dtype: Dtype for audio processing ("bf16", "fp16", "fp32").
|
||
tensor_parallel_size: Number of GPUs for vLLM tensor parallelism.
|
||
gpu_memory_utilization: Fraction of GPU memory for vLLM KV cache.
|
||
max_model_len: Maximum sequence length for vLLM.
|
||
enforce_eager: Disable CUDA graph for debugging.
|
||
|
||
Example:
|
||
>>> engine = FunASRNanoVLLM(
|
||
... model_dir="/path/to/Fun-ASR-Nano-2512",
|
||
... tensor_parallel_size=2,
|
||
... )
|
||
>>> results = engine.generate(["audio1.wav", "audio2.wav"])
|
||
>>> for r in results:
|
||
... print(r["text"])
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
model_dir: str,
|
||
device: str = "cuda:0",
|
||
dtype: str = "bf16",
|
||
tensor_parallel_size: int = 1,
|
||
gpu_memory_utilization: float = 0.8,
|
||
max_model_len: int = 2048,
|
||
enforce_eager: bool = False,
|
||
**kwargs,
|
||
):
|
||
from vllm import LLM, SamplingParams
|
||
try:
|
||
from vllm.inputs import EmbedsPrompt
|
||
except ImportError:
|
||
from vllm.inputs.data import EmbedsPrompt
|
||
|
||
self.device = device
|
||
self.dtype = dtype
|
||
self.torch_dtype = dtype_map.get(dtype, torch.bfloat16)
|
||
if self.torch_dtype == torch.float16:
|
||
logger.warning(
|
||
"dtype='fp16' can produce degraded or garbage transcription for "
|
||
"Fun-ASR-Nano (numerical overflow in the audio embedding path). "
|
||
"Use dtype='bf16' (recommended) or dtype='fp32'. On GPUs without "
|
||
"bfloat16 support (e.g. NVIDIA V100), use 'fp32'."
|
||
)
|
||
self.model_dir = model_dir
|
||
|
||
# Step 1: Prepare LLM weights for vLLM (extract from model.pt if needed)
|
||
vllm_model_dir = prepare_vllm_model_dir(model_dir)
|
||
|
||
# Step 2: Load audio components (encoder + adaptor + frontend)
|
||
self._load_audio_components(model_dir, **kwargs)
|
||
|
||
# Step 3: Initialize vLLM engine
|
||
logger.info(f"Initializing vLLM with model: {vllm_model_dir}")
|
||
logger.info(f" tensor_parallel_size={tensor_parallel_size}")
|
||
logger.info(f" gpu_memory_utilization={gpu_memory_utilization}")
|
||
|
||
vllm_kwargs = kwargs.get("vllm_kwargs", {})
|
||
self.vllm_engine = LLM(
|
||
enable_prompt_embeds=True,
|
||
model=vllm_model_dir,
|
||
tensor_parallel_size=tensor_parallel_size,
|
||
gpu_memory_utilization=gpu_memory_utilization,
|
||
max_model_len=max_model_len,
|
||
enforce_eager=enforce_eager,
|
||
dtype={"bf16": "bfloat16", "fp16": "float16", "fp32": "auto"}.get(dtype, dtype),
|
||
trust_remote_code=True,
|
||
**vllm_kwargs,
|
||
)
|
||
|
||
# Step 4: Get tokenizer and LLM embedding layer
|
||
self.tokenizer = self.vllm_engine.get_tokenizer()
|
||
self._load_embedding_layer(model_dir)
|
||
|
||
def _load_audio_components(self, model_dir: str, **kwargs):
|
||
"""Load audio encoder, adaptor, frontend, and CTC from checkpoint."""
|
||
from omegaconf import OmegaConf
|
||
from funasr.register import tables
|
||
|
||
config_path = os.path.join(model_dir, "config.yaml")
|
||
config = OmegaConf.load(config_path)
|
||
self._config = OmegaConf.to_container(config, resolve=True)
|
||
|
||
# --- Frontend ---
|
||
frontend_class = tables.frontend_classes.get(config["frontend"])
|
||
frontend_conf = OmegaConf.to_container(config.get("frontend_conf", {}), resolve=True)
|
||
cmvn_file = frontend_conf.get("cmvn_file")
|
||
if cmvn_file and not os.path.isabs(cmvn_file):
|
||
frontend_conf["cmvn_file"] = os.path.join(model_dir, cmvn_file)
|
||
self.frontend = frontend_class(**frontend_conf)
|
||
self.frontend.eval()
|
||
|
||
# --- Audio Encoder ---
|
||
encoder_conf = OmegaConf.to_container(config.get("audio_encoder_conf", {}), resolve=True)
|
||
hub = encoder_conf.get("hub", None)
|
||
if hub == "ms":
|
||
from funasr import AutoModel as FunAutoModel
|
||
|
||
enc_model = FunAutoModel(
|
||
model=config["audio_encoder"], model_revision="master", disable_update=True
|
||
)
|
||
self.audio_encoder_output_size = (
|
||
enc_model.model.encoder_output_size
|
||
if hasattr(enc_model.model, "encoder_output_size")
|
||
else -1
|
||
)
|
||
self.audio_encoder = (
|
||
enc_model.model.model.encoder
|
||
if hasattr(enc_model.model, "model")
|
||
else enc_model.model.encoder
|
||
)
|
||
else:
|
||
encoder_class = tables.encoder_classes.get(config["audio_encoder"])
|
||
input_size = self.frontend.output_size()
|
||
self.audio_encoder = encoder_class(input_size=input_size, **encoder_conf)
|
||
self.audio_encoder_output_size = self.audio_encoder.output_size()
|
||
|
||
self.audio_encoder.eval()
|
||
for p in self.audio_encoder.parameters():
|
||
p.requires_grad = False
|
||
|
||
# --- Audio Adaptor ---
|
||
adaptor_conf = OmegaConf.to_container(config.get("audio_adaptor_conf", {}), resolve=True)
|
||
adaptor_class = tables.adaptor_classes.get(config["audio_adaptor"])
|
||
if self.audio_encoder_output_size > 0:
|
||
adaptor_conf["encoder_dim"] = self.audio_encoder_output_size
|
||
self.audio_adaptor = adaptor_class(**adaptor_conf)
|
||
self.audio_adaptor.eval()
|
||
for p in self.audio_adaptor.parameters():
|
||
p.requires_grad = False
|
||
self.use_low_frame_rate = adaptor_conf.get("use_low_frame_rate", False)
|
||
|
||
# --- CTC Decoder (optional, for timestamps) ---
|
||
self.ctc_decoder = None
|
||
self.ctc = None
|
||
self.ctc_tokenizer = None
|
||
self.blank_id = None
|
||
|
||
ctc_decoder_name = self._config.get("ctc_decoder", None)
|
||
if ctc_decoder_name:
|
||
ctc_decoder_class = tables.adaptor_classes.get(ctc_decoder_name)
|
||
ctc_decoder_conf = self._config.get("ctc_decoder_conf", {})
|
||
if self.audio_encoder_output_size > 0:
|
||
ctc_decoder_conf["encoder_dim"] = self.audio_encoder_output_size
|
||
self.ctc_decoder = ctc_decoder_class(**ctc_decoder_conf)
|
||
self.ctc_decoder.eval()
|
||
for p in self.ctc_decoder.parameters():
|
||
p.requires_grad = False
|
||
|
||
from funasr.models.fun_asr_nano.ctc import CTC
|
||
|
||
ctc_conf = self._config.get("ctc_conf", {})
|
||
ctc_vocab_size = self._config.get("ctc_vocab_size", 60515)
|
||
self.blank_id = ctc_conf.get("blank_id", ctc_vocab_size - 1)
|
||
self.ctc = CTC(
|
||
odim=ctc_vocab_size,
|
||
encoder_output_size=self.audio_encoder_output_size,
|
||
blank_id=self.blank_id,
|
||
**ctc_conf,
|
||
)
|
||
|
||
# CTC tokenizer
|
||
ds_conf = self._config.get("dataset_conf", {})
|
||
ctc_tokenizer_name = ds_conf.get("ctc_tokenizer", None)
|
||
ctc_tokenizer_conf = ds_conf.get("ctc_tokenizer_conf", {})
|
||
if ctc_tokenizer_name:
|
||
ctc_tokenizer_class = tables.tokenizer_classes.get(ctc_tokenizer_name)
|
||
vocab_path = ctc_tokenizer_conf.get("vocab_path")
|
||
if vocab_path is None or not os.path.isabs(vocab_path):
|
||
multilingual_path = os.path.join(model_dir, "multilingual.tiktoken")
|
||
if os.path.exists(multilingual_path):
|
||
ctc_tokenizer_conf["vocab_path"] = multilingual_path
|
||
elif vocab_path and not os.path.isabs(vocab_path):
|
||
ctc_tokenizer_conf["vocab_path"] = os.path.join(model_dir, vocab_path)
|
||
self.ctc_tokenizer = ctc_tokenizer_class(**ctc_tokenizer_conf)
|
||
|
||
# --- Load weights from model.pt ---
|
||
model_pt = os.path.join(model_dir, "model.pt")
|
||
if os.path.exists(model_pt):
|
||
logger.info(f"Loading audio component weights from {model_pt}")
|
||
checkpoint = torch.load(model_pt, map_location="cpu")
|
||
state_dict = checkpoint.get("state_dict", checkpoint)
|
||
|
||
# Audio encoder
|
||
enc_state = {
|
||
k[len("audio_encoder."):]: v
|
||
for k, v in state_dict.items()
|
||
if k.startswith("audio_encoder.")
|
||
}
|
||
if enc_state:
|
||
self.audio_encoder.load_state_dict(enc_state, strict=False)
|
||
logger.info(f" Loaded audio_encoder: {len(enc_state)} params")
|
||
|
||
# Audio adaptor
|
||
adp_state = {
|
||
k[len("audio_adaptor."):]: v
|
||
for k, v in state_dict.items()
|
||
if k.startswith("audio_adaptor.")
|
||
}
|
||
if adp_state:
|
||
self.audio_adaptor.load_state_dict(adp_state, strict=False)
|
||
logger.info(f" Loaded audio_adaptor: {len(adp_state)} params")
|
||
|
||
# CTC decoder
|
||
if self.ctc_decoder is not None:
|
||
ctc_dec_state = {
|
||
k[len("ctc_decoder."):]: v
|
||
for k, v in state_dict.items()
|
||
if k.startswith("ctc_decoder.")
|
||
}
|
||
if ctc_dec_state:
|
||
self.ctc_decoder.load_state_dict(ctc_dec_state, strict=False)
|
||
ctc_state = {
|
||
k[len("ctc."):]: v
|
||
for k, v in state_dict.items()
|
||
if k.startswith("ctc.") and not k.startswith("ctc_decoder.")
|
||
}
|
||
if ctc_state:
|
||
self.ctc.load_state_dict(ctc_state, strict=False)
|
||
|
||
# Move to device
|
||
self.audio_encoder = self.audio_encoder.to(self.device, dtype=torch.float32)
|
||
self.audio_adaptor = self.audio_adaptor.to(self.device, dtype=self.torch_dtype)
|
||
if self.ctc_decoder is not None:
|
||
self.ctc_decoder = self.ctc_decoder.to(self.device, dtype=torch.float32)
|
||
self.ctc = self.ctc.to(self.device, dtype=torch.float32)
|
||
|
||
def _load_embedding_layer(self, model_dir: str):
|
||
"""Load the LLM embedding layer for text token embedding computation."""
|
||
model_pt = os.path.join(model_dir, "model.pt")
|
||
checkpoint = torch.load(model_pt, map_location="cpu")
|
||
state_dict = checkpoint.get("state_dict", checkpoint)
|
||
|
||
# Look for embedding weights
|
||
embed_key = None
|
||
for key in state_dict.keys():
|
||
if "embed_tokens.weight" in key and key.startswith("llm."):
|
||
embed_key = key
|
||
break
|
||
|
||
if embed_key is None:
|
||
raise RuntimeError("Could not find LLM embedding weights in model.pt")
|
||
|
||
embed_weight = state_dict[embed_key]
|
||
self.embed_tokens = nn.Embedding.from_pretrained(embed_weight, freeze=True)
|
||
self.embed_tokens = self.embed_tokens.to(self.device, dtype=self.torch_dtype)
|
||
logger.info(f"Loaded embedding layer: {embed_weight.shape}")
|
||
|
||
@torch.no_grad()
|
||
def _encode_audio(self, audio_input: Union[str, torch.Tensor, np.ndarray]):
|
||
"""Encode audio through frontend -> encoder -> adaptor.
|
||
|
||
Returns:
|
||
adaptor_out: (1, T', D_llm) audio embeddings for LLM input
|
||
adaptor_out_lens: (1,) lengths
|
||
encoder_out: (1, T, D_enc) encoder output for CTC
|
||
encoder_out_lens: (1,) encoder output lengths
|
||
"""
|
||
from funasr.utils.load_utils import load_audio_text_image_video, extract_fbank
|
||
|
||
if isinstance(audio_input, str):
|
||
data_src = load_audio_text_image_video(audio_input, fs=self.frontend.fs)
|
||
elif isinstance(audio_input, np.ndarray):
|
||
data_src = torch.from_numpy(audio_input).float()
|
||
elif isinstance(audio_input, torch.Tensor):
|
||
data_src = audio_input.float()
|
||
else:
|
||
raise ValueError(f"Unsupported audio input type: {type(audio_input)}")
|
||
|
||
speech, speech_lengths = extract_fbank(
|
||
data_src, data_type="sound", frontend=self.frontend, is_final=True
|
||
)
|
||
speech = speech.to(self.device, dtype=torch.float32)
|
||
speech_lengths = speech_lengths.to(self.device)
|
||
|
||
encoder_out, encoder_out_lens = self.audio_encoder(speech, speech_lengths)
|
||
encoder_out_for_adaptor = encoder_out.to(dtype=self.torch_dtype)
|
||
adaptor_out, adaptor_out_lens = self.audio_adaptor(encoder_out_for_adaptor, encoder_out_lens)
|
||
|
||
# Apply low frame rate: compute effective token count from fbank length
|
||
# Matches PyTorch model.py data_load_speech formula exactly
|
||
if self.use_low_frame_rate:
|
||
for i in range(adaptor_out.shape[0]):
|
||
fbank_len = speech_lengths[i].item()
|
||
olens = 1 + (fbank_len - 3 + 2 * 1) // 2
|
||
olens = 1 + (olens - 3 + 2 * 1) // 2
|
||
fake_token_len = (olens - 1) // 2 + 1
|
||
adaptor_out_lens[i] = fake_token_len
|
||
|
||
return adaptor_out, adaptor_out_lens, encoder_out, encoder_out_lens
|
||
|
||
def _build_prompt_text(
|
||
self,
|
||
hotwords: List[str] = None,
|
||
language: str = None,
|
||
itn: bool = True,
|
||
) -> str:
|
||
"""Build the ASR prompt string."""
|
||
hotwords = hotwords or []
|
||
if len(hotwords) > 0:
|
||
hotwords_str = ", ".join(hotwords)
|
||
prompt = (
|
||
"请结合上下文信息,更加准确地完成语音转写任务。"
|
||
"如果没有相关信息,我们会留空。\n\n\n**上下文信息:**\n\n\n"
|
||
)
|
||
prompt += f"热词列表:[{hotwords_str}]\n"
|
||
else:
|
||
prompt = ""
|
||
if language is None:
|
||
prompt += "语音转写"
|
||
else:
|
||
prompt += f"语音转写成{language}"
|
||
if not itn:
|
||
prompt += ",不进行文本规整"
|
||
return prompt + ":"
|
||
|
||
@torch.no_grad()
|
||
def _build_input_embeds(
|
||
self,
|
||
audio_embeds: torch.Tensor,
|
||
audio_embed_lens: torch.Tensor,
|
||
hotwords: List[str] = None,
|
||
language: str = None,
|
||
itn: bool = True,
|
||
system_prompt: str = "You are a helpful assistant.",
|
||
) -> torch.Tensor:
|
||
"""Build the full input embedding sequence with audio inserted.
|
||
|
||
Returns:
|
||
Tensor of shape (seq_len, D_llm)
|
||
"""
|
||
prompt = self._build_prompt_text(hotwords, language, itn)
|
||
|
||
# ChatML format with speech markers and thinking prefix
|
||
prefix_text = (
|
||
f"<|im_start|>system\n{system_prompt}<|im_end|>\n"
|
||
f"<|im_start|>user\n{prompt}<|startofspeech|>"
|
||
)
|
||
suffix_text = "<|endofspeech|><|im_end|>\n<|im_start|>assistant\n"
|
||
|
||
# Tokenize
|
||
prefix_ids = self.tokenizer.encode(prefix_text, add_special_tokens=False)
|
||
suffix_ids = self.tokenizer.encode(suffix_text, add_special_tokens=False)
|
||
|
||
# Embed text tokens
|
||
prefix_tensor = torch.tensor(prefix_ids, dtype=torch.long, device=self.device)
|
||
suffix_tensor = torch.tensor(suffix_ids, dtype=torch.long, device=self.device)
|
||
prefix_embeds = self.embed_tokens(prefix_tensor)
|
||
suffix_embeds = self.embed_tokens(suffix_tensor)
|
||
|
||
# Audio embeddings
|
||
audio_len = audio_embed_lens[0].item()
|
||
audio_emb = audio_embeds[0, :audio_len, :]
|
||
|
||
# Concat: [prefix_text_emb | audio_emb | suffix_text_emb]
|
||
inputs_embeds = torch.cat([prefix_embeds, audio_emb, suffix_embeds], dim=0)
|
||
return inputs_embeds
|
||
|
||
def generate(
|
||
self,
|
||
inputs: Union[str, List[str], np.ndarray, torch.Tensor, List],
|
||
hotwords: List[str] = None,
|
||
language: str = None,
|
||
itn: bool = True,
|
||
max_new_tokens: int = 512,
|
||
temperature: float = 0.0,
|
||
top_p: float = 1.0,
|
||
top_k: int = -1,
|
||
repetition_penalty: float = 1.0,
|
||
**kwargs,
|
||
) -> List[dict]:
|
||
"""Run batch ASR inference using vLLM.
|
||
|
||
Args:
|
||
inputs: Audio input(s). Accepts:
|
||
- str: single file path
|
||
- List[str]: batch of file paths
|
||
- np.ndarray / torch.Tensor: raw audio samples (16kHz)
|
||
hotwords: Keywords to boost recognition accuracy.
|
||
language: Language hint (e.g. "中文", "英文", "日文").
|
||
itn: Apply inverse text normalization (default True).
|
||
max_new_tokens: Maximum tokens to generate per sample.
|
||
temperature: Sampling temperature (0 = greedy decoding).
|
||
top_p: Nucleus sampling parameter.
|
||
top_k: Top-k sampling (-1 = disabled).
|
||
repetition_penalty: Repetition penalty factor.
|
||
|
||
Returns:
|
||
List of result dicts: [{"key": str, "text": str, "timestamps": [...]}]
|
||
"""
|
||
from vllm import SamplingParams
|
||
try:
|
||
from vllm.inputs import EmbedsPrompt
|
||
except ImportError:
|
||
from vllm.inputs.data import EmbedsPrompt
|
||
|
||
from funasr.models.fun_asr_nano.vllm_utils import resolve_repetition_penalty
|
||
|
||
if isinstance(inputs, (str, np.ndarray, torch.Tensor)):
|
||
inputs = [inputs]
|
||
|
||
sampling_params = SamplingParams(
|
||
max_tokens=max_new_tokens,
|
||
temperature=temperature,
|
||
top_p=top_p,
|
||
top_k=top_k if top_k > 0 else -1,
|
||
# Prompt-embeds mode has no token IDs to penalize; see #2948.
|
||
repetition_penalty=resolve_repetition_penalty(repetition_penalty),
|
||
skip_special_tokens=True,
|
||
)
|
||
|
||
# Batch encode audio and build embedding prompts
|
||
prompts = []
|
||
encoder_outputs = []
|
||
|
||
t0 = time.perf_counter()
|
||
|
||
# Pre-compute text embeddings (shared across batch)
|
||
prompt_text = self._build_prompt_text(hotwords, language, itn)
|
||
prefix_text = f"<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n<|im_start|>user\n{prompt_text}"
|
||
suffix_text = "<|im_end|>\n<|im_start|>assistant\n"
|
||
prefix_ids = self.tokenizer.encode(prefix_text, add_special_tokens=False)
|
||
suffix_ids = self.tokenizer.encode(suffix_text, add_special_tokens=False)
|
||
prefix_emb = self.embed_tokens(torch.tensor(prefix_ids, dtype=torch.long, device=self.device))
|
||
suffix_emb = self.embed_tokens(torch.tensor(suffix_ids, dtype=torch.long, device=self.device))
|
||
|
||
# Batch encode audio (groups of 8 for memory efficiency)
|
||
batch_size_enc = 8
|
||
all_adaptor_outs = []
|
||
all_adaptor_lens = []
|
||
for i in range(0, len(inputs), batch_size_enc):
|
||
batch_inputs = inputs[i:i+batch_size_enc]
|
||
# Load and extract fbank for batch
|
||
from funasr.utils.load_utils import load_audio_text_image_video, extract_fbank
|
||
audio_tensors = []
|
||
for audio_input in batch_inputs:
|
||
if isinstance(audio_input, str):
|
||
data_src = load_audio_text_image_video(audio_input, fs=self.frontend.fs)
|
||
elif isinstance(audio_input, np.ndarray):
|
||
data_src = torch.from_numpy(audio_input).float()
|
||
elif isinstance(audio_input, torch.Tensor):
|
||
data_src = audio_input.float()
|
||
else:
|
||
raise ValueError(f"Unsupported audio input type: {type(audio_input)}")
|
||
audio_tensors.append(data_src)
|
||
|
||
speech, speech_lengths = extract_fbank(
|
||
audio_tensors, data_type="sound", frontend=self.frontend, is_final=True
|
||
)
|
||
speech = speech.to(self.device, dtype=torch.float32)
|
||
speech_lengths = speech_lengths.to(self.device)
|
||
|
||
with torch.no_grad():
|
||
enc_out, enc_lens = self.audio_encoder(speech, speech_lengths)
|
||
adp_out, adp_lens = self.audio_adaptor(enc_out.to(dtype=self.torch_dtype), enc_lens)
|
||
|
||
# Apply low frame rate token length correction
|
||
if self.use_low_frame_rate:
|
||
for j in range(len(batch_inputs)):
|
||
fbank_len = speech_lengths[j].item()
|
||
olens = 1 + (fbank_len - 3 + 2 * 1) // 2
|
||
olens = 1 + (olens - 3 + 2 * 1) // 2
|
||
adp_lens[j] = (olens - 1) // 2 + 1
|
||
|
||
for j in range(len(batch_inputs)):
|
||
all_adaptor_outs.append(adp_out[j, :adp_lens[j], :])
|
||
all_adaptor_lens.append(adp_lens[j])
|
||
encoder_outputs.append((enc_out[j:j+1, :enc_lens[j], :], enc_lens[j:j+1]))
|
||
|
||
# Build prompts
|
||
for audio_emb in all_adaptor_outs:
|
||
input_embeds = torch.cat([prefix_emb, audio_emb, suffix_emb], dim=0)
|
||
prompts.append(EmbedsPrompt(prompt_embeds=input_embeds.float()))
|
||
|
||
t1 = time.perf_counter()
|
||
logger.info(f"Audio encoding: {len(inputs)} samples in {t1 - t0:.3f}s")
|
||
|
||
# vLLM batch generation
|
||
outputs = self.vllm_engine.generate(prompts, sampling_params, use_tqdm=len(inputs) > 1)
|
||
|
||
t2 = time.perf_counter()
|
||
logger.info(f"vLLM generation: {t2 - t1:.3f}s")
|
||
|
||
# Process results
|
||
results = []
|
||
for i, output in enumerate(outputs):
|
||
token_ids = list(output.outputs[0].token_ids)
|
||
text = self.tokenizer.decode(token_ids, skip_special_tokens=True)
|
||
# Clean vLLM artifacts: remove garbage prefix/tags
|
||
text = re.sub(r'<[^>]*>', '', text)
|
||
text = re.sub(r'\[[^\]]*\]', '', text)
|
||
text = re.sub(r'endofpatch|/sil|FFFF|</strong>', '', text)
|
||
# Strip non-CJK/non-alnum prefix garbage
|
||
text = re.sub(r'^[^\w一-鿿]+', '', text)
|
||
text_clean = re.sub(r"\s+", " ", text).strip()
|
||
|
||
key = (
|
||
os.path.splitext(os.path.basename(inputs[i]))[0]
|
||
if isinstance(inputs[i], str)
|
||
else f"sample_{i}"
|
||
)
|
||
result = {"key": key, "text": text_clean}
|
||
|
||
# Timestamps via CTC forced alignment
|
||
if self.ctc_decoder is not None and self.ctc_tokenizer is not None:
|
||
try:
|
||
timestamps = self._compute_timestamps(
|
||
encoder_outputs[i][0], encoder_outputs[i][1], text_clean
|
||
)
|
||
if timestamps:
|
||
result["timestamps"] = timestamps
|
||
except Exception as e:
|
||
logger.debug(f"Timestamp computation failed for {key}: {e}")
|
||
|
||
results.append(result)
|
||
|
||
return results
|
||
|
||
@torch.no_grad()
|
||
def _compute_timestamps(self, encoder_out, encoder_out_lens, text):
|
||
"""CTC forced alignment for character-level timestamps."""
|
||
from funasr.models.fun_asr_nano.tools.utils import forced_align
|
||
|
||
decoder_out, decoder_out_lens = self.ctc_decoder(encoder_out, encoder_out_lens)
|
||
ctc_logits = self.ctc.log_softmax(decoder_out)
|
||
x = ctc_logits[0, : encoder_out_lens[0].item(), :]
|
||
|
||
target_ids = torch.tensor(self.ctc_tokenizer.encode(text), dtype=torch.int64)
|
||
if len(target_ids) == 0:
|
||
return []
|
||
|
||
timestamps = forced_align(x, target_ids, self.blank_id)
|
||
for ts in timestamps:
|
||
ts["token"] = self.ctc_tokenizer.decode([ts["token"]])
|
||
ts["start_time"] = ts["start_time"] * 6 * 10 / 1000
|
||
ts["end_time"] = ts["end_time"] * 6 * 10 / 1000
|
||
return timestamps
|
||
|
||
@classmethod
|
||
def from_pretrained(
|
||
cls,
|
||
model: str = "FunAudioLLM/Fun-ASR-Nano-2512",
|
||
hub: str = "ms",
|
||
device: str = "cuda:0",
|
||
dtype: str = "bf16",
|
||
tensor_parallel_size: int = 1,
|
||
gpu_memory_utilization: float = 0.8,
|
||
max_model_len: int = 2048,
|
||
**kwargs,
|
||
) -> "FunASRNanoVLLM":
|
||
"""Load model from hub or local path.
|
||
|
||
Args:
|
||
model: Model name or local directory path.
|
||
hub: "ms" (ModelScope) or "hf" (HuggingFace).
|
||
device: Device for audio encoder/adaptor.
|
||
dtype: Compute dtype ("bf16", "fp16", "fp32").
|
||
tensor_parallel_size: GPUs for vLLM tensor parallel.
|
||
gpu_memory_utilization: GPU memory fraction for vLLM.
|
||
max_model_len: Maximum sequence length.
|
||
|
||
Returns:
|
||
Initialized FunASRNanoVLLM engine.
|
||
"""
|
||
if os.path.isdir(model):
|
||
model_dir = model
|
||
else:
|
||
if hub in ("ms", "modelscope"):
|
||
from modelscope.hub.snapshot_download import snapshot_download
|
||
|
||
model_dir = snapshot_download(model, revision=kwargs.pop("revision", "master"))
|
||
elif hub in ("hf", "huggingface"):
|
||
from huggingface_hub import snapshot_download
|
||
|
||
model_dir = snapshot_download(model)
|
||
else:
|
||
raise ValueError(f"Unsupported hub: {hub}. Use 'ms' or 'hf'.")
|
||
|
||
logger.info(f"Model directory: {model_dir}")
|
||
return cls(
|
||
model_dir=model_dir,
|
||
device=device,
|
||
dtype=dtype,
|
||
tensor_parallel_size=tensor_parallel_size,
|
||
gpu_memory_utilization=gpu_memory_utilization,
|
||
max_model_len=max_model_len,
|
||
**kwargs,
|
||
)
|