实验指导书

耳聪目明 — 面向移动陪伴的多模态全双工语音交互机器人

共 10 个实验 适用对象:零基础学生 目标平台:树莓派 / PC

使用说明

📖 如何阅读
  • 每个实验按"原理→步骤→代码→结果→思考"顺序编写
  • 黄色提示框是注意事项,仔细阅读避免踩坑
  • 绿色结果框是预期效果,用来对照检查
  • 橙色思考题帮助加深理解
💻 开发环境
  • 先在 PC 上(Windows/Mac)跑通代码
  • 确认逻辑正确后再移植到树莓派
  • Python 版本 3.9 及以上
  • 推荐 VS Code 作为编辑器
⏱️ 时间安排
  • 实验 0-1:基础入门,约 4+6 学时
  • 实验 2-4:感知模块,约 8+6+8 学时
  • 实验 5-6:智能交互,约 6+6 学时
  • 实验 7-9:全双工集成,约 8+8+10 学时
💡 给零基础同学的建议

遇到报错不要慌!先把报错信息复制到搜索引擎里,90% 的问题都能找到答案。 每个实验的代码都可以先完整复制运行,看到效果后再一行行改、一点点理解。 编程是练出来的,不是看出来的——动手敲代码比看十遍都有用。

0

实验 0:环境准备与树莓派入门

难度 入门 建议学时:4 学时 前置知识:无
🎯 实验目标
  • 了解树莓派硬件组成
  • 学会烧录系统并启动树莓派
  • 掌握 SSH 远程连接方法
  • 学会 Python 基础语法
  • 掌握 GPIO 基本输入输出
🛠️ 所需器材
  • 树莓派 4B/5 主板 ×1
  • 32GB Micro SD 卡 ×1
  • 5V 3A USB-C 电源 ×1
  • LED 灯 ×1
  • 220Ω 电阻 ×1
  • 杜邦线 ×2
  • 面包板 ×1
📚 预备知识
  • 会使用电脑基本操作
  • 知道什么是操作系统
  • 了解基本的电路概念

一、实验原理

树莓派(Raspberry Pi)是一款只有信用卡大小的微型电脑,可以运行 Linux 系统。 它有一排 GPIO 引脚(通用输入输出引脚),可以用来控制 LED、电机、传感器等外部设备。 本实验我们先把系统装好,再用 Python 控制一个 LED 灯闪烁,熟悉基本操作。

图 0-1 LED 连接示意图
graph LR subgraph 树莓派 GPIO17["GPIO17 (Pin11)"] GND["GND (Pin6)"] end R[220Ω电阻] LED[LED灯] GPIO17 --> R R --> LED LED --> GND style GPIO17 fill:#D3F9D8,stroke:#2B8A3E style GND fill:#FFC9C9,stroke:#C92A2A

二、实验步骤

步骤 1:烧录树莓派系统
  1. 下载 Raspberry Pi Imager(官网:raspberrypi.com/software)
  2. 把 SD 卡插到电脑上
  3. 打开 Imager,选择系统:Raspberry Pi OS (64-bit)
  4. 选择你的 SD 卡
  5. 点击 设置按钮 ⚙️,配置以下内容:
    • 设置主机名:比如 robot.local
    • 启用 SSH,选择"使用密码登录"
    • 设置用户名和密码(建议用户名 pi,密码自己记好)
    • 配置 WiFi(输入你家的 WiFi 名称和密码)
    • 设置时区为 Asia/Shanghai
  6. 点击"烧录",等待完成
⚠️ 注意

烧录会清空 SD 卡里的所有数据!确认 SD 卡里没有重要文件再操作。 烧录过程中不要拔出 SD 卡,否则可能损坏卡。

步骤 2:启动树莓派并 SSH 连接
  1. 把烧录好的 SD 卡插入树莓派
  2. 接上电源,树莓派会自动开机(红灯常亮 = 通电,绿灯闪烁 = 在读 SD 卡)
  3. 等待约 1~2 分钟,让它完全启动并连上 WiFi
  4. 在电脑上打开终端(Windows 用 PowerShell,Mac 用 Terminal),输入:
    ssh pi@robot.local
  5. 第一次连接会问你"是否继续连接",输入 yes 回车
  6. 输入你设置的密码(输入时屏幕上不显示字符,这是正常的)
✅ 成功标志

看到命令行提示符变成 pi@robot:~ $,说明你已经成功登录树莓派了! 以后所有的 Python 代码都可以在这里运行。

步骤 3:连接 LED 电路

先拔掉树莓派电源!接线时断电操作更安全。

  1. 找到树莓派的 GPIO17(物理引脚 11)和 GND(物理引脚 6)
  2. 用杜邦线把 GPIO17 → 电阻 → LED 长脚(正极)连起来
  3. LED 短脚(负极)→ GND 连起来
  4. 检查接线无误后,插上电源开机
💡 小知识

LED 长脚是正极(+),短脚是负极(-),接反了不亮但不会烧。 电阻一定要接,不然电流太大会烧坏 LED 和树莓派引脚。

步骤 4:编写第一个 Python 程序——LED 闪烁
  1. 在 SSH 终端里,用 nano 编辑器创建文件:
    nano blink.py
  2. 输入以下代码(可以直接复制粘贴):
blink.py
# 导入需要的库
from gpiozero import LED
import time

# 定义 LED 连接在 GPIO17 引脚
led = LED(17)

# 循环 10 次
for i in range(10):
    print(f"第 {i+1} 次闪烁")
    led.on()      # 开灯
    time.sleep(0.5) # 等 0.5 秒
    led.off()     # 关灯
    time.sleep(0.5) # 再等 0.5 秒

print("闪烁完成!")
  1. 按 Ctrl + O 保存,回车确认文件名
  2. 按 Ctrl + X 退出编辑器
  3. 运行程序:
    python blink.py
✅ 预期结果

LED 灯以每秒一次的频率闪烁,一共闪 10 次后停止。
终端里会依次显示"第 1 次闪烁"、"第 2 次闪烁"……"第 10 次闪烁",最后显示"闪烁完成!"

❌ 常见问题

LED 不亮? 检查正负极是否接反了、电阻是不是串联在电路里、GPIO 引脚有没有接错。
提示 gpiozero 找不到? 运行 pip install gpiozero 安装。
连不上 SSH? 确认电脑和树莓派在同一个 WiFi 下,可以用路由器后台查看树莓派的 IP 地址。

三、思考题

🤔 思考与探索
  1. 如果把 time.sleep(0.5) 改成 time.sleep(0.1),LED 会有什么变化?试一试。
  2. 查一下树莓派一共有多少个 GPIO 引脚,哪些可以用来输出?
  3. (选做)如果再加一个按键,实现"按下按键 LED 亮,松开 LED 灭",该怎么接线和写代码?提示:查一下 gpiozero.Button 的用法。
1

实验 1:唤醒词检测

难度 入门 建议学时:6 学时 前置实验:实验 0
🎯 实验目标
  • 理解音频采样的基本概念
  • 学会录制和播放音频
  • 掌握唤醒词检测的使用方法
  • 实现"听到唤醒词 → LED 亮"的效果
🛠️ 所需器材
  • 树莓派 + 实验 0 的 LED 电路
  • USB 麦克风 / 麦克风阵列 ×1
  • 扬声器 / 耳机 ×1
📦 需要安装
  • pyaudio(音频录制)
  • porcupine(唤醒词检测)
  • numpy(数值计算)

一、实验原理

唤醒词检测(Wake Word Detection)就是让机器人一直"竖着耳朵听", 只有听到特定的关键词(比如"小明小明")时才"醒来"开始工作。 它的原理是:把声音转换成梅尔频率倒谱系数(MFCC)这种声学特征, 然后用一个轻量级的神经网络模型判断这段声音是不是唤醒词。

本实验我们使用 Porcupine 这个开源的唤醒词检测引擎, 它速度快、占用资源少,非常适合在树莓派上运行。

图 1-1 唤醒词检测工作流程
graph LR MIC[麦克风采集音频] --> FEATURE[提取MFCC特征] FEATURE --> MODEL[唤醒词检测模型] MODEL --> JUDGE{是唤醒词?} JUDGE -- 是 --> ACTION[触发动作 LED亮] JUDGE -- 否 --> MIC style ACTION fill:#D3F9D8,stroke:#2B8A3E

二、实验步骤

步骤 1:安装依赖库

在树莓派的终端里依次执行以下命令:

# 先更新一下系统
sudo apt update
sudo apt install -y portaudio19-dev python3-pyaudio

# 安装 Python 库
pip install pyaudio numpy
pip install pvporcupine
⚠️ Porcupine 需要 AccessKey

Porcupine 需要一个免费的 AccessKey 才能使用。去 https://console.picovoice.ai/ 注册一个账号, 登录后就能看到你的 AccessKey。复制下来,后面代码里要用。

步骤 2:测试麦克风是否正常

先确认麦克风能正常录音:

  1. 查看可用的录音设备:
    arecord -l
  2. 录制 5 秒钟的声音(把 hw:2,0 换成你上面看到的设备号):
    arecord -D hw:2,0 -f cd -t wav -d 5 test.wav
  3. 播放刚才录的声音:
    aplay test.wav
✅ 成功标志

能听到自己刚才录制的声音,说明麦克风和扬声器都正常工作。 如果听不到,检查接线和设备号是否正确。

步骤 3:编写唤醒词检测程序

创建 wake_word.py 文件,输入以下代码:

wake_word.py
import pyaudio
import pvporcupine
from gpiozero import LED
import time

# ========== 配置 ==========
ACCESS_KEY = "你的AccessKey"   # 替换成你自己的
KEYWORD = "hey siri"            # 内置唤醒词,可选: "alexa", "hey google", "picovoice", "porcupine", "bumblebee"
LED_PIN = 17                         # LED 引脚
SENSITIVITY = 0.5                    # 灵敏度,0~1,越高越灵敏但也容易误触发

# ========== 初始化 ==========
led = LED(LED_PIN)

# 创建 Porcupine 实例
porcupine = pvporcupine.create(
    access_key=ACCESS_KEY,
    keywords=[KEYWORD],
    sensitivities=[SENSITIVITY]
)

# 初始化音频输入
pa = pyaudio.PyAudio()
audio_stream = pa.open(
    rate=porcupine.sample_rate,
    channels=1,
    format=pyaudio.paInt16,
    input=True,
    frames_per_buffer=porcupine.frame_length
)

print(f"已启动,说 'Hey Siri' 来唤醒我!")
print("按 Ctrl+C 退出")

# ========== 主循环 ==========
try:
    while True:
        # 读取一帧音频
        pcm = audio_stream.read(porcupine.frame_length)
        pcm = list(__import__('struct').unpack_from("h" * porcupine.frame_length, pcm))

        # 送入 Porcupine 检测
        keyword_index = porcupine.process(pcm)

        # 如果检测到唤醒词
        if keyword_index >= 0:
            print("✨ 检测到唤醒词!")
            led.on()                 # LED 亮
            time.sleep(1)            # 亮 1 秒
            led.off()                # LED 灭

except KeyboardInterrupt:
    print("\n程序已停止")

finally:
    # 清理资源
    audio_stream.close()
    pa.terminate()
    porcupine.delete()
  1. 把代码里的 你的AccessKey 替换成你在 Picovoice 官网拿到的 Key
  2. 运行程序:
    python wake_word.py
  3. 对着麦克风说 "Hey Siri",观察 LED 是否亮起
✅ 预期结果

程序启动后会显示"已启动,说 'Hey Siri' 来唤醒我!"。
当你说出 "Hey Siri" 时,终端打印"✨ 检测到唤醒词!",同时 LED 亮 1 秒。
按 Ctrl+C 可以退出程序。

步骤 4:调节灵敏度
  1. 试试把 SENSITIVITY 改成 0.9,看看是不是更容易被唤醒?会不会有很多误触发?
  2. 再试试改成 0.1,是不是需要说得很清楚才能唤醒?
  3. 找到一个你觉得最合适的灵敏度值
💡 进阶:自定义唤醒词

想让机器人叫"小明"怎么办?可以去 Picovoice 官网的 Console 里, 用 Porcupine Console 录制自己的唤醒词(需要读 3 遍),生成 .ppn 文件后下载到树莓派上, 用 keyword_paths=["xiaoming_zh_raspberry-pi_v3_0_0.ppn"] 来加载。

三、思考题

🤔 思考与探索
  1. 为什么唤醒词检测不直接用完整的语音识别(ASR)来做,而要用专门的模型?(提示:考虑功耗和响应速度)
  2. 灵敏度太高和太低分别有什么问题?在实际产品中应该怎么权衡?
  3. (选做)如果想让唤醒词检测到之后,播放一段"你好,我在呢"的语音回应,该怎么修改代码?提示:用 pyttsx3 或 edge-tts 做语音合成。
2

实验 2:声源定位与波束形成

难度 中等 建议学时:8 学时 前置实验:实验 1
🎯 实验目标
  • 理解麦克风阵列的工作原理
  • 掌握 TDOA(到达时间差)计算方法
  • 学会用 GCC-PHAT 算法计算时间差
  • 实现简单的声源方向角估计
🛠️ 所需器材
  • ReSpeaker 4-Mic 阵列 ×1
  • 树莓派主板 ×1
  • 卷尺 / 直尺 ×1
  • 小喇叭 / 手机(播放声音)
📦 需要安装
  • ReSpeaker 驱动
  • numpy
  • scipy
  • matplotlib(画图用)

一、实验原理

声源定位就是判断声音从哪个方向来。我们人有两只耳朵, 声音传到左耳和右耳的时间有微小的差别,大脑就是根据这个时间差来判断方向的。 麦克风阵列做的是同一件事——用多个麦克风收集声音,计算到达时间差(TDOA), 再根据几何关系算出方向角。

本实验用最经典的 GCC-PHAT(广义互相关-相位变换)算法来计算两个麦克风之间的时间差。 简单说就是:把两路音频做互相关运算,相关值最大的那个位置对应的时间,就是时间差。

图 2-1 双麦克风声源定位原理
graph TB subgraph 俯视图 S[声源 S] M1[麦克风1] M2[麦克风2] D[距离 d] end S -- "声程差 = d·sin(θ)" --> M1 S -- "直达" --> M2 note over S,M2: θ = arcsin(c·Δt / d) note right of S: c = 声速(340m/s) note right of S: Δt = 时间差 note right of S: d = 麦克风间距 style S fill:#FFC9C9,stroke:#C92A2A style M1 fill:#D3F9D8,stroke:#2B8A3E style M2 fill:#D3F9D8,stroke:#2B8A3E
💡 波束形成是什么?

波束形成就像是"声学望远镜"——把多个麦克风的信号对齐之后相加, 对准的方向声音会被增强,其他方向的声音会被减弱。 本实验我们先学基础的 TDOA 定位,有兴趣的同学可以自行探索波束形成。

二、实验步骤

步骤 1:安装 ReSpeaker 驱动

ReSpeaker 4-Mic 阵列需要安装专门的驱动:

git clone https://github.com/respeaker/seeed-voicecard.git
cd seeed-voicecard
sudo ./install.sh
sudo reboot

重启后验证是否安装成功:

arecord -l
✅ 成功标志

能看到 seeed4micvoicec 相关的录音设备,说明驱动安装成功了。

步骤 2:安装 Python 依赖
pip install numpy scipy matplotlib
pip install pyaudio
步骤 3:录制 4 通道音频

先确认我们能同时采集 4 个麦克风的数据:

record_4ch.py
import pyaudio
import numpy as np
import wave

# 配置
CHANNELS = 4
RATE = 16000
RECORD_SECONDS = 3
WAVE_OUTPUT_FILENAME = "record_4ch.wav"
DEVICE_INDEX = None   # 如果有多个设备,指定 ReSpeaker 的设备号

pa = pyaudio.PyAudio()

# 查找 ReSpeaker 设备
for i in range(pa.get_device_count()):
    dev = pa.get_device_info_by_index(i)
    if "seeed" in dev['name'].lower() or "4mic" in dev['name'].lower():
        DEVICE_INDEX = i
        print(f"找到设备: {dev['name']}, 设备号: {i}")

if DEVICE_INDEX is None:
    print("没找到 ReSpeaker 设备,请检查驱动")
    exit()

# 开始录音
stream = pa.open(rate=RATE, channels=CHANNELS,
                 format=pyaudio.paInt16, input=True,
                 input_device_index=DEVICE_INDEX,
                 frames_per_buffer=1024)

print("* 录音中...")
frames = []
for i in range(int(RATE / 1024 * RECORD_SECONDS)):
    data = stream.read(1024)
    frames.append(data)
print("* 录音结束")

stream.stop_stream()
stream.close()
pa.terminate()

# 保存为 WAV 文件
wf = wave.open(WAVE_OUTPUT_FILENAME, 'wb')
wf.setnchannels(CHANNELS)
wf.setsampwidth(pa.get_sample_size(pyaudio.paInt16))
wf.setframerate(RATE)
wf.writeframes(b''.join(frames))
wf.close()

print(f"已保存到 {WAVE_OUTPUT_FILENAME}")
步骤 4:实现 GCC-PHAT 时间差计算

核心算法来了!下面的函数用 GCC-PHAT 计算两路信号的时间差:

gcc_phat.py
import numpy as np
from scipy.signal import fftconvolve

def gcc_phat(sig1, sig2, fs, max_tau=None):
    """
    用 GCC-PHAT 算法计算两路信号的时间差
    sig1, sig2: 两路音频信号 (numpy 数组)
    fs: 采样率
    max_tau: 最大可能的时间差(秒),None 表示不限制
    返回: 时间差(秒),正数表示 sig2 比 sig1 晚
    """
    n = sig1.shape[0] + sig2.shape[0]

    # 做 FFT
    SIG1 = np.fft.rfft(sig1, n=n)
    SIG2 = np.fft.rfft(sig2, n=n)

    # PHAT 加权:只保留相位信息
    R = SIG1 * np.conj(SIG2)
    cc = np.fft.irfft(R / np.abs(R), n=n)

    # 找到互相关的峰值位置
    max_shift = int(n / 2)
    if max_tau is not None:
        max_shift = np.minimum(int(fs * max_tau), max_shift)

    cc = np.concatenate((cc[-max_shift:], cc[:max_shift + 1]))
    shift = np.argmax(np.abs(cc)) - max_shift
    tau = shift / float(fs)

    return tau


# ========== 测试一下 ==========
if __name__ == "__main__":
    fs = 16000
    t = np.arange(0, 0.5, 1/fs)

    # 生成一个测试信号(1kHz 正弦波)
    sig1 = np.sin(2 * np.pi * 1000 * t)

    # 把 sig1 延迟 20 个采样点,模拟第二个麦克风的信号
    delay_samples = 20
    sig2 = np.roll(sig1, delay_samples)

    # 加一点噪声
    sig2 += 0.01 * np.random.randn(len(sig2))

    # 计算时间差
    tau = gcc_phat(sig1, sig2, fs)
    print(f"真实延迟: {delay_samples} 个采样点 = {delay_samples/fs*1000:.3f} ms")
    print(f"检测到的时间差: {tau*1000:.3f} ms")
    print(f"检测到的采样差: {tau*fs:.1f} 个采样点")

运行一下看看效果:

python gcc_phat.py
✅ 预期结果

检测到的时间差应该非常接近真实值(20 个采样点 = 1.25 ms), 误差在 1 个采样点以内都算正常。

步骤 5:计算声源方向角

有了时间差,再知道麦克风间距和声速,就能算出角度了:

doa.py
import numpy as np
from gcc_phat import gcc_phat

# 已知参数
MIC_DISTANCE = 0.10    # 麦克风间距,单位:米(10cm)
SOUND_SPEED = 340       # 声速,单位:米/秒
SAMPLE_RATE = 16000     # 采样率

def calculate_doa(sig_left, sig_right):
    """
    根据左右两个麦克风的信号,计算声源方向角
    返回: 角度(度),范围 -90 ~ 90,0 表示正前方
    """
    # 计算时间差
    max_tau = MIC_DISTANCE / SOUND_SPEED  # 最大可能时间差
    tau = gcc_phat(sig_left, sig_right, SAMPLE_RATE, max_tau=max_tau)

    # 计算 sin(theta)
    sin_theta = (tau * SOUND_SPEED) / MIC_DISTANCE

    # 数值范围保护(避免浮点误差导致超出 [-1,1])
    sin_theta = np.clip(sin_theta, -1, 1)

    # 转成角度
    theta = np.arcsin(sin_theta) * 180 / np.pi

    return theta


# ========== 测试 ==========
if __name__ == "__main__":
    # 模拟:声源在 30 度方向
    true_angle = 30  # 度
    print(f"真实角度: {true_angle}°")

    # 计算对应的时间差
    delay_seconds = MIC_DISTANCE * np.sin(true_angle * np.pi / 180) / SOUND_SPEED
    delay_samples = int(delay_seconds * SAMPLE_RATE)
    print(f"对应时间差: {delay_seconds*1000:.3f} ms = {delay_samples} 个采样点")

    # 生成测试信号
    t = np.arange(0, 0.5, 1/SAMPLE_RATE)
    sig_left = np.sin(2 * np.pi * 1000 * t)
    sig_right = np.roll(sig_left, delay_samples)

    # 估计角度
    estimated_angle = calculate_doa(sig_left, sig_right)
    print(f"估计角度: {estimated_angle:.1f}°")
    print(f"误差: {abs(estimated_angle - true_angle):.1f}°")

运行测试:

python doa.py
✅ 预期结果

估计角度应该接近真实角度 30°,误差一般在 5° 以内(无噪声情况下)。 可以试试不同的角度(比如 0°、45°、60°),看看哪个范围的误差最小。

步骤 6:(选做)实时声源定位

把上面的算法和实时录音结合起来,实现"实时显示声源方向":

  • 用 pyaudio 持续读取 4 通道音频
  • 取其中两个相对的麦克风(比如麦克风 0 和麦克风 2)
  • 每 100ms 计算一次方向角
  • 在终端打印方向,或者用条形图可视化

具体代码可以参考实验 1 的实时录音框架,把 porcupine.process() 换成 calculate_doa()。

三、思考题

🤔 思考与探索
  1. 为什么双麦克风只能判断 ±90° 的范围?如果要 360° 全向定位,至少需要几个麦克风?怎么排列?
  2. GCC-PHAT 算法的"PHAT"(相位变换)有什么作用?如果去掉它,直接用普通互相关会怎样?
  3. 如果声源距离很近(比如 10cm),平面波假设还成立吗?这时候定位误差会变大还是变小?
  4. (选做)试着加入噪声(修改 sig2 += 0.1 * np.random.randn(...)),看看多大噪声时算法还能工作?
3

实验 3:卡尔曼滤波与舵机控制

难度 中等 建议学时:6 学时 前置实验:实验 2
🎯 实验目标
  • 理解卡尔曼滤波的基本思想
  • 学会使用舵机(PWM 控制)
  • 实现声源角度的平滑跟踪
  • 对比滤波前后的舵机运动效果
🛠️ 所需器材
  • 树莓派 + ReSpeaker
  • SG90 舵机 ×1
  • PCA9685 舵机驱动板 ×1
  • 2 自由度云台支架
  • 杜邦线若干
📦 需要安装
  • adafruit-circuitpython-servokit
  • numpy
  • matplotlib

一、实验原理

3.1 舵机控制原理

舵机(Servo)是一种可以精确控制角度的电机。它通过 PWM(脉冲宽度调制)信号来控制角度: 不同宽度的脉冲对应不同的角度。常见的 SG90 舵机是 180° 范围的, 通常 0.5ms 脉宽对应 0°,1.5ms 对应 90°(中间位置),2.5ms 对应 180°。

3.2 卡尔曼滤波原理

实验 2 中我们算出的声源角度会有噪声——有时候算出来 30°,下一跳变成 50°,再下一跳又变回 25°。 如果直接用这个角度控制舵机,舵机会抖来抖去,非常不稳。

卡尔曼滤波就是解决这个问题的。它的核心思想是: 用"预测"加"测量"来得到最优估计。 比如,我们知道上一时刻角度是 30°,根据声源移动的规律,预测下一时刻大概还是 30°左右; 同时我们又测到了一个带噪声的角度值 45°。卡尔曼滤波会把这两个信息结合起来, 给出一个比单独用测量值更准、更平滑的结果。

图 3-1 卡尔曼滤波工作循环
graph LR P[预测状态
根据上一时刻推测现在] U[更新状态
用测量值修正预测] P --> U U --> P style P fill:#E7F5FF,stroke:#228BE6 style U fill:#E6FCF5,stroke:#12B886

二、实验步骤

步骤 1:连接舵机和驱动板

直接用树莓派 GPIO 控制舵机不够精确(软件 PWM 有抖动),所以我们用 PCA9685 驱动板(硬件 PWM)。

  1. PCA9685 和树莓派用 I2C 连接:VCC → 3.3V,GND → GND,SDA → SDA,SCL → SCL
  2. 舵机接到 PCA9685 的 0 号通道(棕色线=GND,红色线=V+,橙色线=信号)
  3. PCA9685 的 V+ 外接 5V 电源(舵机电流较大,不要用树莓派供电)
  4. 插上树莓派电源开机
⚠️ 重要!

舵机的电源一定要单独供!树莓派的 5V 引脚输出电流有限, 如果舵机直接从树莓派取电,舵机转动时的电流冲击可能会导致树莓派重启甚至烧坏。

步骤 2:安装库并测试舵机
sudo apt install -y i2c-tools
pip install adafruit-circuitpython-servokit

检查 I2C 设备是否连接:

sudo i2cdetect -y 1
✅ 成功标志

能看到地址 40 出现在表格里,说明 PCA9685 连接正常。

编写舵机测试程序:

servo_test.py
from adafruit_servokit import ServoKit
import time

# 初始化 16 通道舵机驱动板
kit = ServoKit(channels=16)

# 设置第 0 号通道的舵机角度范围(根据你的舵机调整)
kit.servo[0].actuation_range = 180
kit.servo[0].set_pulse_width_range(500, 2500)  # 0.5ms ~ 2.5ms

print("舵机测试开始,按 Ctrl+C 停止")

try:
    while True:
        # 转到 0 度
        print("0°")
        kit.servo[0].angle = 0
        time.sleep(1)

        # 转到 90 度(中间)
        print("90°")
        kit.servo[0].angle = 90
        time.sleep(1)

        # 转到 180 度
        print("180°")
        kit.servo[0].angle = 180
        time.sleep(1)

        # 缓慢转动
        print("缓慢转动中...")
        for angle in range(0, 181, 5):
            kit.servo[0].angle = angle
            time.sleep(0.05)

        for angle in range(180, -1, -5):
            kit.servo[0].angle = angle
            time.sleep(0.05)

except KeyboardInterrupt:
    print("\n测试结束")

运行测试:

python servo_test.py
✅ 预期结果

舵机依次转到 0°、90°、180°,每个位置停留 1 秒,然后缓慢来回转动。
如果舵机抖动严重或角度不准,尝试调整 set_pulse_width_range 的参数。

步骤 3:实现一维卡尔曼滤波

我们来手写一个最简单的一维卡尔曼滤波器:

kalman_filter.py
import numpy as np

class KalmanFilter1D:
    """
    一维卡尔曼滤波器,用于平滑角度数据
    状态量: [角度, 角速度]
    """
    def __init__(self, x0=0, v0=0, dt=0.1):
        self.dt = dt    # 时间步长(秒)

        # 状态向量 [角度, 角速度]
        self.x = np.array([x0, v0])

        # 状态转移矩阵(匀速运动模型)
        self.F = np.array([[1, dt],
                           [0, 1]])

        # 观测矩阵(我们只能观测到角度)
        self.H = np.array([[1, 0]])

        # 过程噪声协方差(模型的不确定性)
        self.Q = np.array([[0.1, 0],
                           [0, 0.1]])

        # 测量噪声协方差(传感器的不确定性)
        self.R = np.array([[10.0]])

        # 估计误差协方差
        self.P = np.eye(2) * 10

    def predict(self):
        """预测步骤:根据上一状态推测当前状态"""
        self.x = self.F @ self.x                     # 状态预测
        self.P = self.F @ self.P @ self.F.T + self.Q # 误差协方差预测
        return self.x[0]  # 返回预测的角度

    def update(self, z):
        """更新步骤:用测量值修正预测"""
        # 计算卡尔曼增益 K
        S = self.H @ self.P @ self.H.T + self.R
        K = self.P @ self.H.T @ np.linalg.inv(S)

        # 更新状态
        y = z - self.H @ self.x   # 测量残差(测量值 - 预测值)
        self.x = self.x + K @ y

        # 更新误差协方差
        I = np.eye(len(self.x))
        self.P = (I - K @ self.H) @ self.P

        return self.x[0]  # 返回更新后的角度

    def step(self, measurement):
        """执行一次完整的预测+更新"""
        self.predict()
        return self.update(measurement)


# ========== 测试 ==========
if __name__ == "__main__":
    import matplotlib.pyplot as plt

    # 模拟:真实角度是一个从 0° 缓慢转到 60° 的过程
    true_angle = np.linspace(0, 60, 100)

    # 加上噪声模拟测量值
    np.random.seed(42)
    measured_angle = true_angle + np.random.normal(0, 8, 100)  # 8° 的测量噪声

    # 卡尔曼滤波
    kf = KalmanFilter1D(x0=0, v0=0, dt=0.1)
    filtered_angle = []
    for z in measured_angle:
        filtered_angle.append(kf.step(z))
    filtered_angle = np.array(filtered_angle)

    # 画图
    plt.figure(figsize=(10, 5))
    plt.plot(true_angle, label='真实角度', linewidth=2)
    plt.plot(measured_angle, label='测量值(带噪声)', alpha=0.6)
    plt.plot(filtered_angle, label='卡尔曼滤波后', linewidth=2)
    plt.xlabel('时间步')
    plt.ylabel('角度 (°)')
    plt.legend()
    plt.title('卡尔曼滤波效果对比')
    plt.grid(alpha=0.3)
    plt.savefig('kalman_result.png')
    print("图像已保存为 kalman_result.png")

    # 计算误差
    err_measured = np.mean(np.abs(measured_angle - true_angle))
    err_filtered = np.mean(np.abs(filtered_angle - true_angle))
    print(f"测量值平均误差: {err_measured:.2f}°")
    print(f"滤波后平均误差: {err_filtered:.2f}°")

运行测试(注意:树莓派上第一次用 matplotlib 可能需要安装依赖):

sudo apt install -y libatlas-base-dev
pip install matplotlib
python kalman_filter.py
✅ 预期结果

生成的图像中,蓝色(测量值)上下抖动很厉害,橙色(滤波后)平滑地跟着真实角度走。
滤波后的平均误差应该明显小于测量值的平均误差。

步骤 4:把卡尔曼滤波和舵机结合

现在我们用模拟的带噪声角度数据,对比"直接控制舵机"和"卡尔曼滤波后控制舵机"的区别:

servo_kalman_demo.py
import time
import numpy as np
from adafruit_servokit import ServoKit
from kalman_filter import KalmanFilter1D

# 初始化舵机
kit = ServoKit(channels=16)
kit.servo[0].actuation_range = 180
kit.servo[0].set_pulse_width_range(500, 2500)

# 初始化卡尔曼滤波器
kf = KalmanFilter1D(x0=90, v0=0, dt=0.05)  # 初始角度 90°,20Hz 更新

# 模拟:真实角度在 30°~150° 之间缓慢移动
t = 0
dt = 0.05

print("=== 第一阶段:直接用带噪声的测量值控制舵机 ===")
print("观察舵机是不是抖得很厉害?")
time.sleep(2)

for i in range(100):
    # 真实角度(正弦运动)
    true_angle = 90 + 40 * np.sin(2 * np.pi * 0.2 * t)

    # 加噪声模拟测量值
    noisy_angle = true_angle + np.random.normal(0, 10)  # 10° 噪声

    # 限制在 0~180 范围内
    noisy_angle = np.clip(noisy_angle, 0, 180)

    # 直接控制舵机
    kit.servo[0].angle = noisy_angle

    t += dt
    time.sleep(dt)

time.sleep(1)
print("\n=== 第二阶段:卡尔曼滤波后再控制舵机 ===")
print("观察舵机是不是平稳多了?")
time.sleep(2)

t = 0
kf2 = KalmanFilter1D(x0=90, v0=0, dt=dt)

for i in range(100):
    true_angle = 90 + 40 * np.sin(2 * np.pi * 0.2 * t)
    noisy_angle = true_angle + np.random.normal(0, 10)
    noisy_angle = np.clip(noisy_angle, 0, 180)

    # 卡尔曼滤波
    smooth_angle = kf2.step(noisy_angle)
    smooth_angle = np.clip(smooth_angle, 0, 180)

    # 控制舵机
    kit.servo[0].angle = smooth_angle

    t += dt
    time.sleep(dt)

print("\n演示结束!")
✅ 预期结果

第一阶段舵机明显抖来抖去(因为噪声大),第二阶段舵机运动平稳顺滑, 能准确地跟着正弦轨迹运动。这就是卡尔曼滤波的神奇效果!

💡 调参技巧

Q(过程噪声)越大 → 越相信测量值,响应越快但越抖
R(测量噪声)越大 → 越不相信测量值,越平滑但有延迟
实际使用时,根据你的传感器噪声大小和想要的响应速度来调整这两个参数。

三、思考题

🤔 思考与探索
  1. 卡尔曼滤波的"预测"步骤如果去掉,只保留"更新"步骤,会变成什么?效果会怎样?
  2. 如果声源移动得很快(比如人快速走过),卡尔曼滤波的延迟会导致舵机跟不上吗?怎么解决?
  3. (选做)把实验 2 的实时声源定位和本实验的卡尔曼滤波+舵机控制连起来,实现"声音在哪,舵机就指向哪"的完整效果。
4

实验 4:人脸检测与人脸跟踪

难度 中等 建议学时:8 学时 前置实验:实验 3
🎯 实验目标
  • 了解人脸检测的基本原理
  • 学会使用 MediaPipe 检测人脸
  • 实现人脸中心点的实时跟踪
  • 用舵机让摄像头"盯"着人脸
🛠️ 所需器材
  • 树莓派 + 2自由度云台
  • USB 摄像头 / CSI 摄像头 ×1
  • PCA9685 驱动板 + 2个舵机
📦 需要安装
  • opencv-python
  • mediapipe
  • adafruit-circuitpython-servokit

一、实验原理

人脸检测就是从图像里找出"哪里有人脸",输出人脸的位置和大小。 本实验使用 Google 的 MediaPipe 库,它速度快、模型轻,非常适合树莓派。

人脸跟踪是在检测的基础上,让人脸保持在画面中心—— 如果人脸偏左,就控制舵机向左转;偏右就向右转。这也叫视觉伺服。

图 4-1 人脸跟踪控制原理
graph LR CAM[摄像头采集图像] --> DETECT[MediaPipe人脸检测] DETECT --> CENTER[计算人脸中心坐标] CENTER --> ERROR[计算与画面中心的偏差] ERROR --> PID[PID控制器] PID --> SERVO[舵机转动] SERVO --> CAM style PID fill:#F3F0FF,stroke:#7950F2

二、实验步骤

步骤 1:安装依赖
pip install opencv-python mediapipe
⚠️ 树莓派注意

树莓派上安装 mediapipe 可能需要编译,耗时较长。 建议先在自己电脑上跑通,再移植到树莓派。 或者使用预编译的 whl 包安装。

步骤 2:人脸检测 Demo
face_detect.py
import cv2
import mediapipe as mp

# 初始化 MediaPipe Face Detection
mp_face = mp.solutions.face_detection
face_detection = mp_face.FaceDetection(
    model_selection=0,  # 0=近距离(2m内),1=远距离
    min_detection_confidence=0.5
)

# 打开摄像头
cap = cv2.VideoCapture(0)

print("按 q 键退出")

while cap.isOpened():
    ret, frame = cap.read()
    if not ret: break

    # 转 RGB(MediaPipe 需要 RGB 格式)
    rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    results = face_detection.process(rgb)

    # 画检测结果
    h, w = frame.shape[:2]
    if results.detections:
        for det in results.detections:
            bbox = det.location_data.relative_bounding_box
            x = int(bbox.xmin * w)
            y = int(bbox.ymin * h)
            bw = int(bbox.width * w)
            bh = int(bbox.height * h)

            # 画人脸框
            cv2.rectangle(frame, (x, y), (x+bw, y+bh), (0, 255, 0), 2)

            # 画人脸中心点
            cx, cy = x + bw//2, y + bh//2
            cv2.circle(frame, (cx, cy), 5, (0, 0, 255), -1)

            # 显示置信度
            conf = det.score[0]
            cv2.putText(frame, f'{conf:.2f}', (x, y-10),
                        cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)

    # 画面中心十字
    cv2.drawMarker(frame, (w//2, h//2), (255, 0, 0),
                   cv2.MARKER_CROSS, 20, 1)

    cv2.imshow('Face Detection', frame)
    if cv2.waitKey(1) & 0xFF == ord('q'):
        break

cap.release()
cv2.destroyAllWindows()

运行效果:摄像头画面里会用绿框标出人脸,红圆点是人脸中心,蓝色十字是画面中心。

✅ 预期结果

打开摄像头窗口,人脸被绿色方框框住,框上方显示置信度(如 0.95)。
移动人脸时,框和中心点能实时跟着移动,延迟很低。

步骤 3:用 P 控制实现人脸跟踪

思路:人脸中心和画面中心的偏差 → 舵机需要转多少度。

最简单的就是 P 控制(比例控制):舵机增量 = Kp × 偏差

face_tracker.py
import cv2
import mediapipe as mp
from adafruit_servokit import ServoKit
import time

# ===== 配置 =====
PAN_SERVO = 0    # 水平舵机通道
TILT_SERVO = 1   # 垂直舵机通道
Kp = 0.05        # 比例系数(像素→度),需要调试
DEAD_ZONE = 30   # 死区(像素),偏差小于这个值不调整,防止抖

# ===== 初始化舵机 =====
kit = ServoKit(channels=16)
for ch in [PAN_SERVO, TILT_SERVO]:
    kit.servo[ch].actuation_range = 180
    kit.servo[ch].set_pulse_width_range(500, 2500)

pan_angle = 90   # 初始角度:中间
tilt_angle = 90
kit.servo[PAN_SERVO].angle = pan_angle
kit.servo[TILT_SERVO].angle = tilt_angle

# ===== 初始化人脸检测 =====
mp_face = mp.solutions.face_detection
face_detection = mp_face.FaceDetection(model_selection=0, min_detection_confidence=0.6)
cap = cv2.VideoCapture(0)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)

print("人脸跟踪启动,按 q 退出")

while cap.isOpened():
    ret, frame = cap.read()
    if not ret: break
    h, w = frame.shape[:2]
    center_x, center_y = w // 2, h // 2

    # 检测人脸
    rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    results = face_detection.process(rgb)

    if results.detections:
        # 取第一个检测到的人脸
        det = results.detections[0]
        bbox = det.location_data.relative_bounding_box
        cx = int((bbox.xmin + bbox.width/2) * w)
        cy = int((bbox.ymin + bbox.height/2) * h)

        # 计算偏差
        dx = cx - center_x  # 正=人脸在右边,需要右转
        dy = cy - center_y  # 正=人脸在下边,需要向下

        # 死区:偏差太小就不动,防止抖
        if abs(dx) > DEAD_ZONE:
            pan_angle -= Kp * dx
            pan_angle = max(0, min(180, pan_angle))
            kit.servo[PAN_SERVO].angle = pan_angle

        if abs(dy) > DEAD_ZONE:
            tilt_angle += Kp * dy
            tilt_angle = max(30, min(150, tilt_angle))  # 限制俯仰角度避免卡
            kit.servo[TILT_SERVO].angle = tilt_angle

        # 画图
        cv2.circle(frame, (cx, cy), 8, (0, 0, 255), -1)
        cv2.line(frame, (center_x, center_y), (cx, cy), (255, 255, 0), 2)

    # 画面中心
    cv2.drawMarker(frame, (center_x, center_y), (0, 255, 0),
                   cv2.MARKER_CROSS, 20, 2)

    cv2.putText(frame, f'Pan: {pan_angle:.0f}  Tilt: {tilt_angle:.0f}',
                (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
    cv2.imshow('Face Tracker', frame)

    if cv2.waitKey(1) & 0xFF == ord('q'):
        break

cap.release()
cv2.destroyAllWindows()
✅ 预期结果

当人脸在画面中心时,舵机不动;当人脸向左/右/上/下移动时, 舵机会带动摄像头跟着转,让人脸始终保持在画面中心附近。

💡 调 Kp 的技巧

Kp 太小 → 反应慢,跟不上
Kp 太大 → 容易过头,来回震荡(抖)
从 0.02 开始慢慢调,找到不抖又跟得上的值。加入死区 DEAD_ZONE 也能减少抖动。

三、思考题

🤔 思考与探索
  1. 为什么只用 P 控制有时候会抖?如果加上 D(微分)控制,会有什么改善?
  2. 人脸检测只检测到人脸框,如果要知道人脸朝向哪边(左看右看),可以用什么方法?
  3. (选做)如果画面里有多个人脸,当前代码会跟踪第一个。如果想跟踪"最大的那个人脸"(通常是最近的),该怎么改?
5

实验 5:语音识别(ASR)

难度 中等 建议学时:6 学时 前置实验:实验 1
🎯 实验目标
  • 理解语音识别的基本概念
  • 学会使用 FunASR 做中文语音识别
  • 实现"录音 → 转文字"的完整流程
  • 了解流式识别和非流式识别的区别
🛠️ 所需器材
  • 树莓派 / PC
  • USB 麦克风
📦 需要安装
  • funasr
  • pyaudio
  • modelscope

一、实验原理

语音识别(ASR)就是把人说的话转成文字。 现在的主流方案是端到端深度学习模型,输入音频波形,直接输出文字。

本实验使用阿里达摩院开源的 FunASR,它对中文支持好、模型轻、速度快, 可以在树莓派上运行。我们用它的 Paraformer 模型, 这是一个非自回归模型,识别速度比传统的 Whisper 更快。

图 5-1 语音识别流程
graph LR MIC[麦克风录音] --> VAD[VAD语音端点检测] VAD --> MODEL[ASR模型识别] MODEL --> TEXT[输出文字] style MODEL fill:#D3F9D8,stroke:#2B8A3E

二、实验步骤

步骤 1:安装 FunASR
pip install funasr modelscope
⚠️ 树莓派注意

树莓派上建议用 funasr 的 micro 版本,或使用 ONNX 量化模型。 如果树莓派跑不动,先在电脑上完成实验,后面再考虑云 API 方案。

步骤 2:离线文件识别

先拿一个音频文件试试水:

asr_file.py
from funasr import AutoModel

# 加载模型(第一次运行会自动下载,约几百MB)
model = AutoModel(model="paraformer-zh")

# 识别一个 wav 文件
result = model(input="test.wav")

print("识别结果:")
print(result[0]['text'])

先用实验 1 里的录音方法录一段自己说话的 wav 文件,然后运行:

python asr_file.py
✅ 预期结果

终端打印出你刚才说的话的文字版本。对于清晰的普通话,准确率应该在 95% 以上。

步骤 3:实时流式语音识别

流式识别就是一边说一边出结果,体验更好。我们用 FunASR 的流式模型:

asr_stream.py
import pyaudio
from funasr import AutoModel

# 配置
CHUNK = 1024
FORMAT = pyaudio.paInt16
CHANNELS = 1
RATE = 16000  # FunASR 要求 16kHz

# 加载流式模型
model = AutoModel(model="paraformer-zh-streaming")

# 打开麦克风
pa = pyaudio.PyAudio()
stream = pa.open(format=FORMAT, channels=CHANNELS,
                 rate=RATE, input=True, frames_per_buffer=CHUNK)

print("开始说话(按 Ctrl+C 停止):")

cache = {}  # 模型缓存
chunk_size = [0, 10, 5]  # 流式模型的 chunk 配置
chunk_stride = chunk_size[1] * 960  # 每次送入的采样点数

try:
    audio_buf = b''
    while True:
        data = stream.read(CHUNK, exception_on_overflow=False)
        audio_buf += data

        # 积累够一帧就送入模型
        while len(audio_buf) >= chunk_stride * 2:
            chunk = audio_buf[:chunk_stride * 2]
            audio_buf = audio_buf[chunk_stride * 2:]

            result = model(input=chunk, cache=cache,
                           chunk_size=chunk_size,
                           is_final=False)

            # 打印部分结果(不换行,实时更新)
            if result[0]['text']:
                print(f"\r识别中: {result[0]['text']}", end='', flush=True)

except KeyboardInterrupt:
    # 结束时再识别一次,确保最后一句完整
    result = model(input=audio_buf, cache=cache,
                   chunk_size=chunk_size, is_final=True)
    print(f"\n最终结果: {result[0]['text']}")

stream.stop_stream()
stream.close()
pa.terminate()
✅ 预期结果

程序启动后开始监听,你说话时文字会实时出现在屏幕上(不断更新)。 按 Ctrl+C 停止后,显示最终的完整识别结果。

三、思考题

🤔 思考与探索
  1. 流式识别和非流式识别各有什么优缺点?分别适合什么场景?
  2. 如果周围环境很吵,识别准确率会下降。有什么办法可以提高嘈杂环境下的识别率?
  3. (选做)试试把唤醒词检测和语音识别连起来——检测到唤醒词后,开始录音并识别接下来的一句话。
6

实验 6:大语言模型(LLM)交互

难度 中等 建议学时:6 学时 前置实验:实验 5
🎯 实验目标
  • 理解大语言模型的基本概念
  • 学会调用 LLM API 进行对话
  • 掌握 Function Calling(函数调用)
  • 实现"语音提问 → LLM回答 → 语音播报"
🛠️ 所需器材
  • 树莓派 / PC
  • 麦克风 + 扬声器
  • 互联网连接
📦 需要安装
  • openai(SDK,兼容很多接口)
  • edge-tts(语音合成)
  • pygame(播放音频)

一、实验原理

大语言模型(LLM)就像一个知识渊博的"大脑",你问它问题,它给你回答。 机器人通过语音识别把用户的话转成文字,发给 LLM,拿到回答后再用语音合成读出来, 就完成了一次对话。

更高级的是 Function Calling(函数调用)—— LLM 不仅能回答问题,还能决定"要不要调用某个工具"。 比如用户说"转过去看看那边",LLM 就会调用"控制舵机转向"的函数。

图 6-1 语音对话完整流程
graph LR USER[用户说话] --> ASR[语音识别] ASR --> TEXT[文字: 你好] TEXT --> LLM[大语言模型] LLM --> REPLY[回答文字] REPLY --> TTS[语音合成] TTS --> SPEAK[机器人说话] style LLM fill:#F3F0FF,stroke:#7950F2

二、实验步骤

步骤 1:安装依赖
pip install openai edge-tts pygame
步骤 2:调用 LLM 对话

我们用 OpenAI 格式的 API(兼容很多国内平台,比如 DeepSeek、智谱、通义千问等)。 你需要先去对应平台申请一个 API Key。

llm_chat.py
from openai import OpenAI

# 配置(换成你自己的 API Key 和 base_url)
client = OpenAI(
    api_key="你的APIKey",
    base_url="https://api.deepseek.com/v1"  # 示例:DeepSeek
)

# 对话历史
messages = [
    {"role": "system",
     "content": "你是一个叫'小明'的陪伴机器人,回答要简洁、友好、口语化,每句不超过50字。"}
]

print("小明:你好呀!有什么我能帮你的吗?(输入 quit 退出)")

while True:
    user_input = input("你:")
    if user_input.lower() == "quit":
        break

    # 把用户说的话加入历史
    messages.append({"role": "user", "content": user_input})

    # 调用 LLM
    response = client.chat.completions.create(
        model="deepseek-chat",
        messages=messages,
        temperature=0.7
    )

    reply = response.choices[0].message.content
    print(f"小明:{reply}")

    # 把回答加入历史
    messages.append({"role": "assistant", "content": reply})

运行试试:

python llm_chat.py
✅ 预期结果

可以和机器人进行多轮对话,机器人能记住上下文。比如你说"我叫张三", 再问"我叫什么",它应该能回答"张三"。

步骤 3:语音合成(TTS)

让机器人把回答读出来:

tts_demo.py
import edge_tts
import pygame
import asyncio

async def speak(text, voice="zh-CN-XiaoxiaoNeural"):
    """把文字转成语音并播放"""
    communicate = edge_tts.Communicate(text, voice)
    await communicate.save("response.mp3")

    # 播放
    pygame.mixer.init()
    pygame.mixer.music.load("response.mp3")
    pygame.mixer.music.play()
    while pygame.mixer.music.get_busy():
        pygame.time.Clock().tick(10)

# 使用
asyncio.run(speak("你好呀,我是小明,很高兴认识你!"))
步骤 4:Function Calling(让机器人控制舵机)

这一步最有意思——让 LLM 自己决定什么时候调用"转舵机"的功能。 用户说"向左转",LLM 就调用 turn_servo 函数;用户问问题,就正常回答。

llm_function_calling.py
from openai import OpenAI
import json

client = OpenAI(api_key="你的APIKey",
                base_url="https://api.deepseek.com/v1")

# 定义工具:控制舵机转向
tools = [
    {
        "type": "function",
        "function": {
            "name": "turn_servo",
            "description": "控制机器人转向指定的角度方向",
            "parameters": {
                "type": "object",
                "properties": {
                    "angle": {
                        "type": "integer",
                        "description": "转向角度,0-180,90是正前方"
                    },
                    "direction": {
                        "type": "string",
                        "enum": ["left", "right"],
                        "description": "向左转还是向右转"
                    }
                },
                "required": []
            }
        }
    }
]

# 当前舵机角度
current_angle = 90

def turn_servo(angle=None, direction=None):
    global current_angle
    if direction == "left":
        current_angle = max(0, current_angle - 30)
    elif direction == "right":
        current_angle = min(180, current_angle + 30)
    elif angle is not None:
        current_angle = max(0, min(180, angle))

    # 这里可以换成真正控制舵机的代码
    # kit.servo[0].angle = current_angle
    return f"已转到 {current_angle}°"


# 主循环
messages = [
    {"role": "system",
     "content": "你是陪伴机器人小明。用户让你转向时,调用 turn_servo 工具。"}
]

while True:
    user_input = input("你:")
    messages.append({"role": "user", "content": user_input})

    response = client.chat.completions.create(
        model="deepseek-chat",
        messages=messages,
        tools=tools,
        tool_choice="auto"
    )

    msg = response.choices[0].message

    # 如果 LLM 想调用工具
    if msg.tool_calls:
        for tool_call in msg.tool_calls:
            func_name = tool_call.function.name
            args = json.loads(tool_call.function.arguments)
            print(f"[调用工具] {func_name}({args})")

            # 执行函数
            result = turn_servo(**args)

            # 把工具调用和结果加回对话历史
            messages.append(msg)
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "name": func_name,
                "content": result
            })

        # 再调一次 LLM,让它根据工具结果生成最终回答
        response2 = client.chat.completions.create(
            model="deepseek-chat",
            messages=messages
        )
        reply = response2.choices[0].message.content
        messages.append({"role": "assistant", "content": reply})
        print(f"小明:{reply}")
    else:
        # 正常回答
        reply = msg.content
        messages.append({"role": "assistant", "content": reply})
        print(f"小明:{reply}")
✅ 预期结果

试试说"向左转",程序会打印 [调用工具] 并执行 turn_servo 函数,然后回复你"好的,已经向左转了"。
再试试说"今天天气怎么样",它就正常回答问题,不会调用工具。

三、思考题

🤔 思考与探索
  1. 为什么需要 Function Calling?直接用关键词判断(比如"包含'转'字就转舵机")不行吗?
  2. 对话历史 messages 列表会越来越长,会有什么问题?有什么解决办法?
  3. (选做)把实验 5 的语音识别、本实验的 LLM 对话、TTS 语音合成连起来,实现真正的"语音对话机器人"。
7

实验 7:回声消除与双讲检测

难度 较难 建议学时:8 学时 前置实验:实验 5
🎯 实验目标
  • 理解回声消除(AEC)的作用
  • 学会使用 WebRTC AEC3
  • 理解双讲(Double Talk)概念
  • 实现简单的双讲检测
🛠️ 所需器材
  • 树莓派 / PC
  • 麦克风 + 扬声器
📦 需要安装
  • speexdsp / webrtc-audio-processing
  • numpy
  • pyaudio

一、实验原理

7.1 什么是回声消除?

想象一下:机器人的扬声器在播放回答的声音,同时麦克风也在录音。 麦克风不仅能录到用户说话,还会录到扬声器自己的声音——这就是回声。 如果不消除回声,机器人会"听到自己说话",导致语音识别混乱,甚至产生啸叫。

回声消除(AEC, Acoustic Echo Cancellation)就是从麦克风信号里, 把"扬声器发出的、经过房间反射回来的那部分声音"减掉,只留下用户的声音。

图 7-1 回声消除原理
graph TB subgraph 回声消除系统 MIC[麦克风信号
= 人声 + 回声] REF[参考信号
= 扬声器播放的声音] AEC[AEC滤波器] OUT[输出 = 只有人声] end REF --> AEC MIC --> AEC AEC --> OUT style AEC fill:#D3F9D8,stroke:#2B8A3E

7.2 什么是双讲?

双讲(Double Talk)就是"两个人同时说话"—— 机器人在说话(扬声器有声音)的时候,用户也开口说话了。 这时候麦克风里既有回声又有人声,AEC 处理不好就会把人声也消掉一部分。

所以需要双讲检测:检测到双讲时,暂时降低 AEC 的强度, 或者通知上层"现在有人打断我说话"。

二、实验步骤

步骤 1:安装 WebRTC AEC 库
pip install webrtc-audio-processing numpy
⚠️ 树莓派注意

webrtc-audio-processing 在树莓派上可能需要编译安装。 如果安装失败,可以试试 speexdsp 作为替代: pip install speexdsp-python

步骤 2:回声消除 Demo(离线文件版)

先做一个直观的实验:用一段音频模拟"扬声器的声音", 加一点延迟和混响模拟"回声",然后用 AEC 把回声消掉。

aec_demo.py
import numpy as np
import webrtc_audio_processing as webrtc_ap
import wave

# 读取参考信号(扬声器播放的声音)
def read_wav(path):
    wf = wave.open(path, 'rb')
    data = wf.readframes(wf.getnframes())
    arr = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
    sr = wf.getframerate()
    wf.close()
    return arr, sr

# 生成"带回声"的麦克风信号
def simulate_echo(ref_signal, sr, delay_ms=100, echo_gain=0.5):
    delay_samples = int(sr * delay_ms / 1000)
    echo = np.zeros_like(ref_signal)
    echo[delay_samples:] = ref_signal[:-delay_samples] * echo_gain
    # 再加一点混响(简单的多回声模拟)
    for i in range(2, 5):
        d = delay_samples * i
        if d < len(ref_signal):
            echo[d:] += ref_signal[:-d] * echo_gain * (0.5 ** (i-1))
    return echo

# 读取参考音频(找一段 wav 或用实验1录的)
ref, sr = read_wav("test.wav")

# 模拟回声 + 一点人声(这里用噪声代替人声做演示)
echo = simulate_echo(ref, sr, delay_ms=80, echo_gain=0.6)
nearend = 0.1 * np.random.randn(len(ref))  # 模拟近端人声
mic_signal = echo + nearend  # 麦克风信号 = 回声 + 近端人声

# 初始化 AEC
ap = webrtc_ap.AudioProcessing()
ap.set_stream_format(sample_rate_hz=sr, num_channels=1)
ap.set_reverse_stream_format(sample_rate_hz=sr, num_channels=1)
ap.echo_cancellation.enable = True
ap.echo_cancellation.suppression_level = 'high'
ap.initialize()

# 按帧处理(WebRTC 要求 10ms 一帧)
frame_size = int(sr * 0.01)  # 10ms 的采样点数
output = np.zeros_like(mic_signal)

for i in range(0, len(mic_signal) - frame_size, frame_size):
    mic_frame = mic_signal[i:i+frame_size]
    ref_frame = ref[i:i+frame_size]

    # 先喂参考信号
    ap.process_reverse_stream(ref_frame)
    # 再处理麦克风信号
    out_frame = ap.process_stream(mic_frame)

    output[i:i+frame_size] = out_frame

# 计算回声抑制量(能量比)
echo_energy = np.mean(echo ** 2)
out_echo_energy = np.mean((output - nearend) ** 2)
suppression_db = 10 * np.log10(echo_energy / out_echo_energy)
print(f"回声抑制量: {suppression_db:.1f} dB")
print("(越大说明消除得越干净,10dB以上算不错)")
✅ 预期结果

回声抑制量应该在 10dB 以上,说明大部分回声被消掉了。 有条件的同学可以把处理前后的音频保存下来听一听,对比效果。

步骤 3:简单双讲检测

双讲检测的核心思路:比较麦克风信号能量和参考信号能量。 如果麦克风能量明显大于参考(回声)能量,说明有人在说话——双讲了!

double_talk_detector.py
import numpy as np

class DoubleTalkDetector:
    """
    简单的双讲检测器
    原理:比较近端(麦克风)和远端(参考/回声)的能量比
    """
    def __init__(self, threshold=2.0, sr=16000, frame_ms=20):
        self.threshold = threshold  # 能量比阈值
        self.frame_size = int(sr * frame_ms / 1000)
        # 平滑因子,让检测更稳定
        self.alpha = 0.9
        self.mic_energy_smooth = 0
        self.ref_energy_smooth = 0

    def process(self, mic_frame, ref_frame):
        """
        返回 True 表示检测到双讲
        """
        mic_energy = np.mean(mic_frame ** 2)
        ref_energy = np.mean(ref_frame ** 2)

        # 指数平滑
        self.mic_energy_smooth = (self.alpha * self.mic_energy_smooth
                                  + (1 - self.alpha) * mic_energy)
        self.ref_energy_smooth = (self.alpha * self.ref_energy_smooth
                                  + (1 - self.alpha) * ref_energy)

        # 避免除以0
        if self.ref_energy_smooth < 1e-6:
            return False

        # 能量比 = 麦克风能量 / 参考能量
        ratio = self.mic_energy_smooth / self.ref_energy_smooth

        return ratio > self.threshold


# ======== 测试 ========
if __name__ == "__main__":
    sr = 16000
    t = np.arange(0, 3, 1/sr)

    # 参考信号(扬声器一直在说话)
    ref = 0.5 * np.sin(2 * np.pi * 440 * t)

    # 麦克风信号:前1秒只有回声,中间1秒双讲,后1秒只有回声
    mic = 0.3 * ref  # 回声分量
    # 第 1~2 秒加入人声(模拟用户说话)
    voice = np.where((t >= 1) & (t < 2), 0.8 * np.sin(2 * np.pi * 300 * t), 0)
    mic += voice

    # 逐帧检测
    detector = DoubleTalkDetector(threshold=2.0, sr=sr)
    frame_size = detector.frame_size

    print("时间(s)\t双讲?")
    for i in range(0, len(t) - frame_size, frame_size):
        dt = detector.process(mic[i:i+frame_size], ref[i:i+frame_size])
        time_sec = i / sr
        if time_sec % 0.5 < 0.02:  # 每 0.5 秒打印一次
            status = "✅ 双讲" if dt else "  -  仅回声"
            print(f"{time_sec:.1f}\t{status}")
✅ 预期结果

0~1秒:显示"仅回声"(只有扬声器的回声)
1~2秒:显示"双讲"(用户也说话了,能量比升高)
2~3秒:回到"仅回声"

💡 为什么需要双讲检测?

在我们的机器人场景里,双讲检测有两个重要作用:
1. 保护 AEC:双讲时如果 AEC 太强,会把人声也消掉,所以要适当降低抑制强度
2. 打断检测:机器人正在说话时,如果检测到用户打断(双讲), 机器人应该停下来听用户说什么——这就是"全双工"的核心体验!

三、思考题

🤔 思考与探索
  1. 如果房间很大,回声会有很长的尾音(混响)。这对 AEC 有什么挑战?
  2. 双讲检测的阈值 threshold 设高了/低了分别有什么问题?
  3. (选做)试试把回声消除放到实时语音识别前面,看看识别准确率有没有提升。
8

实验 8:唇部检测与口型识别

难度 较难 建议学时:8 学时 前置实验:实验 4、7
🎯 实验目标
  • 学会用 MediaPipe Face Mesh 检测唇部关键点
  • 计算嘴巴开合程度(嘴高/嘴宽比)
  • 实现"说话/没说话"的判断
  • 结合双讲检测做二次确认
🛠️ 所需器材
  • 树莓派 / PC
  • USB 摄像头
  • 麦克风 + 扬声器
📦 需要安装
  • opencv-python
  • mediapipe
  • numpy

一、实验原理

当机器人正在说话时,如果检测到双讲(有人打断),我们需要确认一下: 是真的有人在说话要打断我,还是只是环境噪声?

这时候视觉就派上用场了——如果画面里的人嘴巴在动(在说话), 那大概率是真的要打断;如果嘴巴没动,那可能是噪声误判。 这就是音视融合的思路:用两个模态的信息互相验证,提高准确性。

图 8-1 音视融合的双讲确认流程
graph TB A[音频双讲检测触发] --> V{视觉检测
嘴巴在动?} V -- 是 --> CONFIRM[确认被打断
停止说话 开始听] V -- 否 --> IGNORE[可能是噪声
继续说] style CONFIRM fill:#FFC9C9,stroke:#C92A2A style IGNORE fill:#D3F9D8,stroke:#2B8A3E

二、实验步骤

步骤 1:唇部关键点检测

MediaPipe Face Mesh 能检测人脸 468 个关键点,其中嘴唇区域有几十个点。 我们只需要提取嘴唇的几个关键点就能算出嘴巴张多大。

lip_detection.py
import cv2
import mediapipe as mp
import numpy as np

# 唇部关键点索引(上唇上、下唇下、左嘴角、右嘴角)
LIP_TOP = 13      # 上唇中间(上沿)
LIP_BOTTOM = 14   # 下唇中间(下沿)
LIP_LEFT = 61     # 左嘴角
LIP_RIGHT = 291   # 右嘴角
MOUTH_TOP = 0     # 上唇内侧上沿
MOUTH_BOTTOM = 17 # 下唇内侧下沿

# 初始化 Face Mesh
mp_face_mesh = mp.solutions.face_mesh
face_mesh = mp_face_mesh.FaceMesh(
    max_num_faces=1,
    refine_landmarks=True,   # 开启后嘴唇眼睛更精细
    min_detection_confidence=0.5,
    min_tracking_confidence=0.5
)

cap = cv2.VideoCapture(0)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)

# 记录嘴部开合度历史,用来判断"是否在说话"
mouth_ratio_history = []
SPEAKING_THRESHOLD = 0.05  # 嘴高/嘴宽 的变化阈值,需要调试

while cap.isOpened():
    ret, frame = cap.read()
    if not ret: break
    h, w = frame.shape[:2]

    rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    results = face_mesh.process(rgb)

    if results.multi_face_landmarks:
        landmarks = results.multi_face_landmarks[0].landmark

        # 提取唇部关键点像素坐标
        def get_pt(idx):
            return (int(landmarks[idx].x * w),
                    int(landmarks[idx].y * h))

        top = get_pt(LIP_TOP)
        bottom = get_pt(LIP_BOTTOM)
        left = get_pt(LIP_LEFT)
        right = get_pt(LIP_RIGHT)
        m_top = get_pt(MOUTH_TOP)
        m_bottom = get_pt(MOUTH_BOTTOM)

        # 计算嘴宽和嘴高
        mouth_width = np.linalg.norm(np.array(left) - np.array(right))
        mouth_height = np.linalg.norm(np.array(m_top) - np.array(m_bottom))
        ratio = mouth_height / mouth_width # 开合比(越高=嘴张越大)

        # 保存历史(最近 30 帧)
        mouth_ratio_history.append(ratio)
        if len(mouth_ratio_history) > 30:
            mouth_ratio_history.pop(0)

        # 判断是否在说话:开合度变化大 = 在说话
        if len(mouth_ratio_history) >= 10:
            ratio_std = np.std(mouth_ratio_history)
            is_speaking = ratio_std > SPEAKING_THRESHOLD
        else:
            is_speaking = False

        # 画图:嘴唇轮廓
        for idx in [LIP_TOP, LIP_BOTTOM, LIP_LEFT, LIP_RIGHT]:
            cv2.circle(frame, get_pt(idx), 3, (0, 255, 0), -1)

        # 画嘴部开合线
        cv2.line(frame, left, right, (255, 0, 0), 1)  # 嘴宽
        cv2.line(frame, m_top, m_bottom, (0, 0, 255), 2)  # 嘴高

        # 显示结果
        status = "SPEAKING" if is_speaking else "silent"
        color = (0, 0, 255) if is_speaking else (0, 255, 0)
        cv2.putText(frame, status, (10, 30),
                    cv2.FONT_HERSHEY_SIMPLEX, 1, color, 2)
        cv2.putText(frame, f'ratio: {ratio:.3f}', (10, 60),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1)

    cv2.imshow('Lip Detection', frame)
    if cv2.waitKey(1) & 0xFF == ord('q'):
        break

cap.release()
cv2.destroyAllWindows()
✅ 预期结果

画面里能看到嘴唇上的关键点(绿色小点),蓝色横线是嘴宽,红色竖线是嘴高。
张嘴说话时,右上角显示 "SPEAKING"(红色);闭嘴不动时显示 "silent"(绿色)。

💡 调试小技巧

SPEAKING_THRESHOLD 要根据实际情况调:
• 先闭嘴不动,观察 ratio 的波动范围(比如稳定在 0.02 左右)
• 再正常说话,看看 ratio 最大到多少(比如 0.15~0.25)
• 阈值设成两者之间(比如 0.05),用标准差(变化量)而不是绝对值判断更鲁棒

步骤 2:音视融合——双讲二次确认

把实验 7 的双讲检测器和本实验的唇部检测结合起来: 只有当"音频检测到双讲"且"视觉检测到嘴巴在动"时,才认为是真的被打断了。

(这部分作为综合练习,同学们可以参考实验 7 和实验 8 的代码,自己组合一下。核心逻辑:)

# 伪代码:音视融合双讲确认
audio_double_talk = double_talk_detector.process(mic_frame, ref_frame)
lip_speaking = lip_detector.process(frame)

if audio_double_talk and lip_speaking:
    # 确认被打断!
    stop_talking()   # 机器人停止说话
    start_listening() # 开始听用户说什么

三、思考题

🤔 思考与探索
  1. 为什么要用"嘴高/嘴宽"的比值,而不是直接用嘴高的绝对值?
  2. 如果人脸离摄像头时远时近,对嘴部开合度的检测有影响吗?比值法能解决这个问题吗?
  3. (选做)如果用户侧着脸,嘴部关键点检测不准怎么办?有什么办法可以改善?
9

实验 9:系统集成与全双工交互

难度 较难 建议学时:10 学时 前置实验:实验 1-8
🎯 实验目标
  • 理解多线程/多进程架构
  • 学会用消息队列传递数据
  • 掌握状态机的设计方法
  • 把所有模块集成为完整机器人
🛠️ 所需器材
  • 前面所有实验的硬件
  • 树莓派 + 云台 + 摄像头
  • 麦克风阵列 + 扬声器
📚 核心概念
  • 多线程 / 多进程
  • 消息队列(Queue)
  • 有限状态机(FSM)
  • 生产者-消费者模式

一、系统架构

前面 8 个实验我们把每个模块都单独跑通了,但真正的机器人需要它们同时工作—— 麦克风一直在录音、摄像头一直在拍、舵机随时可能要转、LLM 在慢慢思考…… 如果只用一个 while 循环,模块之间会互相阻塞。

解决方案:每个模块一个线程(或进程),用消息队列传递数据。 这就是典型的生产者-消费者模式。

图 9-1 系统整体架构
graph TB subgraph 线程1:音频采集 MIC[麦克风阵列] --> AEC[AEC回声消除] AEC --> VAD[VAD端点检测] end subgraph 线程2:视觉 CAM[摄像头] --> FACE[人脸/唇部检测] end subgraph 线程3:主控制 WAKE{唤醒词
检测?} LOCALIZE[声源定位
+卡尔曼滤波] STATE[状态机] WAKE -- 是 --> LOCALIZE LOCALIZE --> STATE end subgraph 线程4:ASR+LLM ASR[语音识别] --> LLM[大模型] LLM --> TTS[语音合成] end subgraph 线程5:电机控制 SERVO[舵机控制] end VAD -- 音频数据 --> ASR FACE -- 人脸/唇点 --> STATE STATE -- 指令 --> SERVO STATE -- 文本 --> LLM TTS -- 音频 --> SPEAKER[扬声器] style STATE fill:#F3F0FF,stroke:#7950F2

二、状态机设计

机器人有哪些状态?什么时候切换状态?这是系统集成的核心。 我们用有限状态机(FSM)来管理:

图 9-2 机器人状态机
stateDiagram-v2 [*] --> IDLE: 开机 IDLE --> WAKE_DETECTED: 检测到唤醒词 WAKE_DETECTED --> LOCALIZING: 开始声源定位 LOCALIZING --> FACE_SEARCH: 转向声源 FACE_SEARCH --> LISTENING: 找到人脸 LISTENING --> THINKING: VAD检测到语音结束 THINKING --> SPEAKING: LLM返回回答 SPEAKING --> LISTENING: 说完了 继续听 SPEAKING --> INTERRUPTED: 双讲+唇部检测
(被打断) INTERRUPTED --> LISTENING: 停止说话 开始听 LISTENING --> IDLE: 超时没人说话 SPEAKING --> IDLE: 对话结束 超时 style IDLE fill:#E8F3F0 style LISTENING fill:#FFF9DB style SPEAKING fill:#FFC9C9 style THINKING fill:#F3F0FF style INTERRUPTED fill:#FFE0E0

三、代码框架

完整代码量很大,这里给出核心框架。同学们可以把之前实验写好的函数填进去。

main.py(核心框架)
import threading
import queue
import time
from enum import Enum

# ====== 状态定义 ======
class State(Enum):
    IDLE = "idle"
    LOCALIZING = "localizing"
    LISTENING = "listening"
    THINKING = "thinking"
    SPEAKING = "speaking"
    INTERRUPTED = "interrupted"

# ====== 全局消息队列 ======
audio_q = queue.Queue(maxsize=10)     # 音频数据 → ASR
video_q = queue.Queue(maxsize=10)     # 图像 → 人脸/唇检测
command_q = queue.Queue(maxsize=10)   # 指令 → 电机控制
event_q = queue.Queue(maxsize=20)     # 事件 → 状态机

current_state = State.IDLE
state_lock = threading.Lock()  # 状态修改锁,防止多线程冲突

# ====== 线程1:音频采集 + 唤醒词 + AEC + VAD ======
def audio_thread():
    # 初始化麦克风、唤醒词、AEC...
    while True:
        # 读一帧音频
        # 做 AEC 处理
        # 检测唤醒词 → 发事件
        # VAD 检测到语音 → 把音频放进 audio_q
        # 双讲检测 → 发事件
        time.sleep(0.01)

# ====== 线程2:视觉处理 ======
def vision_thread():
    # 初始化摄像头、Face Mesh...
    while True:
        # 读一帧图像
        # 人脸检测 → 计算中心偏差
        # 唇部检测 → 判断是否在说话
        # 结果放进 video_q
        time.sleep(0.03)  # ~30fps

# ====== 线程3:ASR + LLM + TTS ======
def ai_thread():
    while True:
        # 从 audio_q 取完整语音
        # ASR 转文字
        # 调用 LLM
        # 如果要调用工具(Function Calling),发指令到 command_q
        # TTS 合成语音
        # 发事件:开始说话
        # 播放语音
        # 发事件:说话结束
        time.sleep(0.01)

# ====== 线程4:电机控制 ======
def motor_thread():
    while True:
        # 从 command_q 取指令
        # 控制舵机/电机
        time.sleep(0.02)

# ====== 主线程:状态机 ======
def state_machine():
    global current_state
    while True:
        # 从 event_q 取事件
        # 根据当前状态 + 事件,决定做什么、切换到什么状态
        event = event_q.get()

        with state_lock:
            if current_state == State.IDLE:
                if event == "wake_word":
                    current_state = State.LOCALIZING
                    # 开启声源定位

            elif current_state == State.SPEAKING:
                if event == "double_talk_confirmed":
                    current_state = State.INTERRUPTED
                    # 停止播放,切换到听模式

        time.sleep(0.005)

# ====== 启动所有线程 ======
if __name__ == "__main__":
    threads = [
        threading.Thread(target=audio_thread, daemon=True),
        threading.Thread(target=vision_thread, daemon=True),
        threading.Thread(target=ai_thread, daemon=True),
        threading.Thread(target=motor_thread, daemon=True),
    ]
    for t in threads:
        t.start()

    print("机器人已启动!")
    state_machine()  # 主线程跑状态机
💡 集成调试建议

1. 不要一次全加上:先把两个模块连起来调通,再加第三个
2. 打日志:每个状态切换、每个事件都打印出来,出问题好排查
3. 先模拟后真实:用模拟数据测试状态机逻辑,确认没问题再接真实硬件
4. 队列大小要合适:太小会丢数据,太大会延迟高

三、验收标准

功能 验收标准 分值
唤醒词检测 说唤醒词后机器人有响应(LED亮/说话),准确率 > 90% 10分
声源定位+转向 唤醒后机器人能转向说话人方向,误差 < ±15° 15分
人脸跟踪 机器人能"盯"着人脸,人脸移动时摄像头跟随 10分
语音对话 能问问题、得到语音回答,对话流畅 20分
Function Calling 说"向左转"等指令时,机器人能执行对应动作 10分
回声消除 机器人说话时不会把自己的声音识别进去 10分
打断功能(全双工) 机器人说话时用户插话,机器人能停下并回应 15分
演示流畅度 完整演示一遍,运行稳定不崩溃 10分

四、思考题

🤔 思考与探索
  1. 为什么用多线程而不是多进程?什么时候需要用多进程?(提示:GIL锁)
  2. 如果机器人正在回答问题时,又检测到了唤醒词,应该怎么处理?
  3. 系统里有多个模块同时读写同一个变量(比如 current_state),为什么要用锁?不加锁会怎样?
  4. (选做)为你的机器人加一个功能:用 Function Calling 控制机器人"前进/后退/左转/右转"(需要底盘)。

附录 A:常用命令速查

用途命令
SSH 连接树莓派ssh pi@robot.local
查看录音设备arecord -l
查看 I2C 设备sudo i2cdetect -y 1
查看 CPU 温度vcgencmd measure_temp
查看内存使用free -h
安装 Python 包pip install 包名
后台运行程序python main.py &
查看后台进程ps aux | grep python

附录 B:常见问题排查

🔊 麦克风没声音
  • 检查 arecord -l 能否看到设备
  • 确认设备号正确(hw:X,0)
  • 用 alsamixer 调音量
  • 换个 USB 口试试
📷 摄像头打不开
  • CSI 摄像头:raspi-config 里启用
  • USB 摄像头:试试 /dev/video0 或 video1
  • 检查 opencv 是否正确安装
  • 权限问题:sudo usermod -aG video pi
🤖 舵机不动
  • 检查 I2C:sudo i2cdetect -y 1
  • 确认舵机电源单独供
  • 检查信号线接对通道
  • 调整脉冲宽度范围
🌐 LLM 调用失败
  • 检查 API Key 是否正确
  • 确认 base_url 对不对
  • 检查网络是否通畅
  • 看看账户余额够不够