Files
freedakgmail 6116b1f3c6
Update API Documentation / build-api-docs (push) Has been cancelled
Initial commit: FunASR Speech Recognition Toolkit
Add complete FunASR codebase including models, runtime, and documentation.
2026-07-09 22:38:58 +08:00

729 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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,
)