Rhino Pi-X1 打造本地离线语音助手:SenseVoiceSmall + Qwen3-8B 端侧组合实战

azouyana 2026-09-22 11:40:58

继《AidVoice SDK + SenseVoiceSmall 语音识别部署》与《AidGenSE 部署 Qwen3-8B》之后,这篇我们把两者组合成一个 完全本地推理的语音助手:
语音输入 → 语音识别(ASR)→ 大模型(LLM)→ 文字回复。
全程数据不出设备,无网络依赖。文中所有数据均为真机实测。


1. 项目简介与架构

1.1 组合思路

  • 耳朵:AidVoice SDK + SenseVoiceSmall(QNN HTP 硬件加速,ASR)
  • 大脑:AidGenSE + Qwen3-8B W4A16(OpenAI 兼容 HTTP 服务,端口 8888)
  • 胶水:一个约 200 行的 Python 脚本 voice_assistant.py,负责编排「识别 → 提问 → 展示回复」

1.2 整体架构

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/>回复文本"]

1.3 一次完整交互的时间线(实测)

阶段耗时说明
语音识别≈ 0.95 s15 秒音频,含模型初始化
大模型生成≈ 2.7 s约 40 字回复,/no_think 模式
全流程≈ 4 s端到端(不含人工等待)

2. 前置准备

本助手依赖两个已经部署好的环境(详见本目录另外两篇文档):

依赖说明状态检查命令
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

路径不同时可通过命令行参数覆盖。


3. 语音助手实现要点

完整代码见文末附录。实现中有 5 个值得说明的关键点:

3.1 ASR:非流式识别 + 定稿拼接

SenseVoiceSmall 在非流式模式下会按语音片段回调多次结果(id 递增),
只需收集 TYPE_FINAL(定稿)片段并拼接:

class _ASRCollector(ASRCallbacks):
    def onResult(self, result):
        if result.status == AsrStatus.TYPE_FINAL:
            self.finals.append(result.text)

3.2 尾部噪点过滤

实测发现:音频尾部若有轻微噪声,可能被识别成一段短促的"幻听"片段(如 The.)。
处理方式是把结尾处"不含中文的短片段"丢弃(只影响尾部、且至少保留一段结果):

while len(segs) > 1 and len(segs[-1]) <= 6 and not re.search(r"[\u4e00-\u9fff]", segs[-1]):
    segs.pop()

3.3 自动重采样:任何音频都能直接喂给 ASR

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)

3.4 LLM:流式请求 + 默认关闭思考模式

  • 使用 OpenAI 兼容协议 POST /v1/chat/completions,"stream": true 逐字接收;
  • Qwen3 默认开启思考模式,实测简单问答加 /no_think 后生成耗时 ≈ 2.7s,适合语音助手场景;
  • 系统提示词限制回答长度(≤100 字)与口语化风格。

3.5 流式过滤 <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

4. 使用方法

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-modelSenseVoiceSmall 模型目录/home/aidlux/sensevoicesmall/models/QCS8550/FP16
--llm-urlLLM 服务地址http://127.0.0.1:8888/v1/chat/completions
--llm-model模型 IDqwen3-8b-qnn2.36-w4a16-qcs8550
--system-prompt系统提示词(人设/风格)内置语音助手提示词
--think开启思考模式关闭(/no_think)

5. 实测记录

5.1 会议语音转写 + 智能回应(test_meeting.wav,16kHz)

[1/2] 语音识别 (SenseVoiceSmall) ...
      识别耗时 0.96s

👤 你说: 大家好,今天我们开会讨论一下项目进度,目前整体开发已经完成80%,下周需要
完成第一版交付,请大家注意测试进度,按时提交代码。

[2/2] 大模型思考 (Qwen3-8B) ...
🤖 助手: 大家好,开发已完成80%,下周交付第一版。请大家注意测试,按时提交代码,确
保质量。
      (生成耗时 2.68s)

5.2 提问式语音(48kHz 音频,自动重采样)

[1/2] 语音识别 (SenseVoiceSmall) ...
      (音频已自动转换为 16kHz 单声道: /tmp/va_16k_mono.wav)
      识别耗时 0.94s

👤 你说: 请用一句话介绍一下杭州这个城市。

[2/2] 大模型思考 (Qwen3-8B) ...
🤖 助手: 杭州是江南水乡,西湖美景让人心旷神怡,龙井茶香四溢,是一座既有历史又充满
现代活力的城市。
      (生成耗时 3.21s)

5.3 结果汇总

用例ASR 识别LLM 生成退出码
会议语音(16k)✅ 0.96s,全文正确✅ 2.68s0
提问语音(48k→16k)✅ 0.94s,全文正确✅ 3.21s0
文本输入(调试)跳过✅ 1.70s0

6. 踩坑记录

  1. TTS 回调必须实现 onResult,否则进程段错误
    最初版本还包含「语音回复」环节(MeloTTS)。调试时发现:Python 回调类若只实现
    onStop/onError、没有实现 onResult,TTS 合成完成后进程会以 139(SIGSEGV)退出
    (音频文件其实已生成)。补上 onResult 后退出码恢复正常。
    对齐实验(同一句文本):

    回调实现退出码
    官方示例(含 onResult)0
    仅 onStop/onError139(段错误)
    补上 onResult 后0

    因本机无音频输出设备,本篇最终去掉了 TTS 环节,仅保留「识别 + 大模型回复」。

  2. 采样率必须匹配:48kHz 音频直接交给 ASR 会识别成乱码(实测),
    务必先转 16kHz 单声道(脚本已内置自动转换)。

  3. /no_think 仍可能带空 <think></think> 块:展示层已做流式过滤,接口返回值再做
    strip_think 兜底。

  4. 本机无声卡(arecord -l 无设备):麦克风实时方案需在带 USB 麦克风的设备上验证;
    无麦克风时用「音频文件模拟」即可完整演示链路,未来接麦克风时只需把音频来源
    换成录音文件/音频流。

  5. aidllm 服务是单实例串行推理(svr_slot_num=1):语音助手是串行交互场景,
    天然契合;若并发接入需自行加队列。


7. 常见问题(FAQ)

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),运行时无需外网。


8. 工作目录文件清单

/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

...全文
74 回复 打赏 收藏 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

7,720

社区成员

发帖
与我相关
我的任务
社区描述
本论坛以AI、IoT、PC 、XR、Auto等核心板块组成,为开发者提供便捷及高效的学习和交流平台。 高通开发者专区主页:https://qualcomm.csdn.net/
物联网人工智能开源 企业社区 北京·东城区
社区管理员
  • csdnsqst0050
  • chipseeker
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧