欢迎光临
我们一直在努力

AI智能体项目实战记录(16):进阶学习 —— 添加语音识别功能,实现快速提问

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

AI智能体项目实战记录(16):进阶学习 —— 添加语音识别功能,实现快速提问

1. 引言

1.1 为什么给问答系统加语音

前 15 篇我们完成了多智能体编排、技能选择、调用日志等能力,交互方式一直是"打字提问"。但在真实场景里:

  • 移动端打字慢,语音更自然;
  • 用户问题较长时,说话比输入快;
  • 客服/巡检等场景需要"说完即问"。

所以这一篇给系统补上语音提问:按住麦克风 → 说话 → 松开 → 自动识别成文字并发送给主管智能体。

1.2 方案选型风暴

在动手前,先把可选方案摆出来对比:

方案优点缺点适用
浏览器 Web Speech API 零依赖、接入最快 多数浏览器实际走云端、离线不可用、各端行为不一致 快速验证
云端 ASR(讯飞/阿里/腾讯等) 识别率高、模型维护成本低 按量计费、依赖网络、音频出内网有合规风险 对准确率要求极高
本地模型(SenseVoiceSmall + sherpa-onnx) 离线、免费、数据不出内网、CPU 可跑 首次加载模型占内存、需要自己准备模型文件 私有化/内网项目

本项目选择本地 SenseVoiceSmall + sherpa-onnx,原因:

  • 系统本身已经私有化部署,音频不适合出内网;
  • SenseVoiceSmall 支持中/英/日/韩/粤语,覆盖主要场景;
  • sherpa-onnx 提供现成的 ONNX 推理封装,CPU 上 int8 模型速度可接受;
  • 不引入新的云服务账号与费用。
  • 1.3 几个名词先说明

    • ONNX:一种跨框架的模型格式,PyTorch/训练框架导出的模型可以用 ONNX Runtime 推理;
    • sherpa-onnx:基于 ONNX Runtime 的语音识别/合成工具库,封装了流式与非流式识别器;
    • SenseVoiceSmall:多语言语音识别模型,输出带标点的文本;
    • ITN(Inverse Text Normalization):把口语化的数字/单位转成书面形式,例如"一百二十"→"120";
    • PCM:未压缩的音频采样数据;本项目统一转成 16kHz、单声道、float32([-1, 1])。

    1.4 端到端链路

    LangGraphasr_serviceFastAPI浏览器用户LangGraphasr_serviceFastAPI浏览器用户#mermaid-svg-jLtOSakPxGHRWRBv{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-jLtOSakPxGHRWRBv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-jLtOSakPxGHRWRBv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-jLtOSakPxGHRWRBv .error-icon{fill:#552222;}#mermaid-svg-jLtOSakPxGHRWRBv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-jLtOSakPxGHRWRBv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-jLtOSakPxGHRWRBv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-jLtOSakPxGHRWRBv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-jLtOSakPxGHRWRBv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-jLtOSakPxGHRWRBv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-jLtOSakPxGHRWRBv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-jLtOSakPxGHRWRBv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-jLtOSakPxGHRWRBv .marker.cross{stroke:#333333;}#mermaid-svg-jLtOSakPxGHRWRBv svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-jLtOSakPxGHRWRBv p{margin:0;}#mermaid-svg-jLtOSakPxGHRWRBv .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-jLtOSakPxGHRWRBv text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-jLtOSakPxGHRWRBv .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-jLtOSakPxGHRWRBv .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-jLtOSakPxGHRWRBv .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-jLtOSakPxGHRWRBv .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-jLtOSakPxGHRWRBv #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-jLtOSakPxGHRWRBv .sequenceNumber{fill:white;}#mermaid-svg-jLtOSakPxGHRWRBv #sequencenumber{fill:#333;}#mermaid-svg-jLtOSakPxGHRWRBv #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-jLtOSakPxGHRWRBv .messageText{fill:#333;stroke:none;}#mermaid-svg-jLtOSakPxGHRWRBv .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-jLtOSakPxGHRWRBv .labelText,#mermaid-svg-jLtOSakPxGHRWRBv .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-jLtOSakPxGHRWRBv .loopText,#mermaid-svg-jLtOSakPxGHRWRBv .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-jLtOSakPxGHRWRBv .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-jLtOSakPxGHRWRBv .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-jLtOSakPxGHRWRBv .noteText,#mermaid-svg-jLtOSakPxGHRWRBv .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-jLtOSakPxGHRWRBv .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-jLtOSakPxGHRWRBv .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-jLtOSakPxGHRWRBv .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-jLtOSakPxGHRWRBv .actorPopupMenu{position:absolute;}#mermaid-svg-jLtOSakPxGHRWRBv .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-jLtOSakPxGHRWRBv .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-jLtOSakPxGHRWRBv .actor-man circle,#mermaid-svg-jLtOSakPxGHRWRBv line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-jLtOSakPxGHRWRBv :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}按住麦克风,开始说话MediaRecorder 收集 audioChunks松开,结束录音POST /chat/v1/asr(multipart file)model_available()transcribe(audio_bytes)PyAV 解码 → 16kHz 单声道 float32sherpa-onnx SenseVoice 推理识别文本RestResp(data=text)inputText = text自动 send() → POST /chat/completions(SSE)supervisor_graph.astream(…)analysis / answer / complete

    纯文本:

    录音(浏览器)
    → Blob(webm/opus 或 mp4)
    → multipart/form-data 上传
    → PyAV 解码并重采样为 16kHz 单声道
    → 归一化为 float32
    → SenseVoiceSmall 推理
    → 文本
    → 自动填入输入框并发送

    2. 环境准备

    2.1 依赖

    文件:requirements.txt

    # —- 语音识别(SenseVoiceSmall via sherpa-onnx) —-
    sherpa-onnx>=1.10.0
    av>=12.0.0 # 解码浏览器录音(webm/opus、m4a)为 PCM

    依赖作用
    sherpa-onnx 加载 ONNX 模型并执行语音识别
    av(PyAV) 解码浏览器录音格式(webm/opus、m4a 等)为 PCM

    安装:

    pip install -r requirements.txt

    说明:sherpa-onnx 和 av 都采用延迟导入(函数内部 import),这样服务启动时即使没装依赖也不会直接崩,只有在真正调用语音识别时才会报 ImportError,由接口返回友好提示。

    2.2 模型目录

    把 SenseVoiceSmall 的 sherpa-onnx 版本放到服务目录下:

    chat_py_server/
    └── models/
    └── sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09/
    ├── model.int8.onnx # int8 量化模型(体积小、CPU 快)
    ├── tokens.txt # 词表
    ├── configuration.json # 模型配置(sherpa-onnx 使用)
    ├── README.md
    └── test_wavs/ # 官方测试音频(可用于联调)
    ├── zh.wav
    ├── en.wav
    ├── ja.wav
    ├── ko.wav
    └── yue.wav …

    识别服务实际只依赖两样东西:

    • 模型文件:优先 model.int8.onnx,没有则回退 model.onnx;
    • 词表文件:tokens.txt。

    test_wavs/ 不参与推理,但非常适合用来验证后端接口是否正常。

    2.3 配置:.env 与 settings.py

    .env:

    # 语音识别模型目录(sherpa-onnx 格式的 SenseVoiceSmall,含 model.onnx/model.int8.onnx 与 tokens.txt)
    SENSE_VOICE_MODEL_DIR="models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09"

    app/config/settings.py:

    # 语音识别模型目录(sherpa-onnx 格式的 SenseVoiceSmall,含 model.onnx/model.int8.onnx 与 tokens.txt)
    # 相对路径基于服务根目录(app.py 所在目录)解析
    sense_voice_model_dir: Optional[str] = 'models/sherpa-onnx-sense-voice-zh-en-ja-ko-2025-04-15'

    关键点:

    • 配置优先级:pydantic-settings 会优先读取 .env,所以实际生效的是 .env 里的 …-yue-int8-2025-09-09;
    • 默认值必须与真实目录一致:如果 .env 缺失,settings.py 的默认路径 …-2025-04-15 在模型目录不存在时会导致 model_available() == False,接口返回"模型未就绪"。部署新环境时记得同步默认值和实际目录;
    • 相对路径基准:相对路径不是相对当前工作目录,而是相对 app.py 所在目录(见 3.1),避免从不同目录启动时找不到模型。

    3. 后端第 1 步:语音识别服务

    文件:app/service/system/asr_service.py

    整体职责:加载模型 + 解码音频 + 执行识别,不关心 HTTP,也不关心前端格式。

    3.1 解析模型目录

    # 服务根目录(app.py 所在目录),用于解析相对模型路径
    _SERVER_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))

    def _resolve_model_dir() –> str:
    model_dir = setting.sense_voice_model_dir or ''
    if os.path.isabs(model_dir):
    return model_dir
    return os.path.join(_SERVER_ROOT, model_dir)

    关键点:

    • 本文件位于 app/service/system/asr_service.py,向上四级正好是项目根目录;
    • 绝对路径直接用,相对路径拼到项目根目录,保证 uvicorn 从任何目录启动都能找到模型;
    • 目录不做存在性判断,交给后续 model_available() 统一检查。

    3.2 选择模型文件

    def _find_model_file(model_dir: str) –> str:
    """优先 int8 量化模型(体积小、CPU 推理快),回退 fp32"""
    for name in ('model.int8.onnx', 'model.onnx'):
    path = os.path.join(model_dir, name)
    if os.path.isfile(path):
    return path
    return os.path.join(model_dir, 'model.onnx')

    关键点:

    • int8 优先:CPU 推理明显更快、内存更小,识别质量损失很小;
    • 回退 fp32:兼容只提供 model.onnx 的模型包;
    • 找不到也返回默认路径:由 model_available() 判断是否存在,避免这里抛异常。

    3.3 判断模型是否就绪

    def model_available() –> bool:
    """模型文件是否已就绪(含 onnx 模型与 tokens.txt)"""
    model_dir = _resolve_model_dir()
    return os.path.isfile(_find_model_file(model_dir)) and os.path.isfile(os.path.join(model_dir, 'tokens.txt'))

    关键点:

    • 接口层先调用它,未就绪时直接返回"请先下载模型"的提示,而不是等到加载时抛异常;
    • 只检查模型和词表两个必要文件,configuration.json 不是必须。

    3.4 懒加载识别器(单例 + 双重检查锁)

    _recognizer = None
    _recognizer_lock = threading.Lock()

    def get_recognizer():
    """懒加载单例识别器(首次调用时导入 sherpa-onnx,避免服务启动即依赖)"""
    global _recognizer
    if _recognizer is None:
    with _recognizer_lock:
    if _recognizer is None:
    import sherpa_onnx # noqa: PLC0415 延迟导入
    model_dir = _resolve_model_dir()
    _recognizer = sherpa_onnx.OfflineRecognizer.from_sense_voice(
    model=_find_model_file(model_dir),
    tokens=os.path.join(model_dir, 'tokens.txt'),
    num_threads=2,
    use_itn=True,
    )
    logger.info(f'SenseVoice 语音识别模型加载完成: {model_dir}')
    return _recognizer

    关键点:

    • 懒加载:模型加载有几百毫秒到数秒开销,放在首次请求时做,避免拖慢服务启动;
    • 双重检查锁:并发首次请求时只加载一次,其余请求等待;
    • from_sense_voice:sherpa-onnx 针对 SenseVoice 的封装,自动处理模型输入输出;
    • num_threads=2:CPU 推理线程数,按机器核数调整;
    • use_itn=True:开启逆文本归一化,“一百二十"会输出"120”。

    3.5 解码浏览器录音

    def _decode_to_float32(audio_bytes: bytes, sample_rate: int = 16000) –> np.ndarray:
    """将原始音频字节解码为 16kHz 单声道 float32(取值 [-1, 1])"""
    import av # noqa: PLC0415 延迟导入

    with av.open(io.BytesIO(audio_bytes)) as container:
    resampler = av.AudioResampler(format='s16', layout='mono', rate=sample_rate)
    chunks = []
    for frame in container.decode(audio=0):
    for out_frame in resampler.resample(frame):
    chunks.append(out_frame.to_ndarray().reshape(–1))
    # 冲刷重采样器缓存中的剩余数据
    try:
    for out_frame in resampler.resample(None):
    chunks.append(out_frame.to_ndarray().reshape(–1))
    except Exception:
    pass

    if not chunks:
    raise ValueError('音频中未找到可解码的音频流,请确认录音格式')
    return np.concatenate(chunks).astype(np.float32) / 32768.0

    关键点:

    • 用 io.BytesIO 包装字节:不需要先落盘,直接内存解码;
    • AudioResampler(format='s16', layout='mono', rate=16000):一次性完成"转 16 位整型 + 单声道 + 16kHz 重采样",正好是 SenseVoice 需要的输入;
    • resample(None) 冲刷:重采样器内部有缓存,不冲刷会丢掉最后几十毫秒;
    • / 32768.0:把 int16 范围映射到 [-1, 1] 的 float32;
    • ValueError:没有可解码音频流时抛业务异常,接口层会把它转成可读提示。

    3.6 执行识别

    def transcribe(audio_bytes: bytes) –> str:
    """语音识别:输入原始音频字节,返回识别文本(空串表示未识别到有效语音)"""
    samples = _decode_to_float32(audio_bytes)
    recognizer = get_recognizer()
    stream = recognizer.create_stream()
    stream.accept_waveform(16000, samples)
    recognizer.decode_stream(stream)
    return stream.result.text.strip()

    关键点:

    • 每次请求创建一个 stream:识别器是单例,但 stream 不能复用;
    • accept_waveform(16000, samples):采样率必须与解码时一致;
    • strip():去掉首尾空白,空串表示没有识别到有效语音;
    • 同步阻塞:这是 CPU 密集型调用,在 async 接口里应放到线程池(见 4.3)。

    4. 后端第 2 步:ASR 接口

    文件:app/controller/chat_controller.py

    4.1 接口实现

    @router.post("/v{version}/asr", response_model=RestResp, summary='语音识别(SenseVoiceSmall)')
    async def speech_to_text(version: int, file: UploadFile = File(...)):
    """
    语音提问接口

    接收前端上传的录音文件(webm/opus、m4a 等),解码后由 SenseVoiceSmall
    (sherpa-onnx 推理)识别为文字,返回给前端作为提问内容。
    """
    if not asr_service.model_available():
    return RestResp(message='语音识别模型未就绪,请先下载 SenseVoice 模型并配置 sense_voice_model_dir')
    try:
    content = await file.read()
    text = asr_service.transcribe(content)
    if not text:
    return RestResp(message='未识别到有效语音')
    return RestResp(data=text, status=1, message='识别成功')
    except ImportError as e:
    logger.error(f'语音识别依赖缺失: {e}')
    return RestResp(message='服务端未安装语音识别依赖(sherpa-onnx/av),请先安装')
    except ValueError as e:
    return RestResp(message=str(e))
    except Exception as e:
    logger.error(f'语音识别异常: {e}')
    return RestResp(message='语音识别失败,请稍后重试')

    4.2 错误分支设计

    场景返回
    模型文件缺失 语音识别模型未就绪,请先下载 SenseVoice 模型并配置 sense_voice_model_dir
    依赖未安装 服务端未安装语音识别依赖(sherpa-onnx/av),请先安装(ImportError)
    音频无法解码 返回 ValueError 的具体信息,例如"音频中未找到可解码的音频流"
    未识别到文字 未识别到有效语音
    其它异常 记录日志,返回通用提示,避免泄露内部细节

    关键点:

    • 先检查模型再读文件:模型没准备好时没必要把音频读进内存;
    • await file.read():UploadFile 是异步读取,适合处理上传流;
    • 异常分层:ImportError 是部署问题,ValueError 是用户音频问题,其它异常统一兜底;
    • 统一 RestResp:前端只需要判断 status === 1 和 data。

    4.3 一个要注意的点:不要阻塞事件循环

    asr_service.transcribe() 是同步 CPU 调用(解码 + ONNX 推理),直接在 async def 里执行会阻塞整个事件循环,导致同一进程内其它请求(包括流式对话)卡住。

    当前代码是直接调用,单用户调试没问题;生产环境建议改成:

    text = await asyncio.to_thread(asr_service.transcribe, content)

    这样识别在独立线程中执行,事件循环可以继续处理其它请求。asyncio 已经在文件顶部导入,改一行即可。

    5. 前端第 1 步:接口封装

    文件:app/chat_py_front/src/api/chat.js

    // 语音识别:上传录音(webm/mp4),返回识别文本
    export function asrUpload(blob) {
    const formData = new FormData()
    formData.append('file', blob, 'record.webm')
    return request.post('/chat_py_server/chat/v1/asr', formData, { timeout: 60000 })
    }

    关键点:

    • 字段名必须是 file:与后端 file: UploadFile = File(…) 对应;
    • FormData 不要手动设置 Content-Type:浏览器会自动带上 boundary,手动设置反而会导致后端解析失败;
    • timeout: 60000:首次请求可能包含模型加载时间,超时放宽到 60 秒;
    • 文件名固定 record.webm:服务端不依赖文件名判断格式,实际格式由字节内容决定(PyAV 自动识别)。

    6. 前端第 2 步:录音交互

    文件:app/chat_py_front/src/views/Chat.vue

    6.1 麦克风按钮

    <el-tooltip
    :content="recording ? '正在录音,松开结束' : '按住说话'"
    placement="top"
    >
    <el-button
    class="mic-btn"
    circle
    :type="recording ? 'danger' : 'default'"
    :class="{ 'is-recording': recording }"
    @pointerdown.prevent="startRecord"
    @pointerup="stopRecord"
    @pointercancel="stopRecord"
    @contextmenu.prevent
    >
    <el-icon :size="18"><Microphone /></el-icon>
    </el-button>
    </el-tooltip>

    关键点:

    • 按住开始、松开结束:pointerdown 触发录音,pointerup / pointercancel 结束;
    • 一个按钮两种状态:未录音是普通按钮,录音中是红色 + 脉冲动画;
    • @pointerdown.prevent:阻止长按选中文字与移动端默认手势;
    • @pointercancel:处理来电、系统手势等中断,避免一直停留在录音状态;
    • @contextmenu.prevent:阻止移动端长按弹出系统菜单;
    • is-recording 触发 CSS 动画:

    .mic-btn {
    flex: none;
    /* 按住说话:禁止长按选中文字与移动端滚动,保证 pointerup 能正常触发 */
    touch-action: none;
    user-select: none;
    -webkit-user-select: none;
    }

    .mic-btn.is-recording {
    animation: mic-pulse 1s ease-in-out infinite;
    }

    @keyframes mic-pulse {
    0%,
    100%
    {
    box-shadow: 0 0 0 0 rgba(245, 108, 108, 0.5);
    }
    50% {
    box-shadow: 0 0 0 6px rgba(245, 108, 108, 0);
    }
    }

    6.2 选择浏览器支持的录音格式

    function pickMimeType() {
    const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4']
    return candidates.find((t) => window.MediaRecorder && MediaRecorder.isTypeSupported(t)) || ''
    }

    关键点:

    • Chrome/Edge/Firefox 一般支持 audio/webm;codecs=opus;
    • Safari 更可能支持 audio/mp4;
    • 返回空串时使用浏览器默认格式(new MediaRecorder(stream))。

    6.3 开始 / 结束录音

    let pressActive = false // 是否仍处于“按住”状态

    async function startRecord(event) {
    // 已经按住或正在录音时,忽略重复的 pointerdown
    if (pressActive || recording.value) return
    pressActive = true

    // 指针捕获:即使松开时手指/鼠标已经移出按钮,也能收到 pointerup
    try {
    event?.currentTarget?.setPointerCapture?.(event.pointerId)
    } catch (e) {
    // 忽略不支持指针捕获的浏览器
    }

    if (!navigator.mediaDevices?.getUserMedia) {
    pressActive = false
    ElMessage.warning('当前浏览器不支持录音')
    return
    }
    try {
    const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
    // 授权弹窗期间用户已经松手:释放音频流,不启动录音
    if (!pressActive) {
    stream.getTracks().forEach((t) => t.stop())
    return
    }
    const mimeType = pickMimeType()
    mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
    mediaStream = stream
    audioChunks = []
    mediaRecorder.ondataavailable = (e) => {
    if (e.data && e.data.size) audioChunks.push(e.data)
    }
    mediaRecorder.onstop = () => {
    const blob = new Blob(audioChunks, { type: mediaRecorder.mimeType || 'audio/webm' })
    audioChunks = []
    stopMediaTracks()
    uploadAudio(blob)
    }
    mediaRecorder.onerror = () => {
    ElMessage.error('录音失败,请重试')
    resetRecord()
    }
    mediaRecorder.start()
    recording.value = true
    } catch (e) {
    pressActive = false
    ElMessage.error('无法访问麦克风,请检查浏览器权限设置')
    }
    }

    function stopRecord() {
    if (!pressActive && !recording.value) return
    pressActive = false
    if (mediaRecorder && mediaRecorder.state !== 'inactive') {
    mediaRecorder.stop()
    }
    recording.value = false
    }

    function stopMediaTracks() {
    if (mediaStream) {
    mediaStream.getTracks().forEach((t) => t.stop())
    mediaStream = null
    }
    }

    function resetRecord() {
    recording.value = false
    pressActive = false
    stopMediaTracks()
    mediaRecorder = null
    }

    关键点:

    • 按住 / 松开的判定用 pressActive:它是"手指或鼠标是否仍按着"的唯一依据,与录音状态 recording 分开;
    • setPointerCapture:把后续指针事件锁定到按钮上,即使松开时已经移出按钮,也能收到 pointerup;
    • 授权竞态处理:首次使用时 getUserMedia 会弹权限框,用户可能先松手。此时 pressActive 已为 false,拿到音频流后立即释放,不启动录音;
    • getUserMedia({ audio: true }) 会触发浏览器权限弹窗;
    • ondataavailable 收集分片:mediaRecorder.start() 不传 timeslice 时只会在结束时给一个完整块;
    • onstop 里组装 Blob:type 取实际 mediaRecorder.mimeType,保证与录音格式一致;
    • 及时 stopMediaTracks():释放麦克风,浏览器标签页上的录音标识才会消失;
    • stopRecord 幂等:pointerup、pointercancel、组件卸载都可能调用它,开头用 if (!pressActive && !recording.value) return 防止重复停止。

    6.4 上传并自动提问

    async function uploadAudio(blob) {
    statusText.value = '正在识别语音…'
    try {
    const res = await asrUpload(blob)
    const text = res?.status === 1 && res.data ? String(res.data).trim() : ''
    if (text) {
    inputText.value = text
    // 识别完成后直接发送提问
    send()
    } else {
    ElMessage.warning(res?.message || '未识别到有效语音')
    }
    } catch (e) {
    ElMessage.error('语音识别失败')
    } finally {
    statusText.value = ''
    }
    }

    关键点:

    • 状态提示:识别期间显示"正在识别语音…",复用聊天区的 statusText;
    • 成功即发送:把识别文本写进 inputText 后直接调用 send(),实现"说完即问";
    • 失败不覆盖输入框:识别失败只提示,不会清空用户已经输入的内容;
    • finally 清理状态:无论成功失败都恢复状态提示。

    注意:send() 内部有 if (!text || streaming.value) return。如果此时上一轮回答还没结束(streaming=true),识别文本会留在输入框里,等回答结束后用户可以手动发送。

    6.5 组件卸载清理

    onBeforeUnmount(() => {
    pressActive = false
    if (mediaRecorder && mediaRecorder.state !== 'inactive') {
    mediaRecorder.stop()
    }
    stopMediaTracks()
    })

    关键点:

    • 切换路由或关闭页面时必须停止录音并释放麦克风,否则浏览器会一直显示录音中;
    • 同时把 pressActive 置为 false,防止授权回调晚到后又启动录音;
    • stop() 会触发 onstop,但组件已卸载,上传逻辑不会影响页面。

    7. 一次语音提问的完整时序

    ① 用户按住麦克风
    → getUserMedia 获取音频流
    → MediaRecorder.start()
    → 按钮变红、脉冲动画

    ② 用户说话,松开
    → MediaRecorder.stop()
    → ondataavailable 已收集分片
    → onstop 组装 Blob(webm/opus 或 mp4)
    → stopMediaTracks() 释放麦克风

    ③ 前端 uploadAudio(blob)
    → statusText = "正在识别语音…"
    → POST /chat_py_server/chat/v1/asr(multipart,字段名 file)

    ④ 后端 /asr
    → model_available() 检查模型与 tokens.txt
    → await file.read()
    → asr_service.transcribe(bytes)
    → PyAV 解码 + 重采样 16kHz/mono
    → 归一化 float32
    → sherpa-onnx SenseVoice 推理
    → RestResp(data=text, status=1)

    ⑤ 前端拿到文本
    → inputText = text
    → send()
    → POST /chat/v1/chat/completions(SSE)
    → analysis / answer / complete

    ⑥ 用户看到回答,语音提问闭环完成

    8. 关键设计点与踩坑记录

    8.1 模型未就绪要"可解释"

    如果直接 import sherpa_onnx 或加载模型失败,用户只会看到"系统错误"。当前设计把"模型未就绪"和"依赖未安装"拆成两个明确分支,部署时一眼能定位问题。

    8.2 为什么要 int8 优先

    同一份模型,int8 量化后体积约为 fp32 的 1/4,CPU 推理速度通常快 1.5~3 倍,识别质量下降很小。对"语音提问"这种短音频场景,int8 是更划算的选择。

    8.3 相对路径的基准

    _SERVER_ROOT 用的是 __file__ 向上回溯,而不是 os.getcwd()。这样无论用 uvicorn app:app、python app.py 还是 systemd 启动,都能找到模型目录。

    8.4 .env 与默认值不一致的坑

    settings.py 的默认值是 …-2025-04-15,而实际模型目录是 …-yue-int8-2025-09-09。有 .env 时没问题;一旦部署环境漏了 .env,就会提示"模型未就绪"。

    建议:

    • 新环境部署时核对 SENSE_VOICE_MODEL_DIR;
    • 或者把 settings.py 的默认值改成实际目录,减少对 .env 的隐性依赖。

    8.5 浏览器录音格式不统一

    • Chrome/Edge/Firefox:webm(opus);
    • Safari:更常见 mp4(aac)。

    前端用 MediaRecorder.isTypeSupported 逐个探测,后端用 PyAV 按内容解码,不依赖扩展名,所以两端都不需要为格式写分支。

    8.6 HTTPS 与 localhost

    navigator.mediaDevices.getUserMedia 只在安全上下文可用:

    • https:// 域名可用;
    • http://localhost / http://127.0.0.1 可用;
    • 局域网 http://192.168.x.x 在多数浏览器中不可用。

    移动端真机测试时,要么配置 HTTPS,要么用内网穿透工具。

    8.7 事件循环阻塞

    如 4.3 所述,transcribe 是同步 CPU 调用。单用户场景感知不明显,但多人同时语音提问时会互相拖慢,甚至影响正在进行的 SSE 对话。生产环境建议 await asyncio.to_thread(…)。

    8.8 音频大小与时长

    当前没有限制上传大小。建议在接口层加一层保护,例如:

    if len(content) > 10 * 1024 * 1024:
    return RestResp(message='录音文件过大,请控制在 10MB 以内')

    同时前端也可以在 onstop 里判断 Blob 大小,避免无效上传。

    8.9 为什么用"按住说话"而不是"点击切换"

    • 少一次操作:按住即说,松手即发,不需要再点一次结束;
    • 不会忘记停止:点击切换模式下用户容易忘记再点,导致一直录音、占用麦克风;按住模式松手必然结束;
    • 防误触:必须持续按压才会录音;
    • 实现要点:
      • 用 Pointer Events(pointerdown / pointerup / pointercancel)同时兼容鼠标与触摸;
      • setPointerCapture 保证移出按钮后仍能收到 pointerup;
      • pressActive 处理"权限弹窗期间提前松手"的竞态;
      • .mic-btn { touch-action: none; user-select: none; } 禁止移动端滚动与长按选中;
      • @contextmenu.prevent 阻止移动端长按弹出系统菜单。

    9. 联调与测试

    9.1 用官方测试音频验证后端

    模型目录自带 test_wavs/zh.wav,可以直接用 curl 测试接口:

    curl -X POST "http://127.0.0.1:5020/chat_py_server/chat/v1/asr" \\
    -F "file=@models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09/test_wavs/zh.wav"

    预期返回:

    {"status": 1, "data": "识别出的中文文本", "message": "识别成功"}

    9.2 验证异常分支

    测试操作预期
    模型缺失 把 SENSE_VOICE_MODEL_DIR 指向不存在的目录并重启 返回"语音识别模型未就绪…"
    依赖缺失 临时卸载 sherpa-onnx 返回"服务端未安装语音识别依赖…"
    非法音频 上传一个 txt 文件 返回"音频中未找到可解码的音频流…"
    静音录音 录一段无声 返回"未识别到有效语音"

    9.3 前端自测

  • 首次使用:按住麦克风会弹出权限请求,点击"允许"(此时已松手,不会录音);
  • 重新按住麦克风,说一句"今天几号";
  • 松开鼠标/手指,录音结束;
  • 观察状态提示"正在识别语音…";
  • 输入框出现识别文本并自动发送;
  • 聊天区收到 analysis 与 answer,最终 complete。
  • 10. 完整代码汇总

    10.1 app/service/system/asr_service.py

    #!/usr/bin/env python
    # -*- coding: utf-8 -*-
    # @Desc : 语音识别服务 – 基于 sherpa-onnx 加载 SenseVoiceSmall 模型
    # 浏览器上传的音频(webm/opus、m4a 等)由 PyAV 解码为 16kHz 单声道 PCM 后送入离线识别器

    import io
    import os
    import threading

    import numpy as np
    from loguru import logger

    from app.config import setting

    # 服务根目录(app.py 所在目录),用于解析相对模型路径
    _SERVER_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))

    _recognizer = None
    _recognizer_lock = threading.Lock()

    def _resolve_model_dir() –> str:
    model_dir = setting.sense_voice_model_dir or ''
    if os.path.isabs(model_dir):
    return model_dir
    return os.path.join(_SERVER_ROOT, model_dir)

    def _find_model_file(model_dir: str) –> str:
    """优先 int8 量化模型(体积小、CPU 推理快),回退 fp32"""
    for name in ('model.int8.onnx', 'model.onnx'):
    path = os.path.join(model_dir, name)
    if os.path.isfile(path):
    return path
    return os.path.join(model_dir, 'model.onnx')

    def model_available() –> bool:
    """模型文件是否已就绪(含 onnx 模型与 tokens.txt)"""
    model_dir = _resolve_model_dir()
    return os.path.isfile(_find_model_file(model_dir)) and os.path.isfile(os.path.join(model_dir, 'tokens.txt'))

    def get_recognizer():
    """懒加载单例识别器(首次调用时导入 sherpa-onnx,避免服务启动即依赖)"""
    global _recognizer
    if _recognizer is None:
    with _recognizer_lock:
    if _recognizer is None:
    import sherpa_onnx # noqa: PLC0415 延迟导入
    model_dir = _resolve_model_dir()
    _recognizer = sherpa_onnx.OfflineRecognizer.from_sense_voice(
    model=_find_model_file(model_dir),
    tokens=os.path.join(model_dir, 'tokens.txt'),
    num_threads=2,
    use_itn=True,
    )
    logger.info(f'SenseVoice 语音识别模型加载完成: {model_dir}')
    return _recognizer

    def _decode_to_float32(audio_bytes: bytes, sample_rate: int = 16000) –> np.ndarray:
    """将原始音频字节解码为 16kHz 单声道 float32(取值 [-1, 1])"""
    import av # noqa: PLC0415 延迟导入

    with av.open(io.BytesIO(audio_bytes)) as container:
    resampler = av.AudioResampler(format='s16', layout='mono', rate=sample_rate)
    chunks = []
    for frame in container.decode(audio=0):
    for out_frame in resampler.resample(frame):
    chunks.append(out_frame.to_ndarray().reshape(–1))
    # 冲刷重采样器缓存中的剩余数据
    try:
    for out_frame in resampler.resample(None):
    chunks.append(out_frame.to_ndarray().reshape(–1))
    except Exception:
    pass

    if not chunks:
    raise ValueError('音频中未找到可解码的音频流,请确认录音格式')
    return np.concatenate(chunks).astype(np.float32) / 32768.0

    def transcribe(audio_bytes: bytes) –> str:
    """语音识别:输入原始音频字节,返回识别文本(空串表示未识别到有效语音)"""
    samples = _decode_to_float32(audio_bytes)
    recognizer = get_recognizer()
    stream = recognizer.create_stream()
    stream.accept_waveform(16000, samples)
    recognizer.decode_stream(stream)
    return stream.result.text.strip()

    10.2 ASR 接口(chat_controller.py 节选)

    @router.post("/v{version}/asr", response_model=RestResp, summary='语音识别(SenseVoiceSmall)')
    async def speech_to_text(version: int, file: UploadFile = File(...)):
    """
    语音提问接口

    接收前端上传的录音文件(webm/opus、m4a 等),解码后由 SenseVoiceSmall
    (sherpa-onnx 推理)识别为文字,返回给前端作为提问内容。
    """
    if not asr_service.model_available():
    return RestResp(message='语音识别模型未就绪,请先下载 SenseVoice 模型并配置 sense_voice_model_dir')
    try:
    content = await file.read()
    text = asr_service.transcribe(content)
    if not text:
    return RestResp(message='未识别到有效语音')
    return RestResp(data=text, status=1, message='识别成功')
    except ImportError as e:
    logger.error(f'语音识别依赖缺失: {e}')
    return RestResp(message='服务端未安装语音识别依赖(sherpa-onnx/av),请先安装')
    except ValueError as e:
    return RestResp(message=str(e))
    except Exception as e:
    logger.error(f'语音识别异常: {e}')
    return RestResp(message='语音识别失败,请稍后重试')

    10.3 配置(requirements.txt / .env / settings.py)

    # requirements.txt
    sherpa-onnx>=1.10.0
    av>=12.0.0

    # .env
    SENSE_VOICE_MODEL_DIR="models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09"

    # app/config/settings.py
    sense_voice_model_dir: Optional[str] = 'models/sherpa-onnx-sense-voice-zh-en-ja-ko-2025-04-15'

    10.4 前端接口(src/api/chat.js)

    // 语音识别:上传录音(webm/mp4),返回识别文本
    export function asrUpload(blob) {
    const formData = new FormData()
    formData.append('file', blob, 'record.webm')
    return request.post('/chat_py_server/chat/v1/asr', formData, { timeout: 60000 })
    }

    10.5 前端录音与自动发送(src/views/Chat.vue 节选)

    <!– 麦克风按钮 –>
    <el-tooltip
    :content="recording ? '正在录音,松开结束' : '按住说话'"
    placement="top"
    >
    <el-button
    class="mic-btn"
    circle
    :type="recording ? 'danger' : 'default'"
    :class="{ 'is-recording': recording }"
    @pointerdown.prevent="startRecord"
    @pointerup="stopRecord"
    @pointercancel="stopRecord"
    @contextmenu.prevent
    >
    <el-icon :size="18"><Microphone /></el-icon>
    </el-button>
    </el-tooltip>

    function pickMimeType() {
    const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4']
    return candidates.find((t) => window.MediaRecorder && MediaRecorder.isTypeSupported(t)) || ''
    }

    let pressActive = false // 是否仍处于“按住”状态

    async function startRecord(event) {
    // 已经按住或正在录音时,忽略重复的 pointerdown
    if (pressActive || recording.value) return
    pressActive = true

    // 指针捕获:即使松开时手指/鼠标已经移出按钮,也能收到 pointerup
    try {
    event?.currentTarget?.setPointerCapture?.(event.pointerId)
    } catch (e) {
    // 忽略不支持指针捕获的浏览器
    }

    if (!navigator.mediaDevices?.getUserMedia) {
    pressActive = false
    ElMessage.warning('当前浏览器不支持录音')
    return
    }
    try {
    const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
    // 授权弹窗期间用户已经松手:释放音频流,不启动录音
    if (!pressActive) {
    stream.getTracks().forEach((t) => t.stop())
    return
    }
    const mimeType = pickMimeType()
    mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
    mediaStream = stream
    audioChunks = []
    mediaRecorder.ondataavailable = (e) => {
    if (e.data && e.data.size) audioChunks.push(e.data)
    }
    mediaRecorder.onstop = () => {
    const blob = new Blob(audioChunks, { type: mediaRecorder.mimeType || 'audio/webm' })
    audioChunks = []
    stopMediaTracks()
    uploadAudio(blob)
    }
    mediaRecorder.onerror = () => {
    ElMessage.error('录音失败,请重试')
    resetRecord()
    }
    mediaRecorder.start()
    recording.value = true
    } catch (e) {
    pressActive = false
    ElMessage.error('无法访问麦克风,请检查浏览器权限设置')
    }
    }

    function stopRecord() {
    if (!pressActive && !recording.value) return
    pressActive = false
    if (mediaRecorder && mediaRecorder.state !== 'inactive') {
    mediaRecorder.stop()
    }
    recording.value = false
    }

    function stopMediaTracks() {
    if (mediaStream) {
    mediaStream.getTracks().forEach((t) => t.stop())
    mediaStream = null
    }
    }

    function resetRecord() {
    recording.value = false
    pressActive = false
    stopMediaTracks()
    mediaRecorder = null
    }

    async function uploadAudio(blob) {
    statusText.value = '正在识别语音…'
    try {
    const res = await asrUpload(blob)
    const text = res?.status === 1 && res.data ? String(res.data).trim() : ''
    if (text) {
    inputText.value = text
    // 识别完成后直接发送提问
    send()
    } else {
    ElMessage.warning(res?.message || '未识别到有效语音')
    }
    } catch (e) {
    ElMessage.error('语音识别失败')
    } finally {
    statusText.value = ''
    }
    }

    onBeforeUnmount(() => {
    pressActive = false
    if (mediaRecorder && mediaRecorder.state !== 'inactive') {
    mediaRecorder.stop()
    }
    stopMediaTracks()
    })

    11. 小结

  • 选型:私有化场景优先本地模型,SenseVoiceSmall + sherpa-onnx 在 CPU 上即可跑通;
  • 后端分两层:asr_service 只做"模型 + 解码 + 识别",控制器只做"参数校验 + 错误映射";
  • 解码关键:PyAV 一次重采样到 16kHz/单声道/int16,再归一化为 float32,正好匹配 SenseVoice 输入;
  • 前端关键:按住麦克风 → MediaRecorder 录音 → 松开结束 → Blob → FormData(file) 上传 → 识别文本自动填入并 send();
  • 三个必须注意的坑:getUserMedia 需要安全上下文、transcribe 会阻塞事件循环、.env 与 settings.py 的模型路径要一致;
  • 可扩展方向:流式语音识别(边说边出字)、VAD 自动断句、录音大小限制、识别失败重试。
  • 赞(0)
    未经允许不得转载:171主机测评 » AI智能体项目实战记录(16):进阶学习 —— 添加语音识别功能,实现快速提问
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址