Skip to content

Commit 787c282

Browse files
committed
fix: funasr-server uses vLLM engine for Fun-ASR-Nano (fixes #2930 text repetition)
- Merged serve_vllm.py logic into funasr-server (_server_app.py) - Fun-ASR-Nano now uses FunASRNanoVLLM with repetition_penalty=1.3 - Falls back to AutoModel if vLLM unavailable (e.g. CPU or driver mismatch) - Added /asr endpoint with VAD + timestamps + SPK support - Non-LLM models (sensevoice, paraformer) still use AutoModel - Verified: 120.wav repetition bug completely fixed - Bump version to 1.3.6
1 parent 3953072 commit 787c282

3 files changed

Lines changed: 247 additions & 70 deletions

File tree

examples/industrial_data_pretraining/fun_asr_nano/serve_vllm.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ def process_audio(audio_data, sr=16000, language=None, hotwords=None,
107107
return {"text": "", "segments": [], "duration": len(audio_data) / sr}
108108

109109
# vLLM batch ASR
110-
gen_kwargs = {"max_new_tokens": 500}
110+
gen_kwargs = {"max_new_tokens": 500, "repetition_penalty": 1.3}
111111
if language:
112112
gen_kwargs["language"] = language
113113
if hotwords:

funasr/bin/_server_app.py

Lines changed: 245 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1-
"""Internal: FastAPI app for funasr-server command."""
1+
"""FunASR Server — unified vLLM-based inference service.
22
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
58
import os
69
import re
10+
import time
711
import logging
12+
import tempfile
813
from typing import Optional
914

15+
import numpy as np
16+
import soundfile as sf
17+
1018
try:
1119
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
1220
from fastapi.responses import JSONResponse
@@ -17,95 +25,264 @@
1725

1826
logger = logging.getLogger("funasr.server")
1927

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-
4428

4529
def create_app(device: str = "cuda", preload_model: str = "auto") -> FastAPI:
4630
if preload_model == "auto":
4731
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")
5034
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+
}
5152

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
5799
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
60102
cfg["disable_update"] = True
61-
logger.info(f"Loading '{name}' on {device}...")
62-
t0 = time.time()
103+
logger.info(f"Loading fallback model '{name}'...")
63104
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
66106
return model
67107

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+
68183
# 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)
70188

71189
@app.post("/v1/audio/transcriptions")
72190
async def transcribe(
73191
file: UploadFile = File(...),
74-
model: str = Form(default="sensevoice"),
192+
model: str = Form(default="fun-asr-nano"),
75193
language: Optional[str] = Form(default=None),
76194
response_format: Optional[str] = Form(default="json"),
195+
spk: bool = Form(default=False),
77196
):
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)
102274

103275
@app.get("/v1/models")
104276
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]})
106279

107280
@app.get("/health")
108281
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}
110287

111288
return app

funasr/version.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.3.5
1+
1.3.6

0 commit comments

Comments
 (0)