Day 11-12 我们完成了语音的输入输出,但交互方式依然停留在“按键触发”的原始阶段。今天是 Day 13,我们将引入 VAD (语音活动检测) 算法。通过计算音频流的能量阈值 (RMS),让 Project Echo 能够自动判断用户何时开始说话、何时停止说话。我们将重构录音模块,实现 “唤醒 -> 自动聆听 -> 自动静默检测 -> 自动回复” 的全自动闭环,打造真正的 Hands-free 对话体验。


一、 项目进度:Day 13 启动

根据项目路线图,今天是 Phase 4 的收官之战。
我们要把零散的语音模块整合成一个流畅的系统。


二、 核心原理:VAD (语音活动检测)

1. 痛点:“按键说话”显得很傻

真实的人类交流是流式的。你不会跟朋友聊天时说:“等一下,我按下按钮再跟你说话。”
我们要让 AI 像人一样,通过听觉判断对话节奏。

2. 解决方案:能量检测算法 (Energy Threshold)

最简单的 VAD 实现方式是基于音量(能量)

  • 监听状态:程序实时读取麦克风数据块(Chunk)。

  • 计算能量:计算每个块的 RMS (均方根) 值,代表音量大小。

  • 逻辑判断

    1. 触发 (Trigger):音量 > 阈值(Threshold),判定为“开始说话”。

    2. 维持 (Active):持续录音。

    3. 静默 (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 的体验:

  1. 启动:程序显示 👂 正在聆听...

  2. 你开口:“你好傲娇酱。”(无需按任何键)

  3. 检测:程序显示 🎤 检测到声音...

  4. 你闭嘴:(停顿 2 秒)

  5. 处理:程序自动停止录音 -> 识别文字 -> 思考 -> 播放语音。

  6. 循环:语音播放完毕后,程序立刻又变回 👂 正在聆听...,等待你的下一句话。


五、 总结与预告

今天我们通过引入 VAD 技术,彻底消灭了物理按键。Project Echo 变成了一个**“永远在线 (Always Listening)”** 的智能体。
虽然它现在还运行在 Python 终端里,但它的核心交互逻辑已经和 Siri、小爱同学没有区别了。

Phase 5 预告 (Day 14)
作为一个完美的收官,我们不能总是在黑乎乎的命令行里玩。
明天 Day 14,我们将进入 Phase 5:全栈部署。我们将使用 Streamlit,为 Project Echo 穿上一件漂亮的 Web UI 外衣,把聊天气泡、历史记录、语音波形都可视化展示出来!

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐