|
1 | | -"""Internal: FastAPI app for funasr-server command.""" |
| 1 | +"""FunASR Server — unified vLLM-based inference service. |
2 | 2 |
|
3 | | -import tempfile |
4 | | -import time |
| 3 | +Provides OpenAI-compatible API (/v1/audio/transcriptions) and REST API (/asr). |
| 4 | +Uses vLLM for Fun-ASR-Nano (GPU) or falls back to AutoModel for non-LLM models (SenseVoice/Paraformer). |
| 5 | +""" |
| 6 | + |
| 7 | +import io |
5 | 8 | import os |
6 | 9 | import re |
| 10 | +import time |
7 | 11 | import logging |
| 12 | +import tempfile |
8 | 13 | from typing import Optional |
9 | 14 |
|
| 15 | +import numpy as np |
| 16 | +import soundfile as sf |
| 17 | + |
10 | 18 | try: |
11 | 19 | from fastapi import FastAPI, UploadFile, File, Form, HTTPException |
12 | 20 | from fastapi.responses import JSONResponse |
|
17 | 25 |
|
18 | 26 | logger = logging.getLogger("funasr.server") |
19 | 27 |
|
20 | | -MODEL_CONFIGS = { |
21 | | - "sensevoice": { |
22 | | - "model": "iic/SenseVoiceSmall", |
23 | | - "vad_model": "fsmn-vad", |
24 | | - "vad_kwargs": {"max_single_segment_time": 30000}, |
25 | | - }, |
26 | | - "paraformer": { |
27 | | - "model": "paraformer-zh", |
28 | | - "vad_model": "fsmn-vad", |
29 | | - "punc_model": "ct-punc", |
30 | | - }, |
31 | | - "paraformer-en": { |
32 | | - "model": "paraformer-en", |
33 | | - "vad_model": "fsmn-vad", |
34 | | - }, |
35 | | - "fun-asr-nano": { |
36 | | - "model": "FunAudioLLM/Fun-ASR-Nano-2512", |
37 | | - "hub": "hf", |
38 | | - "trust_remote_code": True, |
39 | | - "vad_model": "fsmn-vad", |
40 | | - "vad_kwargs": {"max_single_segment_time": 30000}, |
41 | | - }, |
42 | | -} |
43 | | - |
44 | 28 |
|
45 | 29 | def create_app(device: str = "cuda", preload_model: str = "auto") -> FastAPI: |
46 | 30 | if preload_model == "auto": |
47 | 31 | preload_model = "fun-asr-nano" if device.startswith("cuda") else "sensevoice" |
48 | | - app = FastAPI(title="FunASR Server", version="1.3.2") |
49 | | - app.state.models = {} |
| 32 | + |
| 33 | + app = FastAPI(title="FunASR Server", version="1.3.6") |
50 | 34 | app.state.device = device |
| 35 | + app.state.engine = None |
| 36 | + app.state.vad_model = None |
| 37 | + app.state.fallback_models = {} |
| 38 | + |
| 39 | + # Non-LLM model configs (use AutoModel, no vLLM) |
| 40 | + FALLBACK_CONFIGS = { |
| 41 | + "sensevoice": { |
| 42 | + "model": "iic/SenseVoiceSmall", |
| 43 | + "vad_model": "fsmn-vad", |
| 44 | + "vad_kwargs": {"max_single_segment_time": 30000}, |
| 45 | + }, |
| 46 | + "paraformer": { |
| 47 | + "model": "paraformer-zh", |
| 48 | + "vad_model": "fsmn-vad", |
| 49 | + "punc_model": "ct-punc", |
| 50 | + }, |
| 51 | + } |
51 | 52 |
|
52 | | - def _load_model(name: str): |
53 | | - if name in app.state.models: |
54 | | - return app.state.models[name] |
55 | | - if name not in MODEL_CONFIGS: |
56 | | - raise ValueError(f"Unknown model: {name}. Available: {list(MODEL_CONFIGS.keys())}") |
| 53 | + def _load_vllm_engine(): |
| 54 | + """Load Fun-ASR-Nano vLLM engine. Falls back to AutoModel if vLLM unavailable.""" |
| 55 | + if app.state.engine is not None: |
| 56 | + return |
| 57 | + try: |
| 58 | + from funasr.models.fun_asr_nano.inference_vllm import FunASRNanoVLLM |
| 59 | + from funasr import AutoModel as _AutoModel |
| 60 | + |
| 61 | + logger.info("Loading Fun-ASR-Nano vLLM engine...") |
| 62 | + t0 = time.time() |
| 63 | + app.state.engine = FunASRNanoVLLM.from_pretrained( |
| 64 | + model="FunAudioLLM/Fun-ASR-Nano-2512", |
| 65 | + hub="hf", |
| 66 | + device=device, |
| 67 | + dtype="bf16", |
| 68 | + max_model_len=4096, |
| 69 | + gpu_memory_utilization=0.5, |
| 70 | + ) |
| 71 | + logger.info(f"vLLM engine ready in {time.time()-t0:.1f}s") |
| 72 | + app.state.use_vllm = True |
| 73 | + |
| 74 | + logger.info("Loading VAD model...") |
| 75 | + app.state.vad_model = _AutoModel(model="fsmn-vad", device=device, disable_update=True) |
| 76 | + logger.info("VAD ready.") |
| 77 | + except Exception as e: |
| 78 | + logger.warning(f"vLLM failed ({e}), falling back to AutoModel for fun-asr-nano") |
| 79 | + app.state.use_vllm = False |
| 80 | + from funasr import AutoModel |
| 81 | + cfg = { |
| 82 | + "model": "FunAudioLLM/Fun-ASR-Nano-2512", |
| 83 | + "hub": "hf", |
| 84 | + "trust_remote_code": True, |
| 85 | + "vad_model": "fsmn-vad", |
| 86 | + "vad_kwargs": {"max_single_segment_time": 30000}, |
| 87 | + "device": device, |
| 88 | + "disable_update": True, |
| 89 | + } |
| 90 | + app.state.fallback_models["fun-asr-nano"] = AutoModel(**cfg) |
| 91 | + logger.info("Fallback AutoModel loaded for fun-asr-nano.") |
| 92 | + |
| 93 | + def _load_fallback(name: str): |
| 94 | + """Load non-LLM model via AutoModel.""" |
| 95 | + if name in app.state.fallback_models: |
| 96 | + return app.state.fallback_models[name] |
| 97 | + if name not in FALLBACK_CONFIGS: |
| 98 | + return None |
57 | 99 | from funasr import AutoModel |
58 | | - cfg = MODEL_CONFIGS[name].copy() |
59 | | - cfg["device"] = app.state.device |
| 100 | + cfg = FALLBACK_CONFIGS[name].copy() |
| 101 | + cfg["device"] = device |
60 | 102 | cfg["disable_update"] = True |
61 | | - logger.info(f"Loading '{name}' on {device}...") |
62 | | - t0 = time.time() |
| 103 | + logger.info(f"Loading fallback model '{name}'...") |
63 | 104 | model = AutoModel(**cfg) |
64 | | - logger.info(f"'{name}' ready in {time.time()-t0:.1f}s") |
65 | | - app.state.models[name] = model |
| 105 | + app.state.fallback_models[name] = model |
66 | 106 | return model |
67 | 107 |
|
| 108 | + def _process_vllm(audio_data, sr, language=None, hotwords=None, use_spk=False): |
| 109 | + """Process audio with vLLM engine (Fun-ASR-Nano).""" |
| 110 | + if sr != 16000: |
| 111 | + import librosa |
| 112 | + audio_data = librosa.resample(audio_data, orig_sr=sr, target_sr=16000) |
| 113 | + sr = 16000 |
| 114 | + if audio_data.ndim > 1: |
| 115 | + audio_data = audio_data[:, 0] |
| 116 | + audio_data = audio_data.astype(np.float32) |
| 117 | + |
| 118 | + # VAD |
| 119 | + vad_res = app.state.vad_model.generate(input=audio_data, fs=sr) |
| 120 | + segments = vad_res[0]["value"] if vad_res and vad_res[0].get("value") else [[0, int(len(audio_data)*1000/sr)]] |
| 121 | + |
| 122 | + seg_audios = [] |
| 123 | + seg_times = [] |
| 124 | + for seg in segments: |
| 125 | + s0 = int(seg[0] * sr / 1000) |
| 126 | + s1 = int(seg[1] * sr / 1000) |
| 127 | + seg_audio = audio_data[s0:s1] |
| 128 | + if len(seg_audio) > sr * 0.3: |
| 129 | + seg_audios.append(seg_audio) |
| 130 | + seg_times.append((seg[0], seg[1])) |
| 131 | + |
| 132 | + if not seg_audios: |
| 133 | + return {"text": "", "segments": [], "duration": len(audio_data)/sr} |
| 134 | + |
| 135 | + # vLLM generate with repetition_penalty |
| 136 | + gen_kwargs = {"max_new_tokens": 500, "repetition_penalty": 1.3} |
| 137 | + if language: |
| 138 | + gen_kwargs["language"] = language |
| 139 | + if hotwords: |
| 140 | + gen_kwargs["hotwords"] = hotwords |
| 141 | + |
| 142 | + results = app.state.engine.generate(inputs=seg_audios, **gen_kwargs) |
| 143 | + |
| 144 | + output_segments = [] |
| 145 | + full_text_parts = [] |
| 146 | + for r, (start_ms, end_ms) in zip(results, seg_times): |
| 147 | + text = r["text"] |
| 148 | + seg_info = {"text": text, "start": start_ms/1000, "end": end_ms/1000} |
| 149 | + if "timestamps" in r: |
| 150 | + offset = start_ms / 1000 |
| 151 | + seg_info["words"] = [ |
| 152 | + {"word": ts["token"], "start": ts["start_time"]+offset, "end": ts["end_time"]+offset} |
| 153 | + for ts in r["timestamps"] |
| 154 | + ] |
| 155 | + output_segments.append(seg_info) |
| 156 | + full_text_parts.append(text) |
| 157 | + |
| 158 | + return { |
| 159 | + "text": "".join(full_text_parts), |
| 160 | + "segments": output_segments, |
| 161 | + "duration": len(audio_data) / sr, |
| 162 | + } |
| 163 | + |
| 164 | + def _process_fallback(model_name, audio_path, language=None): |
| 165 | + """Process with non-LLM model (SenseVoice/Paraformer).""" |
| 166 | + model = _load_fallback(model_name) |
| 167 | + kwargs = {"input": audio_path, "batch_size": 1} |
| 168 | + if language: |
| 169 | + kwargs["language"] = language |
| 170 | + result = model.generate(**kwargs) |
| 171 | + text = re.sub(r'<\|[^|]*\|>', '', result[0]["text"]).strip() |
| 172 | + segments = [] |
| 173 | + if "sentence_info" in result[0]: |
| 174 | + for s in result[0]["sentence_info"]: |
| 175 | + segments.append({ |
| 176 | + "start": s.get("start", 0)/1000, |
| 177 | + "end": s.get("end", 0)/1000, |
| 178 | + "text": re.sub(r'<\|[^|]*\|>', '', s.get("text", "")).strip(), |
| 179 | + "speaker": s.get("spk"), |
| 180 | + }) |
| 181 | + return {"text": text, "segments": segments} |
| 182 | + |
68 | 183 | # Pre-load |
69 | | - _load_model(preload_model) |
| 184 | + if preload_model == "fun-asr-nano": |
| 185 | + _load_vllm_engine() |
| 186 | + else: |
| 187 | + _load_fallback(preload_model) |
70 | 188 |
|
71 | 189 | @app.post("/v1/audio/transcriptions") |
72 | 190 | async def transcribe( |
73 | 191 | file: UploadFile = File(...), |
74 | | - model: str = Form(default="sensevoice"), |
| 192 | + model: str = Form(default="fun-asr-nano"), |
75 | 193 | language: Optional[str] = Form(default=None), |
76 | 194 | response_format: Optional[str] = Form(default="json"), |
| 195 | + spk: bool = Form(default=False), |
77 | 196 | ): |
78 | | - if model not in MODEL_CONFIGS: |
79 | | - raise HTTPException(400, f"Unknown model '{model}'. Available: {list(MODEL_CONFIGS.keys())}") |
80 | | - suffix = os.path.splitext(file.filename)[1] if file.filename else ".wav" |
81 | | - with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: |
82 | | - tmp.write(await file.read()) |
83 | | - tmp_path = tmp.name |
84 | | - try: |
85 | | - asr = _load_model(model) |
86 | | - kwargs = {"input": tmp_path, "batch_size": 1} |
87 | | - if language: |
88 | | - kwargs["language"] = language |
89 | | - result = asr.generate(**kwargs) |
90 | | - text = re.sub(r'<\|[^|]*\|>', '', result[0]["text"]).strip() |
91 | | - if response_format == "verbose_json": |
92 | | - segments = [] |
93 | | - if "sentence_info" in result[0]: |
94 | | - for s in result[0]["sentence_info"]: |
95 | | - segments.append({"start": s.get("start",0)/1000, "end": s.get("end",0)/1000, "text": re.sub(r'<\|[^|]*\|>','',s.get("text","")).strip(), "speaker": s.get("spk")}) |
96 | | - return JSONResponse({"text": text, "segments": segments, "model": model}) |
97 | | - return JSONResponse({"text": text}) |
98 | | - except Exception as e: |
99 | | - raise HTTPException(500, str(e)) |
100 | | - finally: |
101 | | - os.unlink(tmp_path) |
| 197 | + content = await file.read() |
| 198 | + t0 = time.perf_counter() |
| 199 | + |
| 200 | + if model == "fun-asr-nano": |
| 201 | + _load_vllm_engine() |
| 202 | + if app.state.use_vllm: |
| 203 | + audio_data, sr = sf.read(io.BytesIO(content)) |
| 204 | + result = _process_vllm(audio_data, sr, language=language, use_spk=spk) |
| 205 | + else: |
| 206 | + suffix = os.path.splitext(file.filename)[1] if file.filename else ".wav" |
| 207 | + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: |
| 208 | + tmp.write(content) |
| 209 | + tmp_path = tmp.name |
| 210 | + try: |
| 211 | + result = _process_fallback("fun-asr-nano", tmp_path, language=language) |
| 212 | + finally: |
| 213 | + os.unlink(tmp_path) |
| 214 | + elif model in FALLBACK_CONFIGS: |
| 215 | + suffix = os.path.splitext(file.filename)[1] if file.filename else ".wav" |
| 216 | + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: |
| 217 | + tmp.write(content) |
| 218 | + tmp_path = tmp.name |
| 219 | + try: |
| 220 | + result = _process_fallback(model, tmp_path, language=language) |
| 221 | + finally: |
| 222 | + os.unlink(tmp_path) |
| 223 | + else: |
| 224 | + raise HTTPException(400, f"Unknown model '{model}'. Available: fun-asr-nano, {', '.join(FALLBACK_CONFIGS.keys())}") |
| 225 | + |
| 226 | + t1 = time.perf_counter() |
| 227 | + |
| 228 | + if response_format == "verbose_json": |
| 229 | + return JSONResponse({ |
| 230 | + "task": "transcribe", |
| 231 | + "language": language or "zh", |
| 232 | + "duration": result.get("duration", 0), |
| 233 | + "text": result["text"], |
| 234 | + "segments": [ |
| 235 | + {"id": i, "start": s["start"], "end": s["end"], "text": s["text"], "words": s.get("words", [])} |
| 236 | + for i, s in enumerate(result["segments"]) |
| 237 | + ], |
| 238 | + }) |
| 239 | + elif response_format == "text": |
| 240 | + return JSONResponse(result["text"]) |
| 241 | + else: |
| 242 | + return JSONResponse({"text": result["text"]}) |
| 243 | + |
| 244 | + @app.post("/asr") |
| 245 | + async def asr_endpoint( |
| 246 | + file: UploadFile = File(...), |
| 247 | + language: Optional[str] = Form(default=None), |
| 248 | + hotwords: str = Form(default=""), |
| 249 | + spk: bool = Form(default=False), |
| 250 | + ): |
| 251 | + """Full-featured ASR endpoint with timestamps and speaker diarization.""" |
| 252 | + content = await file.read() |
| 253 | + _load_vllm_engine() |
| 254 | + hw_list = [w.strip() for w in hotwords.split(",") if w.strip()] if hotwords else None |
| 255 | + |
| 256 | + t0 = time.perf_counter() |
| 257 | + if app.state.use_vllm: |
| 258 | + audio_data, sr = sf.read(io.BytesIO(content)) |
| 259 | + result = _process_vllm(audio_data, sr, language=language, hotwords=hw_list, use_spk=spk) |
| 260 | + else: |
| 261 | + suffix = ".wav" |
| 262 | + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: |
| 263 | + tmp.write(content) |
| 264 | + tmp_path = tmp.name |
| 265 | + try: |
| 266 | + result = _process_fallback("fun-asr-nano", tmp_path, language=language) |
| 267 | + finally: |
| 268 | + os.unlink(tmp_path) |
| 269 | + t1 = time.perf_counter() |
| 270 | + |
| 271 | + result["processing_time"] = round(t1 - t0, 3) |
| 272 | + result["rtf"] = round((t1 - t0) / result["duration"], 4) if result.get("duration", 0) > 0 else 0 |
| 273 | + return JSONResponse(result) |
102 | 274 |
|
103 | 275 | @app.get("/v1/models") |
104 | 276 | async def list_models(): |
105 | | - return JSONResponse({"object": "list", "data": [{"id": n, "object": "model", "owned_by": "funasr"} for n in MODEL_CONFIGS]}) |
| 277 | + all_models = ["fun-asr-nano"] + list(FALLBACK_CONFIGS.keys()) |
| 278 | + return JSONResponse({"object": "list", "data": [{"id": n, "object": "model"} for n in all_models]}) |
106 | 279 |
|
107 | 280 | @app.get("/health") |
108 | 281 | async def health(): |
109 | | - return {"status": "ok", "device": app.state.device, "models_loaded": list(app.state.models.keys())} |
| 282 | + loaded = [] |
| 283 | + if app.state.engine is not None: |
| 284 | + loaded.append("fun-asr-nano (vLLM)") |
| 285 | + loaded.extend(app.state.fallback_models.keys()) |
| 286 | + return {"status": "ok", "device": device, "models_loaded": loaded} |
110 | 287 |
|
111 | 288 | return app |
0 commit comments