7,720
社区成员
发帖
与我相关
我的任务
分享继《AidVoice SDK + SenseVoiceSmall 语音识别部署》与《AidGenSE 部署 Qwen3-8B》之后,这篇我们把两者组合成一个 完全本地推理的语音助手:
语音输入 → 语音识别(ASR)→ 大模型(LLM)→ 文字回复。
全程数据不出设备,无网络依赖。文中所有数据均为真机实测。
voice_assistant.py,负责编排「识别 → 提问 → 展示回复」flowchart LR
A["语音文件<br/>WAV / MP3 / ..."] --> B["SenseVoiceSmall<br/>(AidVoice SDK · QNN HTP)"]
B -->|识别文本| C["voice_assistant.py<br/>编排逻辑"]
C -->|OpenAI 协议请求| D["Qwen3-8B<br/>(AidGenSE · 127.0.0.1:8888)"]
D -->|流式回复| C
C --> E["终端输出<br/>回复文本"]
| 阶段 | 耗时 | 说明 |
|---|---|---|
| 语音识别 | ≈ 0.95 s | 15 秒音频,含模型初始化 |
| 大模型生成 | ≈ 2.7 s | 约 40 字回复,/no_think 模式 |
| 全流程 | ≈ 4 s | 端到端(不含人工等待) |
本助手依赖两个已经部署好的环境(详见本目录另外两篇文档):
| 依赖 | 说明 | 状态检查命令 |
|---|---|---|
| ASR 环境 | AidVoice SDK + SenseVoiceSmall 模型 | ls /home/aidlux/sensevoicesmall/models/QCS8550/FP16 |
| LLM 服务 | AidGenSE + Qwen3-8B,监听 8888 端口 | aidllm status api |
启动 LLM 服务(若未运行):
aidllm start api -m qwen3-8b-qnn2.36-w4a16-qcs8550
助手默认模型路径:
- ASR 模型:
/home/aidlux/sensevoicesmall/models/QCS8550/FP16- LLM:
http://127.0.0.1:8888/v1/chat/completions,模型名qwen3-8b-qnn2.36-w4a16-qcs8550路径不同时可通过命令行参数覆盖。
完整代码见文末附录。实现中有 5 个值得说明的关键点:
SenseVoiceSmall 在非流式模式下会按语音片段回调多次结果(id 递增),
只需收集 TYPE_FINAL(定稿)片段并拼接:
class _ASRCollector(ASRCallbacks):
def onResult(self, result):
if result.status == AsrStatus.TYPE_FINAL:
self.finals.append(result.text)
实测发现:音频尾部若有轻微噪声,可能被识别成一段短促的"幻听"片段(如 The.)。
处理方式是把结尾处"不含中文的短片段"丢弃(只影响尾部、且至少保留一段结果):
while len(segs) > 1 and len(segs[-1]) <= 6 and not re.search(r"[\u4e00-\u9fff]", segs[-1]):
segs.pop()
ASR 模型要求 16kHz 单声道 WAV。实测把 48kHz 的音频直接送入,识别结果会严重劣化
("请用一句话介绍一下杭州这个城市" 被识别成乱码)。
因此在识别前用 ffprobe 检查、必要时 ffmpeg 自动转换:
# 不是 16k 单声道 → 自动转换到临时文件
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", path,
"-ar", "16000", "-ac", "1", "-acodec", "pcm_s16le", tmp], check=True)
POST /v1/chat/completions,"stream": true 逐字接收;/no_think 后生成耗时 ≈ 2.7s,适合语音助手场景;<think> 推理过程即使用 /no_think,模型仍可能先返回一个空的 <think></think> 块。
展示时用"逐步渲染 + 差分打印"的方式把推理段整体隐藏,终端只出现正文:
visible = re.sub(r"<think>.*?(?:</think>|$)", "", full, flags=re.S).lstrip("\n")
if len(visible) > len(shown):
print(visible[len(shown):], end="", flush=True)
shown = visible
cd /home/aidlux/2026_9_22_ASR
# ① 语音输入(任意采样率/格式,自动转 16k 单声道)
python3 voice_assistant.py -a test_meeting.wav
# ② 文本输入(调试用,跳过 ASR)
python3 voice_assistant.py -t "你好,请用一句话介绍你自己"
# ③ 开启思考模式(回答更严谨,但更慢)
python3 voice_assistant.py -a question.wav --think
参数说明:
| 参数 | 说明 | 默认值 |
|---|---|---|
-a, --audio | 输入音频文件(任意采样率,自动转换) | — |
-t, --text | 直接输入文本(跳过 ASR) | — |
--asr-model | SenseVoiceSmall 模型目录 | /home/aidlux/sensevoicesmall/models/QCS8550/FP16 |
--llm-url | LLM 服务地址 | http://127.0.0.1:8888/v1/chat/completions |
--llm-model | 模型 ID | qwen3-8b-qnn2.36-w4a16-qcs8550 |
--system-prompt | 系统提示词(人设/风格) | 内置语音助手提示词 |
--think | 开启思考模式 | 关闭(/no_think) |
test_meeting.wav,16kHz)[1/2] 语音识别 (SenseVoiceSmall) ...
识别耗时 0.96s
👤 你说: 大家好,今天我们开会讨论一下项目进度,目前整体开发已经完成80%,下周需要
完成第一版交付,请大家注意测试进度,按时提交代码。
[2/2] 大模型思考 (Qwen3-8B) ...
🤖 助手: 大家好,开发已完成80%,下周交付第一版。请大家注意测试,按时提交代码,确
保质量。
(生成耗时 2.68s)
[1/2] 语音识别 (SenseVoiceSmall) ...
(音频已自动转换为 16kHz 单声道: /tmp/va_16k_mono.wav)
识别耗时 0.94s
👤 你说: 请用一句话介绍一下杭州这个城市。
[2/2] 大模型思考 (Qwen3-8B) ...
🤖 助手: 杭州是江南水乡,西湖美景让人心旷神怡,龙井茶香四溢,是一座既有历史又充满
现代活力的城市。
(生成耗时 3.21s)
| 用例 | ASR 识别 | LLM 生成 | 退出码 |
|---|---|---|---|
| 会议语音(16k) | ✅ 0.96s,全文正确 | ✅ 2.68s | 0 |
| 提问语音(48k→16k) | ✅ 0.94s,全文正确 | ✅ 3.21s | 0 |
| 文本输入(调试) | 跳过 | ✅ 1.70s | 0 |
TTS 回调必须实现 onResult,否则进程段错误
最初版本还包含「语音回复」环节(MeloTTS)。调试时发现:Python 回调类若只实现onStop/onError、没有实现 onResult,TTS 合成完成后进程会以 139(SIGSEGV)退出
(音频文件其实已生成)。补上 onResult 后退出码恢复正常。
对齐实验(同一句文本):
| 回调实现 | 退出码 |
|---|---|
官方示例(含 onResult) | 0 |
仅 onStop/onError | 139(段错误) |
补上 onResult 后 | 0 |
因本机无音频输出设备,本篇最终去掉了 TTS 环节,仅保留「识别 + 大模型回复」。
采样率必须匹配:48kHz 音频直接交给 ASR 会识别成乱码(实测),
务必先转 16kHz 单声道(脚本已内置自动转换)。
/no_think 仍可能带空 <think></think> 块:展示层已做流式过滤,接口返回值再做strip_think 兜底。
本机无声卡(arecord -l 无设备):麦克风实时方案需在带 USB 麦克风的设备上验证;
无麦克风时用「音频文件模拟」即可完整演示链路,未来接麦克风时只需把音频来源
换成录音文件/音频流。
aidllm 服务是单实例串行推理(svr_slot_num=1):语音助手是串行交互场景,
天然契合;若并发接入需自行加队列。
Q1:提示 "LLM 请求失败"?
先确认服务在运行:aidllm status api;未运行则 aidllm start api -m qwen3-8b-qnn2.36-w4a16-qcs8550。
Q2:如何换成别的 ASR 模型?--asr-model 指向其它模型目录即可(如 Whisper 系列,模型广场 mms list 可查)。
Q3:如何实现多轮对话?
当前脚本单轮问答。扩展方法:在 ask_llm 中维护 messages 列表,每次把历史user/assistant 消息一起发送(注意 4096 tokens 上下文,建议只保留最近 6~10 轮)。
Q4:回答太慢/太长?
默认已加 /no_think;可进一步在系统提示词中收紧字数(如"≤50 字")。
首次请求包含模型加载,会比后续慢(实测首次约 20s,之后 2~3s)。
Q5:想接入麦克风实时说话?
录音成 16k 单声道 WAV → 喂给本脚本即可;或参考 AidVoice 的 test_asr_microphone
实现流式采集(需要 USB 声卡与 sudo)。
Q6:能离线运行吗?
可以。ASR 与 LLM 都是本机推理(QNN HTP),运行时无需外网。
/home/aidlux/2026_9_22_ASR/
├── voice_assistant.py # 语音助手主程序(本篇核心)
├── test_meeting.wav # 测试音频①:16kHz 会议语音
├── demo/
│ └── q_hangzhou.wav # 测试音频②:48kHz 提问语音(自动重采样用例)
├── README.md # 前篇:AidVoice SDK + SenseVoiceSmall 部署
├── README_llm.md # 前篇:AidGenSE + Qwen3-8B 部署
└── aidvoice/examples/ # AidVoice SDK 官方示例(含 C++/Python)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
voice_assistant.py —— 本地全离线语音助手(ASR + LLM)
链路: 语音文件 → SenseVoiceSmall(ASR) → Qwen3-8B(LLM, OpenAI 兼容 HTTP) → 回复文本
依赖:
- AidVoice SDK: Python 模块 aidvoice_speech(语音识别)
- AidGenSE LLM 服务: http://127.0.0.1:8888
aidllm start api -m qwen3-8b-qnn2.36-w4a16-qcs8550
用法:
python3 voice_assistant.py -a question.wav # 语音输入 → 大模型回复
python3 voice_assistant.py -t "你好" # 文本输入(调试,跳过 ASR)
"""
import argparse
import json
import os
import re
import subprocess
import sys
import tempfile
import time
import requests
from aidvoice_speech import (
ASRCallbacks,
ASRMode,
AsrStatus,
FeatureConfig,
FeatureType,
LogLevel,
create_asr,
)
# ---------------------------- 默认配置 ----------------------------
DEFAULT_ASR_MODEL = "/home/aidlux/sensevoicesmall/models/QCS8550/FP16"
DEFAULT_LLM_URL = "http://127.0.0.1:8888/v1/chat/completions"
DEFAULT_LLM_MODEL = "qwen3-8b-qnn2.36-w4a16-qcs8550"
DEFAULT_SYSTEM_PROMPT = (
"你是一个运行在边缘设备上的语音助手,回答要口语化、简洁(尽量 100 字以内),"
"不要输出 Markdown 标记。"
)
# ---------------------------- ASR ----------------------------
class _ASRCollector(ASRCallbacks):
"""收集非流式识别的定稿片段"""
def __init__(self):
super().__init__()
self.finals = []
self.errors = []
def onResult(self, result):
if result.status == AsrStatus.TYPE_FINAL:
self.finals.append(result.text)
def onError(self, error):
self.errors.append(error.message)
def _join_results(finals):
"""拼接定稿片段;过滤尾部无中文的短噪点片段(如 'The.')"""
segs = [s.strip() for s in finals if s and s.strip()]
while len(segs) > 1 and len(segs[-1]) <= 6 and not re.search(r"[\u4e00-\u9fff]", segs[-1]):
segs.pop()
return "".join(segs)
def _ensure_16k_mono(path):
"""ASR 需要 16kHz 单声道 WAV;格式不符时用 ffmpeg 自动转换(失败则原样返回)"""
try:
info = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "a:0",
"-show_entries", "stream=sample_rate,channels", "-of", "csv=p=0", path],
capture_output=True, text=True, timeout=15).stdout.strip()
rate, ch = [int(x) for x in info.split(",")[:2]]
if rate == 16000 and ch == 1:
return path
tmp = os.path.join(tempfile.gettempdir(), "va_16k_mono.wav")
subprocess.run(
["ffmpeg", "-y", "-v", "error", "-i", path, "-ar", "16000", "-ac", "1",
"-acodec", "pcm_s16le", tmp], check=True, timeout=60)
print(f" (音频已自动转换为 16kHz 单声道: {tmp})")
return tmp
except Exception:
return path
def speech_to_text(audio_path, model_path):
"""语音 → 文字(非流式,音频需为 16k 单声道 WAV)"""
cfg = FeatureConfig()
cfg.feature_type = FeatureType.TYPE_ASR
cfg.model_path = model_path
cfg.log_type = LogLevel.TYPE_ERROR
cb = _ASRCollector()
asr = create_asr(cfg)
asr.set_callback(cb) # 注意: 回调需保持引用直到销毁
asr.set_special_tokens(False) # 不输出情感/事件等特殊标签
asr.set_mode(ASRMode.TYPE_NOSTREAM)
asr.init()
asr.write(audio_path)
asr.asr_destroy()
if cb.errors:
raise RuntimeError("ASR 失败: " + "; ".join(cb.errors))
return _join_results(cb.finals)
# ---------------------------- LLM ----------------------------
def ask_llm(text, url, model, system_prompt, think=False):
"""调用本地 OpenAI 兼容接口,流式打印回复并返回完整文本"""
user_content = text if think else text + " /no_think" # 默认关闭思考,降低延迟
payload = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
],
"stream": True,
}
parts = []
try:
resp = requests.post(url, json=payload, stream=True, timeout=300)
resp.raise_for_status()
except Exception as e:
raise RuntimeError(
f"LLM 请求失败: {e}\n提示: 先确认服务已启动 -> aidllm start api -m {model}"
)
full = ""
shown = ""
for line in resp.iter_lines():
if not line:
continue
line = line.decode("utf-8", "ignore")
if not line.startswith("data: "):
continue
data = line[6:].strip()
if data == "[DONE]":
break
delta = json.loads(data)["choices"][0].get("delta", {}).get("content")
if delta:
parts.append(delta)
full += delta
# 流式过滤 <think>...</think> 推理段(含未闭合的情况),只显示正文
visible = re.sub(r"<think>.*?(?:</think>|$)", "", full, flags=re.S).lstrip("\n")
if len(visible) > len(shown):
print(visible[len(shown):], end="", flush=True)
shown = visible
print()
return "".join(parts)
def strip_think(text):
"""去掉 <think> 推理过程(未加 /no_think 时可能出现)"""
return re.sub(r"<think>.*?</think>", "", text, flags=re.S).strip()
# ---------------------------- 主流程 ----------------------------
def main():
parser = argparse.ArgumentParser(description="本地离线语音助手 (ASR + LLM)")
parser.add_argument("-a", "--audio", help="输入音频文件(任意采样率,自动转 16k 单声道)")
parser.add_argument("-t", "--text", help="直接输入文本(调试用,跳过 ASR)")
parser.add_argument("--asr-model", default=DEFAULT_ASR_MODEL)
parser.add_argument("--llm-url", default=DEFAULT_LLM_URL)
parser.add_argument("--llm-model", default=DEFAULT_LLM_MODEL)
parser.add_argument("--system-prompt", default=DEFAULT_SYSTEM_PROMPT)
parser.add_argument("--think", action="store_true", help="开启思考模式(默认关闭)")
args = parser.parse_args()
if not args.audio and not args.text:
parser.error("请用 -a 指定音频文件,或用 -t 输入文本")
# 1) ASR
if args.audio:
t0 = time.time()
print("[1/2] 语音识别 (SenseVoiceSmall) ...")
user_text = speech_to_text(_ensure_16k_mono(args.audio), args.asr_model)
print(f" 识别耗时 {time.time() - t0:.2f}s")
else:
print("[1/2] 跳过语音识别(文本输入)")
user_text = args.text
print(f"\n👤 你说: {user_text}\n")
if not user_text:
print("未识别到有效语音,退出。")
return 1
# 2) LLM
t0 = time.time()
print("[2/2] 大模型思考 (Qwen3-8B) ...")
print("🤖 助手: ", end="", flush=True)
reply = ask_llm(user_text, args.llm_url, args.llm_model, args.system_prompt, args.think)
print(f" (生成耗时 {time.time() - t0:.2f}s)")
reply = strip_think(reply)
if not reply:
print("模型未返回内容,退出。")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
实测日期:2026-09-22 | 环境:Rhino Pi-X1(QCS8550)+ Ubuntu 22.04 + AidVoice SDK 1.4.2 + AidGenSE 3.3.1