一、系统配置与初始化
-
硬件配置
- 音频格式:16位PCM,单声道,16kHz采样率(符合VAD要求)
- 录音块大小:30ms
- VAD灵敏度:2级(0-3范围)
- 静音检测时长:2秒
-
模型初始化
- Whisper语音识别模型(base模型,加载更快)
- pyaudio麦克风管理
- pyttsx3语音合成引擎(尝试使用Siri中文声音)
- webrtcvad语音活动检测
-
对话状态管理
- 激活状态控制(未唤起/已唤起)
- 超时机制(30秒无交互自动休眠)
- 唤起词列表:["在吗", "博士"]
- 结束词列表:["再见", "结束", "拜拜", "退出"]
- AI身份设定:Batac博士(专业、严谨的学术助手)
二、核心功能模块
1. 录音功能 ( record_audio_with_vad )
- 基于VAD的智能录音检测
- 持续监听直到检测到语音活动
- 60%语音块阈值判断语音开始/结束
- 最多录音10秒保护机制
- 自动保存为WAV文件
2. 语音识别功能 ( speech_to_text )
- 使用Whisper模型进行中文语音识别
- 输入:WAV音频文件
- 输出:识别后的文本
3. 语音合成功能 ( text_to_speech )
- 使用pyttsx3引擎进行文本转语音
- 自动选择系统中文语音(优先Siri声音)
- 语速180,音量0.8
- 无长度限制,完整播放长文本
4. 大模型交互 ( chat_with_llm )
- 调用本地Ollama模型(qwen:7b-chat)
- 结合系统提示词设定AI身份
- 无回复长度限制,允许模型自由生成
5. 对话控制功能
- 输入验证 (
is_valid_input):过滤无效输入和纯标点 - 唤起检测 (
contains_activation_word):检测唤起词 - 结束检测 (
contains_end_word):检测结束词 - 纯唤起词判断 (
is_only_activation_word):区分纯唤起和带问题唤起 - 唤起词移除 (
remove_activation_word):提取核心问题 - 超时检测 (
check_timeout):30秒无交互自动休眠
6. 主程序逻辑 ( main )
完整的工作流程:
- 检查超时状态
- VAD录音监听
- 语音转文字
- 输入有效性验证
- 更新交互时间
- 结束词检测
- 唤起状态处理:
- 未唤起:检测唤起词
- 唤起词+问题:先回复"好的"再回答
- 仅唤起词:回复"在呢"
- 已唤起状态:直接回答问题
- 清理临时文件
python
import ollama
import whisper
import pyaudio
import wave
import pyttsx3
import os
import re
import time
import webrtcvad
import collections
import warnings
# 禁用FP16警告
warnings.filterwarnings("ignore", message="FP16 is not supported on CPU; using FP32 instead")
# 配置参数
FORMAT = pyaudio.paInt16
CHANNELS = 1
RATE = 16000 # VAD要求的采样率
CHUNK_DURATION_MS = 30 # VAD块大小(10/20/30 ms)
CHUNK_SIZE = int(RATE * CHUNK_DURATION_MS / 1000) # 每个块的采样数
VAD_MODE = 2 # VAD灵敏度(0-3,降低为2)
SILENCE_DURATION_MS = 2000 # 停止说话后等待的时间(2秒)
OUTPUT_WAV = "input.wav"
# 超时设置(秒)
TIMEOUT_SECONDS = 30
# 系统提示词:设定AI身份为batac博士
SYSTEM_PROMPT = """
你是一名知识渊博的博士,名字叫batac。
- 回答问题时要专业、严谨,同时保持友好的语气。
- 遇到不确定的问题,要坦诚说明,避免误导。
- 尽量用简洁明了的语言解释复杂概念,适合普通用户理解。
- 回答要符合博士的身份,体现出深厚的学术素养。
"""
print("正在初始化系统...")
# 初始化语音识别模型(使用base模型,加载更快)
print("正在加载Whisper模型...")
asr_model = whisper.load_model("base")
print("Whisper模型加载完成")
# 初始化麦克风
print("正在初始化麦克风...")
p = pyaudio.PyAudio()
print("麦克风初始化完成")
# 初始化pyttsx3引擎
print("正在初始化语音合成引擎...")
try:
engine = pyttsx3.init()
# 核心优化1:禁用pyttsx3的内部超时机制
engine.setProperty('voice', engine.getProperty('voices')[0].id)
print("语音合成引擎初始化完成")
except Exception as e:
print(f"语音合成引擎初始化失败: {e}")
engine = None
# 初始化VAD
print("正在初始化VAD...")
vad = webrtcvad.Vad(VAD_MODE)
print("VAD初始化完成")
# 对话激活状态(False:未唤起,True:已唤起)
is_activated = False
# 最后一次交互时间(用于超时计算)
last_interaction_time = time.time()
# 唤起词列表(不区分大小写)
ACTIVATION_WORDS = ["在吗", "博士"]
# 结束对话词列表
END_WORDS = ["再见", "结束", "拜拜", "退出"]
def record_audio_with_vad():
"""使用VAD录音:持续监听,直到用户停止说话超过指定时间"""
print("\n正在监听...(说话后会自动录音,停止说话2秒后结束)")
# 打开麦克风流
stream = p.open(
format=FORMAT,
channels=CHANNELS,
rate=RATE,
input=True,
frames_per_buffer=CHUNK_SIZE
)
frames = [] # 存储录音帧
ring_buffer = collections.deque(maxlen=int(SILENCE_DURATION_MS / CHUNK_DURATION_MS)) # 滑动窗口
triggered = False # 是否检测到语音活动
start_time = time.time() # 开始录音时间
try:
while True:
# 读取一个块
buffer = stream.read(CHUNK_SIZE)
is_speech = vad.is_speech(buffer, RATE) # VAD检测是否为语音
ring_buffer.append((buffer, is_speech))
if not triggered:
# 检测语音开始(降低阈值到60%)
num_voiced = len([f for f, speech in ring_buffer if speech])
if num_voiced > 0.6 * ring_buffer.maxlen: # 60%的块是语音,认为开始说话
triggered = True
print("检测到语音,开始录音...")
# 将滑动窗口中的所有帧加入录音(包括之前的静音)
for f, s in ring_buffer:
frames.append(f)
else:
# 检测语音结束(降低阈值到60%)
frames.append(buffer)
num_unvoiced = len([f for f, speech in ring_buffer if not speech])
if num_unvoiced > 0.6 * ring_buffer.maxlen: # 60%的块是静音,认为结束说话
print("检测到静音,结束录音...")
break
# 超时保护:最长录音时间10秒
if time.time() - start_time > 10:
print("录音超时,结束录音...")
break
except KeyboardInterrupt:
print("手动停止录音...")
except Exception as e:
print(f"录音过程中出错: {e}")
finally:
stream.stop_stream()
stream.close()
# 保存录音
if frames:
wf = wave.open(OUTPUT_WAV, 'wb')
wf.setnchannels(CHANNELS)
wf.setsampwidth(p.get_sample_size(FORMAT))
wf.setframerate(RATE)
wf.writeframes(b''.join(frames))
wf.close()
print(f"录音完成,文件大小: {os.path.getsize(OUTPUT_WAV)} 字节")
return True # 成功录音
else:
print("未检测到有效语音")
return False # 未检测到有效语音
def is_valid_input(text):
"""检查输入是否有效(非空、非空白、有实际内容)"""
if not text:
return False
stripped_text = text.strip()
if not stripped_text:
return False
# 使用Python re模块支持的标点符号匹配方式
if re.match(r'^[\s!"#$%&\'()*+,-./:;<=>?@[\\\]^_`{|}~]+$', stripped_text):
return False
# 过滤常见无效语气词(但保留唤起词)
invalid_words = {'嗯', '哦', '啊', '哎', '唉', '嘿', '喂', '嗨', '咳', '哟', '呜', '哇'}
# 检查是否是唤起词,如果是则视为有效
is_activation = any(word.lower() in stripped_text.lower() for word in ACTIVATION_WORDS)
if stripped_text in invalid_words and not is_activation:
return False
return True
def contains_activation_word(text):
"""检查输入是否包含唤起词(不区分大小写)"""
lower_text = text.lower()
for word in ACTIVATION_WORDS:
if word.lower() in lower_text:
return True
return False
def contains_end_word(text):
"""检查输入是否包含结束对话词"""
for word in END_WORDS:
if word in text:
return True
return False
def is_only_activation_word(text):
"""检查输入是否仅包含唤起词(无其他内容)"""
stripped_text = text.strip().lower()
# 检查是否仅包含唤起词(可能带标点)
for word in ACTIVATION_WORDS:
lower_word = word.lower()
# 如果输入是唤起词本身或唤起词+标点,则视为仅唤起词
if stripped_text == lower_word or re.match(rf'^{re.escape(lower_word)}[\s!"#$%&\'()*+,-./:;<=>?@[\\\]^_`{{|}}~]*$', stripped_text):
return True
return False
def remove_activation_word(text):
"""移除输入中的唤起词,提取核心问题"""
lower_text = text.lower()
for word in ACTIVATION_WORDS:
lower_word = word.lower()
if lower_word in lower_text:
# 替换唤起词为空,保留剩余内容
text = re.sub(re.escape(word), '', text, flags=re.IGNORECASE).strip()
return text
def check_timeout():
"""检查是否超时,超时则重置激活状态"""
global is_activated, last_interaction_time
current_time = time.time()
if is_activated and (current_time - last_interaction_time > TIMEOUT_SECONDS):
print(f"超过 {TIMEOUT_SECONDS} 秒无交互,对话已休眠。")
is_activated = False
return True
return False
def speech_to_text():
"""语音转文字(使用Whisper)"""
try:
print("正在进行语音识别...")
result = asr_model.transcribe(OUTPUT_WAV, language="zh")
print(f"语音识别结果: {result['text']}")
return result["text"]
except Exception as e:
print(f"语音识别失败: {e}")
return ""
def text_to_speech(text):
"""文字转语音(使用Mac系统Siri声音)- 不限制时间和长度"""
if not engine:
print("语音合成引擎未初始化,无法播放语音")
return
try:
print(f"正在播放语音(长度: {len(text)} 字符)...")
# 获取所有系统语音
voices = engine.getProperty('voices')
# 查找Siri声音
siri_voice = None
for voice in voices:
if "Siri" in voice.name and "Chinese" in voice.languages[0]:
siri_voice = voice.id
break
elif "Chinese" in voice.languages[0]:
siri_voice = voice.id
break
# 设置语音为Siri声音
if siri_voice:
engine.setProperty('voice', siri_voice)
# 调整语速和音量
engine.setProperty('rate', 180)
engine.setProperty('volume', 0.8)
# 核心优化2:使用runAndWait()确保完整播放,不设置任何超时
# runAndWait()会阻塞直到所有语音播放完成,确保完整播放长文本
engine.say(text)
engine.runAndWait()
print("语音播放完成")
except Exception as e:
print(f"语音合成失败: {e}")
def chat_with_llm(prompt):
"""调用本地Ollama国内模型生成回复 - 不限制长度"""
try:
print(f"正在调用LLM生成回复,问题: {prompt}")
# 核心优化3:不设置max_tokens或其他长度限制,让模型自由生成回复
response = ollama.chat(
model="qwen:7b-chat",
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt}
],
# 移除任何可能限制回复长度的参数
# 不设置max_tokens,让模型根据上下文自由生成
)
print(f"LLM回复生成完成,长度: {len(response['message']['content'])} 字符")
return response["message"]["content"]
except Exception as e:
print(f"LLM调用失败: {e}")
return "抱歉,我暂时无法回答这个问题。"
def main():
"""主循环:超时检查 → VAD录音 → 识别 → 唤起检测 → 对话 → 合成 → 播放"""
global is_activated, last_interaction_time # 全局状态变量
print("\n=== 本地语音对话大模型(Batac博士) ===")
print(f"提示:请说 '在吗' 或 '博士' 来唤起对话,说 '再见' 结束对话。")
print(f"唤醒后 {TIMEOUT_SECONDS} 秒无交互将自动休眠。")
print("说话时会自动录音,停止说话2秒后处理。")
print("回答内容不限制时间和长度,会完整播放。")
while True:
# 1. 检查是否超时(每次循环开始时检查)
check_timeout()
# 2. 使用VAD录音(持续监听,直到用户停止说话2秒)
if not record_audio_with_vad():
print("未检测到有效语音,继续监听...")
continue
# 3. 语音转文字
user_text = speech_to_text()
print(f"你:{user_text}")
# 4. 检查输入是否有效
if not is_valid_input(user_text):
print("检测到无效输入,继续监听...")
if os.path.exists(OUTPUT_WAV):
os.remove(OUTPUT_WAV)
continue
# 5. 更新最后交互时间(有效输入时更新)
last_interaction_time = time.time()
# 6. 检查是否结束对话
if contains_end_word(user_text):
print("对话结束,期待下次交流!")
text_to_speech("对话结束,期待下次交流!")
is_activated = False # 重置激活状态
if os.path.exists(OUTPUT_WAV):
os.remove(OUTPUT_WAV)
continue
# 7. 唤起检测逻辑
if not is_activated:
if contains_activation_word(user_text):
# 唤起成功
is_activated = True
print("对话已唤起...")
# 检查是否仅说唤起词(无其他内容)
if is_only_activation_word(user_text):
# 仅唤起,回复"在呢"
response_msg = "在呢"
print(f"Batac博士:{response_msg}")
text_to_speech(response_msg)
else:
# 回答前先语音回复"好的"
text_to_speech("好的")
# 唤起词后有问题,提取核心问题并回答
core_question = remove_activation_word(user_text)
if core_question:
llm_response = chat_with_llm(core_question)
print(f"Batac博士:{llm_response}")
text_to_speech(llm_response)
else:
# 意外情况,回复欢迎语
welcome_msg = "你好!我是Batac博士,有什么可以帮助你的吗?"
print(f"Batac博士:{welcome_msg}")
text_to_speech(welcome_msg)
else:
# 未唤起,提示用户
prompt_msg = "请说hi或博士来唤起对话。"
print(prompt_msg)
text_to_speech(prompt_msg)
else:
# 已唤起状态下,回答前先语音回复"好的"
text_to_speech("好的")
# 已唤起,直接回答问题
llm_response = chat_with_llm(user_text)
print(f"Batac博士:{llm_response}")
text_to_speech(llm_response)
# 8. 清理临时录音文件
if os.path.exists(OUTPUT_WAV):
os.remove(OUTPUT_WAV)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n程序已手动终止")
except Exception as e:
print(f"\n程序运行出错: {e}")
finally:
p.terminate()
print("麦克风已关闭")