Mac 本地语音对话系统

一、系统配置与初始化

  1. 硬件配置

    • 音频格式:16位PCM,单声道,16kHz采样率(符合VAD要求)
    • 录音块大小:30ms
    • VAD灵敏度:2级(0-3范围)
    • 静音检测时长:2秒
  2. 模型初始化

    • Whisper语音识别模型(base模型,加载更快)
    • pyaudio麦克风管理
    • pyttsx3语音合成引擎(尝试使用Siri中文声音)
    • webrtcvad语音活动检测
  3. 对话状态管理

    • 激活状态控制(未唤起/已唤起)
    • 超时机制(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 )

完整的工作流程:

  1. 检查超时状态
  2. VAD录音监听
  3. 语音转文字
  4. 输入有效性验证
  5. 更新交互时间
  6. 结束词检测
  7. 唤起状态处理:
    • 未唤起:检测唤起词
    • 唤起词+问题:先回复"好的"再回答
    • 仅唤起词:回复"在呢"
  8. 已唤起状态:直接回答问题
  9. 清理临时文件
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("麦克风已关闭")
相关推荐
艾莉丝努力练剑5 小时前
【Python基础:语法第四课】列表和元组——Python 里的“爱情”:列表善变,元组长情
大数据·人工智能·windows·python·安全·pycharm·编辑器
程序员三藏5 小时前
Jmeter的三种参数化方式
自动化测试·软件测试·python·测试工具·jmeter·测试用例·压力测试
飞Link5 小时前
【算法与模型】One-Class SVM 异常检测全解析:原理、实例、项目实战与工程经验
人工智能·python·算法·机器学习·支持向量机
Channing Lewis6 小时前
pyproject.toml
python
秋刀鱼 ..8 小时前
第七届国际科技创新学术交流大会暨机械工程与自动化国际学术会议(MEA 2025)
运维·人工智能·python·科技·机器人·自动化
xwill*14 小时前
分词器(Tokenizer)-sentencepiece(把训练语料中的字符自动组合成一个最优的子词(subword)集合。)
开发语言·pytorch·python
学历真的很重要14 小时前
VsCode+Roo Code+Gemini 2.5 Pro+Gemini Balance AI辅助编程环境搭建(理论上通过多个Api Key负载均衡达到无限免费Gemini 2.5 Pro)
前端·人工智能·vscode·后端·语言模型·负载均衡·ai编程
咖啡の猫14 小时前
Python列表的查询操作
开发语言·python