FunASR ASRServer Docker 部署与联调文档(含离线环境排障)

关键词:FunASR、asrserver、WebSocket、ModelScope、离线环境、Docker 部署、排障

本文以 funasr-wss-server-2pass 为例,整理一套 ASR Server 部署 + 联调 + 排障 的完整文档,适用于你目前的场景:

  • 使用 Docker 部署 FunASR WebSocket ASR 服务(后文统一称为 asrserver
  • 生产环境 无外网(无法访问 www.modelscope.cn
  • 需要通过 C++ 官方客户端自定义 Python WebSocket 客户端 进行联调
  • 启动和联调过程中遇到过:
    • 容器启动后自动退出
    • TLS 握手超时
    • WAV 头格式报错
    • sended data len=0 却返回空文本结果

1. 服务说明

1.1 服务组件

  • 服务端二进制funasr-wss-server-2pass
  • 启动脚本(镜像内):FunASR/runtime/run_server_2pass.sh
  • 常见启动参数:
    • --download-model-dir:模型下载/缓存目录,一般为 /workspace/models
    • --vad-dir:VAD 模型(ModelScope 模型名或本地路径)
    • --model-dir:离线 ASR 模型
    • --online-model-dir:在线/流式 ASR 模型
    • --punc-dir:标点模型
    • --itn-dir:数字/时间等反规范化模型
    • --lm-dir:语言模型
    • --hotword:热词文件路径

示例(镜像内 ENTRYPOINT 大致如下):

cd FunASR/runtime
bash run_server_2pass.sh   --download-model-dir /workspace/models   --vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx   --model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-onnx   --online-model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online-onnx   --punc-dir damo/punc_ct-transformer_zh-cn-common-vad_realtime-vocab272727-onnx   --itn-dir thuduj12/fst_itn_zh   --lm-dir damo/speech_ngram_lm_zh-cn-ai-wesp-fst   --hotword /workspace/models/hotwords.txt

说明:damo/...thuduj12/...ModelScope 模型 ID,在有外网时会自动从 www.modelscope.cn 下载。


2. 目录规划与端口规划

2.1 宿主机目录规划

建议在宿主机上统一存放模型文件,例如:

/data/funasr/
└── models/         # 挂载到容器的 /workspace/models

2.2 端口规划

  • 容器内默认监听端口:10095
  • 宿主机映射端口示例:
-p 10097:10095      # 宿主机 10097 → 容器 10095

3. 在线环境一次性下载模型(为离线环境做准备)

离线环境无法访问 ModelScope,因此需要在一台 有外网 的机器上,使用同版本镜像预先下载模型到宿主机目录。

3.1 准备挂载目录

mkdir -p /data/funasr/models

3.2 启动容器,让 FunASR 自动下载模型

docker run --rm -it   -v /data/funasr/models:/workspace/models   <你的-funasr镜像>   bash -c "cd /workspace/FunASR/runtime; bash run_server_2pass.sh"

第一次启动时,日志中会看到类似信息:

Download model: damo/speech_fsmn_vad_zh-cn-16k-common-onnx from modelscope
...
Successfully load model from /workspace/models/damo/speech_fsmn_vad_zh-cn-16k-common-onnx/model_quant.onnx
...
asr model init finished. listen on port:10095

说明 /workspace/models 中需要的模型已经下载完整,并且服务成功启动。

3.3 确认模型目录结构

退出容器后,在宿主机查看目录结构:

ls -R /data/funasr/models

理想结构(示意):

/data/funasr/models
├── damo
│   ├── speech_fsmn_vad_zh-cn-16k-common-onnx/
│   ├── speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-onnx/
│   ├── speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online-onnx/
│   ├── punc_ct-transformer_zh-cn-common-vad_realtime-vocab272727-onnx/
│   └── speech_ngram_lm_zh-cn-ai-wesp-fst/
├── thuduj12
│   └── fst_itn_zh/
└── hotwords.txt   # 若无,可手工创建一个空文件

3.4 打包模型目录

cd /data/funasr
tar czf funasr_models.tar.gz models

funasr_models.tar.gz 拷贝到生产/离线服务器,供后续部署使用。


4. 离线环境部署 asrserver

4.1 解压模型并挂载

在离线服务器上:

mkdir -p /data/funasr
tar xzf funasr_models.tar.gz -C /data/funasr

然后启动 asrserver:

docker run -d --name asr-server   -v /data/funasr/models:/workspace/models   -p 10097:10095   <同一版本-funasr镜像>

此时:

  • Docker 中仍然会使用原来的 ENTRYPOINT(run_server_2pass.sh);
  • 虽然仍然会尝试访问 ModelScope,但 本地 /workspace/models 已经有模型,即使下载失败,只要能找到本地模型,服务仍然会启动成功。

4.2 日志确认

查看日志:

docker logs -f asr-server

会看到类似:

Failed to resolve 'www.modelscope.cn' ...
Failed to download model from modelscope. If you set local vad model path, you can ignore the errors.
Successfully load model from /workspace/models/damo/speech_fsmn_vad_zh-cn-16k-common-onnx/model_quant.onnx
...
asr model init finished. listen on port:10095

关键判断点:

  • 前面的下载失败报错在离线环境是正常现象;
  • 只要后面出现 “Successfully load model from …” 和 “listen on port:10095” 即可认定服务成功启动

5. 确认服务状态(容器 + 端口)

5.1 容器状态

docker ps -a | grep asr-server

期望状态:Up ... 而不是 Exited (1)

5.2 容器内检查端口监听

docker exec -it asr-server bash
ss -lntp | grep 10095

输出类似:

LISTEN 0 128 0.0.0.0:10095  0.0.0.0:*  users:(("funasr-wss-server-2pass",pid=...,fd=...))

说明 WebSocket 服务已在 10095 监听。


6. 使用 C++ 官方客户端联调

FunASR 自带 C++ 客户端 funasr-wss-client-2pass,位于镜像内:

/workspace/FunASR/runtime/websocket/build/bin/funasr-wss-client-2pass

6.1 准备测试音频

要求:

  • WAV 格式;
  • 16 kHz 采样率;
  • 单声道(Mono);
  • 16 bit PCM。

如需转换,可使用 ffmpeg:

ffmpeg -y -i input.wav   -ar 16000   -ac 1   -sample_fmt s16   tmp_16k_mono_s16.wav

tmp_16k_mono_s16.wav 拷入容器相应目录,例如:

/workspace/FunASR/runtime/csharp/ws-client/confg/tmp_16k_mono_s16.wav

6.2 在容器内发起测试

docker exec -it asr-server bash
cd /workspace/FunASR/runtime/websocket

./build/bin/funasr-wss-client-2pass   --mode 2pass   --server-ip 127.0.0.1   --port 10095   --wav-path ../csharp/ws-client/confg/tmp_16k_mono_s16.wav   --is-ssl 0

关键参数说明:

  • --mode 2pass:与服务端 2pass 模式对应;
  • --server-ip 127.0.0.1:容器内直接访问自身;
  • --port 10095:服务监听端口;
  • --is-ssl 0关闭 SSL,以明文 ws:// 模式连接(默认是 SSL)。

正常情况下会看到类似日志:

I... sended data len=123456
I... on_message = {"is_final":true,"text":"今天天气不错,我正在测试语音识别服务","wav_name":"wav_default_id"}

此时说明:

  • WebSocket 已经连通;
  • 服务端收到了音频数据;
  • 成功返回了识别文本。

7. 常见问题与排障记录

这一节记录你本次部署过程中遇到的几个典型问题及解决思路,方便以后查阅。

7.1 启动即退出:ModelScope 下载失败 + 模型缺失

现象:

Failed to resolve 'www.modelscope.cn' ([Errno -3] Temporary failure in name resolution)
...
Failed to download model from modelscope. If you set local vad model path, you can ignore the errors.
...
/workspace/models/damo/speech_fsmn_vad_zh-cn-16k-common-onnx/model_quant.onnx do not exists.

原因:

  • 离线环境无 DNS / 无外网;
  • /workspace/models 中没有提前准备好的模型文件;
  • FunASR 无法从本地或 ModelScope 获取模型,启动失败。

解决:

  1. 在有外网环境预下载 /workspace/models 并打包(参考第 3 章);
  2. 在离线环境解压并挂载到 /workspace/models(参考第 4 章);
  3. 再启动容器时,即使仍报下载失败,只要后面出现 “Successfully load model from …”,服务就可以正常运行。

可选进阶:在 ENTRYPOINT/脚本中去掉 --download-model-dir,并将 damo/... 等参数改成本地路径,彻底关闭在线下载逻辑。


7.2 C++ 客户端 TLS 报错:TLS handshake timed out

现象:

[error] handle_transport_init received error: TLS handshake timed out
[info] asio async_shutdown error: asio.ssl:336462231 (shutdown while in init (SSL routines, SSL_shutdown))

原因:

  • 服务端默认运行在明文 ws 模式(未配置 SSL 证书);
  • C++ 客户端未指定 --is-ssl 0,默认按 wss/SSL 模式 握手;
  • 导致 TLS 握手超时。

解决:

使用以下命令进行测试:

./build/bin/funasr-wss-client-2pass   --mode 2pass   --server-ip 127.0.0.1   --port 10095   --wav-path ../csharp/ws-client/confg/tmp_16k_mono_s16.wav   --is-ssl 0

即显式关闭 SSL,使用 ws:// 连到 10095。


7.3 WAV 头格式错误:Expected subchunk1_size 16. Given: 18

现象:

Expected subchunk1_size 16. Given: 18

原因:

  • C++ 客户端对 WAV 头格式要求非常严格,只接受 fmt 子块长度为 16 的标准 PCM WAV;
  • 部分录音工具生成的是扩展 WAV(subchunk1_size=18 等),导致客户端拒绝解析。

解决:

使用 ffmpeg 将音频转换为标准的 16k/单声道/16bit PCM WAV:

ffmpeg -y -i input.wav   -ar 16000   -ac 1   -sample_fmt s16   tmp_16k_mono_s16.wav

再用 tmp_16k_mono_s16.wav 作为 --wav-path


7.4 sended data len=0 + 空文本结果

现象:

I... sended data len=0
I... on_message = {"is_final":true,"text":"","wav_name":"wav_default_id"}

含义:

  • WebSocket 连接成功,服务端返回了最终结果(is_final=true);
  • 但客户端总共发送的音频字节数为 0,因此服务端没有可用音频,只能返回空文本。

常见原因:

  • --wav-path 路径拼写错误(例如 confgconfig);
  • 文件不存在或大小为 0;
  • 客户端读取文件失败。

排查步骤:

在容器内确认文件路径和大小:

cd /workspace/FunASR/runtime/websocket

ls -lh ../csharp/ws-client/confg/tmp_16k_mono_s16.wav
file ../csharp/ws-client/confg/tmp_16k_mono_s16.wav

确认:

  • 路径无误;
  • 文件大小 > 0;
  • file 命令输出为标准 WAV。

修正之后再次运行 C++ 客户端,sended data len 应变为一个非零值,服务端返回的 text 字段也会包含识别结果。


8. Python WebSocket 客户端示例(可直接复用)

下面是一个可直接用于生产联调的 Python WebSocket 客户端示例,已经处理了:

  • mode = "2pass"
  • 自动将 WAV 转为 16k/mono/16bit;
  • 分块发送 4000 字节 PCM;
  • 打印并解析服务端 JSON 结果。

请根据实际情况修改 ASR_HOSTASR_PORTtest_audio 路径。

import asyncio
import json
import uuid
import websockets
import wave
import os
import subprocess
import tempfile
import logging
from typing import Optional

# ==========================
# 配置区
# ==========================
ASR_HOST = "10.131.40.18"   # ASR 服务所在机器 IP
ASR_PORT = 10097            # 宿主机映射端口,如 -p 10097:10095
CHUNK_BYTES = 4000          # 每次发送 4000 字节
RECV_TIMEOUT = 120          # 接收识别结果超时时间(秒)

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)


def build_ws_uri() -> str:
    session_id = str(uuid.uuid4())
    return f"ws://{ASR_HOST}:{ASR_PORT}/{session_id}"


def build_init_request() -> dict:
    """构造初始化 JSON 请求"""
    return {
        "mode": "2pass",
        "chunk_size": [5, 10, 5],
        "chunk_interval": 10,
        "encoder_chunk_look_back": 4,
        "decoder_chunk_look_back": 0,
        "wav_name": str(uuid.uuid4()),
        "is_speaking": True,
        "audio_fs": 16000,
        "wav_format": "pcm",
        "hotwords": "",
        "itn": True,
    }


async def asr_client(audio_file_path: Optional[str] = None) -> None:
    uri = build_ws_uri()
    max_retries = 3

    for attempt in range(1, max_retries + 1):
        logger.info(f"[asr_client] Attempt {attempt}/{max_retries}, connecting to {uri}")
        try:
            async with websockets.connect(uri, ping_interval=None) as websocket:
                logger.info(f"[asr_client] Connected to {uri}")

                # 1) 发送初始化请求
                init_request = build_init_request()
                init_json = json.dumps(init_request, ensure_ascii=False)
                logger.info(f"[asr_client] Sending init_request: {init_json}")
                await websocket.send(init_json)
                logger.info("[asr_client] Initialization request sent")

                # 2) 发送音频
                if audio_file_path and os.path.exists(audio_file_path):
                    await send_audio_data(websocket, audio_file_path)
                    logger.info("[asr_client] Audio data finished, you can now wait for results")
                else:
                    logger.warning(f"[asr_client] Audio file not found: {audio_file_path}")

                # 可选:发送空帧作为结束提示
                try:
                    await websocket.send(b"")
                    logger.info("[asr_client] Sent empty binary frame as end-of-stream hint")
                except Exception as e:
                    logger.warning(f"[asr_client] Failed to send end hint (can be ignored): {e!r}")

                # 3) 接收识别结果
                logger.info("[asr_client] Waiting for recognition results ...")
                await receive_messages(websocket)
                break

        except Exception as e:
            logger.error(f"[asr_client] Error on attempt {attempt}: {e!r}", exc_info=True)
            if attempt < max_retries:
                logger.warning("[asr_client] Retry in 2 seconds ...")
                await asyncio.sleep(2)
            else:
                logger.error("[asr_client] Max retries reached, exit.")


async def receive_messages(websocket: websockets.WebSocketClientProtocol) -> None:
    """循环接收识别结果"""
    while True:
        try:
            message = await asyncio.wait_for(websocket.recv(), timeout=RECV_TIMEOUT)
        except asyncio.TimeoutError:
            logger.warning(f"[receive_messages] No message for {RECV_TIMEOUT} seconds, stop waiting.")
            break
        except websockets.exceptions.ConnectionClosed as e:
            logger.warning(f"[receive_messages] WebSocket closed: code={e.code}, reason={e.reason}")
            break
        except Exception as e:
            logger.error(f"[receive_messages] Error while receiving message: {e!r}", exc_info=True)
            break

        if isinstance(message, bytes):
            logger.info(f"[receive_messages] Received binary message, len={len(message)}")
            preview = message[:200]
            logger.info(f"[receive_messages] Binary preview: {preview!r}")
            try:
                text = message.decode("utf-8")
                logger.info(f"[receive_messages] Decoded as UTF-8: {text}")
            except UnicodeDecodeError:
                text = None
                logger.info("[receive_messages] Binary is not valid UTF-8")
        else:
            text = message
            logger.info(f"[receive_messages] Received text message: {text}")

        if text:
            try:
                data = json.loads(text)
                logger.info("[receive_messages] Parsed JSON result:")
                logger.info(json.dumps(data, indent=2, ensure_ascii=False))
            except json.JSONDecodeError as e:
                logger.warning(f"[receive_messages] Message is not valid JSON: {e}")


async def send_audio_data(websocket: websockets.WebSocketClientProtocol, audio_file_path: str) -> None:
    """按 4000 字节一块发送 16k/mono/16bit PCM 音频"""
    logger.info(f"[send_audio_data] Sending audio from: {audio_file_path}")

    if audio_file_path.lower().endswith(".wav"):
        fixed_path = convert_wav_to_required_format(audio_file_path)
        if fixed_path != audio_file_path:
            logger.info(f"[send_audio_data] Using converted file: {fixed_path}")
        audio_file_path = fixed_path

        with wave.open(audio_file_path, "rb") as wf:
            framerate = wf.getframerate()
            nchannels = wf.getnchannels()
            sampwidth = wf.getsampwidth()
            logger.info(f"[send_audio_data] WAV params: framerate={framerate}, channels={nchannels}, sampwidth={sampwidth}")

            if framerate != 16000 or nchannels != 1 or sampwidth != 2:
                logger.error("[send_audio_data] ERROR: WAV format is still not 16k/mono/16bit after conversion, skip sending.")
                return

            frames_per_chunk = CHUNK_BYTES // 2
            chunk_idx = 0
            while True:
                data = wf.readframes(frames_per_chunk)
                if not data:
                    break
                await websocket.send(data)
                chunk_idx += 1
                logger.info(f"[send_audio_data] Sent WAV chunk #{chunk_idx}, bytes={len(data)}")
                await asyncio.sleep(0.125)
    else:
        chunk_idx = 0
        with open(audio_file_path, "rb") as f:
            while True:
                data = f.read(CHUNK_BYTES)
                if not data:
                    break
                await websocket.send(data)
                chunk_idx += 1
                logger.info(f"[send_audio_data] Sent PCM chunk #{chunk_idx}, bytes={len(data)}")
                await asyncio.sleep(0.125)

    logger.info("[send_audio_data] Audio data transmission completed")


def convert_wav_to_required_format(input_file: str) -> str:
    """将 WAV 转成 16000Hz / 单声道 / 16bit PCM"""
    try:
        with wave.open(input_file, "rb") as wf:
            framerate = wf.getframerate()
            nchannels = wf.getnchannels()
            sampwidth = wf.getsampwidth()

        if framerate == 16000 and nchannels == 1 and sampwidth == 2:
            logger.info("[convert_wav_to_required_format] Input WAV already in required format")
            return input_file

        logger.info("[convert_wav_to_required_format] Converting WAV to 16k/mono/16bit using ffmpeg ...")
        output_file = tempfile.mktemp(suffix=".wav")

        cmd = [
            "ffmpeg",
            "-y",
            "-i", input_file,
            "-ar", "16000",
            "-ac", "1",
            "-sample_fmt", "s16",
            output_file,
        ]
        result = subprocess.run(cmd, capture_output=True, text=True)
        if result.returncode != 0:
            logger.error(f"[convert_wav_to_required_format] ffmpeg failed: {result.stderr}")
            return input_file

        logger.info(f"[convert_wav_to_required_format] Conversion successful: {output_file}")
        return output_file
    except Exception as e:
        logger.error(f"[convert_wav_to_required_format] Error: {e!r}", exc_info=True)
        return input_file


if __name__ == "__main__":
    # 请替换为你实际要测试的文件路径
    test_audio = "/path/to/tmp_16k_mono_s16.wav"
    asyncio.run(asr_client(test_audio))
Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐