diff --git a/funasr/bin/_server_app.py b/funasr/bin/_server_app.py index 02998b2f3..bd6abf81d 100644 --- a/funasr/bin/_server_app.py +++ b/funasr/bin/_server_app.py @@ -101,7 +101,7 @@ def prepare_audio_for_inference(audio_data, sr, target_sr=16000): return audio_data.astype(np.float32), sr -def create_app(device: str = "cuda", preload_model: str = "auto") -> FastAPI: +def create_app(device: str = "cuda", preload_model: str = "auto", model_path: str = None, hub: str = "ms") -> FastAPI: if preload_model == "auto": preload_model = "fun-asr-nano" if device.startswith("cuda") else "sensevoice" @@ -110,6 +110,8 @@ def create_app(device: str = "cuda", preload_model: str = "auto") -> FastAPI: app.state.engine = None app.state.vad_model = None app.state.fallback_models = {} + app.state.model_path = model_path + app.state.hub = hub # Non-LLM model configs (use AutoModel, no vLLM) FALLBACK_CONFIGS = { @@ -135,9 +137,12 @@ def _load_vllm_engine(): logger.info("Loading Fun-ASR-Nano vLLM engine...") t0 = time.time() + # Use custom model_path if provided, otherwise default + vllm_model = app.state.model_path if app.state.model_path else "FunAudioLLM/Fun-ASR-Nano-2512" + vllm_hub = app.state.hub if app.state.model_path else "hf" app.state.engine = FunASRNanoVLLM.from_pretrained( - model="FunAudioLLM/Fun-ASR-Nano-2512", - hub="hf", + model=vllm_model, + hub=vllm_hub, device=device, dtype="bf16", max_model_len=4096, @@ -154,8 +159,8 @@ def _load_vllm_engine(): app.state.use_vllm = False from funasr import AutoModel cfg = { - "model": "FunAudioLLM/Fun-ASR-Nano-2512", - "hub": "hf", + "model": app.state.model_path if app.state.model_path else "FunAudioLLM/Fun-ASR-Nano-2512", + "hub": app.state.hub if app.state.model_path else "hf", "trust_remote_code": True, "vad_model": "fsmn-vad", "vad_kwargs": {"max_single_segment_time": 30000}, @@ -163,19 +168,25 @@ def _load_vllm_engine(): "disable_update": True, } app.state.fallback_models["fun-asr-nano"] = AutoModel(**cfg) - logger.info("Fallback AutoModel loaded for fun-asr-nano.") + logger.info(f"Fallback AutoModel loaded for fun-asr-nano with model={cfg['model']}, hub={cfg['hub']}.") def _load_fallback(name: str): """Load non-LLM model via AutoModel.""" if name in app.state.fallback_models: return app.state.fallback_models[name] - if name not in FALLBACK_CONFIGS: + if name not in FALLBACK_CONFIGS and not app.state.model_path: return None from funasr import AutoModel - cfg = FALLBACK_CONFIGS[name].copy() + cfg = FALLBACK_CONFIGS.get(name, {}).copy() + # Override with custom model_path and hub if provided + if app.state.model_path: + cfg["model"] = app.state.model_path + cfg["hub"] = app.state.hub + elif app.state.hub: + cfg["hub"] = app.state.hub cfg["device"] = device cfg["disable_update"] = True - logger.info(f"Loading fallback model '{name}'...") + logger.info(f"Loading fallback model '{name}' with model={cfg['model']}, hub={cfg['hub']}...") model = AutoModel(**cfg) app.state.fallback_models[name] = model return model @@ -258,7 +269,11 @@ def _process_fallback(model_name, audio_path, language=None): return {"text": text, "segments": segments, "duration": duration} # Pre-load - if preload_model == "fun-asr-nano": + if app.state.model_path: + # When custom model_path is provided, use it as the model name for loading + logger.info(f"Loading custom model: {app.state.model_path} (hub: {app.state.hub})") + _load_fallback("custom") + elif preload_model == "fun-asr-nano": _load_vllm_engine() else: _load_fallback(preload_model) @@ -288,7 +303,7 @@ async def transcribe( result = _process_fallback("fun-asr-nano", tmp_path, language=language) finally: os.unlink(tmp_path) - elif model in FALLBACK_CONFIGS: + elif model in FALLBACK_CONFIGS or model == "custom": suffix = os.path.splitext(file.filename)[1] if file.filename else ".wav" with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: tmp.write(content) @@ -298,7 +313,8 @@ async def transcribe( finally: os.unlink(tmp_path) else: - raise HTTPException(400, f"Unknown model '{model}'. Available: fun-asr-nano, {', '.join(FALLBACK_CONFIGS.keys())}") + available = ["fun-asr-nano", "custom"] + list(FALLBACK_CONFIGS.keys()) + raise HTTPException(400, f"Unknown model '{model}'. Available: {', '.join(available)}") t1 = time.perf_counter() @@ -352,6 +368,8 @@ async def asr_endpoint( @app.get("/v1/models") async def list_models(): all_models = ["fun-asr-nano"] + list(FALLBACK_CONFIGS.keys()) + if app.state.model_path: + all_models.append("custom") return JSONResponse({"object": "list", "data": [{"id": n, "object": "model"} for n in all_models]}) @app.get("/health") diff --git a/funasr/bin/server.py b/funasr/bin/server.py index abab9064f..083863102 100644 --- a/funasr/bin/server.py +++ b/funasr/bin/server.py @@ -5,6 +5,8 @@ funasr-server # default: sensevoice on cuda:0, port 8000 funasr-server --device cpu --port 9000 funasr-server --model paraformer + funasr-server --model-path /path/to/local/model + funasr-server --model-path username/paraformer --hub hf """ import argparse @@ -21,6 +23,8 @@ def main(): funasr-server --device cpu # Start on CPU funasr-server --model paraformer # Use Paraformer model funasr-server --port 9000 # Custom port + funasr-server --model-path /path/to/local/model # Use local model + funasr-server --model-path username/model --hub hf # Use HuggingFace model Then use with OpenAI SDK: from openai import OpenAI @@ -32,6 +36,8 @@ def main(): parser.add_argument("--port", type=int, default=8000, help="Port (default: 8000)") parser.add_argument("--device", default="cuda", help="Device: cuda, cpu, mps (default: cuda)") parser.add_argument("--model", default="auto", help="Pre-load model: auto (GPU=fun-asr-nano, CPU=sensevoice), sensevoice, paraformer, fun-asr-nano") + parser.add_argument("--model-path", default=None, help="Local model path or model ID (overrides --model)") + parser.add_argument("--hub", default="ms", help="Model hub: ms (ModelScope), hf (HuggingFace) (default: ms)") args = parser.parse_args() try: @@ -49,12 +55,15 @@ def main(): # Use inline app to avoid path issues from funasr.bin._server_app import create_app - app = create_app(device=args.device, preload_model=args.model) + app = create_app(device=args.device, preload_model=args.model, model_path=args.model_path, hub=args.hub) print(f"╔══════════════════════════════════════════════╗") print(f"║ FunASR Server v1.3.6 ║") print(f"║ Device: {args.device:<8} ║") print(f"║ Model: {args.model:<12} ║") + if args.model_path: + print(f"║ Model Path: {args.model_path:<25} ║") + print(f"║ Hub: {args.hub:<8} ║") print(f"║ URL: http://{args.host}:{args.port}/v1 ║") print(f"║ Docs: http://{args.host}:{args.port}/docs ║") print(f"╚══════════════════════════════════════════════╝") diff --git a/tests/test_server_app_openai_segments.py b/tests/test_server_app_openai_segments.py index a163716de..1128da567 100644 --- a/tests/test_server_app_openai_segments.py +++ b/tests/test_server_app_openai_segments.py @@ -9,8 +9,18 @@ def load_server_app(monkeypatch): + class DummyFastAPI: + def __init__(self, *args, **kwargs): + self.state = types.SimpleNamespace() + + def post(self, *args, **kwargs): + return lambda func: func + + def get(self, *args, **kwargs): + return lambda func: func + fastapi_stub = types.ModuleType("fastapi") - fastapi_stub.FastAPI = object + fastapi_stub.FastAPI = DummyFastAPI fastapi_stub.UploadFile = object fastapi_stub.File = lambda *args, **kwargs: None fastapi_stub.Form = lambda *args, **kwargs: None @@ -31,6 +41,39 @@ def load_server_app(monkeypatch): return module +def install_dummy_funasr(monkeypatch): + class DummyAutoModel: + instances = [] + + def __init__(self, **kwargs): + self.kwargs = kwargs + self.__class__.instances.append(kwargs) + + funasr_stub = types.ModuleType("funasr") + funasr_stub.AutoModel = DummyAutoModel + monkeypatch.setitem(sys.modules, "funasr", funasr_stub) + return DummyAutoModel + + +def install_dummy_vllm(monkeypatch): + class DummyVLLM: + calls = [] + + @classmethod + def from_pretrained(cls, **kwargs): + cls.calls.append(kwargs) + return object() + + monkeypatch.setitem(sys.modules, "funasr.models", types.ModuleType("funasr.models")) + monkeypatch.setitem( + sys.modules, "funasr.models.fun_asr_nano", types.ModuleType("funasr.models.fun_asr_nano") + ) + vllm_module = types.ModuleType("funasr.models.fun_asr_nano.inference_vllm") + vllm_module.FunASRNanoVLLM = DummyVLLM + monkeypatch.setitem(sys.modules, "funasr.models.fun_asr_nano.inference_vllm", vllm_module) + return DummyVLLM + + def test_fallback_segments_split_long_fun_asr_server_text(monkeypatch): module = load_server_app(monkeypatch) text = ( @@ -56,3 +99,31 @@ def test_fallback_segments_keep_short_text_single_cue(monkeypatch): assert module.build_openai_fallback_segments("hello", duration=1.25) == [ {"start": 0.0, "end": 1.25, "text": "hello"} ] + + +def test_default_fun_asr_nano_uses_huggingface_hub(monkeypatch): + module = load_server_app(monkeypatch) + DummyAutoModel = install_dummy_funasr(monkeypatch) + DummyVLLM = install_dummy_vllm(monkeypatch) + + module.create_app(device="cuda", preload_model="fun-asr-nano", hub="ms") + + assert DummyVLLM.calls[0]["model"] == "FunAudioLLM/Fun-ASR-Nano-2512" + assert DummyVLLM.calls[0]["hub"] == "hf" + assert DummyAutoModel.instances[0]["model"] == "fsmn-vad" + + +def test_custom_model_path_fallback_uses_empty_config_and_requested_hub(monkeypatch): + module = load_server_app(monkeypatch) + DummyAutoModel = install_dummy_funasr(monkeypatch) + + app = module.create_app( + device="cpu", + preload_model="sensevoice", + model_path="org/custom-sensevoice", + hub="hf", + ) + + assert DummyAutoModel.instances[0]["model"] == "org/custom-sensevoice" + assert DummyAutoModel.instances[0]["hub"] == "hf" + assert app.state.fallback_models["custom"] is not None