feat: 开放小爱音箱接入小智 AI 演示源代码

This commit is contained in:
Del Wang
2025-04-09 21:10:42 +08:00
parent d170fadbcc
commit 6237c46441
25 changed files with 3983 additions and 0 deletions
@@ -0,0 +1,271 @@
import logging
import queue
import numpy as np
import pyaudio
import opuslib
from xiaozhi.services.protocols.typing import AudioConfig
import time
import sys
from xiaozhi.services.audio.stream import MyAudio
from xiaozhi.xiaoai import XiaoAi
logger = logging.getLogger("AudioCodec")
class AudioCodec:
"""音频编解码器类,处理音频的录制和播放"""
def __init__(self):
"""初始化音频编解码器"""
self.audio = None
self.input_stream = None
self.output_stream = None
self.opus_encoder = None
self.opus_decoder = None
self.audio_decode_queue = queue.Queue()
self._is_closing = False
self._initialize_audio()
def _initialize_audio(self):
"""初始化音频设备和编解码器"""
try:
self.audio = MyAudio() if XiaoAi.mode == "xiaoai" else pyaudio.PyAudio()
# 初始化音频输入流
self.input_stream = self.audio.open(
format=pyaudio.paInt16,
channels=AudioConfig.CHANNELS,
rate=AudioConfig.SAMPLE_RATE,
input=True,
frames_per_buffer=AudioConfig.FRAME_SIZE,
)
# 初始化音频输出流
self.output_stream = self.audio.open(
format=pyaudio.paInt16,
channels=AudioConfig.CHANNELS,
rate=AudioConfig.SAMPLE_RATE,
output=True,
frames_per_buffer=AudioConfig.FRAME_SIZE,
)
# 初始化Opus编码器
self.opus_encoder = opuslib.Encoder(
fs=AudioConfig.SAMPLE_RATE,
channels=AudioConfig.CHANNELS,
application=opuslib.APPLICATION_AUDIO,
)
# 初始化Opus解码器
self.opus_decoder = opuslib.Decoder(
fs=AudioConfig.SAMPLE_RATE, channels=AudioConfig.CHANNELS
)
logger.info("音频设备和编解码器初始化成功")
except Exception as e:
logger.error(f"初始化音频设备失败: {e}")
raise
def read_audio(self):
"""读取音频输入数据并编码"""
try:
data = self.input_stream.read(
AudioConfig.FRAME_SIZE, exception_on_overflow=False
)
if not data:
return None
return self.opus_encoder.encode(data, AudioConfig.FRAME_SIZE)
except Exception as e:
logger.error(f"读取音频输入时出错: {e}")
return None
def write_audio(self, opus_data):
"""将编码的音频数据添加到播放队列"""
self.audio_decode_queue.put(opus_data)
def play_audio(self):
"""处理并播放队列中的音频数据"""
try:
# 批量处理多个音频包以减少处理延迟
batch_size = min(10, self.audio_decode_queue.qsize())
if batch_size == 0:
return False
# 创建缓冲区存储解码后的数据
buffer = bytearray()
for _ in range(batch_size):
if self.audio_decode_queue.empty():
break
opus_data = self.audio_decode_queue.get_nowait()
try:
pcm_data = self.opus_decoder.decode(
opus_data, AudioConfig.FRAME_SIZE, decode_fec=False
)
buffer.extend(pcm_data)
except Exception as e:
logger.error(f"解码音频数据时出错: {e}")
# 只有在有数据时才处理和播放
if len(buffer) > 0:
# 转换为numpy数组
pcm_array = np.frombuffer(buffer, dtype=np.int16)
# 播放音频
try:
if self.output_stream and self.output_stream.is_active():
self.output_stream.write(pcm_array.tobytes())
return True
else:
# MAC 特定:如果流不活跃,尝试重新初始化
self._reinitialize_output_stream()
if self.output_stream and self.output_stream.is_active():
self.output_stream.write(pcm_array.tobytes())
return True
except OSError as e:
if "Stream closed" in str(e) or "Internal PortAudio error" in str(
e
):
logger.error(f"播放音频时出错: {e}")
self._reinitialize_output_stream()
else:
logger.error(f"播放音频时出错: {e}")
except queue.Empty:
pass
except Exception as e:
logger.error(f"播放音频时出错: {e}")
self._reinitialize_output_stream()
return False
def has_pending_audio(self):
"""检查是否还有待播放的音频数据"""
return not self.audio_decode_queue.empty()
def wait_for_audio_complete(self, timeout=5.0):
# 等待音频队列清空
attempt = 0
max_attempts = 15
while not self.audio_decode_queue.empty() and attempt < max_attempts:
time.sleep(0.1)
attempt += 1
# 在关闭前清空任何剩余数据
while not self.audio_decode_queue.empty():
try:
self.audio_decode_queue.get_nowait()
except queue.Empty:
break
def clear_audio_queue(self):
"""清空音频队列"""
while not self.audio_decode_queue.empty():
try:
self.audio_decode_queue.get_nowait()
except queue.Empty:
break
def start_streams(self):
"""启动音频流"""
if not self.input_stream.is_active():
self.input_stream.start_stream()
if not self.output_stream.is_active():
self.output_stream.start_stream()
def stop_streams(self):
"""停止音频流"""
if self.input_stream and self.input_stream.is_active():
self.input_stream.stop_stream()
if self.output_stream and self.output_stream.is_active():
self.output_stream.stop_stream()
def _reinitialize_output_stream(self):
"""重新初始化音频输出流"""
if self._is_closing: # 如果正在关闭,不要重新初始化
return
try:
if self.output_stream:
try:
if self.output_stream.is_active():
self.output_stream.stop_stream()
self.output_stream.close()
except Exception as e:
# logger.warning(f"关闭旧输出流时出错: {e}")
pass
# 在 MAC 上添加短暂延迟
if sys.platform in ("darwin", "linux"):
time.sleep(0.1)
self.output_stream = self.audio.open(
format=pyaudio.paInt16,
channels=AudioConfig.CHANNELS,
rate=AudioConfig.SAMPLE_RATE,
output=True,
frames_per_buffer=AudioConfig.FRAME_SIZE,
)
logger.info("音频输出流重新初始化成功")
except Exception as e:
logger.error(f"重新初始化音频输出流失败: {e}")
raise
def close(self):
"""关闭音频编解码器,确保资源正确释放"""
if self._is_closing: # 防止重复关闭
return
self._is_closing = True
logger.info("开始关闭音频编解码器...")
try:
# 等待并清理剩余音频数据
self.wait_for_audio_complete()
# 关闭输入流
if self.input_stream:
logger.debug("正在关闭输入流...")
try:
if self.input_stream.is_active():
self.input_stream.stop_stream()
self.input_stream.close()
except Exception as e:
logger.error(f"关闭输入流时出错: {e}")
self.input_stream = None
# 关闭输出流
if self.output_stream:
logger.debug("正在关闭输出流...")
try:
if self.output_stream.is_active():
self.output_stream.stop_stream()
self.output_stream.close()
except Exception as e:
logger.error(f"关闭输出流时出错: {e}")
self.output_stream = None
# 关闭 PyAudio 实例
if self.audio:
logger.debug("正在终止 PyAudio...")
try:
self.audio.terminate()
except Exception as e:
logger.error(f"终止 PyAudio 时出错: {e}")
self.audio = None
# 清理编解码器
self.opus_encoder = None
self.opus_decoder = None
logger.info("音频编解码器关闭完成")
except Exception as e:
logger.error(f"关闭音频编解码器时发生错误: {e}")
finally:
self._is_closing = False
def __del__(self):
"""析构函数,确保资源被释放"""
self.close()
@@ -0,0 +1,45 @@
import os
import sys
import subprocess
import logging
logger = logging.getLogger("Opus")
def setup_opus():
libs_dir = ""
lib_path = ""
logger.info("正在加载 Opus 动态库,请耐心等待...")
if sys.platform == "win32":
libs_dir = os.path.dirname(os.path.abspath(__file__))
lib_path = os.path.join(libs_dir, "opus.dll")
elif sys.platform == "darwin":
result = subprocess.check_output(
"brew list opus | grep libopus.dylib", shell=True
)
lib_path = result.decode("utf-8").strip()
if not lib_path.endswith("libopus.dylib"):
raise RuntimeError("请先安装 Opus brew install opus")
libs_dir = os.path.dirname(lib_path)
else:
raise RuntimeError(f"暂不支持 {sys.platform} 平台")
import ctypes.util
original_find_library = ctypes.util.find_library
def patched_find_library(name):
if name == "opus":
return lib_path
return original_find_library(name)
ctypes.util.find_library = patched_find_library
if hasattr(os, "add_dll_directory"):
os.add_dll_directory(libs_dir)
os.environ["PATH"] = libs_dir + os.pathsep + os.environ.get("PATH", "")
ctypes.CDLL(lib_path)
@@ -0,0 +1,290 @@
from typing import ClassVar, Optional, Callable, Any
from threading import Lock
from collections import deque
class GlobalStream:
"""
全局音频缓冲区,用于存储和分发音频数据
用来将小爱音箱的音频输入输出流,适配到小智 AI 的音频输入输出流
"""
_instance = None
_lock = Lock()
# 默认缓冲区大小(以字节为单位)
DEFAULT_BUFFER_SIZE = 1024 * 1024 # 1MB
def __new__(cls):
with cls._lock:
if cls._instance is None:
cls._instance = super(GlobalStream, cls).__new__(cls)
cls._instance._initialize()
return cls._instance
def _initialize(self):
"""初始化实例变量"""
self._max_buffer_size = self.DEFAULT_BUFFER_SIZE
self._input_buffer = deque(maxlen=self._max_buffer_size)
self._is_input_active = False
self._is_output_active = False
self._input_readers = {} # 跟踪每个流的读取位置
self._reader_counter = 0 # 为每个读取器分配唯一ID
self._buffer_overflow_count = 0 # 记录缓冲区溢出次数
self.on_output_data = None # 输入数据回调函数
def set_buffer_size(self, frames: int) -> None:
if frames <= 0:
raise ValueError("缓冲区大小必须大于0")
# 创建新的有限长度缓冲区
new_input_buffer = deque(self._input_buffer, maxlen=frames * 2)
# 替换旧缓冲区
self._input_buffer = new_input_buffer
self._max_buffer_size = frames * 2
# 重置读取位置,因为缓冲区可能已经改变
for reader_id in self._input_readers:
self._input_readers[reader_id] = 0
def register_reader(self) -> int:
"""注册一个新的读取器并返回其ID"""
reader_id = self._reader_counter
self._input_readers[reader_id] = 0 # 初始位置为0
self._reader_counter += 1
return reader_id
def unregister_reader(self, reader_id: int) -> None:
"""注销一个读取器"""
if reader_id in self._input_readers:
del self._input_readers[reader_id]
def read(self, reader_id: int, num_frames: int) -> bytes:
num_frames = num_frames * 2
if not self._is_input_active:
return bytes(num_frames)
if reader_id not in self._input_readers:
return bytes(num_frames)
# 将输入缓冲区转换为列表以便随机访问
buffer_list = list(self._input_buffer)
current_pos = self._input_readers[reader_id]
# 如果当前位置超出缓冲区大小,返回空字节
if current_pos >= len(buffer_list):
return bytes(num_frames)
# 读取数据
end_pos = min(current_pos + num_frames, len(buffer_list))
data = bytes(buffer_list[current_pos:end_pos])
# 更新读取位置
self._input_readers[reader_id] = end_pos
# 如果数据不足,用零填充
if len(data) < num_frames:
data += bytes(num_frames - len(data))
return data
# 转发输出音频流
def write(self, frames: bytes) -> None:
"""写入数据到输出缓冲区"""
if not self._is_output_active:
return
if self.on_output_data:
self.on_output_data(frames)
# 添加输入音频流
def add_input_data(self, data: bytes) -> None:
if not self._is_input_active:
return
"""添加输入数据到全局缓冲区"""
# 检查是否会溢出
if len(self._input_buffer) + len(data) > self._max_buffer_size:
self._buffer_overflow_count += 1
for b in data:
self._input_buffer.append(b)
# 如果有读取器的位置已经超出了缓冲区大小,需要调整
if len(self._input_buffer) >= self._max_buffer_size:
for reader_id in self._input_readers:
if self._input_readers[reader_id] > len(self._input_buffer) // 2:
# 将读取位置重置到缓冲区中间,避免读取器永远跟不上
self._input_readers[reader_id] = len(self._input_buffer) // 2
def start_input(self) -> None:
"""启动输入流"""
self._is_input_active = True
def stop_input(self) -> None:
"""停止输入流"""
self._is_input_active = False
self.clear()
def start_output(self) -> None:
"""启动输出流"""
self._is_output_active = True
def stop_output(self) -> None:
"""停止输出流"""
self._is_output_active = False
def is_input_active(self) -> bool:
"""检查输入流是否活跃"""
return self._is_input_active
def is_output_active(self) -> bool:
"""检查输出流是否活跃"""
return self._is_output_active
def clear(self) -> None:
"""清空缓冲区"""
self._input_buffer.clear()
for reader_id in self._input_readers:
self._input_readers[reader_id] = 0
class MyStream:
"""音频流类,用于读写音频数据"""
def __init__(
self,
rate: int,
channels: int,
format: int,
input: bool = False,
output: bool = False,
frames_per_buffer: int = 1024,
start: bool = True,
) -> None:
self._rate = rate
self._channels = channels
self._format = format
self._frames_per_buffer = frames_per_buffer
self._is_input = input
self._is_output = output
self._is_active = False
self._is_closed = False
# 获取全局音频缓冲区
self._global_buffer = GlobalStream()
# 注册读取器ID
self._reader_id = self._global_buffer.register_reader() if input else None
# 如果需要,启动流
if start:
self.start_stream()
def close(self) -> None:
"""关闭流"""
if not self._is_closed:
self.stop_stream()
if self._is_input and self._reader_id is not None:
self._global_buffer.unregister_reader(self._reader_id)
self._is_closed = True
def is_active(self) -> bool:
"""检查流是否活跃"""
return self._is_active
def start_stream(self) -> None:
"""启动流"""
if not self._is_active and not self._is_closed:
self._is_active = True
if self._is_input:
self._global_buffer.start_input()
if self._is_output:
self._global_buffer.start_output()
def stop_stream(self) -> None:
"""停止流"""
if self._is_active:
self._is_active = False
if self._is_input:
self._global_buffer.stop_input()
if self._is_output:
self._global_buffer.stop_output()
def read(self, num_frames: int, exception_on_overflow=False) -> bytes:
"""从输入流读取数据"""
if (
not self._is_input
or self._is_closed
or not self._is_active
or self._reader_id is None
):
return bytes(num_frames)
return self._global_buffer.read(self._reader_id, num_frames)
def write(self, frames: bytes) -> None:
"""写入数据到输出流"""
if not self._is_output or self._is_closed or not self._is_active:
return
self._global_buffer.write(frames)
class MyAudio:
"""PyAudio替代品,用于创建和管理音频流"""
Stream: ClassVar[type] = MyStream
def __init__(self, buffer_size: int = GlobalStream.DEFAULT_BUFFER_SIZE) -> None:
# 初始化全局音频缓冲区
self._global_buffer = GlobalStream()
# 设置缓冲区大小
if buffer_size != GlobalStream.DEFAULT_BUFFER_SIZE:
self._global_buffer.set_buffer_size(buffer_size)
self._is_terminated = False
def open(
self,
rate: int,
channels: int,
format: int,
input: bool = False,
output: bool = False,
input_device_index: Optional[int] = None,
output_device_index: Optional[int] = None,
frames_per_buffer: int = 1024,
start: bool = True,
input_host_api_specific_stream_info: Optional[Any] = None,
output_host_api_specific_stream_info: Optional[Any] = None,
stream_callback: Optional[Callable] = None,
) -> MyStream:
"""打开一个新的音频流"""
if self._is_terminated:
raise RuntimeError("MyAudio instance has been terminated")
# 启动全局输入/输出流(如果需要)
if input:
self._global_buffer.start_input()
if output:
self._global_buffer.start_output()
# 创建并返回一个新的流实例
return MyStream(
rate=rate,
channels=channels,
format=format,
input=input,
output=output,
frames_per_buffer=frames_per_buffer,
start=start,
)
def terminate(self) -> None:
"""终止MyAudio实例"""
if not self._is_terminated:
self._global_buffer.stop_input()
self._global_buffer.stop_output()
self._is_terminated = True
@@ -0,0 +1,53 @@
from abc import ABC, abstractmethod
from typing import Optional, Callable
import logging
class BaseDisplay(ABC):
"""显示接口的抽象基类"""
def __init__(self):
self.logger = logging.getLogger(self.__class__.__name__)
self.current_volume = 70 # 默认音量
@abstractmethod
def set_callbacks(self,
press_callback: Optional[Callable] = None,
release_callback: Optional[Callable] = None,
status_callback: Optional[Callable] = None,
text_callback: Optional[Callable] = None,
emotion_callback: Optional[Callable] = None,
mode_callback: Optional[Callable] = None,
auto_callback: Optional[Callable] = None,
abort_callback: Optional[Callable] = None): # 添加打断回调参数
"""设置回调函数"""
pass
@abstractmethod
def update_button_status(self, text: str):
"""更新按钮状态"""
pass
@abstractmethod
def update_status(self, status: str):
"""更新状态文本"""
pass
@abstractmethod
def update_text(self, text: str):
"""更新TTS文本"""
pass
@abstractmethod
def update_emotion(self, emotion: str):
"""更新表情"""
pass
@abstractmethod
def start(self):
"""启动显示"""
pass
@abstractmethod
def on_close(self):
"""关闭显示"""
pass
@@ -0,0 +1,284 @@
import threading
import tkinter as tk
from tkinter import ttk
import queue
import logging
import time
from typing import Optional, Callable
from xiaozhi.services.display.base_display import BaseDisplay
class GuiDisplay(BaseDisplay):
def __init__(self):
super().__init__() # 调用父类初始化
"""创建 GUI 界面"""
# 初始化日志
self.logger = logging.getLogger("Display")
# 创建主窗口
self.root = tk.Tk()
self.root.title("小爱音箱接入小智 AI 演示")
self.root.geometry("520x360")
# 在窗口底部添加作者信息
self.author_label = ttk.Label(self.root, text="作者: https://del.wang")
self.author_label.pack(side=tk.BOTTOM, pady=5)
# 让窗口居中显示
self.root.update_idletasks()
width = self.root.winfo_width()
height = self.root.winfo_height()
x = (self.root.winfo_screenwidth() // 2) - (width // 2)
y = (self.root.winfo_screenheight() // 2) - (height // 2)
self.root.geometry(f"+{x}+{y}")
# 状态显示
self.status_frame = ttk.Frame(self.root)
self.status_frame.pack(pady=20)
self.status_label = ttk.Label(self.status_frame, text="状态: 未连接")
self.status_label.pack(side=tk.LEFT)
# 表情显示
self.emotion_label = tk.Label(self.root, text="😊", font=("Segoe UI Emoji", 32))
self.emotion_label.pack(padx=20, pady=20)
# TTS文本显示
self.tts_text_label = ttk.Label(
self.root, text="很高兴认识你!", wraplength=250
)
self.tts_text_label.pack(padx=20, pady=10)
# 控制按钮
self.btn_frame = ttk.Frame(self.root)
self.btn_frame.pack(pady=20)
# 手动模式按钮
self.manual_btn = ttk.Button(self.btn_frame, text="按住说话")
self.manual_btn.bind("<ButtonPress-1>", self._on_manual_button_press)
self.manual_btn.bind("<ButtonRelease-1>", self._on_manual_button_release)
self.manual_btn.pack(side=tk.LEFT, padx=10)
# 打断按钮
self.abort_btn = ttk.Button(
self.btn_frame, text="停止播放", command=self._on_abort_button_click
)
self.abort_btn.pack(side=tk.LEFT, padx=10)
# 对话模式标志
self.auto_mode = False
# 回调函数
self.button_press_callback = None
self.button_release_callback = None
self.status_update_callback = None
self.text_update_callback = None
self.emotion_update_callback = None
self.mode_callback = None
self.auto_callback = None
self.abort_callback = None
# 更新队列
self.update_queue = queue.Queue()
# 运行标志
self._running = True
# 设置窗口关闭处理
self.root.protocol("WM_DELETE_WINDOW", self.on_close)
# 启动更新处理
self.root.after(100, self._process_updates)
def set_callbacks(
self,
press_callback: Optional[Callable] = None,
release_callback: Optional[Callable] = None,
status_callback: Optional[Callable] = None,
text_callback: Optional[Callable] = None,
emotion_callback: Optional[Callable] = None,
mode_callback: Optional[Callable] = None,
auto_callback: Optional[Callable] = None,
abort_callback: Optional[Callable] = None,
):
"""设置回调函数"""
self.button_press_callback = press_callback
self.button_release_callback = release_callback
self.status_update_callback = status_callback
self.text_update_callback = text_callback
self.emotion_update_callback = emotion_callback
self.mode_callback = mode_callback
self.auto_callback = auto_callback
self.abort_callback = abort_callback
def _process_updates(self):
"""处理更新队列"""
try:
while True:
try:
# 非阻塞方式获取更新
update_func = self.update_queue.get_nowait()
update_func()
self.update_queue.task_done()
except queue.Empty:
break
finally:
if self._running:
self.root.after(100, self._process_updates)
def _on_manual_button_press(self, event):
"""手动模式按钮按下事件处理"""
try:
# 更新按钮文本为"松开以停止"
self.manual_btn.config(text="松开以停止")
# 调用回调函数
if self.button_press_callback:
self.button_press_callback()
except Exception as e:
self.logger.error(f"按钮按下回调执行失败: {e}")
def _on_manual_button_release(self, event):
"""手动模式按钮释放事件处理"""
try:
# 更新按钮文本为"按住说话"
self.manual_btn.config(text="按住说话")
# 调用回调函数
if self.button_release_callback:
self.button_release_callback()
except Exception as e:
self.logger.error(f"按钮释放回调执行失败: {e}")
def _on_auto_button_click(self):
"""自动模式按钮点击事件处理"""
try:
if self.auto_callback:
self.auto_callback()
except Exception as e:
self.logger.error(f"自动模式按钮回调执行失败: {e}")
def _on_abort_button_click(self):
"""打断按钮点击事件处理"""
try:
if self.abort_callback:
self.abort_callback()
except Exception as e:
self.logger.error(f"打断按钮回调执行失败: {e}")
def _on_mode_button_click(self):
"""对话模式切换按钮点击事件"""
try:
# 检查是否可以切换模式(通过回调函数询问应用程序当前状态)
if self.mode_callback:
# 如果回调函数返回False,表示当前不能切换模式
if not self.mode_callback(not self.auto_mode):
return
# 切换模式
self.auto_mode = not self.auto_mode
# 更新按钮显示
if self.auto_mode:
# 切换到自动模式
self.update_mode_button_status("自动对话")
# 隐藏手动按钮,显示自动按钮
self.update_queue.put(lambda: self._switch_to_auto_mode())
else:
# 切换到手动模式
self.update_mode_button_status("手动对话")
# 隐藏自动按钮,显示手动按钮
self.update_queue.put(lambda: self._switch_to_manual_mode())
except Exception as e:
self.logger.error(f"模式切换按钮回调执行失败: {e}")
def _switch_to_auto_mode(self):
"""切换到自动模式的UI更新"""
self.manual_btn.pack_forget() # 移除手动按钮
self.auto_btn.pack(
side=tk.LEFT, padx=10, before=self.abort_btn
) # 显示自动按钮,放在打断按钮前面
def _switch_to_manual_mode(self):
"""切换到手动模式的UI更新"""
self.auto_btn.pack_forget() # 移除自动按钮
self.manual_btn.pack(
side=tk.LEFT, padx=10, before=self.abort_btn
) # 显示手动按钮,放在打断按钮前面
def update_status(self, status: str):
"""更新状态文本"""
self.update_queue.put(lambda: self.status_label.config(text=f"状态: {status}"))
def update_text(self, text: str):
"""更新TTS文本"""
self.update_queue.put(lambda: self.tts_text_label.config(text=text))
def update_emotion(self, emotion: str):
"""更新表情"""
self.update_queue.put(lambda: self.emotion_label.config(text=emotion))
def start_update_threads(self):
"""启动更新线程"""
def update_loop():
while self._running:
try:
# 更新状态
if self.status_update_callback:
status = self.status_update_callback()
if status:
self.update_status(status)
# 更新文本
if self.text_update_callback:
text = self.text_update_callback()
if text:
self.update_text(text)
# 更新表情
if self.emotion_update_callback:
emotion = self.emotion_update_callback()
if emotion:
self.update_emotion(emotion)
except Exception as e:
self.logger.error(f"更新失败: {e}")
time.sleep(0.1)
threading.Thread(target=update_loop, daemon=True).start()
def on_close(self):
"""关闭窗口处理"""
self._running = False
self.root.destroy()
def start(self):
"""启动GUI"""
try:
# 启动更新线程
self.start_update_threads()
# 在主线程中运行主循环
self.logger.info("开始启动GUI主循环")
self.root.mainloop()
except Exception as e:
self.logger.error(f"GUI启动失败: {e}", exc_info=True)
# 尝试回退到CLI模式
print(f"GUI启动失败: {e},请尝试使用CLI模式")
def update_mode_button_status(self, text: str):
"""更新模式按钮状态"""
self.update_queue.put(lambda: self.mode_btn.config(text=text))
def update_button_status(self, text: str):
"""更新按钮状态 - 保留此方法以满足抽象基类要求"""
# 根据当前模式更新相应的按钮
if self.auto_mode:
self.update_queue.put(lambda: self.auto_btn.config(text=text))
else:
# 在手动模式下,不通过此方法更新按钮文本
# 因为按钮文本由按下/释放事件直接控制
pass
@@ -0,0 +1,83 @@
import json
from xiaozhi.services.protocols.typing import AbortReason, ListeningMode
class Protocol:
def __init__(self):
self.session_id = ""
self.on_incoming_json = None
self.on_incoming_audio = None
self.on_audio_channel_opened = None
self.on_audio_channel_closed = None
self.on_network_error = None
def on_incoming_json(self, callback):
"""设置JSON消息接收回调函数"""
self.on_incoming_json = callback
def on_incoming_audio(self, callback):
"""设置音频数据接收回调函数"""
self.on_incoming_audio = callback
def on_audio_channel_opened(self, callback):
"""设置音频通道打开回调函数"""
self.on_audio_channel_opened = callback
def on_audio_channel_closed(self, callback):
"""设置音频通道关闭回调函数"""
self.on_audio_channel_closed = callback
def on_network_error(self, callback):
"""设置网络错误回调函数"""
self.on_network_error = callback
async def send_text(self, message):
"""发送文本消息的抽象方法,需要在子类中实现"""
raise NotImplementedError("send_text方法必须由子类实现")
async def send_abort_speaking(self, reason):
"""发送中止语音的消息"""
message = {"session_id": self.session_id, "type": "abort"}
if reason == AbortReason.WAKE_WORD_DETECTED:
message["reason"] = "wake_word_detected"
await self.send_text(json.dumps(message))
async def send_start_listening(self, mode):
"""发送开始监听的消息"""
mode_map = {
ListeningMode.ALWAYS_ON: "realtime",
ListeningMode.AUTO_STOP: "auto",
ListeningMode.MANUAL: "manual",
}
message = {
"session_id": self.session_id,
"type": "listen",
"state": "start",
"mode": mode_map[mode],
}
await self.send_text(json.dumps(message))
async def send_stop_listening(self):
"""发送停止监听的消息"""
message = {"session_id": self.session_id, "type": "listen", "state": "stop"}
await self.send_text(json.dumps(message))
async def send_iot_descriptors(self, descriptors):
"""发送物联网设备描述信息"""
message = {
"session_id": self.session_id,
"type": "iot",
"descriptors": json.loads(descriptors),
}
await self.send_text(json.dumps(message))
async def send_iot_states(self, states):
"""发送物联网设备状态信息"""
message = {
"session_id": self.session_id,
"type": "iot",
"states": json.loads(states),
}
await self.send_text(json.dumps(message))
@@ -0,0 +1,30 @@
class ListeningMode:
"""监听模式"""
ALWAYS_ON = "always_on"
AUTO_STOP = "auto_stop"
MANUAL = "manual"
class AbortReason:
"""中止原因"""
NONE = "none"
WAKE_WORD_DETECTED = "wake_word_detected"
class DeviceState:
"""设备状态"""
IDLE = "idle"
CONNECTING = "connecting"
LISTENING = "listening"
SPEAKING = "speaking"
class EventType:
"""事件类型"""
SCHEDULE_EVENT = "schedule_event"
AUDIO_INPUT_READY_EVENT = "audio_input_ready_event"
AUDIO_OUTPUT_READY_EVENT = "audio_output_ready_event"
class AudioConfig:
"""音频配置"""
SAMPLE_RATE = 24000
CHANNELS = 1
FRAME_DURATION = 60 # ms
FRAME_SIZE = int(SAMPLE_RATE * (FRAME_DURATION / 1000))
@@ -0,0 +1,214 @@
import asyncio
import json
import logging
import websockets
from xiaozhi.services.protocols.protocol import Protocol
from xiaozhi.utils.config_manager import ConfigManager
logger = logging.getLogger("WebsocketProtocol")
class WebsocketProtocol(Protocol):
def __init__(self):
super().__init__()
# 获取配置管理器实例
self.config = ConfigManager.instance()
self.websocket = None
self.server_sample_rate = 16000
self.connected = False
self.hello_received = None # 初始化时先设为 None
self.WEBSOCKET_URL = self.config.get_config("NETWORK.WEBSOCKET_URL")
self.WEBSOCKET_ACCESS_TOKEN = self.config.get_config(
"NETWORK.WEBSOCKET_ACCESS_TOKEN"
)
self.CLIENT_ID = self.config.get_client_id()
self.DEVICE_ID = self.config.get_device_id()
async def connect(self) -> bool:
"""连接到WebSocket服务器"""
try:
# 在连接时创建 Event,确保在正确的事件循环中
self.hello_received = asyncio.Event()
# 配置连接
headers = {
"Authorization": f"Bearer {self.WEBSOCKET_ACCESS_TOKEN}",
"Protocol-Version": "1",
"Device-Id": self.DEVICE_ID, # 获取设备MAC地址
"Client-Id": self.CLIENT_ID,
}
# 建立WebSocket连接 (兼容不同Python版本的写法)
try:
# 新的写法 (在Python 3.11+版本中)
self.websocket = await websockets.connect(
uri=self.WEBSOCKET_URL, additional_headers=headers
)
except TypeError:
# 旧的写法 (在较早的Python版本中)
self.websocket = await websockets.connect(
self.WEBSOCKET_URL, extra_headers=headers
)
# 启动消息处理循环
asyncio.create_task(self._message_handler())
# 发送客户端hello消息
hello_message = {
"type": "hello",
"version": 1,
"transport": "websocket",
"audio_params": {
"format": "opus",
"sample_rate": 16000,
"channels": 1,
"frame_duration": 60,
},
}
await self.send_text(json.dumps(hello_message))
# 等待服务器hello响应
try:
await asyncio.wait_for(self.hello_received.wait(), timeout=10.0)
self.connected = True
logger.info("已连接到WebSocket服务器")
return True
except asyncio.TimeoutError:
logger.error("等待服务器hello响应超时")
if self.on_network_error:
self.on_network_error("等待响应超时")
return False
except Exception as e:
logger.error(f"WebSocket连接失败: {e}")
if self.on_network_error:
self.on_network_error(f"无法连接服务: {str(e)}")
return False
async def _message_handler(self):
"""处理接收到的WebSocket消息"""
try:
async for message in self.websocket:
if isinstance(message, str):
try:
data = json.loads(message)
msg_type = data.get("type")
if msg_type == "hello":
# 处理服务器 hello 消息
await self._handle_server_hello(data)
else:
if self.on_incoming_json:
self.on_incoming_json(data)
except json.JSONDecodeError as e:
logger.error(f"无效的JSON消息: {message}, 错误: {e}")
elif self.on_incoming_audio: # 使用 elif 更清晰
self.on_incoming_audio(message)
except websockets.ConnectionClosed:
logger.info("WebSocket连接已关闭")
self.connected = False
if self.on_audio_channel_closed:
# 使用 schedule 确保回调在主线程中执行
await self.on_audio_channel_closed()
except Exception as e:
logger.error(f"消息处理错误: {e}")
self.connected = False
if self.on_network_error:
# 使用 schedule 确保错误处理在主线程中执行
self.on_network_error(f"连接错误: {str(e)}")
async def send_audio(self, data: bytes):
"""发送音频数据"""
if not self.is_audio_channel_opened(): # 使用已有的 is_connected 方法
return
try:
await self.websocket.send(data)
except Exception as e:
logger.error(f"发送音频数据失败: {e}")
if self.on_network_error:
self.on_network_error(f"发送音频失败: {str(e)}")
async def send_text(self, message: str):
"""发送文本消息"""
if self.websocket:
try:
await self.websocket.send(message)
except Exception as e:
await self.close_audio_channel()
if self.on_network_error:
self.on_network_error(f"发送消息失败: {str(e)}")
def is_audio_channel_opened(self) -> bool:
"""检查音频通道是否打开"""
return self.websocket is not None and self.connected
async def open_audio_channel(self) -> bool:
"""建立 WebSocket 连接
如果尚未连接,则创建新的 WebSocket 连接
Returns:
bool: 连接是否成功
"""
if not self.connected:
return await self.connect()
return True
async def _handle_server_hello(self, data: dict):
"""处理服务器的 hello 消息
解析服务器返回的 hello 消息,设置相关参数并通知音频通道已打开
Args:
data: 服务器返回的 hello 消息数据
"""
try:
# 验证传输方式
transport = data.get("transport")
if not transport or transport != "websocket":
logger.error(f"不支持的传输方式: {transport}")
return
# 获取音频参数
audio_params = data.get("audio_params")
if audio_params:
# 获取服务器的采样率
sample_rate = audio_params.get("sample_rate")
if sample_rate:
self.server_sample_rate = sample_rate
# 如果服务器采样率与本地不同,记录警告
if sample_rate != self.server_sample_rate:
logger.warning(
f"服务器的音频采样率 {sample_rate} "
f"与设备输出的采样率 {self.server_sample_rate} 不一致,"
"重采样后可能会失真"
)
# 设置 hello 接收事件
self.hello_received.set()
# 通知音频通道已打开
if self.on_audio_channel_opened:
await self.on_audio_channel_opened()
logger.info("成功处理服务器 hello 消息")
except Exception as e:
logger.error(f"处理服务器 hello 消息时出错: {e}")
if self.on_network_error:
self.on_network_error(f"处理服务器响应失败: {str(e)}")
async def close_audio_channel(self):
"""关闭音频通道"""
if self.websocket:
try:
await self.websocket.close()
self.websocket = None
self.connected = False
if self.on_audio_channel_closed:
await self.on_audio_channel_closed()
except Exception as e:
logger.error(f"关闭WebSocket连接失败: {e}")