实验指导书
耳聪目明 — 面向移动陪伴的多模态全双工语音交互机器人
使用说明
- 每个实验按"原理→步骤→代码→结果→思考"顺序编写
- 黄色提示框是注意事项,仔细阅读避免踩坑
- 绿色结果框是预期效果,用来对照检查
- 橙色思考题帮助加深理解
- 先在 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:环境准备与树莓派入门
- 了解树莓派硬件组成
- 学会烧录系统并启动树莓派
- 掌握 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 灯闪烁,熟悉基本操作。
二、实验步骤
- 下载 Raspberry Pi Imager(官网:raspberrypi.com/software)
- 把 SD 卡插到电脑上
- 打开 Imager,选择系统:Raspberry Pi OS (64-bit)
- 选择你的 SD 卡
- 点击 设置按钮 ⚙️,配置以下内容:
- 设置主机名:比如
robot.local - 启用 SSH,选择"使用密码登录"
- 设置用户名和密码(建议用户名
pi,密码自己记好) - 配置 WiFi(输入你家的 WiFi 名称和密码)
- 设置时区为
Asia/Shanghai
- 设置主机名:比如
- 点击"烧录",等待完成
烧录会清空 SD 卡里的所有数据!确认 SD 卡里没有重要文件再操作。 烧录过程中不要拔出 SD 卡,否则可能损坏卡。
- 把烧录好的 SD 卡插入树莓派
- 接上电源,树莓派会自动开机(红灯常亮 = 通电,绿灯闪烁 = 在读 SD 卡)
- 等待约 1~2 分钟,让它完全启动并连上 WiFi
- 在电脑上打开终端(Windows 用 PowerShell,Mac 用 Terminal),输入:
ssh pi@robot.local - 第一次连接会问你"是否继续连接",输入
yes回车 - 输入你设置的密码(输入时屏幕上不显示字符,这是正常的)
看到命令行提示符变成 pi@robot:~ $,说明你已经成功登录树莓派了!
以后所有的 Python 代码都可以在这里运行。
先拔掉树莓派电源!接线时断电操作更安全。
- 找到树莓派的 GPIO17(物理引脚 11)和 GND(物理引脚 6)
- 用杜邦线把 GPIO17 → 电阻 → LED 长脚(正极)连起来
- LED 短脚(负极)→ GND 连起来
- 检查接线无误后,插上电源开机
LED 长脚是正极(+),短脚是负极(-),接反了不亮但不会烧。 电阻一定要接,不然电流太大会烧坏 LED 和树莓派引脚。
- 在 SSH 终端里,用 nano 编辑器创建文件:
nano 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("闪烁完成!")
- 按
Ctrl + O保存,回车确认文件名 - 按
Ctrl + X退出编辑器 - 运行程序:
python blink.py
LED 灯以每秒一次的频率闪烁,一共闪 10 次后停止。
终端里会依次显示"第 1 次闪烁"、"第 2 次闪烁"……"第 10 次闪烁",最后显示"闪烁完成!"
LED 不亮? 检查正负极是否接反了、电阻是不是串联在电路里、GPIO 引脚有没有接错。
提示 gpiozero 找不到? 运行 pip install gpiozero 安装。
连不上 SSH? 确认电脑和树莓派在同一个 WiFi 下,可以用路由器后台查看树莓派的 IP 地址。
三、思考题
- 如果把
time.sleep(0.5)改成time.sleep(0.1),LED 会有什么变化?试一试。 - 查一下树莓派一共有多少个 GPIO 引脚,哪些可以用来输出?
- (选做)如果再加一个按键,实现"按下按键 LED 亮,松开 LED 灭",该怎么接线和写代码?提示:查一下
gpiozero.Button的用法。
实验 1:唤醒词检测
- 理解音频采样的基本概念
- 学会录制和播放音频
- 掌握唤醒词检测的使用方法
- 实现"听到唤醒词 → LED 亮"的效果
- 树莓派 + 实验 0 的 LED 电路
- USB 麦克风 / 麦克风阵列 ×1
- 扬声器 / 耳机 ×1
- pyaudio(音频录制)
- porcupine(唤醒词检测)
- numpy(数值计算)
一、实验原理
唤醒词检测(Wake Word Detection)就是让机器人一直"竖着耳朵听", 只有听到特定的关键词(比如"小明小明")时才"醒来"开始工作。 它的原理是:把声音转换成梅尔频率倒谱系数(MFCC)这种声学特征, 然后用一个轻量级的神经网络模型判断这段声音是不是唤醒词。
本实验我们使用 Porcupine 这个开源的唤醒词检测引擎, 它速度快、占用资源少,非常适合在树莓派上运行。
二、实验步骤
在树莓派的终端里依次执行以下命令:
# 先更新一下系统
sudo apt update
sudo apt install -y portaudio19-dev python3-pyaudio
# 安装 Python 库
pip install pyaudio numpy
pip install pvporcupine
Porcupine 需要一个免费的 AccessKey 才能使用。去 https://console.picovoice.ai/ 注册一个账号, 登录后就能看到你的 AccessKey。复制下来,后面代码里要用。
先确认麦克风能正常录音:
- 查看可用的录音设备:
arecord -l - 录制 5 秒钟的声音(把
hw:2,0换成你上面看到的设备号):arecord -D hw:2,0 -f cd -t wav -d 5 test.wav - 播放刚才录的声音:
aplay test.wav
能听到自己刚才录制的声音,说明麦克风和扬声器都正常工作。 如果听不到,检查接线和设备号是否正确。
创建 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()
- 把代码里的
你的AccessKey替换成你在 Picovoice 官网拿到的 Key - 运行程序:
python wake_word.py - 对着麦克风说 "Hey Siri",观察 LED 是否亮起
程序启动后会显示"已启动,说 'Hey Siri' 来唤醒我!"。
当你说出 "Hey Siri" 时,终端打印"✨ 检测到唤醒词!",同时 LED 亮 1 秒。
按 Ctrl+C 可以退出程序。
- 试试把
SENSITIVITY改成0.9,看看是不是更容易被唤醒?会不会有很多误触发? - 再试试改成
0.1,是不是需要说得很清楚才能唤醒? - 找到一个你觉得最合适的灵敏度值
想让机器人叫"小明"怎么办?可以去 Picovoice 官网的 Console 里,
用 Porcupine Console 录制自己的唤醒词(需要读 3 遍),生成 .ppn 文件后下载到树莓派上,
用 keyword_paths=["xiaoming_zh_raspberry-pi_v3_0_0.ppn"] 来加载。
三、思考题
- 为什么唤醒词检测不直接用完整的语音识别(ASR)来做,而要用专门的模型?(提示:考虑功耗和响应速度)
- 灵敏度太高和太低分别有什么问题?在实际产品中应该怎么权衡?
- (选做)如果想让唤醒词检测到之后,播放一段"你好,我在呢"的语音回应,该怎么修改代码?提示:用
pyttsx3或edge-tts做语音合成。
实验 2:声源定位与波束形成
- 理解麦克风阵列的工作原理
- 掌握 TDOA(到达时间差)计算方法
- 学会用 GCC-PHAT 算法计算时间差
- 实现简单的声源方向角估计
- ReSpeaker 4-Mic 阵列 ×1
- 树莓派主板 ×1
- 卷尺 / 直尺 ×1
- 小喇叭 / 手机(播放声音)
- ReSpeaker 驱动
- numpy
- scipy
- matplotlib(画图用)
一、实验原理
声源定位就是判断声音从哪个方向来。我们人有两只耳朵, 声音传到左耳和右耳的时间有微小的差别,大脑就是根据这个时间差来判断方向的。 麦克风阵列做的是同一件事——用多个麦克风收集声音,计算到达时间差(TDOA), 再根据几何关系算出方向角。
本实验用最经典的 GCC-PHAT(广义互相关-相位变换)算法来计算两个麦克风之间的时间差。 简单说就是:把两路音频做互相关运算,相关值最大的那个位置对应的时间,就是时间差。
波束形成就像是"声学望远镜"——把多个麦克风的信号对齐之后相加, 对准的方向声音会被增强,其他方向的声音会被减弱。 本实验我们先学基础的 TDOA 定位,有兴趣的同学可以自行探索波束形成。
二、实验步骤
ReSpeaker 4-Mic 阵列需要安装专门的驱动:
git clone https://github.com/respeaker/seeed-voicecard.git
cd seeed-voicecard
sudo ./install.sh
sudo reboot
重启后验证是否安装成功:
arecord -l
能看到 seeed4micvoicec 相关的录音设备,说明驱动安装成功了。
pip install numpy scipy matplotlib
pip install pyaudio
先确认我们能同时采集 4 个麦克风的数据:
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}")
核心算法来了!下面的函数用 GCC-PHAT 计算两路信号的时间差:
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 个采样点以内都算正常。
有了时间差,再知道麦克风间距和声速,就能算出角度了:
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°),看看哪个范围的误差最小。
把上面的算法和实时录音结合起来,实现"实时显示声源方向":
- 用 pyaudio 持续读取 4 通道音频
- 取其中两个相对的麦克风(比如麦克风 0 和麦克风 2)
- 每 100ms 计算一次方向角
- 在终端打印方向,或者用条形图可视化
具体代码可以参考实验 1 的实时录音框架,把 porcupine.process() 换成 calculate_doa()。
三、思考题
- 为什么双麦克风只能判断 ±90° 的范围?如果要 360° 全向定位,至少需要几个麦克风?怎么排列?
- GCC-PHAT 算法的"PHAT"(相位变换)有什么作用?如果去掉它,直接用普通互相关会怎样?
- 如果声源距离很近(比如 10cm),平面波假设还成立吗?这时候定位误差会变大还是变小?
- (选做)试着加入噪声(修改
sig2 += 0.1 * np.random.randn(...)),看看多大噪声时算法还能工作?
实验 3:卡尔曼滤波与舵机控制
- 理解卡尔曼滤波的基本思想
- 学会使用舵机(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°。卡尔曼滤波会把这两个信息结合起来, 给出一个比单独用测量值更准、更平滑的结果。
根据上一时刻推测现在] U[更新状态
用测量值修正预测] P --> U U --> P style P fill:#E7F5FF,stroke:#228BE6 style U fill:#E6FCF5,stroke:#12B886
二、实验步骤
直接用树莓派 GPIO 控制舵机不够精确(软件 PWM 有抖动),所以我们用 PCA9685 驱动板(硬件 PWM)。
- PCA9685 和树莓派用 I2C 连接:VCC → 3.3V,GND → GND,SDA → SDA,SCL → SCL
- 舵机接到 PCA9685 的 0 号通道(棕色线=GND,红色线=V+,橙色线=信号)
- PCA9685 的 V+ 外接 5V 电源(舵机电流较大,不要用树莓派供电)
- 插上树莓派电源开机
舵机的电源一定要单独供!树莓派的 5V 引脚输出电流有限, 如果舵机直接从树莓派取电,舵机转动时的电流冲击可能会导致树莓派重启甚至烧坏。
sudo apt install -y i2c-tools
pip install adafruit-circuitpython-servokit
检查 I2C 设备是否连接:
sudo i2cdetect -y 1
能看到地址 40 出现在表格里,说明 PCA9685 连接正常。
编写舵机测试程序:
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 的参数。
我们来手写一个最简单的一维卡尔曼滤波器:
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
生成的图像中,蓝色(测量值)上下抖动很厉害,橙色(滤波后)平滑地跟着真实角度走。
滤波后的平均误差应该明显小于测量值的平均误差。
现在我们用模拟的带噪声角度数据,对比"直接控制舵机"和"卡尔曼滤波后控制舵机"的区别:
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(测量噪声)越大 → 越不相信测量值,越平滑但有延迟
实际使用时,根据你的传感器噪声大小和想要的响应速度来调整这两个参数。
三、思考题
- 卡尔曼滤波的"预测"步骤如果去掉,只保留"更新"步骤,会变成什么?效果会怎样?
- 如果声源移动得很快(比如人快速走过),卡尔曼滤波的延迟会导致舵机跟不上吗?怎么解决?
- (选做)把实验 2 的实时声源定位和本实验的卡尔曼滤波+舵机控制连起来,实现"声音在哪,舵机就指向哪"的完整效果。
实验 4:人脸检测与人脸跟踪
- 了解人脸检测的基本原理
- 学会使用 MediaPipe 检测人脸
- 实现人脸中心点的实时跟踪
- 用舵机让摄像头"盯"着人脸
- 树莓派 + 2自由度云台
- USB 摄像头 / CSI 摄像头 ×1
- PCA9685 驱动板 + 2个舵机
- opencv-python
- mediapipe
- adafruit-circuitpython-servokit
一、实验原理
人脸检测就是从图像里找出"哪里有人脸",输出人脸的位置和大小。 本实验使用 Google 的 MediaPipe 库,它速度快、模型轻,非常适合树莓派。
人脸跟踪是在检测的基础上,让人脸保持在画面中心—— 如果人脸偏左,就控制舵机向左转;偏右就向右转。这也叫视觉伺服。
二、实验步骤
pip install opencv-python mediapipe
树莓派上安装 mediapipe 可能需要编译,耗时较长。 建议先在自己电脑上跑通,再移植到树莓派。 或者使用预编译的 whl 包安装。
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)。
移动人脸时,框和中心点能实时跟着移动,延迟很低。
思路:人脸中心和画面中心的偏差 → 舵机需要转多少度。
最简单的就是 P 控制(比例控制):舵机增量 = Kp × 偏差
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 太大 → 容易过头,来回震荡(抖)
从 0.02 开始慢慢调,找到不抖又跟得上的值。加入死区 DEAD_ZONE 也能减少抖动。
三、思考题
- 为什么只用 P 控制有时候会抖?如果加上 D(微分)控制,会有什么改善?
- 人脸检测只检测到人脸框,如果要知道人脸朝向哪边(左看右看),可以用什么方法?
- (选做)如果画面里有多个人脸,当前代码会跟踪第一个。如果想跟踪"最大的那个人脸"(通常是最近的),该怎么改?
实验 5:语音识别(ASR)
- 理解语音识别的基本概念
- 学会使用 FunASR 做中文语音识别
- 实现"录音 → 转文字"的完整流程
- 了解流式识别和非流式识别的区别
- 树莓派 / PC
- USB 麦克风
- funasr
- pyaudio
- modelscope
一、实验原理
语音识别(ASR)就是把人说的话转成文字。 现在的主流方案是端到端深度学习模型,输入音频波形,直接输出文字。
本实验使用阿里达摩院开源的 FunASR,它对中文支持好、模型轻、速度快, 可以在树莓派上运行。我们用它的 Paraformer 模型, 这是一个非自回归模型,识别速度比传统的 Whisper 更快。
二、实验步骤
pip install funasr modelscope
树莓派上建议用 funasr 的 micro 版本,或使用 ONNX 量化模型。 如果树莓派跑不动,先在电脑上完成实验,后面再考虑云 API 方案。
先拿一个音频文件试试水:
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% 以上。
流式识别就是一边说一边出结果,体验更好。我们用 FunASR 的流式模型:
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 停止后,显示最终的完整识别结果。
三、思考题
- 流式识别和非流式识别各有什么优缺点?分别适合什么场景?
- 如果周围环境很吵,识别准确率会下降。有什么办法可以提高嘈杂环境下的识别率?
- (选做)试试把唤醒词检测和语音识别连起来——检测到唤醒词后,开始录音并识别接下来的一句话。
实验 6:大语言模型(LLM)交互
- 理解大语言模型的基本概念
- 学会调用 LLM API 进行对话
- 掌握 Function Calling(函数调用)
- 实现"语音提问 → LLM回答 → 语音播报"
- 树莓派 / PC
- 麦克风 + 扬声器
- 互联网连接
- openai(SDK,兼容很多接口)
- edge-tts(语音合成)
- pygame(播放音频)
一、实验原理
大语言模型(LLM)就像一个知识渊博的"大脑",你问它问题,它给你回答。 机器人通过语音识别把用户的话转成文字,发给 LLM,拿到回答后再用语音合成读出来, 就完成了一次对话。
更高级的是 Function Calling(函数调用)—— LLM 不仅能回答问题,还能决定"要不要调用某个工具"。 比如用户说"转过去看看那边",LLM 就会调用"控制舵机转向"的函数。
二、实验步骤
pip install openai edge-tts pygame
我们用 OpenAI 格式的 API(兼容很多国内平台,比如 DeepSeek、智谱、通义千问等)。 你需要先去对应平台申请一个 API Key。
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
可以和机器人进行多轮对话,机器人能记住上下文。比如你说"我叫张三", 再问"我叫什么",它应该能回答"张三"。
让机器人把回答读出来:
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("你好呀,我是小明,很高兴认识你!"))
这一步最有意思——让 LLM 自己决定什么时候调用"转舵机"的功能。 用户说"向左转",LLM 就调用 turn_servo 函数;用户问问题,就正常回答。
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 函数,然后回复你"好的,已经向左转了"。
再试试说"今天天气怎么样",它就正常回答问题,不会调用工具。
三、思考题
- 为什么需要 Function Calling?直接用关键词判断(比如"包含'转'字就转舵机")不行吗?
- 对话历史 messages 列表会越来越长,会有什么问题?有什么解决办法?
- (选做)把实验 5 的语音识别、本实验的 LLM 对话、TTS 语音合成连起来,实现真正的"语音对话机器人"。
实验 7:回声消除与双讲检测
- 理解回声消除(AEC)的作用
- 学会使用 WebRTC AEC3
- 理解双讲(Double Talk)概念
- 实现简单的双讲检测
- 树莓派 / PC
- 麦克风 + 扬声器
- speexdsp / webrtc-audio-processing
- numpy
- pyaudio
一、实验原理
7.1 什么是回声消除?
想象一下:机器人的扬声器在播放回答的声音,同时麦克风也在录音。 麦克风不仅能录到用户说话,还会录到扬声器自己的声音——这就是回声。 如果不消除回声,机器人会"听到自己说话",导致语音识别混乱,甚至产生啸叫。
回声消除(AEC, Acoustic Echo Cancellation)就是从麦克风信号里, 把"扬声器发出的、经过房间反射回来的那部分声音"减掉,只留下用户的声音。
= 人声 + 回声] REF[参考信号
= 扬声器播放的声音] AEC[AEC滤波器] OUT[输出 = 只有人声] end REF --> AEC MIC --> AEC AEC --> OUT style AEC fill:#D3F9D8,stroke:#2B8A3E
7.2 什么是双讲?
双讲(Double Talk)就是"两个人同时说话"—— 机器人在说话(扬声器有声音)的时候,用户也开口说话了。 这时候麦克风里既有回声又有人声,AEC 处理不好就会把人声也消掉一部分。
所以需要双讲检测:检测到双讲时,暂时降低 AEC 的强度, 或者通知上层"现在有人打断我说话"。
二、实验步骤
pip install webrtc-audio-processing numpy
webrtc-audio-processing 在树莓派上可能需要编译安装。
如果安装失败,可以试试 speexdsp 作为替代:
pip install speexdsp-python
先做一个直观的实验:用一段音频模拟"扬声器的声音", 加一点延迟和混响模拟"回声",然后用 AEC 把回声消掉。
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 以上,说明大部分回声被消掉了。 有条件的同学可以把处理前后的音频保存下来听一听,对比效果。
双讲检测的核心思路:比较麦克风信号能量和参考信号能量。 如果麦克风能量明显大于参考(回声)能量,说明有人在说话——双讲了!
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. 打断检测:机器人正在说话时,如果检测到用户打断(双讲),
机器人应该停下来听用户说什么——这就是"全双工"的核心体验!
三、思考题
- 如果房间很大,回声会有很长的尾音(混响)。这对 AEC 有什么挑战?
- 双讲检测的阈值 threshold 设高了/低了分别有什么问题?
- (选做)试试把回声消除放到实时语音识别前面,看看识别准确率有没有提升。
实验 8:唇部检测与口型识别
- 学会用 MediaPipe Face Mesh 检测唇部关键点
- 计算嘴巴开合程度(嘴高/嘴宽比)
- 实现"说话/没说话"的判断
- 结合双讲检测做二次确认
- 树莓派 / PC
- USB 摄像头
- 麦克风 + 扬声器
- opencv-python
- mediapipe
- numpy
一、实验原理
当机器人正在说话时,如果检测到双讲(有人打断),我们需要确认一下: 是真的有人在说话要打断我,还是只是环境噪声?
这时候视觉就派上用场了——如果画面里的人嘴巴在动(在说话), 那大概率是真的要打断;如果嘴巴没动,那可能是噪声误判。 这就是音视融合的思路:用两个模态的信息互相验证,提高准确性。
嘴巴在动?} V -- 是 --> CONFIRM[确认被打断
停止说话 开始听] V -- 否 --> IGNORE[可能是噪声
继续说] style CONFIRM fill:#FFC9C9,stroke:#C92A2A style IGNORE fill:#D3F9D8,stroke:#2B8A3E
二、实验步骤
MediaPipe Face Mesh 能检测人脸 468 个关键点,其中嘴唇区域有几十个点。 我们只需要提取嘴唇的几个关键点就能算出嘴巴张多大。
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),用标准差(变化量)而不是绝对值判断更鲁棒
把实验 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() # 开始听用户说什么
三、思考题
- 为什么要用"嘴高/嘴宽"的比值,而不是直接用嘴高的绝对值?
- 如果人脸离摄像头时远时近,对嘴部开合度的检测有影响吗?比值法能解决这个问题吗?
- (选做)如果用户侧着脸,嘴部关键点检测不准怎么办?有什么办法可以改善?
实验 9:系统集成与全双工交互
- 理解多线程/多进程架构
- 学会用消息队列传递数据
- 掌握状态机的设计方法
- 把所有模块集成为完整机器人
- 前面所有实验的硬件
- 树莓派 + 云台 + 摄像头
- 麦克风阵列 + 扬声器
- 多线程 / 多进程
- 消息队列(Queue)
- 有限状态机(FSM)
- 生产者-消费者模式
一、系统架构
前面 8 个实验我们把每个模块都单独跑通了,但真正的机器人需要它们同时工作—— 麦克风一直在录音、摄像头一直在拍、舵机随时可能要转、LLM 在慢慢思考…… 如果只用一个 while 循环,模块之间会互相阻塞。
解决方案:每个模块一个线程(或进程),用消息队列传递数据。 这就是典型的生产者-消费者模式。
检测?} 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)来管理:
(被打断) 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
三、代码框架
完整代码量很大,这里给出核心框架。同学们可以把之前实验写好的函数填进去。
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分 |
四、思考题
- 为什么用多线程而不是多进程?什么时候需要用多进程?(提示:GIL锁)
- 如果机器人正在回答问题时,又检测到了唤醒词,应该怎么处理?
- 系统里有多个模块同时读写同一个变量(比如 current_state),为什么要用锁?不加锁会怎样?
- (选做)为你的机器人加一个功能:用 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 - 确认舵机电源单独供
- 检查信号线接对通道
- 调整脉冲宽度范围
- 检查 API Key 是否正确
- 确认 base_url 对不对
- 检查网络是否通畅
- 看看账户余额够不够