/* 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,原因:
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 前端自测
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()
})





