【AI实战日记-手搓情感聊天机器人】Day 13:彻底解放双手!基于 VAD 算法实现 AI 自动静默检测与连续对话
Day 11-12 我们完成了语音的输入输出,但交互方式依然停留在“按键触发”的原始阶段。今天是 Day 13,我们将引入 VAD (语音活动检测) 算法。通过计算音频流的能量阈值 (RMS),让 Project Echo 能够自动判断用户何时开始说话、何时停止说话。我们将重构录音模块,实现 “唤醒 -> 自动聆听 -> 自动静默检测 -> 自动回复” 的全自动闭环,打造真正的 Hands-fre
Day 11-12 我们完成了语音的输入输出,但交互方式依然停留在“按键触发”的原始阶段。今天是 Day 13,我们将引入 VAD (语音活动检测) 算法。通过计算音频流的能量阈值 (RMS),让 Project Echo 能够自动判断用户何时开始说话、何时停止说话。我们将重构录音模块,实现 “唤醒 -> 自动聆听 -> 自动静默检测 -> 自动回复” 的全自动闭环,打造真正的 Hands-free 对话体验。
一、 项目进度:Day 13 启动
根据项目路线图,今天是 Phase 4 的收官之战。
我们要把零散的语音模块整合成一个流畅的系统。

二、 核心原理:VAD (语音活动检测)
1. 痛点:“按键说话”显得很傻
真实的人类交流是流式的。你不会跟朋友聊天时说:“等一下,我按下按钮再跟你说话。”
我们要让 AI 像人一样,通过听觉判断对话节奏。
2. 解决方案:能量检测算法 (Energy Threshold)
最简单的 VAD 实现方式是基于音量(能量)。
-
监听状态:程序实时读取麦克风数据块(Chunk)。
-
计算能量:计算每个块的 RMS (均方根) 值,代表音量大小。
-
逻辑判断:
-
触发 (Trigger):音量 > 阈值(Threshold),判定为“开始说话”。
-
维持 (Active):持续录音。
-
静默 (Silence):音量 < 阈值,且持续了 X 秒(例如 2秒),判定为“说话结束”。
-
3. 核心组件:VADRecorder (自动录音机)
在 Day 13 的代码中,VADRecorder 是实现 Hands-free 体验的绝对核心。它不再是一个简单的录音脚本,而是一个具备**“听觉感知”**能力的实时状态机。
它的工作原理基于 能量检测 (Energy-based Detection),具体包含以下三个关键要素:
(1) 听觉量化:RMS (均方根)
计算机听不懂声音的内容,但它能感知声音的震动强度。
我们使用 audioop.rms(chunk) 方法来计算每一小段音频块(Chunk)的能量值。
-
环境噪音:RMS 通常在 100~500 之间。
-
说话声音:RMS 通常在 1000~5000 之间(取决于麦克风距离)。
(2) 两个核心参数 (The Knobs)
我们在代码中定义了两个“旋钮”来调节 AI 的听觉敏感度:
-
threshold (触发阈值):比如设为 1000。声音超过这个线,AI 认为“有人说话了”。
-
silence_limit (静默容忍度):比如设为 2.0 秒。意思是:“如果声音低于阈值持续了 2 秒,我就认为你说完了。”
(3) 录音状态机 (The State Machine)
VADRecorder 的内部逻辑其实是一个在 [监听] 和 [录制] 之间切换的状态机。
请看下面的逻辑流转图:

(4) 为什么这种设计体验好?
-
抗干扰:通过 Threshold,过滤掉键盘声、空调声等低频噪音,只有人声才能唤醒录音。
-
自然断句:通过 Silence Limit,允许用户在说话中间有短暂的停顿(思考),而不会因为喘口气就被强行打断。
总结:VADRecorder 就像一个不知疲倦的守门员,它时刻盯着麦克风的数据流,只有当“真正的声音”出现时,才会打开大门进行录制,从而实现了全自动交互。
三、 实战:代码实现
1. 升级录音模块 (src/core/vad_recorder.py)
我们不再使用 Day 12 那个简陋的 AudioRecorder,而是新建一个高级的 VADRecorder。
这个类会自动计算音量,并自动停止录音。
新建文件:src/core/vad_recorder.py
import pyaudio
import wave
import audioop
import math
import time
from collections import deque
from src.utils.logger import logger
class VADRecorder:
def __init__(self, filename="input_voice.wav"):
self.filename = filename
self.chunk = 1024
self.format = pyaudio.paInt16
self.channels = 1
self.rate = 44100
# --- VAD 核心参数 ---
# 能量阈值 (根据你的麦克风灵敏度调整,通常 500-3000)
self.threshold = 1000
# 静默超时 (秒):说完话后停顿多久算结束
self.silence_limit = 2.0
self.pyaudio_instance = pyaudio.PyAudio()
def listen_and_record(self):
"""
全自动录音:等待声音 -> 开始录音 -> 检测静默 -> 停止录音
"""
stream = self.pyaudio_instance.open(
format=self.format,
channels=self.channels,
rate=self.rate,
input=True,
frames_per_buffer=self.chunk
)
logger.info("👂 正在聆听... (请直接说话)")
frames = []
started = False
silence_start_time = None
while True:
data = stream.read(self.chunk)
# 计算当前块的音量 (RMS)
rms = audioop.rms(data, 2)
if not started:
# 阶段 1: 等待声音触发
if rms > self.threshold:
started = True
logger.info("🎤 检测到声音,开始录音...")
frames.append(data)
else:
# 阶段 2: 正在录音
frames.append(data)
if rms < self.threshold:
# 如果当前是静音
if silence_start_time is None:
silence_start_time = time.time()
else:
# 检查静默是否超时
if time.time() - silence_start_time > self.silence_limit:
logger.info("🛑 检测到静默,录音结束")
break
else:
# 如果又有声音了,重置静默计时器
silence_start_time = None
# 停止流
stream.stop_stream()
stream.close()
# 保存文件
self._save_file(frames)
return True
def _save_file(self, frames):
wf = wave.open(self.filename, 'wb')
wf.setnchannels(self.channels)
wf.setsampwidth(self.pyaudio_instance.get_sample_size(self.format))
wf.setframerate(self.rate)
wf.writeframes(b''.join(frames))
wf.close()
2. 终极集成 (main.py)
我们需要修改主循环。不再是 input() 阻塞,而是 vad_recorder.listen_and_record() 阻塞。
修改 main.py:
# ==============================================================================
# Project Echo Day 9: Multi-Query RAG Integration
# 集成特性: Redis记忆 + 情绪识别 + Multi-Query知识库检索
# ==============================================================================
# LangChain LCEL 核心组件
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_community.chat_message_histories import RedisChatMessageHistory
from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
from operator import itemgetter # 【修复】用于从字典中提取字段
# 项目核心模块
from src.core.llm import LLMClient
from src.core.prompts import PROMPTS
from src.utils.logger import logger
from src.config.settings import settings
from src.core.emotion import EmotionEngine # Day 5: 情绪
from src.core.knowledge import KnowledgeBase # Day 7-9: 知识库
from src.core.reranker import RerankEngine # Day 10: 重排序
from src.core.voice import VoiceEngine # 导入语音引擎
from src.core.vad_recorder import VADRecorder
from src.core.stt import STTEngine
# --- Redis 历史记录工厂 (Day 6) ---
def get_session_history(session_id: str) -> BaseChatMessageHistory:
return RedisChatMessageHistory(
session_id=session_id,
url=settings.REDIS_URL,
ttl=3600 * 24 * 7 # 记忆保留 7 天
)
# --- 辅助函数:格式化文档 ---
def format_docs(docs):
return "\n\n".join([d.page_content for d in docs])
def main():
logger.info("🚀 --- Project Echo: Day 9 集成版启动 ---")
# ==========================================
# 1. 初始化组件
# ==========================================
# 1.1 大模型 (Brain)
client = LLMClient()
llm = client.get_client()
# 1.2 情绪引擎 (Heart)
emotion_engine = EmotionEngine()
# 1.3 知识库 (Book)
kb = KnowledgeBase()
# 【Day 9 核心】获取多重查询检索器
# 这里我们将 LLM 传进去,让检索器具备"思考"能力
base_retriever = kb.get_multiquery_retriever(llm)
# 【Day 10 新增】重排序引擎:对粗排结果进行精排
reranker = RerankEngine(model_name="BAAI/bge-reranker-base", top_n=3)
# 封装成 LCEL Runnable:先粗排,再精排
def retriever_with_rerank(query: str):
docs = base_retriever.invoke(query)
return reranker.rerank(query, docs)
retriever = RunnableLambda(retriever_with_rerank)
# ==========================================
# 2. 构建 Prompt 模板
# ==========================================
sys_prompt_base = PROMPTS["tsundere"]
prompt = ChatPromptTemplate.from_messages([
("system", sys_prompt_base), # 1. 基础人设 (傲娇)
("system", "{emotion_context}"), # 2. 情绪指令 (动态注入)
("system", "【参考资料(必须基于此回答)】:\n{context}"), # 3. 知识库资料 (RAG)
MessagesPlaceholder(variable_name="history"), # 4. 历史记忆 (Redis)
("human", "{input}") # 5. 用户输入
])
# ==========================================
# 3. 组装 LCEL 流水线
# ==========================================
rag_chain = (
{
# 分支 A: 智能检索 (用户输入 -> 裂变3个问题 -> 并行检索 -> 汇总 -> 格式化)
"context": itemgetter("input") | retriever | format_docs, # 【修复】使用 itemgetter 提取 input
# 分支 B: 透传参数 (直接传递给 Prompt)
"input": itemgetter("input"), # 【修复】提取 input 字段
"emotion_context": itemgetter("emotion_context"), # 【修复】提取 emotion_context 字段
"history": itemgetter("history") # 【修复】提取 history 字段
}
| prompt # 填入模板
| llm # 大模型推理
)
# ==========================================
# 4. 挂载持久化记忆
# ==========================================
final_chain = RunnableWithMessageHistory(
rag_chain,
get_session_history,
input_messages_key="input",
history_messages_key="history",
)
print("\n✨ 系统就绪!试试问得模糊一点,比如“那个写代码的人爱吃啥?”\n")
session_id = "user_day9_demo"
voice_engine = VoiceEngine(voice="zh-CN-XiaoyiNeural")
# recorder = AudioRecorder()
vad_recorder = VADRecorder()
stt_engine = STTEngine(model_size="base")
# ==========================================
# 5. 对话循环
# ==========================================
while True:
# --- Step 1: 录音 (Recording) ---
vad_recorder.listen_and_record()
user_input = stt_engine.transcribe("input_voice.wav")
if not user_input or len(user_input) < 2:
print("⚠️ 似乎是杂音,忽略...")
continue
print(f"\nYou (语音): {user_input}")
# --- Step 2: 识别 (STT) ---
user_input = stt_engine.transcribe()
if not user_input:
print("⚠️ 没听清,请再说一遍。")
continue
print(f"You (语音): {user_input}")
if user_input.strip():
# --- Phase A: 情绪侦探 ---
current_emotion = emotion_engine.analyze(user_input)
emotion_instruction = "用户情绪平稳。"
if "[愤怒]" in current_emotion:
emotion_instruction = "⚠️ 警告:用户很生气!请示弱道歉。"
elif "[悲伤]" in current_emotion:
emotion_instruction = "⚠️ 提示:用户很难过。请温柔安慰。"
try:
# --- Phase B: 执行 RAG 主链 ---
# 这一步会自动触发 Multi-Query 检索
logger.info("🔍 正在进行多重检索与思考...")
response = final_chain.invoke(
{
"input": user_input,
"emotion_context": emotion_instruction
},
config={"configurable": {"session_id": session_id}}
)
ai_text = response.content
print(f"Bot ({current_emotion}): {ai_text}\n")
voice_engine.speak(ai_text, emotion=current_emotion)
except Exception as e:
logger.error(f"❌ 调用失败: {e}")
if __name__ == "__main__":
main()
四、 效果验证:丝滑的对话体验
现在运行 python main.py,你将获得类似 钢铁侠 Jarvis 的体验:
-
启动:程序显示 👂 正在聆听...
-
你开口:“你好傲娇酱。”(无需按任何键)
-
检测:程序显示 🎤 检测到声音...
-
你闭嘴:(停顿 2 秒)
-
处理:程序自动停止录音 -> 识别文字 -> 思考 -> 播放语音。
-
循环:语音播放完毕后,程序立刻又变回 👂 正在聆听...,等待你的下一句话。
五、 总结与预告
今天我们通过引入 VAD 技术,彻底消灭了物理按键。Project Echo 变成了一个**“永远在线 (Always Listening)”** 的智能体。
虽然它现在还运行在 Python 终端里,但它的核心交互逻辑已经和 Siri、小爱同学没有区别了。
Phase 5 预告 (Day 14):
作为一个完美的收官,我们不能总是在黑乎乎的命令行里玩。
明天 Day 14,我们将进入 Phase 5:全栈部署。我们将使用 Streamlit,为 Project Echo 穿上一件漂亮的 Web UI 外衣,把聊天气泡、历史记录、语音波形都可视化展示出来!
更多推荐


所有评论(0)