571 lines
18 KiB
Python
571 lines
18 KiB
Python
import asyncio
|
|
import json
|
|
import re
|
|
import threading
|
|
import time
|
|
|
|
from xiaozhi.event import EventManager
|
|
from xiaozhi.ref import set_xiaozhi
|
|
from xiaozhi.services.audio.kws import KWS
|
|
from xiaozhi.services.audio.vad import VAD
|
|
from xiaozhi.services.protocols.typing import (
|
|
AbortReason,
|
|
AudioConfig,
|
|
DeviceState,
|
|
EventType,
|
|
ListeningMode,
|
|
)
|
|
from xiaozhi.services.protocols.websocket_protocol import WebsocketProtocol
|
|
from xiaozhi.utils.base import get_env
|
|
from xiaozhi.utils.config import ConfigManager
|
|
from xiaozhi.xiaoai import XiaoAI
|
|
|
|
|
|
class XiaoZhi:
|
|
"""智能音箱应用程序主类"""
|
|
|
|
_instance = None
|
|
|
|
@classmethod
|
|
def instance(cls):
|
|
"""获取单例实例"""
|
|
if cls._instance is None:
|
|
cls._instance = XiaoZhi()
|
|
return cls._instance
|
|
|
|
def __init__(self):
|
|
"""初始化应用程序"""
|
|
# 确保单例模式
|
|
if XiaoZhi._instance is not None:
|
|
raise Exception("XiaoZhi是单例类,请使用instance()获取实例")
|
|
XiaoZhi._instance = self
|
|
|
|
# 获取配置管理器实例
|
|
self.config = ConfigManager.instance()
|
|
|
|
# 状态变量
|
|
self.device_state = DeviceState.IDLE
|
|
self.voice_detected = False
|
|
self.current_text = ""
|
|
self.current_emotion = "neutral"
|
|
|
|
# 音频处理相关
|
|
self.audio_codec = None
|
|
|
|
# 事件循环和线程
|
|
self.loop = asyncio.new_event_loop()
|
|
self.loop_thread = None
|
|
self.running = False
|
|
|
|
# 任务队列和锁
|
|
self.main_tasks = []
|
|
self.mutex = threading.Lock()
|
|
|
|
# 协议实例
|
|
self.protocol = None
|
|
|
|
# 回调函数
|
|
self.on_state_changed_callbacks = []
|
|
|
|
# 初始化事件对象
|
|
self.events = {
|
|
EventType.SCHEDULE_EVENT: threading.Event(),
|
|
EventType.AUDIO_INPUT_READY_EVENT: threading.Event(),
|
|
}
|
|
|
|
# 创建显示界面
|
|
self.display = None
|
|
set_xiaozhi(self)
|
|
|
|
def run(self):
|
|
self.protocol = WebsocketProtocol()
|
|
|
|
# 创建并启动事件循环线程
|
|
self.loop_thread = threading.Thread(target=self._run_event_loop)
|
|
self.loop_thread.daemon = True
|
|
self.loop_thread.start()
|
|
|
|
# 等待事件循环准备就绪
|
|
time.sleep(0.1)
|
|
|
|
# 初始化应用程序(移除自动连接)
|
|
asyncio.run_coroutine_threadsafe(XiaoAI.init_xiaoai(), self.loop)
|
|
asyncio.run_coroutine_threadsafe(self._initialize_without_connect(), self.loop)
|
|
|
|
# 启动主循环线程
|
|
main_loop_thread = threading.Thread(target=self._main_loop)
|
|
main_loop_thread.daemon = True
|
|
main_loop_thread.start()
|
|
|
|
VAD.start()
|
|
KWS.start()
|
|
|
|
# 启动 GUI
|
|
self._initialize_display()
|
|
self.display.start()
|
|
|
|
def _run_event_loop(self):
|
|
"""运行事件循环的线程函数"""
|
|
asyncio.set_event_loop(self.loop)
|
|
self.loop.run_forever()
|
|
|
|
async def _initialize_without_connect(self):
|
|
"""初始化应用程序组件(不建立连接)"""
|
|
|
|
# 初始化音频编解码器
|
|
self._initialize_audio()
|
|
|
|
# 设置协议回调
|
|
self.protocol.on_network_error = self._on_network_error
|
|
self.protocol.on_incoming_audio = self._on_incoming_audio
|
|
self.protocol.on_incoming_json = self._on_incoming_json
|
|
self.protocol.on_audio_channel_opened = self._on_audio_channel_opened
|
|
self.protocol.on_audio_channel_closed = self._on_audio_channel_closed
|
|
|
|
# 设置设备状态为待命
|
|
self.set_device_state(DeviceState.IDLE)
|
|
|
|
def _initialize_audio(self):
|
|
"""初始化音频设备和编解码器"""
|
|
try:
|
|
from xiaozhi.services.audio.codec import AudioCodec
|
|
|
|
self.audio_codec = AudioCodec()
|
|
except Exception as e:
|
|
self.alert("错误", f"初始化音频设备失败: {e}")
|
|
|
|
def _initialize_display(self):
|
|
"""初始化显示界面"""
|
|
if get_env("CLI"):
|
|
from xiaozhi.services.display import no_display
|
|
|
|
self.display = no_display.NoDisplay()
|
|
else:
|
|
from xiaozhi.services.display import gui_display
|
|
|
|
self.display = gui_display.GuiDisplay()
|
|
|
|
# 设置回调函数
|
|
self.display.set_callbacks(
|
|
press_callback=self.start_listening,
|
|
release_callback=self.stop_listening,
|
|
status_callback=self._get_status_text,
|
|
text_callback=self._get_current_text,
|
|
emotion_callback=self._get_current_emotion,
|
|
mode_callback=self._on_mode_changed,
|
|
auto_callback=self.toggle_chat_state,
|
|
abort_callback=lambda: self.abort_speaking(AbortReason.WAKE_WORD_DETECTED),
|
|
)
|
|
|
|
def _main_loop(self):
|
|
"""应用程序主循环"""
|
|
self.running = True
|
|
|
|
while self.running:
|
|
# 等待事件
|
|
for event_type, event in self.events.items():
|
|
if event.is_set():
|
|
event.clear()
|
|
|
|
if event_type == EventType.AUDIO_INPUT_READY_EVENT:
|
|
self._handle_input_audio()
|
|
elif event_type == EventType.SCHEDULE_EVENT:
|
|
self._process_scheduled_tasks()
|
|
|
|
time.sleep(0.01)
|
|
|
|
def _process_scheduled_tasks(self):
|
|
"""处理调度任务"""
|
|
with self.mutex:
|
|
tasks = self.main_tasks.copy()
|
|
self.main_tasks.clear()
|
|
|
|
for task in tasks:
|
|
try:
|
|
task()
|
|
except Exception:
|
|
pass
|
|
|
|
def schedule(self, callback):
|
|
"""调度任务到主循环"""
|
|
with self.mutex:
|
|
# 如果是中止语音的任务,检查是否已经存在相同类型的任务
|
|
if "abort_speaking" in str(callback):
|
|
# 如果已经有中止任务在队列中,就不再添加
|
|
if any("abort_speaking" in str(task) for task in self.main_tasks):
|
|
return
|
|
self.main_tasks.append(callback)
|
|
self.events[EventType.SCHEDULE_EVENT].set()
|
|
|
|
def _handle_input_audio(self):
|
|
"""处理音频输入"""
|
|
if self.device_state != DeviceState.LISTENING:
|
|
return
|
|
|
|
encoded_data = self.audio_codec.read_audio()
|
|
if encoded_data and self.protocol and self.protocol.is_audio_channel_opened():
|
|
asyncio.run_coroutine_threadsafe(
|
|
self.protocol.send_audio(encoded_data), self.loop
|
|
)
|
|
|
|
def _on_network_error(self, message):
|
|
"""网络错误回调"""
|
|
self.set_device_state(DeviceState.IDLE)
|
|
if self.device_state != DeviceState.CONNECTING:
|
|
self.set_device_state(DeviceState.IDLE)
|
|
|
|
# 关闭现有连接
|
|
if self.protocol:
|
|
asyncio.run_coroutine_threadsafe(
|
|
self.protocol.close_audio_channel(), self.loop
|
|
)
|
|
|
|
def _attempt_reconnect(self):
|
|
"""尝试重新连接服务器"""
|
|
if self.device_state != DeviceState.CONNECTING:
|
|
self.set_device_state(DeviceState.CONNECTING)
|
|
|
|
# 关闭现有连接
|
|
if self.protocol:
|
|
asyncio.run_coroutine_threadsafe(
|
|
self.protocol.close_audio_channel(), self.loop
|
|
)
|
|
|
|
# 延迟一秒后尝试重新连接
|
|
def delayed_reconnect():
|
|
time.sleep(1)
|
|
asyncio.run_coroutine_threadsafe(self._reconnect(), self.loop)
|
|
|
|
threading.Thread(target=delayed_reconnect, daemon=True).start()
|
|
|
|
async def _reconnect(self):
|
|
"""重新连接到服务器"""
|
|
|
|
# 设置协议回调
|
|
self.protocol.on_network_error = self._on_network_error
|
|
self.protocol.on_incoming_audio = self._on_incoming_audio
|
|
self.protocol.on_incoming_json = self._on_incoming_json
|
|
self.protocol.on_audio_channel_opened = self._on_audio_channel_opened
|
|
self.protocol.on_audio_channel_closed = self._on_audio_channel_closed
|
|
|
|
# 连接到服务器
|
|
retry_count = 0
|
|
max_retries = 3
|
|
|
|
while retry_count < max_retries:
|
|
if await self.protocol.connect():
|
|
self.set_device_state(DeviceState.IDLE)
|
|
return True
|
|
|
|
retry_count += 1
|
|
await asyncio.sleep(2) # 等待2秒后重试
|
|
|
|
self.schedule(lambda: self.alert("连接错误", "无法重新连接到服务器"))
|
|
self.set_device_state(DeviceState.IDLE)
|
|
return False
|
|
|
|
def _on_incoming_audio(self, data):
|
|
"""接收音频数据回调"""
|
|
if self.device_state == DeviceState.SPEAKING:
|
|
self.audio_codec.write_audio(data)
|
|
|
|
def _on_incoming_json(self, json_data):
|
|
"""接收JSON数据回调"""
|
|
try:
|
|
if not json_data:
|
|
return
|
|
|
|
# 解析JSON数据
|
|
if isinstance(json_data, str):
|
|
data = json.loads(json_data)
|
|
else:
|
|
data = json_data
|
|
|
|
# 处理不同类型的消息
|
|
msg_type = data.get("type", "")
|
|
if msg_type == "tts":
|
|
self._handle_tts_message(data)
|
|
elif msg_type == "stt":
|
|
self._handle_stt_message(data)
|
|
elif msg_type == "llm":
|
|
self._handle_llm_message(data)
|
|
except Exception:
|
|
pass
|
|
|
|
def _handle_tts_message(self, data):
|
|
"""处理TTS消息"""
|
|
state = data.get("state", "")
|
|
if state == "start":
|
|
EventManager.on_tts_start()
|
|
self.schedule(lambda: self._handle_tts_start())
|
|
elif state == "stop":
|
|
EventManager.on_tts_end()
|
|
self.schedule(lambda: self._handle_tts_stop())
|
|
elif state == "sentence_start":
|
|
text = data.get("text", "")
|
|
if text:
|
|
print(f"🤖 小智:{text}")
|
|
|
|
verification_code = re.findall(r"验证码.*\d+", text)
|
|
if verification_code:
|
|
self.config.update_config_file(
|
|
"VERIFICATION_CODE", verification_code[0]
|
|
)
|
|
|
|
self.schedule(lambda: self.set_chat_message("assistant", text))
|
|
|
|
def _handle_tts_start(self):
|
|
"""处理TTS开始事件"""
|
|
if (
|
|
self.device_state == DeviceState.IDLE
|
|
or self.device_state == DeviceState.LISTENING
|
|
):
|
|
self.set_device_state(DeviceState.SPEAKING)
|
|
|
|
def _handle_tts_stop(self):
|
|
"""处理TTS停止事件"""
|
|
pass
|
|
|
|
def _handle_stt_message(self, data):
|
|
"""处理STT消息"""
|
|
text = data.get("text", "")
|
|
if text:
|
|
print(f"💬 我说:{text}")
|
|
self.schedule(lambda: self.set_chat_message("user", text))
|
|
|
|
def _handle_llm_message(self, data):
|
|
"""处理LLM消息"""
|
|
emotion = data.get("emotion", "")
|
|
if emotion:
|
|
self.schedule(lambda: self.set_emotion(emotion))
|
|
|
|
async def _on_audio_channel_opened(self):
|
|
"""音频通道打开回调"""
|
|
self.schedule(lambda: self._start_audio_streams())
|
|
|
|
def _start_audio_streams(self):
|
|
"""启动音频流"""
|
|
try:
|
|
# 确保流已关闭后再重新打开
|
|
if self.audio_codec.input_stream.is_active():
|
|
self.audio_codec.input_stream.stop_stream()
|
|
|
|
# 重新打开流
|
|
self.audio_codec.input_stream.start_stream()
|
|
|
|
if self.audio_codec.output_stream.is_active():
|
|
self.audio_codec.output_stream.stop_stream()
|
|
|
|
# 重新打开流
|
|
self.audio_codec.output_stream.start_stream()
|
|
|
|
# 设置事件触发器
|
|
threading.Thread(
|
|
target=self._audio_input_event_trigger, daemon=True
|
|
).start()
|
|
|
|
except Exception:
|
|
pass
|
|
|
|
def _audio_input_event_trigger(self):
|
|
"""音频输入事件触发器"""
|
|
while self.running:
|
|
try:
|
|
if self.audio_codec.input_stream.is_active():
|
|
self.events[EventType.AUDIO_INPUT_READY_EVENT].set()
|
|
except OSError as e:
|
|
if "Stream not open" in str(e):
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
time.sleep(AudioConfig.FRAME_DURATION / 1000) # 按帧时长触发
|
|
|
|
async def _on_audio_channel_closed(self):
|
|
"""音频通道关闭回调"""
|
|
self.set_device_state(DeviceState.IDLE)
|
|
self.audio_codec.stop_streams()
|
|
|
|
def set_device_state(self, state):
|
|
"""设置设备状态"""
|
|
self.device_state = state
|
|
|
|
VAD.pause() # 停用 VAD
|
|
self.audio_codec.stop_streams() # 停用输入输出流
|
|
|
|
if state == DeviceState.IDLE:
|
|
self.display.update_status("待命")
|
|
self.display.update_emotion("😶")
|
|
elif state == DeviceState.CONNECTING:
|
|
self.display.update_status("连接中...")
|
|
elif state == DeviceState.LISTENING:
|
|
self.display.update_status("聆听中...")
|
|
self.display.update_emotion("🙂")
|
|
# 停止输出流
|
|
if self.audio_codec.output_stream.is_active():
|
|
self.audio_codec.output_stream.stop_stream()
|
|
# 打开输入流
|
|
if not self.audio_codec.input_stream.is_active():
|
|
self.audio_codec.input_stream.start_stream()
|
|
elif state == DeviceState.SPEAKING:
|
|
self.display.update_status("说话中...")
|
|
# 停止输入流
|
|
if self.audio_codec.input_stream.is_active():
|
|
self.audio_codec.input_stream.stop_stream()
|
|
# 打开输出流
|
|
if not self.audio_codec.output_stream.is_active():
|
|
self.audio_codec.output_stream.start_stream()
|
|
|
|
# 通知状态变化
|
|
for callback in self.on_state_changed_callbacks:
|
|
try:
|
|
callback(state)
|
|
except Exception:
|
|
pass
|
|
|
|
def _get_status_text(self):
|
|
"""获取当前状态文本"""
|
|
states = {
|
|
DeviceState.IDLE: "待命",
|
|
DeviceState.CONNECTING: "连接中...",
|
|
DeviceState.LISTENING: "聆听中...",
|
|
DeviceState.SPEAKING: "说话中...",
|
|
}
|
|
return states.get(self.device_state, "未知")
|
|
|
|
def _get_current_text(self):
|
|
"""获取当前显示文本"""
|
|
return self.current_text
|
|
|
|
def _get_current_emotion(self):
|
|
"""获取当前表情"""
|
|
emotions = {
|
|
"neutral": "😶",
|
|
"happy": "🙂",
|
|
"laughing": "😆",
|
|
"funny": "😂",
|
|
"sad": "😔",
|
|
"angry": "😠",
|
|
"crying": "😭",
|
|
"loving": "😍",
|
|
"embarrassed": "😳",
|
|
"surprised": "😲",
|
|
"shocked": "😱",
|
|
"thinking": "🤔",
|
|
"winking": "😉",
|
|
"cool": "😎",
|
|
"relaxed": "😌",
|
|
"delicious": "🤤",
|
|
"kissy": "😘",
|
|
"confident": "😏",
|
|
"sleepy": "😴",
|
|
"silly": "😜",
|
|
"confused": "🙄",
|
|
}
|
|
return emotions.get(self.current_emotion, "😶")
|
|
|
|
def set_chat_message(self, role, message):
|
|
"""设置聊天消息"""
|
|
self.current_text = message
|
|
# 更新显示
|
|
if self.display:
|
|
self.display.update_text(message)
|
|
|
|
def set_emotion(self, emotion):
|
|
"""设置表情"""
|
|
self.current_emotion = emotion
|
|
# 更新显示
|
|
if self.display:
|
|
self.display.update_emotion(self._get_current_emotion())
|
|
|
|
def start_listening(self):
|
|
"""开始监听"""
|
|
self.schedule(self._start_listening_impl)
|
|
|
|
def _start_listening_impl(self):
|
|
"""开始监听的实现"""
|
|
if not self.protocol:
|
|
return
|
|
|
|
self.set_device_state(DeviceState.IDLE)
|
|
asyncio.run_coroutine_threadsafe(
|
|
self.protocol.send_abort_speaking(AbortReason.ABORT),
|
|
self.loop,
|
|
)
|
|
|
|
# 尝试打开音频通道
|
|
if not self.protocol.is_audio_channel_opened():
|
|
self.set_device_state(DeviceState.CONNECTING) # 设置设备状态为连接中
|
|
try:
|
|
# 等待异步操作完成
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
self.protocol.open_audio_channel(), self.loop
|
|
)
|
|
# 等待操作完成并获取结果
|
|
assert future.result(timeout=10.0) # 添加超时时间
|
|
except Exception as e:
|
|
self.alert("错误", f"打开音频通道失败: {str(e)}")
|
|
self.set_device_state(DeviceState.IDLE)
|
|
return
|
|
|
|
asyncio.run_coroutine_threadsafe(
|
|
self.protocol.send_start_listening(ListeningMode.MANUAL), self.loop
|
|
)
|
|
self.set_device_state(DeviceState.LISTENING) # 设置设备状态为监听中
|
|
|
|
def stop_listening(self):
|
|
"""停止监听"""
|
|
self.schedule(self._stop_listening_impl)
|
|
|
|
def _stop_listening_impl(self):
|
|
"""停止监听的实现"""
|
|
self.set_device_state(DeviceState.IDLE)
|
|
asyncio.run_coroutine_threadsafe(self.protocol.send_stop_listening(), self.loop)
|
|
|
|
def abort_speaking(self, reason):
|
|
"""中止语音输出"""
|
|
self.set_device_state(DeviceState.IDLE)
|
|
asyncio.run_coroutine_threadsafe(
|
|
self.protocol.send_abort_speaking(AbortReason.ABORT),
|
|
self.loop,
|
|
)
|
|
|
|
def alert(self, title, message):
|
|
"""显示警告信息"""
|
|
if self.display:
|
|
self.display.update_text(f"{title}: {message}")
|
|
|
|
def on_state_changed(self, callback):
|
|
"""注册状态变化回调"""
|
|
self.on_state_changed_callbacks.append(callback)
|
|
|
|
def shutdown(self):
|
|
"""关闭应用程序"""
|
|
self.running = False
|
|
|
|
# 关闭音频编解码器
|
|
if self.audio_codec:
|
|
self.audio_codec.close()
|
|
|
|
# 关闭协议
|
|
if self.protocol:
|
|
asyncio.run_coroutine_threadsafe(
|
|
self.protocol.close_audio_channel(), self.loop
|
|
)
|
|
|
|
# 停止事件循环
|
|
if self.loop and self.loop.is_running():
|
|
self.loop.call_soon_threadsafe(self.loop.stop)
|
|
|
|
# 等待事件循环线程结束
|
|
if self.loop_thread and self.loop_thread.is_alive():
|
|
self.loop_thread.join(timeout=1.0)
|
|
|
|
def toggle_chat_state(self):
|
|
"""切换聊天状态"""
|
|
pass
|
|
|
|
def _on_mode_changed(self, auto_mode):
|
|
"""处理对话模式变更"""
|
|
pass
|