ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

OpenDuckMini语音控制教程:树莓派4B从麦克风到舵机的完整链路

OpenDuckMini语音控制教程:树莓派4B从麦克风到舵机的完整链路 做桌面级机器人时最让人兴奋也最容易劝退的往往不是机械组装而是“语音控制”这个听起来直接的需求。你希望机器人听到“向左看”就转脑袋听到“介绍一下你自己”就调用大模型回答但实际开发中却要同时处理音频采集、语音识别、大模型意图解析、舵机控制、资源受限设备调度这一整条链路。任何一个环节断掉机器人都只会像一块沉默的砖头。OpenDuckMini 开源机器鸭是最近很火的桌面级 AI 机器人项目社区里有中文文档、CAD 图纸、套件说明也经常能看到在树莓派 4B 上运行的案例。这篇教程会围绕“树莓派 4B OpenDuckMini 机器人 语音控制”展开从硬件准备、软件架构到语音识别、大模型解析、动作控制一步一步带你跑通一条可验证的完整链路。即使你暂时没有舵机也可以用 Mock 模式在电脑上先验证程序逻辑。1. OpenDuckMini 与语音控制方案概览1.1 什么是 OpenDuckMiniOpenDuckMini 是一个桌面级开源机器人项目因为外形像一只小鸭子社区也常叫它“开源机器鸭”。它通常以树莓派 4B 或更高性能的开发板作为主控搭配多路小型舵机、3D 打印结构件、USB 麦克风、扬声器和可选摄像头。同济子豪兄等开发者持续维护了中文知识库包含套件及整机购买入口、中文文档、CAD 图纸等降低了初学者进入具身智能和机器人控制领域的门槛。与传统遥控车不同OpenDuckMini 的价值在于它的“大脑”是可以替换的。你可以用纯规则控制它也可以接上大语言模型让它理解自然语言并转换为具体动作。官方社区经常展示的场景包括语音对话、机械臂抓取、视觉识别等。正因为主控是树莓派 4B很多玩法都能复用到其他树莓派项目上这也是这个项目特别适合学习的原因。1.2 语音控制难在链路而不是单点技术说到机器人语音控制不少人第一反应是“我调一个语音识别 API 就好”。但真实场景下语音识别只是链路中的一环。一套完整的语音控制方案通常包括以下阶段麦克风拾音将模拟声音变成 PCM 音频数据。语音活动检测区分“有人说话”和“环境静音”减少无效计算。自动语音识别把音频转写成文本也就是常见的 ASR 功能。意图理解让大模型理解“向左看”“转个圈”这些自然语言。动作规划把意图映射成具体的舵机角度或动作序列。执行与反馈控制舵机运动并通过语音或表情反馈执行结果。从技术栈来看语音识别可以用 Whisper、faster-whisper 这类开源模型意图理解可以交给 Ollama 本地部署的小参数大模型动作执行则要处理树莓派 GPIO、I2C 或串口通信。多模块协同是新手最容易卡住的地方所以本文会把每一层拆开先用最小代码验证最后再整合成主程序。1.3 树莓派 4B 的资源边界树莓派 4B 拥有四核 Cortex-A72 CPU常见版本为 2GB、4GB 或 8GB 内存。相比树莓派 3B它的性能足以运行轻量化语音识别和极小参数 LLM但仍然属于资源受限设备。跑较大模型时 CPU 会长时间满载散热不良还会触发降频。因此在树莓派 4B 上做语音控制设计原则应该是语音识别优先使用 int8 量化的小模型例如 faster-whisper 的 base 或 small。大语言模型优先通过 Ollama 运行 0.5B 到 1.5B 级别的量化模型。保持模块解耦ASR、LLM、动作执行尽量不要互相阻塞。这些原则会贯穿下面每一段代码。2. 硬件准备与树莓派系统环境2.1 OpenDuckMini 常用硬件清单建议先准备以下核心硬件硬件用途注意事项树莓派 4B主控建议 4GB 以上内存8GB 更从容TF 卡 / SSD系统与模型存储模型文件多优先使用高速卡或 USB SSD5V 3A 以上电源树莓派供电舵机不建议直接吃树莓派电源USB 麦克风语音输入不要依赖板载音频接口扬声器或耳机语音输出可选用于 TTS 播报多路舵机头部、手臂等关节OpenDuckMini 常用小型总线舵机舵机驱动模块控制舵机总线舵机走串口普通舵机可用 PCA96853D 打印结构件机身支架官方社区提供 CAD 图纸需要提醒的是树莓派的 3.5mm 音频接口和 USB 声卡同时存在时Linux 默认声卡可能不是你插入的 USB 麦克风。后面会专门说明如何确认录音设备。2.2 烧录系统与远程连接树莓派官方推荐使用 Raspberry Pi Imager 烧录 Raspberry Pi OS。建议选择 64 位版本因为较新的 Python 生态和二进制依赖对 64 位支持更好。烧录时可在 Imager 的高级选项中预先开启 SSH、配置 WiFi 和用户名密码。完成系统启动后通过终端连接树莓派ssh pi树莓派IP先更新系统软件源和基础工具sudo apt update sudo apt upgrade -y如果你的 TF 卡空间紧张在安装模型前最好挂载一块 USB 移动硬盘或 SSD。faster-whisper 的 small 模型和 Ollama 模型加起来可能占用数 GB 空间。2.3 Python 虚拟环境与依赖安装树莓派系统自带 Python 3但系统 Python 环境通常不建议直接安装一堆项目依赖。推荐为 OpenDuckMini 语音控制单独创建虚拟环境python3 -m venv ~/venvs/openduckmini source ~/venvs/openduckmini/bin/activate pip install --upgrade pip音频采集需要系统级音频库先安装 PortAudiosudo apt install -y libportaudio2 portaudio19-dev然后安装 Python 依赖pip install sounddevice numpy faster-whisper requests说明sounddevice 负责麦克风采集比 PyAudio 配置更简单底层依赖 PortAudio。numpy 用于音量计算和音频数据拼接。faster-whisper 是 CTranslate2 加速的 Whisper 推理库在 CPU 设备上比原始 Whisper 快很多。requests 用来调用 Ollama 的 OpenAI 兼容接口比额外安装 openai SDK 更轻量。版本不需要完全固定。faster-whisper 和 Ollama 都在快速迭代如果你的项目依赖其他框架请按实际兼容情况调整不要盲抄版本号。3. 软件架构把语音控制拆成四层3.1 数据流设计理解架构比写代码更重要。下图用文字表达语音控制的完整数据流麦克风 - 音频采集与语音活动检测 - 本地 ASR 语音识别 - 大模型意图解析 - 动作执行器 - 舵机 / 提示消息在树莓派 4B 上建议主循环采用“触发式识别”。机器人不持续识别全部语音而是先等待环境音量超过阈值再把后续音频送入 ASR 模型。这样可以避免麦克风一直开着消耗大量 CPU也方便你测试整套逻辑。3.2 项目目录设计为了让代码清晰可维护我会把项目拆成下面几个文件openduckmini-voice/ ├── audio.py # 录音与语音活动检测 ├── asr_engine.py # 语音识别 ├── brain.py # 大模型意图解析与兜底逻辑 ├── servo_controller.py # 舵机控制抽象与 Mock 实现 ├── action_executor.py # 动作执行器 └── main.py # 主程序入口每个文件只负责一个独立能力。后面无论你是想换成云端 ASR还是换一个真实舵机驱动都只需要替换对应模块。4. 实现语音采集与本地识别4.1 方案选型语音识别有两种常见方向在线 API识别准确率高但需要网络延迟受网络波动影响。本地推理隐私好、延迟稳定但模型受限于树莓派算力。树莓派 4B 上推荐使用 faster-whisper。它支持 int8 计算内存占用比原始 Whisper 低很多并能设置 vad_filter 过滤静音片段减少幻觉文本。对于中文指令small 模型已经能覆盖“向左看”“举起手臂”这类短句如果现场调试比较慢可以先从 base 模型开始。4.2 麦克风录音与语音活动检测在audio.py中我们实现一个最基本的“听到声音就开始录音声音停止后自动结束”的函数。为了判断是否有人说话可以计算音频帧的 RMS 能量值。RMS 超过阈值就认为开始说话说话结束后静音超过一定时间则认为指令结束。# audio.py import time import wave import numpy as np import sounddevice as sd SAMPLE_RATE 16000 CHANNELS 1 BLOCK_SECONDS 0.1 def _rms(frame: np.ndarray) - float: 计算一帧音频的 RMS 能量用于简单语音活动检测。 audio_float frame.astype(np.float32) / 32768.0 return float(np.sqrt(np.mean(audio_float ** 2) 1e-6)) def listen_once( rate: int SAMPLE_RATE, voice_threshold: float 0.04, silence_seconds: float 1.2, max_wait: float 15.0, ) - np.ndarray | None: 检测到语音后录音语音结束后返回音频数据。 block_size int(rate * BLOCK_SECONDS) frames [] recording False silence_blocks 0 max_silence_blocks int(silence_seconds / BLOCK_SECONDS) with sd.InputStream( sampleraterate, channelsCHANNELS, dtypeint16, blocksizeblock_size, ) as stream: start time.time() while time.time() - start max_wait: frame, overflowed stream.read(block_size) if overflowed: print(警告音频缓冲区溢出可能有丢帧) volume _rms(frame) if not recording and volume voice_threshold: recording True print(检测到语音开始录音...) if recording: frames.append(frame.copy()) if volume voice_threshold: silence_blocks 1 else: silence_blocks 0 if silence_blocks max_silence_blocks: print(检测到停顿录音结束) break if not frames: return None return np.concatenate(frames, axis0) def save_wav(audio_data: np.ndarray, file_path: str, rate: int SAMPLE_RATE) - None: 将 int16 音频数据保存为 WAV 文件。 with wave.open(file_path, wb) as wf: wf.setnchannels(CHANNELS) wf.setsampwidth(2) wf.setframerate(rate) wf.writeframes(audio_data.tobytes())这段代码的核心思路是循环读取小块音频计算每块音量。由于树莓派麦克风灵敏度、环境噪声都不同voice_threshold可能需要现场调整。实际部署时可以打印每帧音量观察安静房间和说话时的差异。如果一直不停识别可能是麦克风采集到了风扇声或电流声应该把阈值调高。4.3 用 faster-whisper 转写语音在asr_engine.py中封装 ASR 模型。faster-whisper 会自动从 Hugging Face 下载模型到本地缓存目录如果树莓派下载慢可以先在其他电脑下载好模型文件再拷贝到~/.cache/huggingface/hub中。# asr_engine.py from faster_whisper import WhisperModel class ASREngine: def __init__(self, model_name: str small, language: str zh): self.language language # 树莓派 CPU 推理使用 int8 量化速度比 float16 更友好 self.model WhisperModel(model_name, devicecpu, compute_typeint8) def recognize(self, wav_path: str) - str: segments, info self.model.transcribe( wav_path, languageself.language, vad_filterTrue, ) text .join(segment.text.strip() for segment in segments) return text首次运行 ASR 模型时需要加载文件和模型热启动耗时可能达到几十秒。正式执行时建议在程序初始化阶段就加载好模型而不是每次识别都重新加载。上面的__init__中已经预加载后续只需调用recognize。5. 让机器人理解命令大模型意图解析5.1 为什么需要大模型而不是关键词匹配有读者可能会问“向左看”用if 左 in text不好吗对于固定几个动作当然可以但真实场景中用户表达变化太多。比如“转过头来看我”“右转一点”“把脑袋摆到中间”关键词规则会越来越多最终难以维护。大模型的优势在于用一段 Prompt 就能完成意图分类、参数抽取和闲聊回复。但树莓派 4B 不适合运行 7B 级别的大模型因此我们把 Ollama 作为推理服务选择 0.5B 或 1.5B 的轻量中文模型。如果内存充足qwen2.5:1.5b效果更好如果只是跑通链路qwen2.5:0.5b足够。5.2 安装 Ollama 并下载模型在树莓派终端执行 Ollama 官方安装脚本curl -fsSL https://ollama.com/install.sh | sh安装完成后拉取轻量模型。写成 0.5b 主要是为了先验证流程ollama pull qwen2.5:0.5b检查 Ollama 是否已经在本机运行ollama serveOllama 默认监听11434端口并提供 OpenAI 兼容接口。你不需要额外安装 Flask因为大模型服务由 Ollama 进程独立承载。5.3 编写 Brain 模块brain.py要做的事情是把用户语音文本发送给 Ollama要求模型只返回 JSON不允许输出多余解释。我们仍然用requests直接调用 HTTP 接口避免引入不必要的 SDK。# brain.py import json import requests def _extract_json(content: str) - dict: 从模型输出中提取 JSON兼容模型额外输出 Markdown 代码块的情况。 start content.find({) end content.rfind(}) if start -1 or end -1: raise ValueError(模型输出中没有找到 JSON 内容) return json.loads(content[start:end 1]) def fallback_action(text: str) - dict | None: 关键词兜底方案避免 Ollama 服务异常时机器人完全瘫痪。 rules [ ((左, 往左, 看左边), {action: look, direction: left, angle: 30}), ((右, 往右, 看右边), {action: look, direction: right, angle: 30}), ((中间, 正前方, 回正), {action: look, direction: center, angle: 0}), ((点头, 同意, 是), {action: nod, count: 1}), ((摇头, 不同意, 不是), {action: shake, count: 1}), ] for keywords, action in rules: if any(keyword in text for keyword in keywords): return action return None class Brain: def __init__( self, model: str qwen2.5:0.5b, base_url: str http://localhost:11434/v1, ): self.model model self.base_url base_url self.system_prompt 你是桌面机器人 OpenDuckMini 的动作解析器。 用户指令可能是动作指令也可能是普通聊天。 如果是动作指令请输出 JSON格式如下 {action: look, direction: left, angle: 30} 支持的动作包括 1. look转头direction 取 left/right/centerangle 是目标角度 0-180。 2. nod点头count 是次数。 3. shake摇头count 是次数。 4. chat普通聊天message 字段填写你想对用户说的话。 如果用户只是聊天不涉及动作请输出 {action: chat, message: 你的回答} 只输出 JSON不要输出任何解释。 def parse(self, user_text: str) - dict | None: payload { model: self.model, messages: [ {role: system, content: self.system_prompt}, {role: user, content: user_text}, ], temperature: 0.2, stream: False, } try: resp requests.post( f{self.base_url}/chat/completions, jsonpayload, timeout60, ) resp.raise_for_status() content resp.json()[choices][0][message][content] parsed _extract_json(content) return parsed except Exception as exc: print(LLM 调用失败尝试关键词兜底, exc) return fallback_action(user_text)大模型输出并不总是完全规范可能带着中文标点、Markdown 代码块甚至解释文字。_extract_json直接截取第一个{到最后一个}的部分再交给json.loads能显著提高解析成功率。真正的生产级项目还应该定义 JSON Schema并对每个字段做合法性校验本文示例保留“能跑通”的优先级方便初学者理解。5.4 闲聊与指令分离上面的 Prompt 中chat动作用于普通对话。这样可以避免用户问“你叫什么名字”时模型硬造出一个不存在的动作。主程序拿到chat动作后只需要打印或通过 TTS 播报message字段即可。在树莓派 4B 上0.5B 模型回答简单问题尚可复杂推理会很吃力。如果你希望机器人具备更真实的对话能力建议把闲聊请求切到云端大模型接口树莓派本地只保留动作解析功能。6. 动作控制层让舵机动起来6.1 舵机选型与树莓派连接OpenDuckMini 常用的舵机分为两类普通 PWM 舵机通过 PCA9685 模块或树莓派硬件 PWM 控制优点是便宜缺点是线路多。串行总线舵机只需一根总线连接多个舵机可以读取角度、温度适合多关节机器人。树莓派 GPIO 输出电流非常有限如果直接用 GPIO 给多个舵机供电很容易造成电压跌落甚至重启。真实项目中一定要使用外部电源给舵机供电树莓派和舵机控制板只传输信号线并且共地。上电之前先单独测试每个舵机是否能转动检查机械结构有无卡死。由于不同舵机厂商的串口指令协议不同本文不写死具体指令格式而是定义一个set_angle(name, angle)接口。真实舵机驱动只需照着这个接口补齐通信逻辑上层动作代码完全不用改。6.2 Mock 舵机控制器没有舵机或者想先在电脑上调试的读者可以用MockServoController先跑通主链路。它在控制台中打印出每个关节的目标角度看起来就像舵机真的在转动。# servo_controller.py class MockServoController: 无硬件时的模拟舵机控制器方便调试主流程。 def __init__(self): self.servos { head_pan: 90, head_tilt: 90, arm_left: 0, arm_right: 0, } def set_angle(self, name: str, angle: int) - None: angle max(0, min(180, int(angle))) self.servos[name] angle print(f[MockServo] {name} 角度设置为 {angle})如果使用真实舵机可以用下面的骨架创建SerialServoController类把串口发送指令的部分替换成你的舵机协议# servo_controller.py import serial class SerialServoController: 串行总线舵机控制器示例骨架。 请根据你的舵机型号和指令协议把发送方法中的构造帧、 校验位等逻辑补全。 def __init__(self, port: str /dev/ttyAMA0, baudrate: int 115200): self.ser serial.Serial(port, baudrate, timeout0.1) def set_angle(self, name: str, angle: int) - None: servo_id int(name.replace(head_, ).replace(arm_, )) # TODO: 根据舵机手册构造角度命令帧 # self.ser.write(build_command(servo_id, angle)) print(f[SerialServo] {name}(id{servo_id}) - {angle} 度)把接口层抽象出来是非常重要的一步。以后即使换舵机型号也只动servo_controller.py不用改写主程序、语音识别和大模型逻辑。6.3 动作执行器action_executor.py负责把大模型输出的动作映射为具体的舵机指令。它依赖一个实现了set_angle的 controller因此既能接收 Mock 控制器也能接收真实串口控制器。# action_executor.py import time class ActionExecutor: def __init__(self, servo_controller): self.servo servo_controller def execute(self, plan: dict | None) - str | None: 根据解析结果执行动作。返回需要语音反馈的消息。 if not plan or not isinstance(plan, dict): return None action plan.get(action) handler getattr(self, f_do_{action}, None) if handler is None: print(f未知动作: {action}) return None return handler(plan) def _do_look(self, plan: dict) - None: direction plan.get(direction, center) angle_map { left: 150, right: 30, center: 90, } target plan.get(angle, 0) if direction in angle_map: target angle_map[direction] self.servo.set_angle(head_pan, max(0, min(180, target))) time.sleep(0.3) def _do_nod(self, plan: dict) - None: count int(plan.get(count, 1)) for _ in range(count): self.servo.set_angle(head_tilt, 60) time.sleep(0.2) self.servo.set_angle(head_tilt, 120) time.sleep(0.2) self.servo.set_angle(head_tilt, 90) def _do_shake(self, plan: dict) - None: count int(plan.get(count, 1)) for _ in range(count): self.servo.set_angle(head_pan, 140) time.sleep(0.2) self.servo.set_angle(head_pan, 40) time.sleep(0.2) self.servo.set_angle(head_pan, 90) def _do_chat(self, plan: dict) - str: message plan.get(message, ) return message可以看到节点动作本质上是“多个关节角度随时间变化”的序列。真实机器人在执行动作时还应该加入加速度限制、关节限位检查和急停机制避免舵机堵转发热。7. 主程序集成与运行7.1 主循环设计主程序需要完成四件事等待用户说话。把录音文件交给 ASR 转写。把文本交给大模型做意图解析。根据动作类型让机器人执行并打印或播报回复。考虑到树莓派 4B 的 CPU 资源主循环采用串行执行已经足够。如果你想边说话边控制机器人运动就需要把动作放在独立线程里并加入 FIFO 动作队列避免新指令覆盖当前动作导致机械结构冲突。这里先给出最简单可靠的串行版本。7.2 项目入口 main.py# main.py import time import audio from action_executor import ActionExecutor from asr_engine import ASREngine from brain import Brain from servo_controller import MockServoController TEMP_WAV command.wav def main(): print(初始化语音识别模型...) asr ASREngine(model_namesmall, languagezh) print(初始化大模型解析器...) brain Brain(modelqwen2.5:0.5b, base_urlhttp://localhost:11434/v1) print(初始化舵机控制与动作执行器...) servo MockServoController() executor ActionExecutor(servo) print(OpenDuckMini 语音控制已启动。请对麦克风说话...) time.sleep(1) while True: try: audio_data audio.listen_once( voice_threshold0.04, silence_seconds1.2, max_wait15.0, ) if audio_data is None: print(未检测到有效语音继续监听...) continue audio.save_wav(audio_data, TEMP_WAV) # 第一步语音识别 text asr.recognize(TEMP_WAV) print(识别文本:, text) if not text: print(识别结果为空请重试) continue # 第二步大模型解析意图 plan brain.parse(text) print(动作解析结果:,
返回列表